| 1 | import * as greengrassv2 from "@distilled.cloud/aws/greengrassv2"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts"; |
| 12 | import { AWSEnvironment } from "../Environment.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | |
| 15 | /** |
| 16 | * A configuration update applied to a component when the deployment reaches |
| 17 | * a core device. |
| 18 | */ |
| 19 | export interface DeploymentComponentConfigurationUpdate { |
| 20 | /** |
| 21 | * A JSON-serialized object that the deployment merges into the component's |
| 22 | * default configuration. |
| 23 | */ |
| 24 | merge?: string; |
| 25 | /** |
| 26 | * Configuration paths to reset to their default values. |
| 27 | */ |
| 28 | reset?: string[]; |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * A component to include in a deployment, keyed by component name on the |
| 33 | * deployment's `components` map. |
| 34 | */ |
| 35 | export interface DeploymentComponent { |
| 36 | /** |
| 37 | * The version of the component to deploy, e.g. `1.0.0`. |
| 38 | */ |
| 39 | componentVersion: string; |
| 40 | /** |
| 41 | * Configuration update the deployment applies to the component. |
| 42 | */ |
| 43 | configurationUpdate?: DeploymentComponentConfigurationUpdate; |
| 44 | } |
| 45 | |
| 46 | export interface DeploymentProps { |
| 47 | /** |
| 48 | * The ARN of the target IoT thing or thing group. Each target has at most |
| 49 | * one deployment; creating a new deployment for a target supersedes the |
| 50 | * previous revision. |
| 51 | * |
| 52 | * Changing the target replaces the deployment. |
| 53 | */ |
| 54 | targetArn: string; |
| 55 | /** |
| 56 | * A friendly name for the deployment. |
| 57 | * @default ${app}-${stage}-${id} |
| 58 | */ |
| 59 | deploymentName?: string; |
| 60 | /** |
| 61 | * The components to deploy, keyed by component name. Updating this map |
| 62 | * creates a new deployment revision for the target. |
| 63 | */ |
| 64 | components?: Record<string, DeploymentComponent>; |
| 65 | /** |
| 66 | * Tags to apply to the deployment. Merged with internal Alchemy tags. |
| 67 | */ |
| 68 | tags?: Record<string, string>; |
| 69 | } |
| 70 | |
| 71 | export interface Deployment extends Resource< |
| 72 | "AWS.GreengrassV2.Deployment", |
| 73 | DeploymentProps, |
| 74 | { |
| 75 | /** The unique ID of the deployment. */ |
| 76 | deploymentId: string; |
| 77 | /** The ARN of the deployment. */ |
| 78 | deploymentArn: string; |
| 79 | /** The ARN of the target core device or thing group. */ |
| 80 | targetArn: string; |
| 81 | /** The revision of the deployment. */ |
| 82 | revisionId?: string; |
| 83 | /** The current status of the deployment (`ACTIVE`, `COMPLETED`, ...). */ |
| 84 | deploymentStatus?: string; |
| 85 | /** The ID of the IoT job that rolls the deployment out to core devices. */ |
| 86 | iotJobId?: string; |
| 87 | /** The ARN of the IoT job that rolls the deployment out to core devices. */ |
| 88 | iotJobArn?: string; |
| 89 | }, |
| 90 | never, |
| 91 | Providers |
| 92 | > {} |
| 93 | |
| 94 | /** |
| 95 | * An IoT Greengrass V2 continuous deployment that installs a set of component |
| 96 | * versions on a target IoT thing or thing group. |
| 97 | * |
| 98 | * Updating the deployment's components or name creates a new deployment |
| 99 | * revision for the target (the `deploymentId` attribute changes); the |
| 100 | * previous revision is canceled and deleted. |
| 101 | * |
| 102 | * ### Creating Deployments |
| 103 | * **Example:** Deploy a component to a thing |
| 104 | * ```typescript |
| 105 | * import * as GreengrassV2 from "alchemy/AWS/GreengrassV2"; |
| 106 | * import * as IoT from "alchemy/AWS/IoT"; |
| 107 | * |
| 108 | * const core = yield* IoT.Thing("Core", {}); |
| 109 | * const component = yield* GreengrassV2.ComponentVersion("Hello", { recipe }); |
| 110 | * |
| 111 | * const deployment = yield* GreengrassV2.Deployment("Rollout", { |
| 112 | * targetArn: core.thingArn, |
| 113 | * components: { |
| 114 | * [component.componentName]: { |
| 115 | * componentVersion: component.componentVersion, |
| 116 | * }, |
| 117 | * }, |
| 118 | * }); |
| 119 | * ``` |
| 120 | * |
| 121 | * **Example:** Deployment with a configuration update |
| 122 | * ```typescript |
| 123 | * const deployment = yield* GreengrassV2.Deployment("Rollout", { |
| 124 | * targetArn: core.thingArn, |
| 125 | * components: { |
| 126 | * "com.example.Hello": { |
| 127 | * componentVersion: "1.0.0", |
| 128 | * configurationUpdate: { merge: JSON.stringify({ interval: 30 }) }, |
| 129 | * }, |
| 130 | * }, |
| 131 | * }); |
| 132 | * ``` |
| 133 | * |
| 134 | * @resource |
| 135 | */ |
| 136 | export const Deployment = Resource<Deployment>("AWS.GreengrassV2.Deployment"); |
| 137 | |
| 138 | /** |
| 139 | * Raised when `createDeployment` returns without a deployment ID, which the |
| 140 | * provider needs to track the deployment revision. |
| 141 | */ |
| 142 | export class GreengrassDeploymentMissingId extends Data.TaggedError( |
| 143 | "GreengrassDeploymentMissingId", |
| 144 | )<{ message: string }> {} |
| 145 | |
| 146 | // Explicitly-typed pipeable retry helper (see EC2/VolumeAttachment.ts) — |
| 147 | // deleting a deployment right after cancellation can race the state change. |
| 148 | const retryWhileConflict = <A, E extends { readonly _tag: string }, R>( |
| 149 | self: Effect.Effect<A, E, R>, |
| 150 | ): Effect.Effect<A, E, R> => |
| 151 | Effect.retry(self, { |
| 152 | while: (e) => e._tag === "ConflictException", |
| 153 | schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(10)]), |
| 154 | }); |
| 155 | |
| 156 | const normalizeTags = ( |
| 157 | tags: { [key: string]: string | undefined } | undefined, |
| 158 | ): Record<string, string> => |
| 159 | Object.fromEntries( |
| 160 | Object.entries(tags ?? {}).filter( |
| 161 | (entry): entry is [string, string] => entry[1] !== undefined, |
| 162 | ), |
| 163 | ); |
| 164 | |
| 165 | /** |
| 166 | * Canonicalizes a components map (ours or the wire's) for drift comparison: |
| 167 | * sorted keys, only defined fields. |
| 168 | */ |
| 169 | interface ComparableComponentSpec { |
| 170 | componentVersion?: string; |
| 171 | configurationUpdate?: { merge?: string; reset?: readonly string[] }; |
| 172 | } |
| 173 | |
| 174 | const canonicalComponents = ( |
| 175 | components: |
| 176 | | { [key: string]: ComparableComponentSpec | undefined } |
| 177 | | undefined, |
| 178 | ): string => |
| 179 | JSON.stringify( |
| 180 | Object.keys(components ?? {}) |
| 181 | .sort() |
| 182 | .flatMap((name) => { |
| 183 | const spec = components?.[name]; |
| 184 | return spec === undefined |
| 185 | ? [] |
| 186 | : [ |
| 187 | [ |
| 188 | name, |
| 189 | { |
| 190 | componentVersion: spec.componentVersion, |
| 191 | merge: spec.configurationUpdate?.merge, |
| 192 | reset: spec.configurationUpdate?.reset?.slice().sort(), |
| 193 | }, |
| 194 | ], |
| 195 | ]; |
| 196 | }), |
| 197 | ); |
| 198 | |
| 199 | export const DeploymentProvider = () => |
| 200 | Provider.effect( |
| 201 | Deployment, |
| 202 | Effect.gen(function* () { |
| 203 | const createDeploymentName = Effect.fn(function* ( |
| 204 | id: string, |
| 205 | props: Pick<DeploymentProps, "deploymentName">, |
| 206 | ) { |
| 207 | return props.deploymentName ?? (yield* createPhysicalName({ id })); |
| 208 | }); |
| 209 | |
| 210 | const deploymentArn = Effect.fn(function* (deploymentId: string) { |
| 211 | const { accountId, region } = yield* AWSEnvironment.current; |
| 212 | return `arn:aws:greengrass:${region}:${accountId}:deployments:${deploymentId}`; |
| 213 | }); |
| 214 | |
| 215 | const observeDeployment = (deploymentId: string) => |
| 216 | greengrassv2 |
| 217 | .getDeployment({ deploymentId }) |
| 218 | .pipe( |
| 219 | Effect.catchTag("ResourceNotFoundException", () => |
| 220 | Effect.succeed(undefined), |
| 221 | ), |
| 222 | ); |
| 223 | |
| 224 | // The latest deployment revision for a target (cloud-authoritative). |
| 225 | const observeLatestForTarget = (targetArn: string) => |
| 226 | Effect.gen(function* () { |
| 227 | const deployments = yield* greengrassv2.listDeployments |
| 228 | .items({ targetArn, historyFilter: "LATEST_ONLY" }) |
| 229 | .pipe(Stream.runCollect); |
| 230 | const latest = Array.from(deployments)[0]; |
| 231 | if (latest?.deploymentId === undefined) return undefined; |
| 232 | return yield* observeDeployment(latest.deploymentId); |
| 233 | }); |
| 234 | |
| 235 | const attributesOf = Effect.fn(function* ( |
| 236 | live: greengrassv2.GetDeploymentResponse, |
| 237 | fallbackTargetArn: string, |
| 238 | ) { |
| 239 | const deploymentId = live.deploymentId; |
| 240 | if (deploymentId === undefined) { |
| 241 | return yield* Effect.fail( |
| 242 | new GreengrassDeploymentMissingId({ |
| 243 | message: "getDeployment returned no deploymentId", |
| 244 | }), |
| 245 | ); |
| 246 | } |
| 247 | return { |
| 248 | deploymentId, |
| 249 | deploymentArn: yield* deploymentArn(deploymentId), |
| 250 | targetArn: live.targetArn ?? fallbackTargetArn, |
| 251 | revisionId: live.revisionId, |
| 252 | deploymentStatus: live.deploymentStatus, |
| 253 | iotJobId: live.iotJobId, |
| 254 | iotJobArn: live.iotJobArn, |
| 255 | }; |
| 256 | }); |
| 257 | |
| 258 | // Cancel (if still active) and delete a superseded/destroyed revision. |
| 259 | const cancelAndDelete = (deploymentId: string) => |
| 260 | Effect.gen(function* () { |
| 261 | yield* greengrassv2.cancelDeployment({ deploymentId }).pipe( |
| 262 | // Already completed/canceled/inactive revisions reject the |
| 263 | // cancellation — that is exactly the state we want. |
| 264 | Effect.catchTag( |
| 265 | [ |
| 266 | "ResourceNotFoundException", |
| 267 | "ConflictException", |
| 268 | "ValidationException", |
| 269 | ], |
| 270 | () => Effect.succeed(undefined), |
| 271 | ), |
| 272 | ); |
| 273 | yield* greengrassv2 |
| 274 | .deleteDeployment({ deploymentId }) |
| 275 | .pipe(retryWhileConflict) |
| 276 | .pipe( |
| 277 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 278 | ); |
| 279 | }); |
| 280 | |
| 281 | return Deployment.Provider.of({ |
| 282 | stables: ["targetArn"], |
| 283 | list: () => |
| 284 | Effect.gen(function* () { |
| 285 | const deployments = yield* greengrassv2.listDeployments |
| 286 | .items({ historyFilter: "LATEST_ONLY" }) |
| 287 | .pipe(Stream.runCollect); |
| 288 | const results: { |
| 289 | deploymentId: string; |
| 290 | deploymentArn: string; |
| 291 | targetArn: string; |
| 292 | revisionId?: string; |
| 293 | deploymentStatus?: string; |
| 294 | iotJobId?: string; |
| 295 | iotJobArn?: string; |
| 296 | }[] = []; |
| 297 | for (const deployment of deployments) { |
| 298 | if ( |
| 299 | deployment.deploymentId === undefined || |
| 300 | deployment.targetArn === undefined |
| 301 | ) { |
| 302 | continue; |
| 303 | } |
| 304 | results.push({ |
| 305 | deploymentId: deployment.deploymentId, |
| 306 | deploymentArn: yield* deploymentArn(deployment.deploymentId), |
| 307 | targetArn: deployment.targetArn, |
| 308 | revisionId: deployment.revisionId, |
| 309 | deploymentStatus: deployment.deploymentStatus, |
| 310 | }); |
| 311 | } |
| 312 | return results; |
| 313 | }), |
| 314 | read: Effect.fn(function* ({ id, olds, output }) { |
| 315 | const live = |
| 316 | output?.deploymentId !== undefined |
| 317 | ? yield* observeDeployment(output.deploymentId) |
| 318 | : olds !== undefined |
| 319 | ? yield* observeLatestForTarget(olds.targetArn) |
| 320 | : undefined; |
| 321 | if (live?.deploymentId === undefined) return undefined; |
| 322 | const attrs = yield* attributesOf( |
| 323 | live, |
| 324 | output?.targetArn ?? olds?.targetArn ?? "", |
| 325 | ); |
| 326 | return (yield* hasAlchemyTags(id, normalizeTags(live.tags))) |
| 327 | ? attrs |
| 328 | : Unowned(attrs); |
| 329 | }), |
| 330 | diff: Effect.fn(function* ({ news, olds }) { |
| 331 | if (!isResolved(news)) return undefined; |
| 332 | // A deployment belongs to exactly one target. |
| 333 | if (olds !== undefined && news.targetArn !== olds.targetArn) { |
| 334 | return { action: "replace" } as const; |
| 335 | } |
| 336 | // fall through: engine default update (new revision in reconcile) |
| 337 | }), |
| 338 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 339 | const deploymentName = yield* createDeploymentName(id, news); |
| 340 | const internalTags = yield* createInternalTags(id); |
| 341 | const desiredTags = { ...news.tags, ...internalTags }; |
| 342 | const desiredComponents = canonicalComponents(news.components); |
| 343 | |
| 344 | // 1. OBSERVE — the latest revision for the target is authoritative; |
| 345 | // output.deploymentId is only a cache of the revision we made. |
| 346 | let live = yield* observeLatestForTarget(news.targetArn); |
| 347 | |
| 348 | // 2. ENSURE/SYNC — a deployment revision is immutable: when the |
| 349 | // observed latest revision drifts from the desired spec (or none |
| 350 | // exists), create a new revision that supersedes it. |
| 351 | const inSync = |
| 352 | live !== undefined && |
| 353 | live.deploymentName === deploymentName && |
| 354 | canonicalComponents(live.components) === desiredComponents; |
| 355 | if (!inSync) { |
| 356 | const created = yield* greengrassv2.createDeployment({ |
| 357 | targetArn: news.targetArn, |
| 358 | deploymentName, |
| 359 | components: news.components ?? {}, |
| 360 | tags: desiredTags, |
| 361 | }); |
| 362 | if (created.deploymentId === undefined) { |
| 363 | return yield* Effect.fail( |
| 364 | new GreengrassDeploymentMissingId({ |
| 365 | message: `createDeployment for ${news.targetArn} returned no deploymentId`, |
| 366 | }), |
| 367 | ); |
| 368 | } |
| 369 | // Clean up the revision we previously created, now superseded. |
| 370 | if ( |
| 371 | output?.deploymentId !== undefined && |
| 372 | output.deploymentId !== created.deploymentId |
| 373 | ) { |
| 374 | yield* cancelAndDelete(output.deploymentId); |
| 375 | } |
| 376 | live = yield* observeDeployment(created.deploymentId); |
| 377 | if (live === undefined) { |
| 378 | return yield* Effect.fail( |
| 379 | new GreengrassDeploymentMissingId({ |
| 380 | message: `deployment ${created.deploymentId} disappeared after creation`, |
| 381 | }), |
| 382 | ); |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | // Post-ensure narrowing: `inSync` implies `live` was observed and |
| 387 | // the !inSync branch re-observed after create, but TS cannot track |
| 388 | // that through the reassignment — guard with a typed error. |
| 389 | if (live === undefined) { |
| 390 | return yield* Effect.fail( |
| 391 | new GreengrassDeploymentMissingId({ |
| 392 | message: `no deployment observed for target ${news.targetArn} after reconcile`, |
| 393 | }), |
| 394 | ); |
| 395 | } |
| 396 | |
| 397 | const attrs = yield* attributesOf(live, news.targetArn); |
| 398 | |
| 399 | // 3. SYNC TAGS — diff against OBSERVED cloud tags so adoption and |
| 400 | // no-op updates converge (create-time tags only apply on create). |
| 401 | const observedTags = normalizeTags(live.tags); |
| 402 | const { upsert, removed } = diffTags(observedTags, desiredTags); |
| 403 | if (upsert.length > 0) { |
| 404 | yield* greengrassv2.tagResource({ |
| 405 | resourceArn: attrs.deploymentArn, |
| 406 | tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])), |
| 407 | }); |
| 408 | } |
| 409 | if (removed.length > 0) { |
| 410 | yield* greengrassv2.untagResource({ |
| 411 | resourceArn: attrs.deploymentArn, |
| 412 | tagKeys: removed, |
| 413 | }); |
| 414 | } |
| 415 | |
| 416 | yield* session.note(attrs.deploymentId); |
| 417 | return attrs; |
| 418 | }), |
| 419 | delete: Effect.fn(function* ({ output }) { |
| 420 | yield* cancelAndDelete(output.deploymentId); |
| 421 | }), |
| 422 | }); |
| 423 | }), |
| 424 | ); |