1import * as sitewise from "@distilled.cloud/aws/iotsitewise";
2import * as Effect from "effect/Effect";
3import * as EffectStream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags, type Tags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 fetchSiteWiseTags,
13 matchesDesired,
14 syncSiteWiseTags,
15} from "./internal.ts";
16
17export type GatewayPlatform = sitewise.GatewayPlatform;
18
19export interface GatewayProps {
20 /**
21 * A unique name for the gateway.
22 * @default ${app}-${stage}-${id}
23 */
24 gatewayName?: string;
25 /**
26 * The gateway's platform: exactly one of `greengrassV2` (an IoT
27 * Greengrass V2 core device), `siemensIE` (a Siemens Industrial Edge
28 * device), or the legacy `greengrass` group. Changing the platform
29 * replaces the gateway.
30 */
31 gatewayPlatform: GatewayPlatform;
32 /**
33 * The version of the gateway to create (`"2.0"` or `"3.0"` for
34 * Greengrass V2 platforms). Changing the version replaces the gateway.
35 */
36 gatewayVersion?: string;
37 /**
38 * Tags to associate with the gateway.
39 */
40 tags?: Record<string, string>;
41}
42
43export interface Gateway extends Resource<
44 "AWS.IoTSiteWise.Gateway",
45 GatewayProps,
46 {
47 /**
48 * Service-assigned UUID of the gateway.
49 */
50 gatewayId: string;
51 /**
52 * ARN of the gateway.
53 */
54 gatewayArn: string;
55 /**
56 * The gateway's name.
57 */
58 gatewayName: string;
59 /**
60 * The gateway's platform, as reported by the service.
61 */
62 gatewayPlatform: GatewayPlatform | undefined;
63 /**
64 * The gateway's version, as reported by the service.
65 */
66 gatewayVersion: string | undefined;
67 /**
68 * Current tags reported for the gateway.
69 */
70 tags: Record<string, string>;
71 },
72 never,
73 Providers
74> {}
75
76/**
77 * An AWS IoT SiteWise gateway — the ingestion point that connects
78 * on-premises industrial data sources (e.g. OPC UA servers) to IoT
79 * SiteWise, hosted on an IoT Greengrass V2 core device or a Siemens
80 * Industrial Edge device.
81 *
82 * The cloud-side gateway resource registers immediately; the edge
83 * software syncs to it asynchronously once the referenced core device is
84 * online (the device does not need to exist to create the gateway).
85 *
86 * ### Creating Gateways
87 * **Example:** Greengrass V2 Gateway
88 * ```typescript
89 * import * as AWS from "alchemy/AWS";
90 *
91 * const gateway = yield* AWS.IoTSiteWise.Gateway("FactoryGateway", {
92 * gatewayPlatform: {
93 * greengrassV2: { coreDeviceThingName: "FactoryCoreDevice" },
94 * },
95 * });
96 * ```
97 *
98 * @resource
99 */
100export const Gateway = Resource<Gateway>("AWS.IoTSiteWise.Gateway");
101
102const createGatewayName = (
103 id: string,
104 props: { gatewayName?: string | undefined },
105) =>
106 props.gatewayName
107 ? Effect.succeed(props.gatewayName)
108 : createPhysicalName({ id, maxLength: 256 });
109
110interface GatewayState {
111 attrs: Gateway["Attributes"];
112 described: sitewise.DescribeGatewayResponse;
113}
114
115const readGatewayById = Effect.fn(function* (gatewayId: string) {
116 const described = yield* sitewise
117 .describeGateway({ gatewayId })
118 .pipe(
119 Effect.catchTag("ResourceNotFoundException", () =>
120 Effect.succeed(undefined),
121 ),
122 );
123 if (!described) return undefined;
124 const state: GatewayState = {
125 described,
126 attrs: {
127 gatewayId: described.gatewayId,
128 gatewayArn: described.gatewayArn,
129 gatewayName: described.gatewayName,
130 gatewayPlatform: described.gatewayPlatform,
131 gatewayVersion: described.gatewayVersion,
132 tags: yield* fetchSiteWiseTags(described.gatewayArn),
133 },
134 };
135 return state;
136});
137
138const findGatewayByName = Effect.fn(function* (name: string) {
139 const summaries = yield* sitewise.listGateways.pages({}).pipe(
140 EffectStream.runCollect,
141 Effect.map((chunk) =>
142 Array.from(chunk).flatMap((page) => page.gatewaySummaries),
143 ),
144 );
145 const match = summaries.find((summary) => summary.gatewayName === name);
146 if (!match) return undefined;
147 return yield* readGatewayById(match.gatewayId);
148});
149
150export const GatewayProvider = () =>
151 Provider.effect(
152 Gateway,
153 Effect.gen(function* () {
154 return {
155 stables: ["gatewayId", "gatewayArn"],
156 list: () =>
157 Effect.gen(function* () {
158 const summaries = yield* sitewise.listGateways.pages({}).pipe(
159 EffectStream.runCollect,
160 Effect.map((chunk) =>
161 Array.from(chunk).flatMap((page) => page.gatewaySummaries),
162 ),
163 );
164 const hydrated = yield* Effect.forEach(
165 summaries.map((summary) => summary.gatewayId),
166 (gatewayId) => readGatewayById(gatewayId),
167 { concurrency: 5 },
168 );
169 return hydrated.flatMap((state) =>
170 state === undefined ? [] : [state.attrs],
171 );
172 }),
173 read: Effect.fn(function* ({ id, olds, output }) {
174 const state = output?.gatewayId
175 ? yield* readGatewayById(output.gatewayId)
176 : yield* findGatewayByName(
177 yield* createGatewayName(id, olds ?? {}),
178 );
179 if (!state) return undefined;
180 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
181 ? state.attrs
182 : Unowned(state.attrs);
183 }),
184 diff: Effect.fn(function* ({ news, olds }) {
185 if (!isResolved(news)) return;
186 if (olds === undefined) return;
187 // Platform and version are fixed at creation; only the name is
188 // mutable via UpdateGateway.
189 if (
190 !matchesDesired(news.gatewayPlatform, olds.gatewayPlatform) ||
191 !matchesDesired(olds.gatewayPlatform, news.gatewayPlatform) ||
192 (news.gatewayVersion !== undefined &&
193 news.gatewayVersion !== olds.gatewayVersion)
194 ) {
195 return { action: "replace" } as const;
196 }
197 }),
198 reconcile: Effect.fn(function* ({ id, news, output, session }) {
199 if (!news) {
200 return yield* Effect.fail(
201 new Error("IoT SiteWise Gateway requires props"),
202 );
203 }
204 const name = yield* createGatewayName(id, news);
205 const internalTags = yield* createInternalTags(id);
206 const desiredTags = { ...internalTags, ...news.tags };
207
208 // Observe — prefer the cached id; fall back to a name lookup.
209 let state = output?.gatewayId
210 ? yield* readGatewayById(output.gatewayId)
211 : yield* findGatewayByName(name);
212
213 // Ensure — create if missing (gateway creation is synchronous).
214 if (state === undefined) {
215 const created = yield* sitewise
216 .createGateway({
217 gatewayName: name,
218 gatewayPlatform: news.gatewayPlatform,
219 gatewayVersion: news.gatewayVersion,
220 tags: desiredTags,
221 })
222 .pipe(
223 // A concurrent create of the same name is a race — adopt it.
224 Effect.catchTag("ResourceAlreadyExistsException", () =>
225 Effect.gen(function* () {
226 const existing = yield* findGatewayByName(name);
227 if (existing === undefined) {
228 return yield* Effect.fail(
229 new Error(
230 `gateway '${name}' already exists but was not found by name`,
231 ),
232 );
233 }
234 return {
235 gatewayId: existing.attrs.gatewayId,
236 gatewayArn: existing.attrs.gatewayArn,
237 };
238 }),
239 ),
240 );
241 yield* session.note(
242 `Created gateway ${name} (${created.gatewayId})`,
243 );
244 state = yield* readGatewayById(created.gatewayId);
245 if (state === undefined) {
246 return yield* Effect.fail(
247 new Error(`failed to read created gateway ${name}`),
248 );
249 }
250 }
251
252 // Sync — only the name is mutable.
253 if (name !== state.described.gatewayName) {
254 yield* sitewise.updateGateway({
255 gatewayId: state.attrs.gatewayId,
256 gatewayName: name,
257 });
258 yield* session.note(`Renamed gateway to ${name}`);
259 }
260
261 // Sync tags — diff against observed cloud tags.
262 yield* syncSiteWiseTags(
263 state.attrs.gatewayArn,
264 state.attrs.tags,
265 desiredTags,
266 );
267
268 yield* session.note(state.attrs.gatewayArn);
269
270 const final = yield* readGatewayById(state.attrs.gatewayId);
271 if (!final) {
272 return yield* Effect.fail(
273 new Error(`failed to read reconciled gateway ${name}`),
274 );
275 }
276 return final.attrs;
277 }),
278 delete: Effect.fn(function* ({ output }) {
279 yield* sitewise
280 .deleteGateway({ gatewayId: output.gatewayId })
281 .pipe(
282 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
283 );
284 }),
285 };
286 }),
287 );