1import * as greengrassv2 from "@distilled.cloud/aws/greengrassv2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import { AWSEnvironment } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14
15/**
16 * A configuration update applied to a component when the deployment reaches
17 * a core device.
18 */
19export 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 */
35export 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
46export 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
71export 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 */
136export 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 */
142export 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.
148const 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
156const 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 */
169interface ComparableComponentSpec {
170 componentVersion?: string;
171 configurationUpdate?: { merge?: string; reset?: readonly string[] };
172}
173
174const 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
199export 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 );