1import * as deploymentmanager from "@distilled.cloud/gcp/deploymentmanager_v2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 createInternalLabels,
11 diffLabels,
12 hasAlchemyLabels,
13 toLabels,
14} from "../Labels.ts";
15import type { Providers } from "../Providers.ts";
16import {
17 fingerprint,
18 getDeployment,
19 getManifest,
20 labelsToRecord,
21 listOwnedDeployments,
22 recordToLabels,
23 ResourceNotResolved,
24 sameText,
25 settleDeployment,
26 toPhysicalId,
27 userLabels,
28 waitForOperation,
29 waitUntilExists,
30 waitUntilGone,
31} from "./internal.ts";
32
33export type ImportFile = {
34 /**
35 * Import name, as referenced from `target.config` (`path` / `type`).
36 */
37 name?: string;
38 /**
39 * Full contents of the imported template or file.
40 */
41 content?: string;
42};
43
44export type ConfigFile = {
45 /**
46 * YAML configuration that lists the resources in this deployment.
47 */
48 content: string;
49};
50
51export type TargetConfiguration = {
52 /**
53 * Root configuration file for the deployment.
54 */
55 config: ConfigFile;
56 /**
57 * Templates or other files imported by `config`.
58 */
59 imports?: ImportFile[];
60};
61
62export type DeploymentProps = {
63 /**
64 * Deployment name. If omitted, a unique RFC1035 name is generated
65 * from the stack, stage, and logical id. Immutable — changing it
66 * replaces the deployment.
67 */
68 deploymentId?: string;
69 /**
70 * User-provided description of the deployment.
71 */
72 description?: string;
73 /**
74 * User labels. Alchemy ownership labels are merged in automatically.
75 * Keys and values must match `[a-z]([-a-z0-9]*[a-z0-9])?`.
76 */
77 labels?: Record<string, string>;
78 /**
79 * Configuration and imports that describe the resources to deploy.
80 */
81 target: TargetConfiguration;
82 /**
83 * Policy for creating resources in the configuration
84 * (`CREATE_OR_ACQUIRE` or `ACQUIRE`).
85 * @default "CREATE_OR_ACQUIRE"
86 */
87 createPolicy?:
88 | deploymentmanager.InsertDeploymentsCreatePolicyEnum
89 | (string & {});
90 /**
91 * Policy for deleting resources when the deployment is destroyed
92 * (`DELETE` or `ABANDON`).
93 * @default "DELETE"
94 */
95 deletePolicy?:
96 | deploymentmanager.DeleteDeploymentsDeletePolicyEnum
97 | (string & {});
98 /**
99 * When true, create or update shell resources without instantiating
100 * them. A later reconcile with `preview: false` deploys the preview.
101 * @default false
102 */
103 preview?: boolean;
104};
105
106export type Deployment = Resource<
107 "GCP.DeploymentManager.Deployment",
108 DeploymentProps,
109 {
110 /** Deployment name (RFC1035). */
111 name: string;
112 /** Deployment id; same as `name`. */
113 deploymentId: string;
114 /** Project id. */
115 project: string;
116 /** User description. */
117 description: string | undefined;
118 /** User labels (Alchemy ownership labels stripped). */
119 labels: Record<string, string>;
120 /** Optimistic-locking fingerprint for update, stop, and cancelPreview. */
121 fingerprint: string | undefined;
122 /** URL of the last successfully deployed manifest. */
123 manifest: string | undefined;
124 /** Server-defined URL for the deployment. */
125 selfLink: string | undefined;
126 /** RFC3339 creation timestamp. */
127 insertTime: string | undefined;
128 /** RFC3339 last-update timestamp. */
129 updateTime: string | undefined;
130 /** Server-assigned numeric id. */
131 id: string | undefined;
132 /**
133 * True when Deployment Manager is previewing an update (`update` is
134 * present on the live resource).
135 */
136 preview: boolean;
137 /** Delete policy applied when destroying this deployment. */
138 deletePolicy: string;
139 },
140 never,
141 Providers
142>;
143
144/**
145 * A Google Cloud Deployment Manager deployment — a configuration that
146 * creates and manages a set of Google Cloud resources together.
147 *
148 * Changing `deploymentId` replaces the deployment. Description, labels,
149 * target configuration, and `preview` update in place. Create, patch,
150 * and delete return long-running operations.
151 *
152 * ### Creating a Deployment
153 * **Example:** Generated name
154 * ```typescript
155 * const deployment = yield* GCP.DeploymentManager.Deployment("App", {
156 * target: {
157 * config: {
158 * content: `resources:
159 * - name: topic
160 * type: pubsub.v1.topic
161 * properties:
162 * topic: my-topic
163 * `,
164 * },
165 * },
166 * });
167 * ```
168 *
169 * **Example:** Explicit name, labels, and imported template
170 * ```typescript
171 * const deployment = yield* GCP.DeploymentManager.Deployment("App", {
172 * deploymentId: "order-events",
173 * description: "pubsub topic for orders",
174 * labels: { env: "prod" },
175 * target: {
176 * config: {
177 * content: `imports:
178 * - path: topic.jinja
179 * resources:
180 * - name: topic
181 * type: topic.jinja
182 * `,
183 * },
184 * imports: [
185 * {
186 * name: "topic.jinja",
187 * content: `resources:
188 * - name: topic
189 * type: pubsub.v1.topic
190 * properties:
191 * topic: {{ env["deployment"] }}
192 * `,
193 * },
194 * ],
195 * },
196 * });
197 * ```
198 *
199 * ### Updating a Deployment
200 * Re-declare the same logical id with changed props; the engine keeps the
201 * physical resource and updates it in place.
202 *
203 * **Example:** Labels and description
204 * ```typescript
205 * const deployment = yield* GCP.DeploymentManager.Deployment("App", {
206 * description: "pubsub topic for orders (prod)",
207 * labels: { env: "prod", team: "platform" },
208 * target: existingTarget,
209 * });
210 * ```
211 *
212 * @resource
213 * @category DeploymentManager
214 */
215export const Deployment = Resource<Deployment>(
216 "GCP.DeploymentManager.Deployment",
217);
218
219const DEFAULT_CREATE_POLICY = "CREATE_OR_ACQUIRE";
220const DEFAULT_DELETE_POLICY = "DELETE";
221
222const desiredTarget = (
223 target: TargetConfiguration,
224): deploymentmanager.TargetConfiguration => ({
225 config: { content: target.config.content },
226 imports:
227 target.imports === undefined
228 ? undefined
229 : target.imports.map((file) => ({
230 name: file.name,
231 content: file.content,
232 })),
233});
234
235const observedTarget = (
236 manifest: deploymentmanager.Manifest | undefined,
237): TargetConfiguration | undefined => {
238 if (manifest === undefined) return undefined;
239 return {
240 config: { content: manifest.config?.content ?? "" },
241 imports: (manifest.imports ?? []).map((file) => ({
242 name: file.name,
243 content: file.content,
244 })),
245 };
246};
247
248const toAttrs = (
249 item: deploymentmanager.Deployment,
250 project: string,
251 deletePolicy: string,
252) => ({
253 name: item.name ?? "",
254 deploymentId: item.name ?? "",
255 project,
256 description: item.description,
257 labels: userLabels(labelsToRecord(item.labels)),
258 fingerprint: item.fingerprint,
259 manifest: item.manifest,
260 selfLink: item.selfLink,
261 insertTime: item.insertTime,
262 updateTime: item.updateTime,
263 id: item.id,
264 preview: item.update !== undefined,
265 deletePolicy,
266});
267
268const replaceOnName = (input: {
269 previousId: string | undefined;
270 nextId: string | undefined;
271}) => {
272 if (
273 input.previousId === undefined ||
274 input.nextId === undefined ||
275 input.nextId === input.previousId
276 ) {
277 return undefined;
278 }
279 return {
280 action: "replace" as const,
281 deleteFirst: false,
282 };
283};
284
285export const DeploymentProvider = () =>
286 Provider.succeed(Deployment, {
287 stables: ["name", "deploymentId", "project", "id", "insertTime"],
288
289 diff: Effect.fn(function* ({ news, olds, output }) {
290 if (!isResolved(news)) return undefined;
291 return replaceOnName({
292 previousId: olds?.deploymentId ?? output?.deploymentId,
293 nextId: news.deploymentId ?? olds?.deploymentId ?? output?.deploymentId,
294 });
295 }),
296
297 read: Effect.fn(function* ({ id, olds, output }) {
298 const env = yield* GcpEnvironment.current;
299 const deploymentId = yield* toPhysicalId(
300 id,
301 olds?.deploymentId,
302 output?.deploymentId,
303 );
304 const existing = yield* getDeployment(
305 env.project,
306 output?.name ?? deploymentId,
307 );
308 if (existing === undefined) return undefined;
309 const attrs = toAttrs(
310 existing,
311 env.project,
312 olds?.deletePolicy ?? output?.deletePolicy ?? DEFAULT_DELETE_POLICY,
313 );
314 return (yield* hasAlchemyLabels(id, labelsToRecord(existing.labels)))
315 ? attrs
316 : Unowned(attrs);
317 }),
318
319 list: () =>
320 Effect.gen(function* () {
321 const env = yield* GcpEnvironment.current;
322 const items = yield* listOwnedDeployments(env.project);
323 return items.map((item) =>
324 toAttrs(item, env.project, DEFAULT_DELETE_POLICY),
325 );
326 }),
327
328 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
329 const env = yield* GcpEnvironment.current;
330 const deploymentId = yield* toPhysicalId(
331 id,
332 news.deploymentId,
333 output?.deploymentId,
334 );
335 const desiredLabels = {
336 ...toLabels(news.labels),
337 ...(yield* createInternalLabels(id)),
338 };
339 const target = desiredTarget(news.target);
340 const preview = news.preview === true;
341 const createPolicy = news.createPolicy ?? DEFAULT_CREATE_POLICY;
342 const deletePolicy = news.deletePolicy ?? DEFAULT_DELETE_POLICY;
343
344 let current = yield* getDeployment(
345 env.project,
346 output?.name ?? deploymentId,
347 );
348 if (current !== undefined) {
349 yield* settleDeployment(env.project, current);
350 current = yield* getDeployment(
351 env.project,
352 current.name ?? deploymentId,
353 );
354 }
355
356 if (current === undefined) {
357 const created = yield* deploymentmanager
358 .insertDeployments({
359 project: env.project,
360 preview,
361 createPolicy,
362 body: {
363 name: deploymentId,
364 description: news.description,
365 labels: recordToLabels(desiredLabels),
366 target,
367 },
368 })
369 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
370 if (created !== undefined) {
371 yield* waitForOperation(env.project, created);
372 }
373 current = yield* waitUntilExists(
374 getDeployment(env.project, deploymentId),
375 deploymentId,
376 );
377 }
378
379 if (current === undefined) {
380 return yield* new ResourceNotResolved({ name: deploymentId });
381 }
382
383 const name = current.name ?? deploymentId;
384 const observedLabels = labelsToRecord(current.labels);
385 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
386 const labelsChanged = upsert.length > 0 || removed.length > 0;
387 const descriptionChanged = !sameText(
388 current.description,
389 news.description,
390 );
391 const observedPreview = current.update !== undefined;
392 const previewChanged = observedPreview !== preview;
393 const manifest = yield* getManifest(
394 env.project,
395 name,
396 current.manifest ?? current.update?.manifest,
397 );
398 const targetChanged =
399 olds === undefined
400 ? fingerprint(observedTarget(manifest)) !== fingerprint(news.target)
401 : fingerprint(olds.target) !== fingerprint(news.target);
402
403 if (
404 labelsChanged ||
405 descriptionChanged ||
406 previewChanged ||
407 targetChanged
408 ) {
409 const operation = yield* deploymentmanager
410 .patchDeployments({
411 project: env.project,
412 deployment: name,
413 preview,
414 createPolicy,
415 deletePolicy,
416 body: {
417 name,
418 fingerprint: current.fingerprint,
419 description: news.description,
420 labels: recordToLabels(desiredLabels),
421 target,
422 },
423 })
424 .pipe(
425 Effect.retry({
426 while: (error) => error._tag === "Conflict",
427 times: 8,
428 schedule: Schedule.spaced("2 seconds"),
429 }),
430 );
431 yield* waitForOperation(env.project, operation);
432 current = yield* waitUntilExists(
433 getDeployment(env.project, name),
434 name,
435 );
436 }
437
438 if (current === undefined) {
439 return yield* new ResourceNotResolved({ name });
440 }
441
442 return toAttrs(current, env.project, deletePolicy);
443 }),
444
445 delete: Effect.fn(function* ({ output }) {
446 const env = yield* GcpEnvironment.current;
447 const existing = yield* getDeployment(env.project, output.name);
448 if (existing !== undefined) {
449 yield* settleDeployment(env.project, existing);
450 }
451 const operation = yield* deploymentmanager
452 .deleteDeployments({
453 project: env.project,
454 deployment: output.name,
455 deletePolicy: output.deletePolicy ?? DEFAULT_DELETE_POLICY,
456 })
457 .pipe(
458 Effect.retry({
459 while: (error) => error._tag === "Conflict",
460 times: 8,
461 schedule: Schedule.spaced("2 seconds"),
462 }),
463 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
464 );
465 if (operation !== undefined) {
466 yield* waitForOperation(env.project, operation, { notFoundOk: true });
467 }
468 yield* waitUntilGone(
469 getDeployment(env.project, output.name),
470 output.name,
471 );
472 }),
473 });