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 fieldMask,
19 fingerprint,
20 listAtLocation,
21 listLabeledPages,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 sameBool,
28 sameText,
29 stringMap,
30 toPhysicalId,
31 userLabels,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35} from "./internal.ts";
36
37export type Stage = {
38 /**
39 * Target id this stage points at (the last segment of a Target name).
40 */
41 targetId?: string;
42 /**
43 * Skaffold profiles used when rendering this stage's Target.
44 */
45 profiles?: string[];
46 /**
47 * Rollout strategy for this stage (`standard` or `canary`).
48 */
49 strategy?: clouddeploy.Strategy;
50 /**
51 * Deploy parameters applied to the target in this stage.
52 */
53 deployParameters?: clouddeploy.DeployParameters[];
54};
55
56export type SerialPipeline = {
57 /**
58 * Ordered stages. The list order is the promotion flow.
59 */
60 stages?: Stage[];
61};
62
63export type PipelineCondition = {
64 /** Whether referenced targets exist. */
65 targetsPresentCondition?: clouddeploy.TargetsPresentCondition;
66 /** Whether the pipeline's target types are compatible. */
67 targetsTypeCondition?: clouddeploy.TargetsTypeCondition;
68 /** Whether the pipeline is ready. */
69 pipelineReadyCondition?: clouddeploy.PipelineReadyCondition;
70};
71
72export type DeliveryPipelineProps = {
73 /**
74 * Delivery pipeline id (the `{deliveryPipeline}` segment of
75 * `projects/{project}/locations/{location}/deliveryPipelines/{deliveryPipeline}`).
76 * If omitted, a unique RFC1035 name is generated from the stack, stage,
77 * and logical id. Immutable — changing it replaces the pipeline.
78 */
79 deliveryPipelineId?: string;
80 /**
81 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
82 * replaces the pipeline. `US-CENTRAL1` is accepted and normalized to
83 * `us-central1`.
84 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
85 */
86 location?: string;
87 /**
88 * Sequential stages this pipeline promotes through.
89 */
90 serialPipeline?: SerialPipeline;
91 /**
92 * Human-readable description. Max length 255 characters.
93 */
94 description?: string;
95 /**
96 * When true, no new releases or rollouts can be created. In-progress
97 * ones still complete.
98 * @default false
99 */
100 suspended?: boolean;
101 /**
102 * User annotations (not used by Cloud Deploy).
103 */
104 annotations?: Record<string, string>;
105 /**
106 * User labels. Alchemy ownership labels are merged in automatically.
107 */
108 labels?: Record<string, string>;
109};
110
111export type DeliveryPipeline = Resource<
112 "GCP.CloudDeploy.DeliveryPipeline",
113 DeliveryPipelineProps,
114 {
115 /** Full resource name. */
116 name: string;
117 /** Delivery pipeline id (last path segment). */
118 deliveryPipelineId: string;
119 /** Project id. */
120 project: string;
121 /** Location id. */
122 location: string;
123 /** Sequential pipeline configuration. */
124 serialPipeline: SerialPipeline | undefined;
125 /** Human-readable description. */
126 description: string | undefined;
127 /** Whether the pipeline is suspended. */
128 suspended: boolean;
129 /** Server-reported pipeline condition. */
130 condition: PipelineCondition | undefined;
131 /** User annotations. */
132 annotations: Record<string, string>;
133 /** User labels (Alchemy ownership labels stripped). */
134 labels: Record<string, string>;
135 /** Server-generated resource uid. */
136 uid: string | undefined;
137 /** RFC3339 creation timestamp. */
138 createTime: string | undefined;
139 /** RFC3339 last-update timestamp. */
140 updateTime: string | undefined;
141 /** Server-computed etag. */
142 etag: string | undefined;
143 },
144 never,
145 Providers
146>;
147
148/**
149 * A Cloud Deploy delivery pipeline — the ordered stages a Skaffold
150 * configuration progresses through.
151 *
152 * Changing `deliveryPipelineId` or `location` replaces the pipeline.
153 * `serialPipeline`, description, labels, annotations, and `suspended`
154 * update in place.
155 *
156 * ### Creating a Delivery Pipeline
157 * **Example:** Generated name
158 * ```typescript
159 * const pipeline = yield* GCP.CloudDeploy.DeliveryPipeline("App", {
160 * serialPipeline: { stages: [{ targetId: "prod" }] },
161 * });
162 * ```
163 *
164 * **Example:** Explicit id and labels
165 * ```typescript
166 * const pipeline = yield* GCP.CloudDeploy.DeliveryPipeline("App", {
167 * deliveryPipelineId: "app-pipeline",
168 * serialPipeline: {
169 * stages: [
170 * { targetId: "staging" },
171 * { targetId: "prod" },
172 * ],
173 * },
174 * labels: { env: "prod" },
175 * });
176 * ```
177 *
178 * ### Updating a Delivery Pipeline
179 * Re-declare the same logical id with changed props; the engine keeps the
180 * physical resource and updates it in place.
181 *
182 * **Example:** Description, suspend, and labels
183 * ```typescript
184 * const pipeline = yield* GCP.CloudDeploy.DeliveryPipeline("App", {
185 * serialPipeline: { stages: [{ targetId: "prod" }] },
186 * description: "app pipeline v2",
187 * suspended: true,
188 * labels: { env: "prod", team: "platform" },
189 * });
190 * ```
191 *
192 * @resource
193 * @category CloudDeploy
194 */
195export const DeliveryPipeline = Resource<DeliveryPipeline>(
196 "GCP.CloudDeploy.DeliveryPipeline",
197);
198
199const resourceName = (
200 project: string,
201 location: string,
202 deliveryPipelineId: string,
203) =>
204 `projects/${project}/locations/${location}/deliveryPipelines/${deliveryPipelineId}`;
205
206const toSerialPipeline = (
207 value: clouddeploy.SerialPipeline | undefined,
208): SerialPipeline | undefined =>
209 value === undefined
210 ? undefined
211 : {
212 stages: value.stages?.map((stage) => ({
213 targetId: stage.targetId,
214 profiles: stage.profiles,
215 strategy: stage.strategy,
216 deployParameters: stage.deployParameters,
217 })),
218 };
219
220const toCondition = (
221 value: clouddeploy.PipelineCondition | undefined,
222): PipelineCondition | undefined =>
223 value === undefined
224 ? undefined
225 : {
226 targetsPresentCondition: value.targetsPresentCondition,
227 targetsTypeCondition: value.targetsTypeCondition,
228 pipelineReadyCondition: value.pipelineReadyCondition,
229 };
230
231const toAttrs = (item: clouddeploy.DeliveryPipeline, project: string) => {
232 const name = item.name ?? "";
233 const parsed = parseName(name, "deliveryPipelines");
234 return {
235 name,
236 deliveryPipelineId: parsed.id,
237 project: parsed.project || project,
238 location: parsed.location,
239 serialPipeline: toSerialPipeline(item.serialPipeline),
240 description: item.description,
241 suspended: item.suspended === true,
242 condition: toCondition(item.condition),
243 annotations: stringMap(item.annotations),
244 labels: userLabels(item.labels),
245 uid: item.uid,
246 createTime: item.createTime,
247 updateTime: item.updateTime,
248 etag: item.etag,
249 };
250};
251
252const getByName = (name: string) =>
253 clouddeploy
254 .getProjectsLocationsDeliveryPipelines({ name })
255 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
256
257const listOwned = (project: string, region: string) =>
258 listAtLocation(project, region, (parent) =>
259 listLabeledPages(
260 clouddeploy.listProjectsLocationsDeliveryPipelines.pages({
261 parent,
262 pageSize: 1000,
263 }),
264 (page) => page.deliveryPipelines,
265 (item) => item.labels,
266 ),
267 );
268
269export const DeliveryPipelineProvider = () =>
270 Provider.succeed(DeliveryPipeline, {
271 stables: [
272 "name",
273 "deliveryPipelineId",
274 "project",
275 "location",
276 "uid",
277 "createTime",
278 ],
279
280 diff: Effect.fn(function* ({ news, olds, output }) {
281 if (!isResolved(news)) return undefined;
282 const env = yield* GcpEnvironment.current;
283 return replaceOnIdentity({
284 previousId: olds?.deliveryPipelineId ?? output?.deliveryPipelineId,
285 nextId:
286 news.deliveryPipelineId ??
287 olds?.deliveryPipelineId ??
288 output?.deliveryPipelineId,
289 previousLocation: normalizeLocation(
290 olds?.location ?? output?.location ?? env.region,
291 ),
292 nextLocation: normalizeLocation(
293 news.location ?? olds?.location ?? output?.location ?? env.region,
294 ),
295 });
296 }),
297
298 read: Effect.fn(function* ({ id, olds, output }) {
299 const env = yield* GcpEnvironment.current;
300 const deliveryPipelineId = yield* toPhysicalId(
301 id,
302 olds?.deliveryPipelineId,
303 output?.deliveryPipelineId,
304 "deliverypipeline",
305 );
306 const location = normalizeLocation(
307 olds?.location ?? output?.location ?? env.region,
308 );
309 const name =
310 output?.name ?? resourceName(env.project, location, deliveryPipelineId);
311 const existing = yield* getByName(name);
312 if (existing === undefined) return undefined;
313 const attrs = toAttrs(existing, env.project);
314 return (yield* hasAlchemyLabels(id, tagRecord(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* listOwned(env.project, env.region);
323 return items.map((item) => toAttrs(item, env.project));
324 }),
325
326 reconcile: Effect.fn(function* ({ id, news, output }) {
327 const env = yield* GcpEnvironment.current;
328 const deliveryPipelineId = yield* toPhysicalId(
329 id,
330 news.deliveryPipelineId,
331 output?.deliveryPipelineId,
332 "deliverypipeline",
333 );
334 const location = normalizeLocation(
335 news.location ?? output?.location ?? env.region,
336 );
337 const name = resourceName(env.project, location, deliveryPipelineId);
338 const desiredLabels = {
339 ...toLabels(news.labels),
340 ...(yield* createInternalLabels(id)),
341 };
342 const desiredAnnotations = news.annotations ?? {};
343 const desiredSuspended = news.suspended === true;
344
345 let current = yield* getByName(output?.name ?? name);
346
347 if (current === undefined) {
348 const created = yield* clouddeploy
349 .createProjectsLocationsDeliveryPipelines({
350 parent: parentOf(env.project, location),
351 deliveryPipelineId,
352 body: {
353 description: news.description,
354 serialPipeline: news.serialPipeline,
355 suspended: desiredSuspended,
356 annotations: desiredAnnotations,
357 labels: desiredLabels,
358 },
359 })
360 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
361 if (created !== undefined) {
362 yield* waitForOperation(created);
363 }
364 current = yield* waitUntilExists(getByName(name), name);
365 }
366
367 if (current === undefined) {
368 return yield* new ResourceNotResolved({ name });
369 }
370
371 const observedLabels = tagRecord(current.labels);
372 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
373 const mask = fieldMask([
374 (upsert.length > 0 || removed.length > 0) && "labels",
375 fingerprint(stringMap(current.annotations)) !==
376 fingerprint(desiredAnnotations) && "annotations",
377 !sameText(current.description, news.description) && "description",
378 !sameBool(current.suspended, desiredSuspended) && "suspended",
379 fingerprint(toSerialPipeline(current.serialPipeline)) !==
380 fingerprint(news.serialPipeline) && "serialPipeline",
381 ]);
382
383 if (mask.length > 0) {
384 const operation =
385 yield* clouddeploy.patchProjectsLocationsDeliveryPipelines({
386 name: current.name ?? name,
387 updateMask: mask,
388 body: {
389 etag: current.etag,
390 description: news.description,
391 serialPipeline: news.serialPipeline,
392 suspended: desiredSuspended,
393 annotations: desiredAnnotations,
394 labels: desiredLabels,
395 },
396 });
397 yield* waitForOperation(operation);
398 current = yield* waitUntilExists(
399 getByName(current.name ?? name),
400 current.name ?? name,
401 );
402 }
403
404 return toAttrs(current, env.project);
405 }),
406
407 delete: Effect.fn(function* ({ output }) {
408 const operation = yield* clouddeploy
409 .deleteProjectsLocationsDeliveryPipelines({
410 name: output.name,
411 force: true,
412 allowMissing: true,
413 })
414 .pipe(
415 Effect.retry({
416 while: (error) => error._tag === "Conflict",
417 times: 8,
418 schedule: Schedule.spaced("2 seconds"),
419 }),
420 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
421 );
422 if (operation !== undefined) {
423 yield* waitForOperation(operation, { notFoundOk: true });
424 }
425 yield* waitUntilGone(getByName(output.name), output.name);
426 }),
427 });