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 DeliveryPipelineAttribute = {
38 /**
39 * Delivery pipeline id, the last segment of a pipeline name, or `*`
40 * for all pipelines in the location.
41 */
42 id?: string;
43 /** Pipeline labels that must match. */
44 labels?: Record<string, string>;
45};
46
47export type DeployPolicyTargetAttribute = {
48 /**
49 * Target id, the last segment of a Target name, or `*` for all
50 * targets in the location.
51 */
52 id?: string;
53 /** Target labels that must match. */
54 labels?: Record<string, string>;
55};
56
57export type DeployPolicyResourceSelector = {
58 /** Delivery pipeline attributes that must match. */
59 deliveryPipeline?: DeliveryPipelineAttribute;
60 /** Target attributes that must match. */
61 target?: DeployPolicyTargetAttribute;
62};
63
64export type TimeOfDay = {
65 /** Hours of day in 24-hour format (`0`–`23`, or `24` for end of day). */
66 hours?: number;
67 /** Minutes of hour (`0`–`59`). */
68 minutes?: number;
69 /** Seconds of minute (`0`–`59`). */
70 seconds?: number;
71 /** Fractional seconds, in nanoseconds. */
72 nanos?: number;
73};
74
75export type CalendarDate = {
76 /** Year (`1`–`9999`), or `0` to omit. */
77 year?: number;
78 /** Month (`1`–`12`), or `0` to omit. */
79 month?: number;
80 /** Day (`1`–`31`), or `0` to omit. */
81 day?: number;
82};
83
84export type OneTimeWindow = {
85 /** Inclusive start date. */
86 startDate?: CalendarDate;
87 /** Inclusive end date. */
88 endDate?: CalendarDate;
89 /** Inclusive start time. Use `00:00` for the beginning of the day. */
90 startTime?: TimeOfDay;
91 /** Exclusive end time. Use `24:00` for the end of the day. */
92 endTime?: TimeOfDay;
93};
94
95export type WeeklyWindow = {
96 /**
97 * Days of week. Empty means every day.
98 */
99 daysOfWeek?: Array<
100 clouddeploy.WeeklyWindowDaysOfWeekItemEnum | (string & {})
101 >;
102 /** Inclusive start time. Must be set with `endTime`. */
103 startTime?: TimeOfDay;
104 /** Exclusive end time. Must be set with `startTime`. */
105 endTime?: TimeOfDay;
106};
107
108export type TimeWindows = {
109 /** IANA time zone (for example `America/Los_Angeles`). */
110 timeZone?: string;
111 /** Recurring weekly restriction windows. */
112 weeklyWindows?: WeeklyWindow[];
113 /** One-time restriction windows. */
114 oneTimeWindows?: OneTimeWindow[];
115};
116
117export type RolloutRestriction = {
118 /** Unique restriction id within the policy. */
119 id?: string;
120 /**
121 * Restricted invokers (`USER`, `DEPLOY_AUTOMATION`). Empty means all
122 * invokers.
123 */
124 invokers?: Array<
125 clouddeploy.RolloutRestrictionInvokersItemEnum | (string & {})
126 >;
127 /**
128 * Restricted rollout actions (`ADVANCE`, `APPROVE`, `CANCEL`,
129 * `CREATE`, `IGNORE_JOB`, `RETRY_JOB`, `ROLLBACK`,
130 * `TERMINATE_JOBRUN`). Empty means all actions.
131 */
132 actions?: Array<
133 clouddeploy.RolloutRestrictionActionsItemEnum | (string & {})
134 >;
135 /** Time windows during which actions are restricted. */
136 timeWindows?: TimeWindows;
137};
138
139export type PolicyRule = {
140 /** Rollout restriction for this rule. */
141 rolloutRestriction?: RolloutRestriction;
142};
143
144export type DeployPolicyProps = {
145 /**
146 * Deploy policy id (the `{deployPolicy}` segment of
147 * `projects/{project}/locations/{location}/deployPolicies/{deployPolicy}`).
148 * If omitted, a unique RFC1035 name is generated from the stack, stage,
149 * and logical id. Immutable — changing it replaces the policy.
150 */
151 deployPolicyId?: string;
152 /**
153 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
154 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
155 * `us-central1`.
156 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
157 */
158 location?: string;
159 /**
160 * Resources the policy applies to. At least one selector is required.
161 */
162 selectors: DeployPolicyResourceSelector[];
163 /**
164 * Policy rules. At least one rule is required.
165 */
166 rules: PolicyRule[];
167 /**
168 * Human-readable description. Max length 255 characters.
169 */
170 description?: string;
171 /**
172 * When true, the policy does not block actions even if they violate it.
173 * @default false
174 */
175 suspended?: boolean;
176 /**
177 * User annotations (not used by Cloud Deploy).
178 */
179 annotations?: Record<string, string>;
180 /**
181 * User labels. Alchemy ownership labels are merged in automatically.
182 */
183 labels?: Record<string, string>;
184};
185
186export type DeployPolicy = Resource<
187 "GCP.CloudDeploy.DeployPolicy",
188 DeployPolicyProps,
189 {
190 /** Full resource name. */
191 name: string;
192 /** Deploy policy id (last path segment). */
193 deployPolicyId: string;
194 /** Project id. */
195 project: string;
196 /** Location id. */
197 location: string;
198 /** Resource selectors. */
199 selectors: DeployPolicyResourceSelector[];
200 /** Policy rules. */
201 rules: PolicyRule[];
202 /** Human-readable description. */
203 description: string | undefined;
204 /** Whether the policy is suspended. */
205 suspended: boolean;
206 /** User annotations. */
207 annotations: Record<string, string>;
208 /** User labels (Alchemy ownership labels stripped). */
209 labels: Record<string, string>;
210 /** Server-generated resource uid. */
211 uid: string | undefined;
212 /** RFC3339 creation timestamp. */
213 createTime: string | undefined;
214 /** RFC3339 last-update timestamp. */
215 updateTime: string | undefined;
216 /** Server-computed etag. */
217 etag: string | undefined;
218 },
219 never,
220 Providers
221>;
222
223/**
224 * A Cloud Deploy policy that restricts rollout actions on selected
225 * delivery pipelines and targets (for example a weekend freeze).
226 *
227 * Changing `deployPolicyId` or `location` replaces the policy.
228 * Selectors, rules, description, labels, annotations, and `suspended`
229 * update in place.
230 *
231 * ### Creating a Deploy Policy
232 * **Example:** Weekend freeze
233 * ```typescript
234 * const policy = yield* GCP.CloudDeploy.DeployPolicy("Freeze", {
235 * selectors: [{ target: { id: "*" } }],
236 * rules: [{
237 * rolloutRestriction: {
238 * id: "weekends",
239 * timeWindows: {
240 * timeZone: "America/Los_Angeles",
241 * weeklyWindows: [{ daysOfWeek: ["SATURDAY", "SUNDAY"] }],
242 * },
243 * },
244 * }],
245 * });
246 * ```
247 *
248 * **Example:** Business-hours only
249 * ```typescript
250 * const policy = yield* GCP.CloudDeploy.DeployPolicy("Freeze", {
251 * selectors: [{ deliveryPipeline: { id: "prod" } }],
252 * rules: [{
253 * rolloutRestriction: {
254 * id: "after-hours",
255 * actions: ["CREATE", "APPROVE"],
256 * timeWindows: {
257 * timeZone: "America/New_York",
258 * weeklyWindows: [{
259 * daysOfWeek: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"],
260 * startTime: { hours: 17 },
261 * endTime: { hours: 24 },
262 * }],
263 * },
264 * },
265 * }],
266 * labels: { env: "prod" },
267 * });
268 * ```
269 *
270 * ### Updating a Deploy Policy
271 * Re-declare the same logical id with changed props; the engine keeps the
272 * physical resource and updates it in place.
273 *
274 * **Example:** Description and labels
275 * ```typescript
276 * const policy = yield* GCP.CloudDeploy.DeployPolicy("Freeze", {
277 * selectors: [{ target: { id: "*" } }],
278 * rules: [{
279 * rolloutRestriction: {
280 * id: "weekends",
281 * timeWindows: {
282 * timeZone: "America/Los_Angeles",
283 * weeklyWindows: [{ daysOfWeek: ["SATURDAY", "SUNDAY"] }],
284 * },
285 * },
286 * }],
287 * description: "weekend freeze v2",
288 * labels: { env: "prod", team: "platform" },
289 * });
290 * ```
291 *
292 * @resource
293 * @category CloudDeploy
294 */
295export const DeployPolicy = Resource<DeployPolicy>(
296 "GCP.CloudDeploy.DeployPolicy",
297);
298
299const resourceName = (
300 project: string,
301 location: string,
302 deployPolicyId: string,
303) =>
304 `projects/${project}/locations/${location}/deployPolicies/${deployPolicyId}`;
305
306const toSelectors = (
307 value: clouddeploy.DeployPolicyResourceSelectorList | undefined,
308): DeployPolicyResourceSelector[] =>
309 (value ?? []).map((selector) => ({
310 deliveryPipeline:
311 selector.deliveryPipeline === undefined
312 ? undefined
313 : {
314 id: selector.deliveryPipeline.id,
315 labels: stringMap(selector.deliveryPipeline.labels),
316 },
317 target:
318 selector.target === undefined
319 ? undefined
320 : {
321 id: selector.target.id,
322 labels: stringMap(selector.target.labels),
323 },
324 }));
325
326const toRules = (value: clouddeploy.PolicyRuleList | undefined): PolicyRule[] =>
327 (value ?? []).map((rule) => ({
328 rolloutRestriction:
329 rule.rolloutRestriction === undefined
330 ? undefined
331 : {
332 id: rule.rolloutRestriction.id,
333 invokers: rule.rolloutRestriction.invokers,
334 actions: rule.rolloutRestriction.actions,
335 timeWindows: rule.rolloutRestriction.timeWindows,
336 },
337 }));
338
339const toAttrs = (item: clouddeploy.DeployPolicy, project: string) => {
340 const name = item.name ?? "";
341 const parsed = parseName(name, "deployPolicies");
342 return {
343 name,
344 deployPolicyId: parsed.id,
345 project: parsed.project || project,
346 location: parsed.location,
347 selectors: toSelectors(item.selectors),
348 rules: toRules(item.rules),
349 description: item.description,
350 suspended: item.suspended === true,
351 annotations: stringMap(item.annotations),
352 labels: userLabels(item.labels),
353 uid: item.uid,
354 createTime: item.createTime,
355 updateTime: item.updateTime,
356 etag: item.etag,
357 };
358};
359
360const getByName = (name: string) =>
361 clouddeploy
362 .getProjectsLocationsDeployPolicies({ name })
363 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
364
365const listOwned = (project: string, region: string) =>
366 listAtLocation(project, region, (parent) =>
367 listLabeledPages(
368 clouddeploy.listProjectsLocationsDeployPolicies.pages({
369 parent,
370 pageSize: 1000,
371 }),
372 (page) => page.deployPolicies,
373 (item) => item.labels,
374 ),
375 );
376
377export const DeployPolicyProvider = () =>
378 Provider.succeed(DeployPolicy, {
379 stables: [
380 "name",
381 "deployPolicyId",
382 "project",
383 "location",
384 "uid",
385 "createTime",
386 ],
387
388 diff: Effect.fn(function* ({ news, olds, output }) {
389 if (!isResolved(news)) return undefined;
390 const env = yield* GcpEnvironment.current;
391 return replaceOnIdentity({
392 previousId: olds?.deployPolicyId ?? output?.deployPolicyId,
393 nextId:
394 news.deployPolicyId ?? olds?.deployPolicyId ?? output?.deployPolicyId,
395 previousLocation: normalizeLocation(
396 olds?.location ?? output?.location ?? env.region,
397 ),
398 nextLocation: normalizeLocation(
399 news.location ?? olds?.location ?? output?.location ?? env.region,
400 ),
401 });
402 }),
403
404 read: Effect.fn(function* ({ id, olds, output }) {
405 const env = yield* GcpEnvironment.current;
406 const deployPolicyId = yield* toPhysicalId(
407 id,
408 olds?.deployPolicyId,
409 output?.deployPolicyId,
410 "deploypolicy",
411 );
412 const location = normalizeLocation(
413 olds?.location ?? output?.location ?? env.region,
414 );
415 const name =
416 output?.name ?? resourceName(env.project, location, deployPolicyId);
417 const existing = yield* getByName(name);
418 if (existing === undefined) return undefined;
419 const attrs = toAttrs(existing, env.project);
420 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
421 ? attrs
422 : Unowned(attrs);
423 }),
424
425 list: () =>
426 Effect.gen(function* () {
427 const env = yield* GcpEnvironment.current;
428 const items = yield* listOwned(env.project, env.region);
429 return items.map((item) => toAttrs(item, env.project));
430 }),
431
432 reconcile: Effect.fn(function* ({ id, news, output }) {
433 const env = yield* GcpEnvironment.current;
434 const deployPolicyId = yield* toPhysicalId(
435 id,
436 news.deployPolicyId,
437 output?.deployPolicyId,
438 "deploypolicy",
439 );
440 const location = normalizeLocation(
441 news.location ?? output?.location ?? env.region,
442 );
443 const name = resourceName(env.project, location, deployPolicyId);
444 const desiredLabels = {
445 ...toLabels(news.labels),
446 ...(yield* createInternalLabels(id)),
447 };
448 const desiredAnnotations = news.annotations ?? {};
449 const desiredSuspended = news.suspended === true;
450
451 let current = yield* getByName(output?.name ?? name);
452
453 if (current === undefined) {
454 const created = yield* clouddeploy
455 .createProjectsLocationsDeployPolicies({
456 parent: parentOf(env.project, location),
457 deployPolicyId,
458 body: {
459 selectors: news.selectors,
460 rules: news.rules,
461 description: news.description,
462 suspended: desiredSuspended,
463 annotations: desiredAnnotations,
464 labels: desiredLabels,
465 },
466 })
467 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
468 if (created !== undefined) {
469 yield* waitForOperation(created);
470 }
471 current = yield* waitUntilExists(getByName(name), name);
472 }
473
474 if (current === undefined) {
475 return yield* new ResourceNotResolved({ name });
476 }
477
478 const observedLabels = tagRecord(current.labels);
479 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
480 const mask = fieldMask([
481 (upsert.length > 0 || removed.length > 0) && "labels",
482 fingerprint(stringMap(current.annotations)) !==
483 fingerprint(desiredAnnotations) && "annotations",
484 !sameText(current.description, news.description) && "description",
485 !sameBool(current.suspended, desiredSuspended) && "suspended",
486 fingerprint(toSelectors(current.selectors)) !==
487 fingerprint(news.selectors) && "selectors",
488 fingerprint(toRules(current.rules)) !== fingerprint(news.rules) &&
489 "rules",
490 ]);
491
492 if (mask.length > 0) {
493 const operation =
494 yield* clouddeploy.patchProjectsLocationsDeployPolicies({
495 name: current.name ?? name,
496 updateMask: mask,
497 body: {
498 etag: current.etag,
499 selectors: news.selectors,
500 rules: news.rules,
501 description: news.description,
502 suspended: desiredSuspended,
503 annotations: desiredAnnotations,
504 labels: desiredLabels,
505 },
506 });
507 yield* waitForOperation(operation);
508 current = yield* waitUntilExists(
509 getByName(current.name ?? name),
510 current.name ?? name,
511 );
512 }
513
514 return toAttrs(current, env.project);
515 }),
516
517 delete: Effect.fn(function* ({ output }) {
518 const operation = yield* clouddeploy
519 .deleteProjectsLocationsDeployPolicies({
520 name: output.name,
521 allowMissing: true,
522 })
523 .pipe(
524 Effect.retry({
525 while: (error) => error._tag === "Conflict",
526 times: 8,
527 schedule: Schedule.spaced("2 seconds"),
528 }),
529 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
530 );
531 if (operation !== undefined) {
532 yield* waitForOperation(operation, { notFoundOk: true });
533 }
534 yield* waitUntilGone(getByName(output.name), output.name);
535 }),
536 });