1import * as cloudcontrol from "@distilled.cloud/aws/cloudcontrol";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource as makeResource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface CloudControlResourceProps {
12 /**
13 * The CloudFormation resource type name, e.g. `"AWS::SNS::Topic"` or
14 * `"AWS::SSM::Parameter"`. Changing the type replaces the resource. See the
15 * Cloud Control API's supported resource types in the AWS documentation.
16 */
17 typeName: string;
18 /**
19 * A specific version of the resource type to use. Defaults to the account's
20 * default version.
21 */
22 typeVersionId?: string;
23 /**
24 * The desired state of the resource as a plain object matching the CFN
25 * resource type's property schema (e.g. `{ Name, Type, Value }` for
26 * `AWS::SSM::Parameter`). Only the keys you specify are managed — on update,
27 * a JSON Patch is computed over exactly these keys, so create-only
28 * properties you never change are left untouched.
29 */
30 desiredState: Record<string, unknown>;
31 /**
32 * ARN of an IAM role Cloud Control assumes to perform the operation.
33 * Defaults to the deploying principal's credentials.
34 */
35 roleArn?: string;
36}
37
38export interface CloudControlResource extends makeResource<
39 "AWS.CloudControl.Resource",
40 CloudControlResourceProps,
41 {
42 /** The CloudFormation resource type name (e.g. `AWS::SSM::Parameter`). */
43 typeName: string;
44 /** The resource's primary identifier assigned by Cloud Control. */
45 identifier: string;
46 /** The observed properties of the resource as a plain object. */
47 properties: Record<string, unknown>;
48 },
49 never,
50 Providers
51> {}
52
53/**
54 * A generic AWS resource managed through the Cloud Control API — the escape
55 * hatch that covers hundreds of CloudFormation resource types with a single
56 * Alchemy resource.
57 *
58 * Provide a CloudFormation `typeName` and a `desiredState` object; the provider
59 * drives Cloud Control's asynchronous create/update/delete and polls the
60 * request token (bounded) until it reaches `SUCCESS`, surfacing a `FAILED`
61 * operation as a typed error rather than hanging. Updates are expressed as an
62 * RFC 6902 JSON Patch computed over the keys you specify.
63 * ### Managing a Resource
64 * **Example:** SSM Parameter
65 * ```typescript
66 * const param = yield* CloudControl.Resource("Greeting", {
67 * typeName: "AWS::SSM::Parameter",
68 * desiredState: {
69 * Name: "/app/greeting",
70 * Type: "String",
71 * Value: "hello",
72 * },
73 * });
74 * // param.identifier -> "/app/greeting"
75 * // param.properties.Value -> "hello"
76 * ```
77 *
78 * **Example:** SNS Topic
79 * ```typescript
80 * const topic = yield* CloudControl.Resource("Alerts", {
81 * typeName: "AWS::SNS::Topic",
82 * desiredState: { TopicName: "alerts", DisplayName: "Alerts" },
83 * });
84 * // topic.identifier -> "arn:aws:sns:us-west-2:...:alerts"
85 * ```
86 *
87 * @resource
88 */
89export const Resource = makeResource<CloudControlResource>(
90 "AWS.CloudControl.Resource",
91);
92
93class ResourceRequestNotSettled extends Data.TaggedError(
94 "ResourceRequestNotSettled",
95)<{
96 readonly requestToken: string;
97 readonly status: string;
98}> {}
99
100class ResourceRequestFailed extends Data.TaggedError("ResourceRequestFailed")<{
101 readonly typeName: string;
102 readonly operation: string;
103 readonly errorCode: string | undefined;
104 readonly statusMessage: string | undefined;
105}> {}
106
107/** Unwrap distilled's sensitive-string decoding to a plain string. */
108const plain = (
109 value: string | Redacted.Redacted<string> | undefined,
110): string | undefined =>
111 value === undefined || typeof value === "string"
112 ? value
113 : Redacted.value(value);
114
115const parseProperties = (
116 properties: string | Redacted.Redacted<string> | undefined,
117): Record<string, unknown> => {
118 const raw = plain(properties);
119 if (raw === undefined) return {};
120 try {
121 const parsed = JSON.parse(raw);
122 return parsed !== null && typeof parsed === "object" ? parsed : {};
123 } catch {
124 return {};
125 }
126};
127
128/** RFC 6901 escape for a JSON Pointer path segment. */
129const escapePointer = (key: string): string =>
130 key.replace(/~/g, "~0").replace(/\//g, "~1");
131
132/**
133 * Build an RFC 6902 JSON Patch that converges the observed properties to the
134 * user-specified desired state. Only keys present in `desired` are touched —
135 * create-only properties the user never changes are left alone (patching them
136 * would trigger `NotUpdatableException`).
137 */
138const buildPatch = (
139 observed: Record<string, unknown>,
140 desired: Record<string, unknown>,
141): Array<{ op: "add" | "replace"; path: string; value: unknown }> =>
142 Object.entries(desired).flatMap(([key, value]) => {
143 const hasKey = Object.prototype.hasOwnProperty.call(observed, key);
144 if (hasKey && JSON.stringify(observed[key]) === JSON.stringify(value)) {
145 return [];
146 }
147 return [
148 {
149 op: hasKey ? ("replace" as const) : ("add" as const),
150 path: `/${escapePointer(key)}`,
151 value,
152 },
153 ];
154 });
155
156export const CloudControlResourceProvider = () =>
157 Provider.effect(
158 Resource,
159 Effect.gen(function* () {
160 /**
161 * Poll a Cloud Control request token until the operation settles.
162 * Operations are typically fast (seconds); budget ~5 min (60 * 5s).
163 * Returns the terminal ProgressEvent, failing on FAILED.
164 */
165 const waitForRequest = Effect.fn(function* (
166 requestToken: string,
167 typeName: string,
168 ) {
169 const event = yield* cloudcontrol
170 .getResourceRequestStatus({ RequestToken: requestToken })
171 .pipe(
172 Effect.map((r) => r.ProgressEvent),
173 Effect.flatMap((e) => {
174 const status = e?.OperationStatus;
175 if (
176 status === "PENDING" ||
177 status === "IN_PROGRESS" ||
178 status === "CANCEL_IN_PROGRESS"
179 ) {
180 return Effect.fail(
181 new ResourceRequestNotSettled({
182 requestToken,
183 status: status ?? "UNKNOWN",
184 }),
185 );
186 }
187 return Effect.succeed(e);
188 }),
189 Effect.retry({
190 while: (e) => e._tag === "ResourceRequestNotSettled",
191 schedule: Schedule.max([
192 Schedule.fixed("5 seconds"),
193 Schedule.recurs(60),
194 ]),
195 }),
196 );
197 if (event?.OperationStatus !== "SUCCESS") {
198 return yield* Effect.fail(
199 new ResourceRequestFailed({
200 typeName,
201 operation: event?.Operation ?? "UNKNOWN",
202 errorCode: event?.ErrorCode,
203 statusMessage: event?.StatusMessage,
204 }),
205 );
206 }
207 return event;
208 });
209
210 /** Read live resource properties; a missing resource reads as absent. */
211 const getResource = Effect.fn(function* (
212 typeName: string,
213 identifier: string,
214 ) {
215 return yield* cloudcontrol
216 .getResource({ TypeName: typeName, Identifier: identifier })
217 .pipe(
218 Effect.map((r) => r.ResourceDescription),
219 Effect.catchTag("ResourceNotFoundException", () =>
220 Effect.succeed(undefined),
221 ),
222 );
223 });
224
225 return {
226 stables: ["typeName", "identifier"],
227
228 diff: Effect.fn(function* ({ olds, news }) {
229 if (!isResolved(news)) return undefined;
230 if ((olds?.typeName ?? undefined) !== (news?.typeName ?? undefined)) {
231 return { action: "replace" } as const;
232 }
233 }),
234
235 read: Effect.fn(function* ({ output }) {
236 if (output?.identifier === undefined) return undefined;
237 const description = yield* getResource(
238 output.typeName,
239 output.identifier,
240 );
241 if (description?.Identifier === undefined) return undefined;
242 return {
243 typeName: output.typeName,
244 identifier: description.Identifier,
245 properties: parseProperties(description.Properties),
246 };
247 }),
248
249 reconcile: Effect.fn(function* ({ news, output, session }) {
250 const typeName = news.typeName;
251
252 // 1. Observe — cloud state is authoritative; output is only an id
253 // cache.
254 let identifier = output?.identifier;
255 let description =
256 identifier !== undefined
257 ? yield* getResource(typeName, identifier)
258 : undefined;
259
260 // 2. Ensure — create if missing, then poll to SUCCESS.
261 if (description === undefined) {
262 const created = yield* cloudcontrol.createResource({
263 TypeName: typeName,
264 TypeVersionId: news.typeVersionId,
265 RoleArn: news.roleArn,
266 DesiredState: JSON.stringify(news.desiredState),
267 });
268 const settled = yield* waitForRequest(
269 created.ProgressEvent!.RequestToken!,
270 typeName,
271 );
272 identifier = settled.Identifier!;
273 description = yield* getResource(typeName, identifier);
274 } else {
275 // 3. Sync — patch only the drifted user-specified keys.
276 identifier = description.Identifier!;
277 const patch = buildPatch(
278 parseProperties(description.Properties),
279 news.desiredState,
280 );
281 if (patch.length > 0) {
282 const updated = yield* cloudcontrol.updateResource({
283 TypeName: typeName,
284 TypeVersionId: news.typeVersionId,
285 RoleArn: news.roleArn,
286 Identifier: identifier,
287 PatchDocument: JSON.stringify(patch),
288 });
289 yield* waitForRequest(
290 updated.ProgressEvent!.RequestToken!,
291 typeName,
292 );
293 description = yield* getResource(typeName, identifier);
294 }
295 }
296
297 // 4. Return fresh attributes.
298 yield* session.note(identifier);
299 return {
300 typeName,
301 identifier,
302 properties: parseProperties(description?.Properties),
303 };
304 }),
305
306 delete: Effect.fn(function* ({ output }) {
307 const deleted = yield* cloudcontrol
308 .deleteResource({
309 TypeName: output.typeName,
310 Identifier: output.identifier,
311 })
312 .pipe(
313 Effect.map((r) => r.ProgressEvent),
314 Effect.catchTag("ResourceNotFoundException", () =>
315 Effect.succeed(undefined),
316 ),
317 );
318 if (deleted?.RequestToken !== undefined) {
319 yield* waitForRequest(deleted.RequestToken, output.typeName).pipe(
320 // A resource already gone by the time deletion settles is not an
321 // error.
322 Effect.catchTag("ResourceRequestFailed", (e) =>
323 e.errorCode === "NotFound" ? Effect.void : Effect.fail(e),
324 ),
325 );
326 }
327 }),
328
329 // Cloud Control's ListResources requires a TypeName; there is no
330 // global enumeration across every managed type, so the generic
331 // escape-hatch resource is not listable.
332 list: () => Effect.succeed([]),
333 };
334 }),
335 );