1import * as AWS from "@/AWS";
2import * as AppConfig from "@/AWS/AppConfig";
3import { AWSEnvironment } from "@/AWS/Environment.ts";
4import * as Test from "@/Test/Alchemy";
5import * as appconfig from "@distilled.cloud/aws/appconfig";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13const getApp = (applicationId: string) =>
14 appconfig
15 .getApplication({ ApplicationId: applicationId })
16 .pipe(
17 Effect.catchTag("ResourceNotFoundException", () =>
18 Effect.succeed(undefined),
19 ),
20 );
21
22const waitUntilAppGone = (applicationId: string) =>
23 getApp(applicationId).pipe(
24 Effect.repeat({
25 schedule: Schedule.spaced("2 seconds"),
26 until: (a) => a === undefined,
27 times: 10,
28 }),
29 );
30
31// Full AppConfig control-plane lifecycle in one stack: application ->
32// environment -> hosted configuration profile -> hosted version, plus a
33// standalone deployment strategy. Everything is fast and free. Update the
34// application description in place, then destroy and verify out-of-band.
35test.provider(
36 "appconfig control plane: create the full chain, update, and destroy",
37 (stack) =>
38 Effect.gen(function* () {
39 yield* stack.destroy();
40
41 const deploy = (description: string) =>
42 stack.deploy(
43 Effect.gen(function* () {
44 const app = yield* AppConfig.Application("App", {
45 description,
46 tags: { team: "platform" },
47 });
48 const env = yield* AppConfig.Environment("Env", {
49 applicationId: app.applicationId,
50 });
51 const profile = yield* AppConfig.ConfigurationProfile("Profile", {
52 applicationId: app.applicationId,
53 locationUri: "hosted",
54 });
55 const version = yield* AppConfig.HostedConfigurationVersion("V1", {
56 applicationId: app.applicationId,
57 configurationProfileId: profile.configurationProfileId,
58 content: JSON.stringify({ featureX: true }),
59 contentType: "application/json",
60 });
61 const strategy = yield* AppConfig.DeploymentStrategy("Strategy", {
62 deploymentDuration: 0,
63 growthFactor: 100,
64 finalBakeTime: 0,
65 replicateTo: "NONE",
66 });
67 return {
68 applicationId: app.applicationId.as<string>(),
69 applicationArn: app.applicationArn.as<string>(),
70 environmentId: env.environmentId.as<string>(),
71 configurationProfileId:
72 profile.configurationProfileId.as<string>(),
73 versionNumber: version.versionNumber.as<number>(),
74 strategyId: strategy.deploymentStrategyId.as<string>(),
75 };
76 }),
77 );
78
79 const created = yield* deploy("v1 description");
80 expect(created.applicationId).toBeTruthy();
81 expect(created.environmentId).toBeTruthy();
82 expect(created.configurationProfileId).toBeTruthy();
83 expect(created.versionNumber).toBe(1);
84 expect(created.strategyId).toBeTruthy();
85
86 // Out-of-band: the application exists and carries alchemy + user tags.
87 const app = yield* getApp(created.applicationId);
88 expect(app?.Description).toBe("v1 description");
89 const tags = yield* appconfig.listTagsForResource({
90 ResourceArn: created.applicationArn,
91 });
92 expect(tags.Tags?.["alchemy::id"]).toBe("App");
93 expect(tags.Tags?.team).toBe("platform");
94
95 // Out-of-band: the hosted version content round-trips.
96 const version = yield* appconfig.getHostedConfigurationVersion({
97 ApplicationId: created.applicationId,
98 ConfigurationProfileId: created.configurationProfileId,
99 VersionNumber: created.versionNumber,
100 });
101 const content = yield* Stream.mkString(
102 Stream.decodeText(version.Content!),
103 );
104 expect(JSON.parse(content)).toEqual({ featureX: true });
105
106 // Update the description in place — the application id is stable.
107 const updated = yield* deploy("v2 description");
108 expect(updated.applicationId).toBe(created.applicationId);
109 const appAfter = yield* getApp(created.applicationId);
110 expect(appAfter?.Description).toBe("v2 description");
111
112 // Destroy — the application (and its children) are gone.
113 yield* stack.destroy();
114 const gone = yield* waitUntilAppGone(created.applicationId);
115 expect(gone).toBeUndefined();
116 }),
117 {
118 tags: ["provider:aws", "provider:aws:appconfig", "live"],
119 timeout: 240_000,
120 },
121);
122
123// Extension + association lifecycle: an extension whose action emits
124// deployment events to the default EventBridge bus (AppConfig EventBridge
125// extension actions only support the default bus; no invoke role required),
126// associated with the application. Update the extension description in
127// place, then destroy and verify out-of-band.
128test.provider(
129 "appconfig extension: create, associate, update, and destroy",
130 (stack) =>
131 Effect.gen(function* () {
132 yield* stack.destroy();
133
134 const { accountId, region } = yield* AWSEnvironment.current;
135 const defaultBusArn = `arn:aws:events:${region}:${accountId}:event-bus/default`;
136
137 const deploy = (description: string) =>
138 stack.deploy(
139 Effect.gen(function* () {
140 const app = yield* AppConfig.Application("ExtApp", {});
141 const extension = yield* AppConfig.Extension("Ext", {
142 description,
143 actions: {
144 ON_DEPLOYMENT_COMPLETE: [
145 { name: "notify-bus", uri: defaultBusArn },
146 ],
147 },
148 tags: { team: "platform" },
149 });
150 const association = yield* AppConfig.ExtensionAssociation(
151 "ExtAssoc",
152 {
153 extensionIdentifier: extension.extensionId,
154 resourceIdentifier: app.applicationArn,
155 },
156 );
157 return {
158 extensionId: extension.extensionId.as<string>(),
159 extensionArn: extension.extensionArn.as<string>(),
160 versionNumber: extension.versionNumber.as<number>(),
161 associationId: association.extensionAssociationId.as<string>(),
162 };
163 }),
164 );
165
166 const created = yield* deploy("v1 hook");
167 expect(created.extensionId).toBeTruthy();
168 expect(created.versionNumber).toBeGreaterThan(0);
169 expect(created.associationId).toBeTruthy();
170
171 // Out-of-band: the extension exists with the action and alchemy tags.
172 const extension = yield* appconfig.getExtension({
173 ExtensionIdentifier: created.extensionId,
174 });
175 expect(extension.Description).toBe("v1 hook");
176 expect(extension.Actions?.ON_DEPLOYMENT_COMPLETE?.[0]?.Name).toBe(
177 "notify-bus",
178 );
179 const tags = yield* appconfig.listTagsForResource({
180 ResourceArn: created.extensionArn,
181 });
182 expect(tags.Tags?.["alchemy::id"]).toBe("Ext");
183
184 // Out-of-band: the association binds the extension to the application.
185 const association = yield* appconfig.getExtensionAssociation({
186 ExtensionAssociationId: created.associationId,
187 });
188 expect(association.ExtensionArn).toBeTruthy();
189 expect(association.ResourceArn).toContain(":application/");
190
191 // Update the description in place — id and association are stable.
192 const updated = yield* deploy("v2 hook");
193 expect(updated.extensionId).toBe(created.extensionId);
194 expect(updated.associationId).toBe(created.associationId);
195 const after = yield* appconfig.getExtension({
196 ExtensionIdentifier: created.extensionId,
197 });
198 expect(after.Description).toBe("v2 hook");
199
200 // Destroy — association first, then the extension.
201 yield* stack.destroy();
202 const goneExtension = yield* appconfig
203 .getExtension({ ExtensionIdentifier: created.extensionId })
204 .pipe(
205 Effect.catchTag("ResourceNotFoundException", () =>
206 Effect.succeed(undefined),
207 ),
208 Effect.repeat({
209 schedule: Schedule.spaced("2 seconds"),
210 until: (e): boolean => e === undefined,
211 times: 10,
212 }),
213 );
214 expect(goneExtension).toBeUndefined();
215 const goneAssociation = yield* appconfig
216 .getExtensionAssociation({
217 ExtensionAssociationId: created.associationId,
218 })
219 .pipe(
220 Effect.catchTag("ResourceNotFoundException", () =>
221 Effect.succeed(undefined),
222 ),
223 );
224 expect(goneAssociation).toBeUndefined();
225 }),
226 {
227 tags: ["provider:aws", "provider:aws:appconfig", "live"],
228 timeout: 240_000,
229 },
230);