| 1 | import * as logs from "@distilled.cloud/aws/cloudwatch-logs"; |
| 2 | import * as imagebuilder from "@distilled.cloud/aws/imagebuilder"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 8 | import type { Input } from "../../Input.ts"; |
| 9 | import * as Output from "../../Output.ts"; |
| 10 | import { diffTags } from "../../Tags.ts"; |
| 11 | import { 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 | */ |
| 23 | export 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 | */ |
| 46 | export 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 | */ |
| 59 | export 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 | */ |
| 97 | export 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 | */ |
| 110 | export 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 | */ |
| 121 | export 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 | */ |
| 145 | export 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 | */ |
| 161 | export 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 | }; |