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 * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface ComponentVersionProps {
15 /**
16 * The inline component recipe, as a JSON or YAML string. The recipe defines
17 * the component's name (`ComponentName`), version (`ComponentVersion`),
18 * lifecycle, and platform capability.
19 *
20 * Component versions are immutable — changing the recipe replaces the
21 * component version.
22 */
23 recipe: string;
24 /**
25 * Tags to apply to the component version. Merged with internal Alchemy tags.
26 */
27 tags?: Record<string, string>;
28}
29
30export interface ComponentVersion extends Resource<
31 "AWS.GreengrassV2.ComponentVersion",
32 ComponentVersionProps,
33 {
34 /** The ARN of this component version. */
35 arn: string;
36 /** The name of the component. */
37 componentName: string;
38 /** The semantic version of the component. */
39 componentVersion: string;
40 },
41 never,
42 Providers
43> {}
44
45/**
46 * An IoT Greengrass V2 component version created from an inline recipe.
47 * Components are software modules that run on Greengrass core devices.
48 *
49 * Component versions are immutable in the cloud: any change to the recipe
50 * replaces the component version (a new name/version pair is registered and
51 * the previous one is deleted). Only tags are mutable in place.
52 *
53 * ### Creating Component Versions
54 * **Example:** Component from an inline JSON recipe
55 * ```typescript
56 * import * as GreengrassV2 from "alchemy/AWS/GreengrassV2";
57 *
58 * const component = yield* GreengrassV2.ComponentVersion("Hello", {
59 * recipe: JSON.stringify({
60 * RecipeFormatVersion: "2020-01-25",
61 * ComponentName: "com.example.Hello",
62 * ComponentVersion: "1.0.0",
63 * ComponentDescription: "Prints a greeting",
64 * ComponentPublisher: "Example",
65 * Manifests: [
66 * {
67 * Platform: { os: "linux" },
68 * Lifecycle: { run: "echo hello" },
69 * },
70 * ],
71 * }),
72 * });
73 * ```
74 *
75 * **Example:** Tagged component version
76 * ```typescript
77 * const component = yield* GreengrassV2.ComponentVersion("Hello", {
78 * recipe,
79 * tags: { team: "edge" },
80 * });
81 * ```
82 *
83 * @resource
84 */
85export const ComponentVersion = Resource<ComponentVersion>(
86 "AWS.GreengrassV2.ComponentVersion",
87);
88
89/**
90 * Raised when the inline recipe does not declare a `ComponentName` and
91 * `ComponentVersion` that Alchemy can derive the component identity from.
92 */
93export class GreengrassInvalidRecipe extends Data.TaggedError(
94 "GreengrassInvalidRecipe",
95)<{ message: string }> {}
96
97/**
98 * Raised when the cloud reports the component version entered the `FAILED`
99 * or `DEPRECATED` state instead of becoming `DEPLOYABLE`.
100 */
101export class GreengrassComponentFailed extends Data.TaggedError(
102 "GreengrassComponentFailed",
103)<{ message: string }> {}
104
105/**
106 * Internal signal used to poll a freshly created component version until the
107 * cloud marks it `DEPLOYABLE`.
108 */
109export class GreengrassComponentNotReady extends Data.TaggedError(
110 "GreengrassComponentNotReady",
111)<{ message: string }> {}
112
113// Explicitly-typed pipeable retry helper — inlining `Effect.retry` in a
114// provider lifecycle op leaks `Retry.Return`'s conditional into declaration
115// emit and widens the provider layer to `unknown` R for every consumer of
116// `AWS.providers()`.
117const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
118 self: Effect.Effect<A, E, R>,
119): Effect.Effect<A, E, R> =>
120 Effect.retry(self, {
121 while: (e) => e._tag === "GreengrassComponentNotReady",
122 schedule: Schedule.max([Schedule.fixed(2000), Schedule.recurs(15)]),
123 });
124
125/**
126 * Derives `{ componentName, componentVersion }` from an inline recipe string.
127 * JSON recipes are parsed; YAML recipes are scanned for the top-level
128 * `ComponentName:` / `ComponentVersion:` keys.
129 */
130const parseRecipeIdentity = Effect.fn(function* (recipe: string) {
131 const fromJson = yield* Effect.sync(() => {
132 try {
133 const parsed = JSON.parse(recipe) as Record<string, unknown>;
134 const name = parsed.ComponentName;
135 const version = parsed.ComponentVersion;
136 return typeof name === "string" && typeof version === "string"
137 ? { componentName: name, componentVersion: version }
138 : undefined;
139 } catch {
140 return undefined;
141 }
142 });
143 if (fromJson !== undefined) return fromJson;
144 const yamlValue = (key: string) => {
145 const match = recipe.match(
146 new RegExp(`^${key}:[ \\t]*['"]?([^'"#\\s]+)`, "m"),
147 );
148 return match?.[1];
149 };
150 const componentName = yamlValue("ComponentName");
151 const componentVersion = yamlValue("ComponentVersion");
152 if (componentName === undefined || componentVersion === undefined) {
153 return yield* Effect.fail(
154 new GreengrassInvalidRecipe({
155 message:
156 "the inline recipe must declare top-level ComponentName and ComponentVersion (JSON or YAML)",
157 }),
158 );
159 }
160 return { componentName, componentVersion };
161});
162
163const normalizeTags = (
164 tags: { [key: string]: string | undefined } | undefined,
165): Record<string, string> =>
166 Object.fromEntries(
167 Object.entries(tags ?? {}).filter(
168 (entry): entry is [string, string] => entry[1] !== undefined,
169 ),
170 );
171
172export const ComponentVersionProvider = () =>
173 Provider.effect(
174 ComponentVersion,
175 Effect.gen(function* () {
176 // The component version ARN is deterministic from the recipe identity,
177 // so read/reconcile never depend on persisted output to find it.
178 const componentArn = Effect.fn(function* (recipe: string) {
179 const { accountId, region } = yield* AWSEnvironment.current;
180 const { componentName, componentVersion } =
181 yield* parseRecipeIdentity(recipe);
182 return {
183 componentName,
184 componentVersion,
185 arn: `arn:aws:greengrass:${region}:${accountId}:components:${componentName}:versions:${componentVersion}`,
186 };
187 });
188
189 const observeComponent = (arn: string) =>
190 greengrassv2
191 .describeComponent({ arn })
192 .pipe(
193 Effect.catchTag("ResourceNotFoundException", () =>
194 Effect.succeed(undefined),
195 ),
196 );
197
198 return ComponentVersion.Provider.of({
199 stables: ["arn", "componentName", "componentVersion"],
200 list: () =>
201 Effect.gen(function* () {
202 const components = yield* greengrassv2.listComponents
203 .items({ scope: "PRIVATE" })
204 .pipe(Stream.runCollect);
205 const results: {
206 arn: string;
207 componentName: string;
208 componentVersion: string;
209 }[] = [];
210 for (const component of components) {
211 if (component.arn === undefined) continue;
212 const versions = yield* greengrassv2.listComponentVersions
213 .items({ arn: component.arn })
214 .pipe(
215 Stream.runCollect,
216 Effect.catchTag("ResourceNotFoundException", () =>
217 Effect.succeed([] as const),
218 ),
219 );
220 for (const version of versions) {
221 if (
222 version.arn !== undefined &&
223 version.componentName !== undefined &&
224 version.componentVersion !== undefined
225 ) {
226 results.push({
227 arn: version.arn,
228 componentName: version.componentName,
229 componentVersion: version.componentVersion,
230 });
231 }
232 }
233 }
234 return results;
235 }),
236 read: Effect.fn(function* ({ id, olds, output }) {
237 const arn =
238 output?.arn ??
239 (olds !== undefined
240 ? (yield* componentArn(olds.recipe)).arn
241 : undefined);
242 if (arn === undefined) return undefined;
243 const found = yield* observeComponent(arn);
244 if (
245 found?.arn === undefined ||
246 found.componentName === undefined ||
247 found.componentVersion === undefined
248 ) {
249 return undefined;
250 }
251 const attrs = {
252 arn: found.arn,
253 componentName: found.componentName,
254 componentVersion: found.componentVersion,
255 };
256 return (yield* hasAlchemyTags(id, normalizeTags(found.tags)))
257 ? attrs
258 : Unowned(attrs);
259 }),
260 diff: Effect.fn(function* ({ news, olds }) {
261 if (!isResolved(news)) return undefined;
262 // Component versions are immutable — any recipe change replaces.
263 if (olds !== undefined && news.recipe !== olds.recipe) {
264 return { action: "replace" } as const;
265 }
266 // fall through: engine default update (tags sync in reconcile)
267 }),
268 reconcile: Effect.fn(function* ({ id, news, session }) {
269 const identity = yield* componentArn(news.recipe);
270 const internalTags = yield* createInternalTags(id);
271 const desiredTags = { ...news.tags, ...internalTags };
272
273 // 1. OBSERVE — cloud state is authoritative.
274 let live = yield* observeComponent(identity.arn);
275
276 // 2. ENSURE — create when missing. A concurrent create surfaces as
277 // the typed ConflictException / RequestAlreadyInProgressException
278 // tags, which we treat as races and re-observe.
279 if (live === undefined) {
280 yield* greengrassv2
281 .createComponentVersion({
282 inlineRecipe: new TextEncoder().encode(news.recipe),
283 tags: desiredTags,
284 })
285 .pipe(
286 Effect.catchTag(
287 ["ConflictException", "RequestAlreadyInProgressException"],
288 () => Effect.succeed(undefined),
289 ),
290 );
291 // Wait until the cloud marks the version DEPLOYABLE (bounded ~30s;
292 // inline-recipe components typically settle within seconds).
293 live = yield* Effect.gen(function* () {
294 const observed = yield* observeComponent(identity.arn);
295 const state = observed?.status?.componentState;
296 if (state === "FAILED" || state === "DEPRECATED") {
297 return yield* Effect.fail(
298 new GreengrassComponentFailed({
299 message: `component ${identity.componentName}@${identity.componentVersion} entered state ${state}: ${
300 observed?.status?.message ?? "no message"
301 }`,
302 }),
303 );
304 }
305 if (observed === undefined || state !== "DEPLOYABLE") {
306 return yield* Effect.fail(
307 new GreengrassComponentNotReady({
308 message: `component ${identity.componentName}@${identity.componentVersion} is ${state ?? "missing"}`,
309 }),
310 );
311 }
312 return observed;
313 }).pipe(retryWhileNotReady);
314 }
315
316 // 3. SYNC TAGS — diff against OBSERVED cloud tags so adoption
317 // converges (create-time tags only apply on first create).
318 const observedTags = normalizeTags(live.tags);
319 const { upsert, removed } = diffTags(observedTags, desiredTags);
320 if (upsert.length > 0) {
321 yield* greengrassv2.tagResource({
322 resourceArn: identity.arn,
323 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
324 });
325 }
326 if (removed.length > 0) {
327 yield* greengrassv2.untagResource({
328 resourceArn: identity.arn,
329 tagKeys: removed,
330 });
331 }
332
333 yield* session.note(
334 `${identity.componentName}@${identity.componentVersion}`,
335 );
336 return {
337 arn: identity.arn,
338 componentName: identity.componentName,
339 componentVersion: identity.componentVersion,
340 };
341 }),
342 delete: Effect.fn(function* ({ output }) {
343 yield* greengrassv2
344 .deleteComponent({ arn: output.arn })
345 .pipe(
346 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
347 );
348 }),
349 });
350 }),
351 );