1import * as AWS from "@/AWS";
2import { ComponentVersion, Deployment } from "@/AWS/GreengrassV2";
3import { Thing } from "@/AWS/IoT";
4import * as Test from "@/Test/Alchemy";
5import * as greengrassv2 from "@distilled.cloud/aws/greengrassv2";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Ungated typed-error probe: prove the distilled error union carries the
13// not-found tag the delete path depends on (deploymentId is a UUID, so a
14// random-looking constant is safely nonexistent).
15test.provider(
16 "getDeployment on a nonexistent id fails with ResourceNotFoundException",
17 () =>
18 Effect.gen(function* () {
19 const error = yield* Effect.flip(
20 greengrassv2.getDeployment({
21 deploymentId: "00000000-dead-beef-0000-000000000000",
22 }),
23 );
24 expect(error._tag).toBe("ResourceNotFoundException");
25 }),
26 { tags: ["provider:aws", "provider:aws:greengrassv2", "live"] },
27);
28
29const COMPONENT_NAME = "com.alchemy.test.GgDeploy";
30
31const recipe = JSON.stringify({
32 RecipeFormatVersion: "2020-01-25",
33 ComponentName: COMPONENT_NAME,
34 ComponentVersion: "1.0.0",
35 ComponentDescription: "Alchemy GreengrassV2 deployment test component",
36 ComponentPublisher: "Alchemy",
37 Manifests: [
38 {
39 Platform: { os: "linux" },
40 Lifecycle: { run: "echo deployed by alchemy" },
41 },
42 ],
43});
44
45const deploymentExists = (deploymentId: string) =>
46 greengrassv2.getDeployment({ deploymentId }).pipe(
47 Effect.map(() => true),
48 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(false)),
49 );
50
51const waitUntilDeploymentGone = (deploymentId: string) =>
52 deploymentExists(deploymentId).pipe(
53 Effect.repeat({
54 schedule: Schedule.spaced("2 seconds"),
55 until: (exists) => exists === false,
56 times: 10,
57 }),
58 Effect.map((exists) => expect(exists).toBe(false)),
59 );
60
61// A deployment targets an IoT thing (no live core device required — the
62// deployment revision is created in the cloud and stays ACTIVE).
63test.provider(
64 "create deployment for a thing, revise components, destroy",
65 (stack) =>
66 Effect.gen(function* () {
67 yield* stack.destroy();
68
69 const program = (merge?: string) =>
70 Effect.gen(function* () {
71 const core = yield* Thing("GgCore", {});
72 const component = yield* ComponentVersion("DeployHello", {
73 recipe,
74 });
75 const deployment = yield* Deployment("Rollout", {
76 targetArn: core.thingArn,
77 components: {
78 [COMPONENT_NAME]: {
79 // Output-valued reference makes the deployment depend on the
80 // component version being registered first.
81 componentVersion: component.componentVersion,
82 ...(merge === undefined
83 ? {}
84 : { configurationUpdate: { merge } }),
85 },
86 },
87 tags: { fixture: "greengrass-deployment" },
88 });
89 return { core, component, deployment };
90 });
91
92 // 1. CREATE.
93 const { core, deployment } = yield* stack.deploy(program());
94 expect(deployment.deploymentId).toBeDefined();
95 expect(deployment.targetArn).toBe(core.thingArn);
96 expect(deployment.deploymentArn).toContain(
97 `:deployments:${deployment.deploymentId}`,
98 );
99
100 // Out-of-band verification via distilled.
101 const observed = yield* greengrassv2.getDeployment({
102 deploymentId: deployment.deploymentId,
103 });
104 expect(observed.targetArn).toBe(core.thingArn);
105 expect(observed.isLatestForTarget).toBe(true);
106 expect(observed.components?.[COMPONENT_NAME]?.componentVersion).toBe(
107 "1.0.0",
108 );
109 expect(observed.tags?.["alchemy::id"]).toBe("Rollout");
110
111 // 2. UPDATE — changing the component spec creates a NEW deployment
112 // revision (fresh deploymentId) and deletes the superseded one.
113 const { deployment: revised } = yield* stack.deploy(
114 program(JSON.stringify({ interval: 30 })),
115 );
116 expect(revised.deploymentId).not.toBe(deployment.deploymentId);
117 const revisedObserved = yield* greengrassv2.getDeployment({
118 deploymentId: revised.deploymentId,
119 });
120 expect(
121 revisedObserved.components?.[COMPONENT_NAME]?.configurationUpdate
122 ?.merge,
123 ).toBe(JSON.stringify({ interval: 30 }));
124 yield* waitUntilDeploymentGone(deployment.deploymentId);
125
126 // 3. NO-OP — re-deploying the same spec keeps the same revision.
127 const { deployment: stable } = yield* stack.deploy(
128 program(JSON.stringify({ interval: 30 })),
129 );
130 expect(stable.deploymentId).toBe(revised.deploymentId);
131
132 // 4. DESTROY — the deployment is canceled and deleted.
133 yield* stack.destroy();
134 yield* waitUntilDeploymentGone(revised.deploymentId);
135 }),
136 {
137 tags: [
138 "provider:aws",
139 "provider:aws:greengrassv2",
140 "provider:aws:iot",
141 "live",
142 ],
143 timeout: 300_000,
144 },
145);