1import * as clouddeploy from "@distilled.cloud/gcp/clouddeploy_v1";
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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 expandParent,
19 fieldMask,
20 fingerprint,
21 listAtNested,
22 listLabeledPages,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 sameBool,
28 sameText,
29 stringMap,
30 stripAutomationRules,
31 toPhysicalId,
32 userLabels,
33 waitForOperation,
34 waitUntilExists,
35 waitUntilGone,
36} from "./internal.ts";
37
38export type TargetAttribute = {
39 /**
40 * Target id, the last segment of a Target name, or `*` for all
41 * targets in the location.
42 */
43 id?: string;
44 /** Target labels that must match. */
45 labels?: Record<string, string>;
46};
47
48export type AutomationResourceSelector = {
49 /** Target attributes the automation applies to. */
50 targets?: TargetAttribute[];
51};
52
53export type AdvanceRolloutRule = {
54 /** Unique rule id within the Automation. */
55 id?: string;
56 /** How long to wait after a rollout finishes. */
57 wait?: string;
58 /** Source phases that must match before advancing. */
59 sourcePhases?: string[];
60};
61
62export type PromoteReleaseRule = {
63 /** Unique rule id within the Automation. */
64 id?: string;
65 /**
66 * Destination stage id, or `@next` for the next stage in the
67 * promotion flow.
68 */
69 destinationTargetId?: string;
70 /** Starting phase of the created rollout. */
71 destinationPhase?: string;
72 /** How long to wait before promoting. */
73 wait?: string;
74};
75
76export type RepairRolloutRule = {
77 /** Unique rule id within the Automation. */
78 id?: string;
79 /** Phases whose failed jobs are repaired. */
80 phases?: string[];
81 /** Repair phases (retry and/or rollback). */
82 repairPhases?: clouddeploy.RepairPhaseConfig[];
83 /** Job names to repair. Empty means all jobs. */
84 jobs?: string[];
85};
86
87export type TimedPromoteReleaseRule = {
88 /** Unique rule id within the Automation. */
89 id?: string;
90 /** Crontab schedule (for example `0 9 * * 1`). */
91 schedule?: string;
92 /** Destination stage id, or `@next`. */
93 destinationTargetId?: string;
94 /** Starting phase of the created rollout. */
95 destinationPhase?: string;
96 /** IANA time zone (for example `America/New_York`). */
97 timeZone?: string;
98};
99
100export type AutomationRule = {
101 /** Automatically advance a successful rollout. */
102 advanceRolloutRule?: AdvanceRolloutRule;
103 /** Automatically promote a release to the next (or specified) target. */
104 promoteReleaseRule?: PromoteReleaseRule;
105 /** Automatically repair a failed rollout. */
106 repairRolloutRule?: RepairRolloutRule;
107 /** Promote on a cron schedule. */
108 timedPromoteReleaseRule?: TimedPromoteReleaseRule;
109};
110
111export type DeliveryPipelinesAutomationProps = {
112 /**
113 * Parent delivery pipeline. Full name
114 * `projects/{project}/locations/{location}/deliveryPipelines/{deliveryPipeline}`
115 * or the pipeline id (combined with `location`). Immutable — changing
116 * it replaces the automation.
117 */
118 deliveryPipeline: string;
119 /**
120 * Region used when `deliveryPipeline` is a bare id.
121 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
122 */
123 location?: string;
124 /**
125 * Automation id (the `{automation}` segment). If omitted, a unique
126 * RFC1035 name is generated from the stack, stage, and logical id.
127 * Immutable — changing it replaces the automation.
128 */
129 automationId?: string;
130 /**
131 * Email of the user-managed IAM service account that creates Cloud
132 * Deploy release and rollout resources.
133 */
134 serviceAccount: string;
135 /**
136 * Resources the automation applies to.
137 */
138 selector: AutomationResourceSelector;
139 /**
140 * Automation rules. At least one is required.
141 */
142 rules: AutomationRule[];
143 /**
144 * Human-readable description. Max length 255 characters.
145 */
146 description?: string;
147 /**
148 * When true, the automation is deactivated.
149 * @default false
150 */
151 suspended?: boolean;
152 /**
153 * User annotations (not used by Cloud Deploy).
154 */
155 annotations?: Record<string, string>;
156 /**
157 * User labels. Alchemy ownership labels are merged in automatically.
158 */
159 labels?: Record<string, string>;
160};
161
162export type DeliveryPipelinesAutomation = Resource<
163 "GCP.CloudDeploy.DeliveryPipelinesAutomation",
164 DeliveryPipelinesAutomationProps,
165 {
166 /** Full resource name. */
167 name: string;
168 /** Automation id (last path segment). */
169 automationId: string;
170 /** Parent delivery pipeline name. */
171 deliveryPipeline: string;
172 /** Project id. */
173 project: string;
174 /** Location id. */
175 location: string;
176 /** Service account email. */
177 serviceAccount: string | undefined;
178 /** Resource selector. */
179 selector: AutomationResourceSelector | undefined;
180 /** Automation rules (output-only conditions stripped). */
181 rules: AutomationRule[];
182 /** Human-readable description. */
183 description: string | undefined;
184 /** Whether the automation is suspended. */
185 suspended: boolean;
186 /** User annotations. */
187 annotations: Record<string, string>;
188 /** User labels (Alchemy ownership labels stripped). */
189 labels: Record<string, string>;
190 /** Server-generated resource uid. */
191 uid: string | undefined;
192 /** RFC3339 creation timestamp. */
193 createTime: string | undefined;
194 /** RFC3339 last-update timestamp. */
195 updateTime: string | undefined;
196 /** Server-computed etag. */
197 etag: string | undefined;
198 },
199 never,
200 Providers
201>;
202
203/**
204 * A Cloud Deploy automation attached to a delivery pipeline. Automations
205 * promote releases, repair failed rollouts, and advance rollout phases
206 * without a human in the loop.
207 *
208 * Changing `automationId`, `location`, or `deliveryPipeline` replaces
209 * the automation. Rules, selector, service account, description, labels,
210 * annotations, and `suspended` update in place.
211 *
212 * ### Creating an Automation
213 * **Example:** Promote to the next stage
214 * ```typescript
215 * const automation = yield* GCP.CloudDeploy.DeliveryPipelinesAutomation("Promote", {
216 * deliveryPipeline: pipeline.name,
217 * serviceAccount: "deployer@my-project.iam.gserviceaccount.com",
218 * selector: { targets: [{ id: "*" }] },
219 * rules: [{ promoteReleaseRule: { id: "promote-release" } }],
220 * });
221 * ```
222 *
223 * **Example:** Timed promote
224 * ```typescript
225 * const automation = yield* GCP.CloudDeploy.DeliveryPipelinesAutomation("Nightly", {
226 * deliveryPipeline: pipeline.name,
227 * serviceAccount: "deployer@my-project.iam.gserviceaccount.com",
228 * selector: { targets: [{ id: "staging" }] },
229 * rules: [{
230 * timedPromoteReleaseRule: {
231 * id: "weekday-promote",
232 * schedule: "0 9 * * 1-5",
233 * timeZone: "America/New_York",
234 * },
235 * }],
236 * labels: { env: "prod" },
237 * });
238 * ```
239 *
240 * ### Updating an Automation
241 * Re-declare the same logical id with changed props; the engine keeps the
242 * physical resource and updates it in place.
243 *
244 * **Example:** Description and labels
245 * ```typescript
246 * const automation = yield* GCP.CloudDeploy.DeliveryPipelinesAutomation("Promote", {
247 * deliveryPipeline: pipeline.name,
248 * serviceAccount: "deployer@my-project.iam.gserviceaccount.com",
249 * selector: { targets: [{ id: "*" }] },
250 * rules: [{ promoteReleaseRule: { id: "promote-release" } }],
251 * description: "promote v2",
252 * labels: { env: "prod", team: "platform" },
253 * });
254 * ```
255 *
256 * @resource
257 * @category CloudDeploy
258 */
259export const DeliveryPipelinesAutomation =
260 Resource<DeliveryPipelinesAutomation>(
261 "GCP.CloudDeploy.DeliveryPipelinesAutomation",
262 );
263
264const resourceName = (deliveryPipeline: string, automationId: string) =>
265 `${deliveryPipeline}/automations/${automationId}`;
266
267const toSelector = (
268 value: clouddeploy.AutomationResourceSelector | undefined,
269): AutomationResourceSelector | undefined =>
270 value === undefined
271 ? undefined
272 : {
273 targets: value.targets?.map((target) => ({
274 id: target.id,
275 labels: stringMap(target.labels),
276 })),
277 };
278
279const toAttrs = (item: clouddeploy.Automation, project: string) => {
280 const name = item.name ?? "";
281 const parsed = parseName(name, "automations");
282 return {
283 name,
284 automationId: parsed.id,
285 deliveryPipeline: parsed.parent,
286 project: parsed.project || project,
287 location: parsed.location,
288 serviceAccount: item.serviceAccount,
289 selector: toSelector(item.selector),
290 rules: stripAutomationRules(item.rules),
291 description: item.description,
292 suspended: item.suspended === true,
293 annotations: stringMap(item.annotations),
294 labels: userLabels(item.labels),
295 uid: item.uid,
296 createTime: item.createTime,
297 updateTime: item.updateTime,
298 etag: item.etag,
299 };
300};
301
302const getByName = (name: string) =>
303 clouddeploy
304 .getProjectsLocationsDeliveryPipelinesAutomations({ name })
305 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
306
307const listOwned = (project: string, region: string) =>
308 listAtNested(project, region, "deliveryPipelines/-", (parent) =>
309 listLabeledPages(
310 clouddeploy.listProjectsLocationsDeliveryPipelinesAutomations.pages({
311 parent,
312 pageSize: 1000,
313 }),
314 (page) => page.automations,
315 (item) => item.labels,
316 ),
317 );
318
319export const DeliveryPipelinesAutomationProvider = () =>
320 Provider.succeed(DeliveryPipelinesAutomation, {
321 stables: [
322 "name",
323 "automationId",
324 "deliveryPipeline",
325 "project",
326 "location",
327 "uid",
328 "createTime",
329 ],
330
331 diff: Effect.fn(function* ({ news, olds, output }) {
332 if (!isResolved(news)) return undefined;
333 const env = yield* GcpEnvironment.current;
334 const location = normalizeLocation(
335 news.location ?? olds?.location ?? output?.location ?? env.region,
336 );
337 const previousParent = olds?.deliveryPipeline ?? output?.deliveryPipeline;
338 const nextParent = expandParent(
339 news.deliveryPipeline,
340 env.project,
341 location,
342 "deliveryPipelines",
343 );
344 return replaceOnIdentity({
345 previousId: olds?.automationId ?? output?.automationId,
346 nextId: news.automationId ?? olds?.automationId ?? output?.automationId,
347 previousLocation: normalizeLocation(
348 olds?.location ?? output?.location ?? env.region,
349 ),
350 nextLocation: location,
351 previousParent,
352 nextParent,
353 });
354 }),
355
356 read: Effect.fn(function* ({ id, olds, output }) {
357 const env = yield* GcpEnvironment.current;
358 const automationId = yield* toPhysicalId(
359 id,
360 olds?.automationId,
361 output?.automationId,
362 "automation",
363 );
364 const location = normalizeLocation(
365 olds?.location ?? output?.location ?? env.region,
366 );
367 const deliveryPipeline =
368 output?.deliveryPipeline ??
369 (olds?.deliveryPipeline
370 ? expandParent(
371 olds.deliveryPipeline,
372 env.project,
373 location,
374 "deliveryPipelines",
375 )
376 : undefined);
377 const name =
378 output?.name ??
379 (deliveryPipeline
380 ? resourceName(deliveryPipeline, automationId)
381 : undefined);
382 if (name === undefined) return undefined;
383 const existing = yield* getByName(name);
384 if (existing === undefined) return undefined;
385 const attrs = toAttrs(existing, env.project);
386 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
387 ? attrs
388 : Unowned(attrs);
389 }),
390
391 list: () =>
392 Effect.gen(function* () {
393 const env = yield* GcpEnvironment.current;
394 const items = yield* listOwned(env.project, env.region);
395 return items.map((item) => toAttrs(item, env.project));
396 }),
397
398 reconcile: Effect.fn(function* ({ id, news, output }) {
399 const env = yield* GcpEnvironment.current;
400 const automationId = yield* toPhysicalId(
401 id,
402 news.automationId,
403 output?.automationId,
404 "automation",
405 );
406 const location = normalizeLocation(
407 news.location ?? output?.location ?? env.region,
408 );
409 const deliveryPipeline = expandParent(
410 news.deliveryPipeline,
411 env.project,
412 location,
413 "deliveryPipelines",
414 );
415 const name = resourceName(deliveryPipeline, automationId);
416 const desiredLabels = {
417 ...toLabels(news.labels),
418 ...(yield* createInternalLabels(id)),
419 };
420 const desiredAnnotations = news.annotations ?? {};
421 const desiredSuspended = news.suspended === true;
422 const desiredRules = stripAutomationRules(news.rules);
423 const desiredSelector = news.selector;
424
425 let current = yield* getByName(output?.name ?? name);
426
427 if (current === undefined) {
428 const created = yield* clouddeploy
429 .createProjectsLocationsDeliveryPipelinesAutomations({
430 parent: deliveryPipeline,
431 automationId,
432 body: {
433 serviceAccount: news.serviceAccount,
434 selector: desiredSelector,
435 rules: desiredRules,
436 description: news.description,
437 suspended: desiredSuspended,
438 annotations: desiredAnnotations,
439 labels: desiredLabels,
440 },
441 })
442 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
443 if (created !== undefined) {
444 yield* waitForOperation(created);
445 }
446 current = yield* waitUntilExists(getByName(name), name);
447 }
448
449 if (current === undefined) {
450 return yield* new ResourceNotResolved({ name });
451 }
452
453 const observedLabels = tagRecord(current.labels);
454 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
455 const mask = fieldMask([
456 (upsert.length > 0 || removed.length > 0) && "labels",
457 fingerprint(stringMap(current.annotations)) !==
458 fingerprint(desiredAnnotations) && "annotations",
459 !sameText(current.description, news.description) && "description",
460 !sameBool(current.suspended, desiredSuspended) && "suspended",
461 !sameText(current.serviceAccount, news.serviceAccount) &&
462 "serviceAccount",
463 fingerprint(toSelector(current.selector)) !==
464 fingerprint(desiredSelector) && "selector",
465 fingerprint(stripAutomationRules(current.rules)) !==
466 fingerprint(desiredRules) && "rules",
467 ]);
468
469 if (mask.length > 0) {
470 const operation =
471 yield* clouddeploy.patchProjectsLocationsDeliveryPipelinesAutomations(
472 {
473 name: current.name ?? name,
474 updateMask: mask,
475 body: {
476 etag: current.etag,
477 serviceAccount: news.serviceAccount,
478 selector: desiredSelector,
479 rules: desiredRules,
480 description: news.description,
481 suspended: desiredSuspended,
482 annotations: desiredAnnotations,
483 labels: desiredLabels,
484 },
485 },
486 );
487 yield* waitForOperation(operation);
488 current = yield* waitUntilExists(
489 getByName(current.name ?? name),
490 current.name ?? name,
491 );
492 }
493
494 return toAttrs(current, env.project);
495 }),
496
497 delete: Effect.fn(function* ({ output }) {
498 const operation = yield* clouddeploy
499 .deleteProjectsLocationsDeliveryPipelinesAutomations({
500 name: output.name,
501 allowMissing: true,
502 })
503 .pipe(
504 Effect.retry({
505 while: (error) => error._tag === "Conflict",
506 times: 8,
507 schedule: Schedule.spaced("2 seconds"),
508 }),
509 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
510 );
511 if (operation !== undefined) {
512 yield* waitForOperation(operation, { notFoundOk: true });
513 }
514 yield* waitUntilGone(getByName(output.name), output.name);
515 }),
516 });