1import * as AWS from "@/AWS";
2import { ConfigurationRecorder, DeliveryChannel } from "@/AWS/Config";
3import { Bucket } from "@/AWS/S3";
4import * as Test from "@/Test/Alchemy";
5import * as config from "@distilled.cloud/aws/config-service";
6import * as iam from "@distilled.cloud/aws/iam";
7import * as sts from "@distilled.cloud/aws/sts";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { makeConfigTestLease } from "./TestLease.ts";
11
12const { test } = Test.make({ providers: AWS.providers() });
13const testLease = makeConfigTestLease();
14
15// Ungated typed-error probes: prove the distilled error unions carry the
16// not-found tags the recorder/channel providers' read/delete paths depend
17// on. Run in every CI pass at near-zero cost, unlike the gated lifecycle
18// below.
19test.provider(
20 "describeConfigurationRecorders on a nonexistent recorder fails with NoSuchConfigurationRecorderException",
21 () =>
22 Effect.gen(function* () {
23 const error = yield* Effect.flip(
24 config.describeConfigurationRecorders({
25 ConfigurationRecorderNames: ["alchemy-nonexistent-recorder-probe"],
26 }),
27 );
28 expect(error._tag).toBe("NoSuchConfigurationRecorderException");
29 }),
30 { tags: ["provider:aws", "provider:aws:config", "live"] },
31);
32
33test.provider(
34 "describeDeliveryChannels on a nonexistent channel fails with NoSuchDeliveryChannelException",
35 () =>
36 Effect.gen(function* () {
37 const error = yield* Effect.flip(
38 config.describeDeliveryChannels({
39 DeliveryChannelNames: ["alchemy-nonexistent-channel-probe"],
40 }),
41 );
42 expect(error._tag).toBe("NoSuchDeliveryChannelException");
43 }),
44 { tags: ["provider:aws", "provider:aws:config", "live"] },
45);
46
47// AWS allows only ONE customer managed configuration recorder and ONE
48// delivery channel per account per region, so the full lifecycle is an
49// account-region singleton mutation — gated behind AWS_TEST_CONFIG_RECORDER=1.
50// The gated run requires the account/region to have NO existing recorder.
51test.provider.skipIf(!process.env.AWS_TEST_CONFIG_RECORDER)(
52 "create recorder + delivery channel, start/stop recording, destroy",
53 (stack) =>
54 Effect.gen(function* () {
55 yield* stack.destroy();
56
57 // Preflight: refuse to clobber a foreign recorder.
58 const preexisting = yield* config.describeConfigurationRecorders({});
59 if ((preexisting.ConfigurationRecorders ?? []).length > 0) {
60 return yield* Effect.die(
61 new Error(
62 "account/region already has a configuration recorder — the gated singleton lifecycle requires a clean account/region",
63 ),
64 );
65 }
66
67 // The recorder assumes the Config service-linked role.
68 yield* iam
69 .createServiceLinkedRole({ AWSServiceName: "config.amazonaws.com" })
70 .pipe(
71 Effect.catchTag("InvalidInputException", () =>
72 Effect.succeed(undefined),
73 ),
74 );
75 const role = yield* iam.getRole({ RoleName: "AWSServiceRoleForConfig" });
76 const roleArn = role.Role.Arn;
77 const { Account: accountId } = yield* sts.getCallerIdentity({});
78 const bucketName = `alchemy-config-delivery-${accountId}`;
79 const bucketArn = `arn:aws:s3:::${bucketName}`;
80
81 const outputs = yield* stack.deploy(
82 Effect.gen(function* () {
83 const bucket = yield* Bucket("DeliveryBucket", {
84 bucketName,
85 forceDestroy: true,
86 policy: [
87 {
88 Effect: "Allow",
89 Principal: { Service: "config.amazonaws.com" },
90 Action: ["s3:GetBucketAcl", "s3:ListBucket"],
91 Resource: [bucketArn],
92 },
93 {
94 Effect: "Allow",
95 Principal: { Service: "config.amazonaws.com" },
96 Action: ["s3:PutObject"],
97 Resource: [`${bucketArn}/AWSLogs/${accountId}/Config/*`],
98 Condition: {
99 StringEquals: {
100 "s3:x-amz-acl": "bucket-owner-full-control",
101 },
102 },
103 },
104 ],
105 });
106 const recorder = yield* ConfigurationRecorder("Recorder", {
107 roleArn,
108 recordingGroup: { resourceTypes: ["AWS::S3::Bucket"] },
109 });
110 const channel = yield* DeliveryChannel("Channel", {
111 s3BucketName: bucket.bucketName,
112 });
113 return { recorder, channel };
114 }),
115 );
116
117 expect(outputs.recorder.recorderName).toBeDefined();
118 expect(outputs.recorder.recorderArn).toContain(
119 ":configuration-recorder/",
120 );
121 expect(outputs.channel.deliveryChannelName).toBeDefined();
122 expect(outputs.channel.s3BucketName).toBe(bucketName);
123
124 // Out-of-band verification via distilled.
125 const described = yield* config.describeConfigurationRecorders({
126 ConfigurationRecorderNames: [outputs.recorder.recorderName],
127 });
128 const observed = (described.ConfigurationRecorders ?? []).at(0);
129 expect(observed?.roleARN).toBe(roleArn);
130 expect(observed?.recordingGroup?.resourceTypes).toEqual([
131 "AWS::S3::Bucket",
132 ]);
133 const channels = yield* config.describeDeliveryChannels({
134 DeliveryChannelNames: [outputs.channel.deliveryChannelName],
135 });
136 expect((channels.DeliveryChannels ?? []).at(0)?.s3BucketName).toBe(
137 bucketName,
138 );
139
140 // Start recording (requires the delivery channel), verify, stop.
141 yield* stack.deploy(
142 Effect.gen(function* () {
143 const bucket = yield* Bucket("DeliveryBucket", {
144 bucketName,
145 forceDestroy: true,
146 });
147 const recorder = yield* ConfigurationRecorder("Recorder", {
148 roleArn,
149 recordingGroup: { resourceTypes: ["AWS::S3::Bucket"] },
150 recording: true,
151 });
152 const channel = yield* DeliveryChannel("Channel", {
153 s3BucketName: bucket.bucketName,
154 });
155 return { recorder, channel };
156 }),
157 );
158 const startedStatus = yield* config.describeConfigurationRecorderStatus({
159 ConfigurationRecorderNames: [outputs.recorder.recorderName],
160 });
161 expect(
162 (startedStatus.ConfigurationRecordersStatus ?? []).at(0)?.recording,
163 ).toBe(true);
164
165 yield* stack.deploy(
166 Effect.gen(function* () {
167 const bucket = yield* Bucket("DeliveryBucket", {
168 bucketName,
169 forceDestroy: true,
170 });
171 const recorder = yield* ConfigurationRecorder("Recorder", {
172 roleArn,
173 recordingGroup: { resourceTypes: ["AWS::S3::Bucket"] },
174 recording: false,
175 });
176 const channel = yield* DeliveryChannel("Channel", {
177 s3BucketName: bucket.bucketName,
178 });
179 return { recorder, channel };
180 }),
181 );
182 const stoppedStatus = yield* config.describeConfigurationRecorderStatus({
183 ConfigurationRecorderNames: [outputs.recorder.recorderName],
184 });
185 expect(
186 (stoppedStatus.ConfigurationRecordersStatus ?? []).at(0)?.recording,
187 ).toBe(false);
188
189 // Destroy and verify both singletons are gone (deletion is
190 // synchronous for recorder and channel).
191 yield* stack.destroy();
192 const recorderGone = yield* Effect.flip(
193 config.describeConfigurationRecorders({
194 ConfigurationRecorderNames: [outputs.recorder.recorderName],
195 }),
196 );
197 expect(recorderGone._tag).toBe("NoSuchConfigurationRecorderException");
198 const channelGone = yield* Effect.flip(
199 config.describeDeliveryChannels({
200 DeliveryChannelNames: [outputs.channel.deliveryChannelName],
201 }),
202 );
203 expect(channelGone._tag).toBe("NoSuchDeliveryChannelException");
204 }).pipe(
205 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.orDie)),
206 testLease.use,
207 ),
208 {
209 tags: [
210 "provider:aws",
211 "provider:aws:config",
212 "provider:aws:iam",
213 "provider:aws:s3",
214 "live",
215 ],
216 timeout: 240_000,
217 },
218);