1import * as appconfig from "@distilled.cloud/aws/appconfig";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 configurationProfileArn,
14 readAppConfigTags,
15 syncAppConfigTags,
16} from "./internal.ts";
17
18/**
19 * A validator run against configuration data before it is deployed.
20 */
21export interface ConfigurationValidator {
22 /** Validator kind. */
23 type: "JSON_SCHEMA" | "LAMBDA";
24 /**
25 * For `JSON_SCHEMA`, the JSON Schema document. For `LAMBDA`, the ARN of the
26 * validating Lambda function.
27 */
28 content: string;
29}
30
31export interface ConfigurationProfileProps {
32 /**
33 * ID of the application this profile belongs to. Changing it replaces the
34 * profile.
35 */
36 applicationId: string;
37 /**
38 * Name of the configuration profile. Must be 1-64 characters. If omitted, a
39 * deterministic physical name is generated. Changing the name replaces the
40 * profile.
41 */
42 configurationProfileName?: string;
43 /**
44 * URI of the configuration source. Use `"hosted"` for the AppConfig hosted
45 * configuration store, or an S3 URI (`s3://bucket/key`), SSM parameter/
46 * document name, Secrets Manager secret, or CodePipeline pipeline ARN.
47 * Immutable — changing it replaces the profile.
48 * @default "hosted"
49 */
50 locationUri?: string;
51 /**
52 * Description of the configuration profile.
53 */
54 description?: string;
55 /**
56 * ARN of an IAM role AppConfig assumes to fetch configuration from a
57 * non-hosted source (S3, SSM, Secrets Manager). Not required for `"hosted"`.
58 */
59 retrievalRoleArn?: string;
60 /**
61 * Validators run against the configuration data before deployment.
62 */
63 validators?: ConfigurationValidator[];
64 /**
65 * Configuration profile type. `"AWS.Freeform"` for free-form configuration
66 * or `"AWS.AppConfig.FeatureFlags"` for feature flags. Immutable — changing
67 * it replaces the profile.
68 * @default "AWS.Freeform"
69 */
70 type?: string;
71 /**
72 * Customer-managed KMS key ARN/id used to encrypt the configuration data.
73 */
74 kmsKeyIdentifier?: string;
75 /**
76 * User-defined tags for the configuration profile.
77 */
78 tags?: Record<string, string>;
79}
80
81export interface ConfigurationProfile extends Resource<
82 "AWS.AppConfig.ConfigurationProfile",
83 ConfigurationProfileProps,
84 {
85 configurationProfileId: string;
86 configurationProfileName: string;
87 applicationId: string;
88 locationUri: string;
89 configurationProfileArn: string;
90 },
91 never,
92 Providers
93> {}
94
95/**
96 * An AWS AppConfig configuration profile — describes where the configuration
97 * data lives (the AppConfig hosted store, S3, SSM, Secrets Manager, or
98 * CodePipeline) and how to validate it.
99 *
100 * ### Creating a Configuration Profile
101 * **Example:** Hosted Configuration Profile
102 * ```typescript
103 * const profile = yield* AppConfig.ConfigurationProfile("Settings", {
104 * applicationId: app.applicationId,
105 * locationUri: "hosted",
106 * });
107 * ```
108 *
109 * **Example:** S3-sourced Profile with a JSON Schema Validator
110 * ```typescript
111 * const profile = yield* AppConfig.ConfigurationProfile("Settings", {
112 * applicationId: app.applicationId,
113 * locationUri: "s3://my-bucket/config.json",
114 * retrievalRoleArn: role.roleArn,
115 * validators: [{ type: "JSON_SCHEMA", content: schemaJson }],
116 * });
117 * ```
118 *
119 * @resource
120 */
121export const ConfigurationProfile = Resource<ConfigurationProfile>(
122 "AWS.AppConfig.ConfigurationProfile",
123);
124
125const toWireValidators = (
126 validators: ConfigurationValidator[] | undefined,
127): appconfig.Validator[] | undefined =>
128 validators?.map((v) => ({ Type: v.type, Content: v.content }));
129
130export const ConfigurationProfileProvider = () =>
131 Provider.effect(
132 ConfigurationProfile,
133 Effect.gen(function* () {
134 const toName = (id: string, props: Partial<ConfigurationProfileProps>) =>
135 props.configurationProfileName
136 ? Effect.succeed(props.configurationProfileName)
137 : createPhysicalName({ id, maxLength: 64 });
138
139 const readProfile = Effect.fn(function* (
140 applicationId: string,
141 configurationProfileId: string,
142 ) {
143 return yield* appconfig
144 .getConfigurationProfile({
145 ApplicationId: applicationId,
146 ConfigurationProfileId: configurationProfileId,
147 })
148 .pipe(
149 Effect.catchTag("ResourceNotFoundException", () =>
150 Effect.succeed(undefined),
151 ),
152 );
153 });
154
155 const findByName = Effect.fn(function* (
156 applicationId: string,
157 name: string,
158 ) {
159 const profiles = yield* appconfig.listConfigurationProfiles
160 .pages({ ApplicationId: applicationId })
161 .pipe(
162 Stream.runCollect,
163 Effect.map((chunk) =>
164 Array.from(chunk).flatMap((page) => page.Items ?? []),
165 ),
166 Effect.catchTag("ResourceNotFoundException", () =>
167 Effect.succeed([] as appconfig.ConfigurationProfileSummary[]),
168 ),
169 );
170 const summary = profiles.find((p) => p.Name === name);
171 if (summary?.Id === undefined) return undefined;
172 return yield* readProfile(applicationId, summary.Id);
173 });
174
175 return {
176 stables: [
177 "configurationProfileId",
178 "configurationProfileName",
179 "applicationId",
180 "locationUri",
181 "configurationProfileArn",
182 ],
183
184 diff: Effect.fn(function* ({ id, olds, news }) {
185 if (!isResolved(news)) return undefined;
186 if (
187 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
188 ) {
189 return { action: "replace" } as const;
190 }
191 if ((olds?.applicationId ?? undefined) !== news?.applicationId) {
192 return { action: "replace" } as const;
193 }
194 // LocationUri and Type are create-only.
195 if (
196 (olds?.locationUri ?? "hosted") !== (news?.locationUri ?? "hosted")
197 ) {
198 return { action: "replace" } as const;
199 }
200 if (
201 (olds?.type ?? "AWS.Freeform") !== (news?.type ?? "AWS.Freeform")
202 ) {
203 return { action: "replace" } as const;
204 }
205 }),
206
207 read: Effect.fn(function* ({ id, olds, output }) {
208 const { accountId, region } = yield* AWSEnvironment.current;
209 const applicationId = output?.applicationId ?? olds?.applicationId;
210 if (applicationId === undefined) return undefined;
211 const profile = output?.configurationProfileId
212 ? yield* readProfile(applicationId, output.configurationProfileId)
213 : yield* findByName(applicationId, yield* toName(id, olds ?? {}));
214 if (profile?.Id === undefined) return undefined;
215 const arn = configurationProfileArn(
216 region,
217 accountId,
218 applicationId,
219 profile.Id,
220 );
221 const attrs = {
222 configurationProfileId: profile.Id,
223 configurationProfileName: profile.Name!,
224 applicationId,
225 locationUri: profile.LocationUri ?? "hosted",
226 configurationProfileArn: arn,
227 };
228 const tags = yield* readAppConfigTags(arn);
229 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
230 }),
231
232 reconcile: Effect.fn(function* ({ id, news, output, session }) {
233 const { accountId, region } = yield* AWSEnvironment.current;
234 const applicationId = news.applicationId;
235 const name =
236 output?.configurationProfileName ?? (yield* toName(id, news));
237 const locationUri = news.locationUri ?? "hosted";
238 const internalTags = yield* createInternalTags(id);
239 const desiredTags = { ...internalTags, ...news.tags };
240
241 // 1. Observe.
242 let observed = output?.configurationProfileId
243 ? yield* readProfile(applicationId, output.configurationProfileId)
244 : undefined;
245 if (observed === undefined) {
246 observed = yield* findByName(applicationId, name);
247 }
248
249 // 2. Ensure.
250 if (observed?.Id === undefined) {
251 observed = yield* appconfig.createConfigurationProfile({
252 ApplicationId: applicationId,
253 Name: name,
254 LocationUri: locationUri,
255 Description: news.description,
256 RetrievalRoleArn: news.retrievalRoleArn,
257 Validators: toWireValidators(news.validators),
258 Type: news.type,
259 KmsKeyIdentifier: news.kmsKeyIdentifier,
260 Tags: desiredTags,
261 });
262 } else {
263 // 3. Sync — description, retrieval role, validators, and KMS key
264 // are mutable in place.
265 observed = yield* appconfig.updateConfigurationProfile({
266 ApplicationId: applicationId,
267 ConfigurationProfileId: observed.Id,
268 Description: news.description,
269 RetrievalRoleArn: news.retrievalRoleArn,
270 Validators: toWireValidators(news.validators),
271 KmsKeyIdentifier: news.kmsKeyIdentifier,
272 });
273 }
274
275 const arn = configurationProfileArn(
276 region,
277 accountId,
278 applicationId,
279 observed.Id!,
280 );
281
282 // 3b. Sync tags.
283 yield* syncAppConfigTags(arn, desiredTags);
284
285 yield* session.note(name);
286 return {
287 configurationProfileId: observed.Id!,
288 configurationProfileName: observed.Name ?? name,
289 applicationId,
290 locationUri: observed.LocationUri ?? locationUri,
291 configurationProfileArn: arn,
292 };
293 }),
294
295 delete: Effect.fn(function* ({ output }) {
296 // Hosted configuration versions block profile deletion, and
297 // runtime writers (the CreateHostedConfigurationVersion binding)
298 // can add versions the engine never tracked — delete whatever
299 // versions remain before deleting the profile.
300 const versions = yield* appconfig.listHostedConfigurationVersions
301 .pages({
302 ApplicationId: output.applicationId,
303 ConfigurationProfileId: output.configurationProfileId,
304 })
305 .pipe(
306 Stream.runCollect,
307 Effect.map((chunk) =>
308 Array.from(chunk).flatMap((page) => page.Items ?? []),
309 ),
310 Effect.catchTag("ResourceNotFoundException", () =>
311 Effect.succeed([]),
312 ),
313 );
314 for (const version of versions) {
315 if (version.VersionNumber !== undefined) {
316 yield* appconfig
317 .deleteHostedConfigurationVersion({
318 ApplicationId: output.applicationId,
319 ConfigurationProfileId: output.configurationProfileId,
320 VersionNumber: version.VersionNumber,
321 })
322 .pipe(
323 Effect.catchTag(
324 "ResourceNotFoundException",
325 () => Effect.void,
326 ),
327 );
328 }
329 }
330 yield* appconfig
331 .deleteConfigurationProfile({
332 ApplicationId: output.applicationId,
333 ConfigurationProfileId: output.configurationProfileId,
334 })
335 .pipe(
336 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
337 );
338 }),
339
340 // Configuration profiles are keyed under their parent application, so
341 // enumeration walks every application and lists its profiles. An
342 // application cannot be deleted while profiles exist under it, so
343 // nuke needs these enumerated as first-class resources.
344 list: () =>
345 Effect.gen(function* () {
346 const { accountId, region } = yield* AWSEnvironment.current;
347 const apps = yield* appconfig.listApplications.pages({}).pipe(
348 Stream.runCollect,
349 Effect.map((chunk) =>
350 Array.from(chunk).flatMap((page) => page.Items ?? []),
351 ),
352 );
353 const results: {
354 configurationProfileId: string;
355 configurationProfileName: string;
356 applicationId: string;
357 locationUri: string;
358 configurationProfileArn: string;
359 }[] = [];
360 for (const app of apps) {
361 if (app.Id === undefined) continue;
362 const profiles = yield* appconfig.listConfigurationProfiles
363 .pages({ ApplicationId: app.Id })
364 .pipe(
365 Stream.runCollect,
366 Effect.map((chunk) =>
367 Array.from(chunk).flatMap((page) => page.Items ?? []),
368 ),
369 // The application may be deleted between the two calls.
370 Effect.catchTag("ResourceNotFoundException", () =>
371 Effect.succeed(
372 [] as appconfig.ConfigurationProfileSummary[],
373 ),
374 ),
375 );
376 for (const profile of profiles) {
377 if (profile.Id === undefined || profile.Name === undefined) {
378 continue;
379 }
380 results.push({
381 configurationProfileId: profile.Id,
382 configurationProfileName: profile.Name,
383 applicationId: app.Id,
384 locationUri: profile.LocationUri ?? "hosted",
385 configurationProfileArn: configurationProfileArn(
386 region,
387 accountId,
388 app.Id,
389 profile.Id,
390 ),
391 });
392 }
393 }
394 return results;
395 }),
396 };
397 }),
398 );