1import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
2import * as imagebuilder from "@distilled.cloud/aws/imagebuilder";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { deepEqual, isResolved } from "../../Diff.ts";
8import type { Input } from "../../Input.ts";
9import * as Output from "../../Output.ts";
10import { diffTags } from "../../Tags.ts";
11import { AWSEnvironment } from "../Environment.ts";
12
13/**
14 * Plan-time replacement check for immutable Image Builder versions
15 * (components, image recipes). `news` may still carry unresolved Outputs
16 * at plan time — e.g. the build-version ARN of a component being replaced
17 * in the same deploy — so compare per key and skip only the keys that are
18 * unresolved. A resolved change on an immutable key (like a
19 * `semanticVersion` bump) must still plan a replacement; drift hidden
20 * behind an unresolved key is caught by the provider's reconcile
21 * immutability guard.
22 */
23export const immutableVersionKeysChanged = <Props extends object>(
24 olds: Props,
25 news: Input<Props>,
26 keys: readonly (keyof Props)[],
27): boolean => {
28 // The whole props object itself may be an unresolved expression — no
29 // per-key comparison is possible; defer to the reconcile guard.
30 if (Output.isExpr(news) || Effect.isEffect(news)) return false;
31 const desired = news as { [K in keyof Props]: Input<Props[K]> };
32 return keys.some((key) => {
33 const value = desired[key];
34 return isResolved(value) && !deepEqual(olds[key], value);
35 });
36};
37
38/**
39 * Image Builder rejects deletion of a resource that another resource still
40 * references (e.g. a component referenced by a not-yet-deleted recipe, or a
41 * recipe referenced by a pipeline) with `ResourceDependencyException`.
42 * During a replacement the engine deletes the displaced resources without
43 * ordering guarantees, so retry through the window while the dependents are
44 * deleted (bounded).
45 */
46export const retryWhileDependedOn = <A, E extends { _tag: string }, R>(
47 self: Effect.Effect<A, E, R>,
48): Effect.Effect<A, E, R> =>
49 Effect.retry(self, {
50 while: (e) => e._tag === "ResourceDependencyException",
51 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(12)]),
52 });
53
54/**
55 * Delete one exact CloudWatch log group that Image Builder auto-created for
56 * an Alchemy-owned recipe or pipeline. Callers derive the name from their
57 * owned resource output; this helper never scans or deletes by broad prefix.
58 */
59export const deleteImageBuilderLogGroup = Effect.fn(function* (
60 logGroupName: string,
61) {
62 yield* logs.deleteLogGroup({ logGroupName }).pipe(
63 Effect.retry({
64 while: (error) =>
65 error._tag === "OperationAbortedException" ||
66 error._tag === "ServiceUnavailableException",
67 schedule: Schedule.max([
68 Schedule.fixed("500 millis"),
69 Schedule.recurs(10),
70 ]),
71 }),
72 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
73 );
74
75 for (let attempt = 0; attempt < 20; attempt++) {
76 const present = yield* logs.describeLogGroups
77 .items({ logGroupNamePrefix: logGroupName })
78 .pipe(
79 Stream.filter((group) => group.logGroupName === logGroupName),
80 Stream.runHead,
81 Effect.map(Option.isSome),
82 );
83 if (!present) return;
84 yield* Effect.sleep("500 millis");
85 }
86 return yield* Effect.die(
87 new Error(
88 `Image Builder log group ${logGroupName} remained observable 10 seconds after delete`,
89 ),
90 );
91});
92
93/**
94 * Convert an Image Builder wire tag map (values may be undefined) into a
95 * plain string record.
96 */
97export const toTagRecord = (
98 tags: { [key: string]: string | undefined } | undefined,
99): Record<string, string> =>
100 Object.fromEntries(
101 Object.entries(tags ?? {}).filter(
102 (entry): entry is [string, string] => typeof entry[1] === "string",
103 ),
104 );
105
106/**
107 * Read the observed tags of an Image Builder resource. Tag reads are
108 * best-effort — a failure (e.g. a race with deletion) reports no tags.
109 */
110export const readImageBuilderTags = Effect.fn(function* (arn: string) {
111 const response = yield* imagebuilder
112 .listTagsForResource({ resourceArn: arn })
113 .pipe(Effect.catch(() => Effect.succeed(undefined)));
114 return toTagRecord(response?.tags);
115});
116
117/**
118 * Sync tags on an Image Builder resource: diff the OBSERVED cloud tags
119 * against the desired set and apply only the delta.
120 */
121export const syncImageBuilderTags = Effect.fn(function* (
122 arn: string,
123 desiredTags: Record<string, string>,
124) {
125 const observedTags = yield* readImageBuilderTags(arn);
126 const { removed, upsert } = diffTags(observedTags, desiredTags);
127 if (upsert.length > 0) {
128 yield* imagebuilder.tagResource({
129 resourceArn: arn,
130 tags: Object.fromEntries(upsert.map((tag) => [tag.Key, tag.Value])),
131 });
132 }
133 if (removed.length > 0) {
134 yield* imagebuilder.untagResource({ resourceArn: arn, tagKeys: removed });
135 }
136});
137
138/**
139 * Construct the deterministic ARN of an Image Builder resource in the
140 * ambient account/region. Image Builder lowercases resource names in ARNs.
141 *
142 * @param resourceType e.g. `image-recipe`, `infrastructure-configuration`
143 * @param resourcePath the name (plus `/{version}` segments where relevant)
144 */
145export const imageBuilderArn = Effect.fn(function* (
146 resourceType: string,
147 resourcePath: string,
148) {
149 const { accountId, region } = yield* AWSEnvironment.current;
150 return `arn:aws:imagebuilder:${region}:${accountId}:${resourceType}/${resourcePath.toLowerCase()}`;
151});
152
153/**
154 * Structural drift check between the desired value the user specified and
155 * the observed cloud value. `undefined` desired values are "unspecified"
156 * (no drift — the service fills defaults we must not fight). Objects are
157 * compared as subsets: every key the user specified must match; extra
158 * observed keys (server defaults) are ignored. Arrays compare length +
159 * element-wise.
160 */
161export const driftedFrom = (observed: unknown, desired: unknown): boolean => {
162 if (desired === undefined) return false;
163 if (observed === undefined) return true;
164 if (Array.isArray(desired)) {
165 if (!Array.isArray(observed) || observed.length !== desired.length) {
166 return true;
167 }
168 return desired.some((item, index) => driftedFrom(observed[index], item));
169 }
170 if (typeof desired === "object" && desired !== null) {
171 if (typeof observed !== "object" || observed === null) return true;
172 return Object.entries(desired).some(([key, value]) =>
173 driftedFrom((observed as Record<string, unknown>)[key], value),
174 );
175 }
176 return observed !== desired;
177};