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 gkeClusterName,
22 listAtLocation,
23 listLabeledPages,
24 membershipName,
25 normalizeLocation,
26 parentOf,
27 parseName,
28 replaceOnIdentity,
29 ResourceNotResolved,
30 runLocationName,
31 sameBool,
32 sameText,
33 stringMap,
34 toPhysicalId,
35 userLabels,
36 waitForOperation,
37 waitUntilExists,
38 waitUntilGone,
39} from "./internal.ts";
40
41export type GkeCluster = {
42 /**
43 * GKE cluster resource name
44 * `projects/{project}/locations/{location}/clusters/{cluster}` or the
45 * cluster id.
46 */
47 cluster?: string;
48 /**
49 * Access the cluster using the DNS endpoint. Cannot be combined with
50 * `internalIp`.
51 */
52 dnsEndpoint?: boolean;
53 /**
54 * Access a private cluster using the private control-plane IP.
55 */
56 internalIp?: boolean;
57 /**
58 * Optional HTTP proxy URL for the Kubernetes API server.
59 */
60 proxyUrl?: string;
61};
62
63export type AnthosCluster = {
64 /**
65 * GKE Hub membership
66 * `projects/{project}/locations/{location}/memberships/{membership}`
67 * or the membership id.
68 */
69 membership?: string;
70};
71
72export type CloudRunLocation = {
73 /**
74 * Cloud Run location
75 * `projects/{project}/locations/{location}` or a location id.
76 */
77 location?: string;
78};
79
80export type CustomTarget = {
81 /**
82 * Custom target type resource name
83 * `projects/{project}/locations/{location}/customTargetTypes/{customTargetType}`
84 * or the type id.
85 */
86 customTargetType?: string;
87};
88
89export type MultiTarget = {
90 /** Child target ids. */
91 targetIds?: string[];
92};
93
94export type DefaultPool = {
95 /** Cloud Storage location for execution outputs. */
96 artifactStorage?: string;
97 /** Service account used for execution. */
98 serviceAccount?: string;
99};
100
101export type PrivatePool = {
102 /**
103 * Cloud Build worker pool
104 * `projects/{project}/locations/{location}/workerPools/{pool}`.
105 */
106 workerPool?: string;
107 /** Cloud Storage location for execution outputs. */
108 artifactStorage?: string;
109 /** Service account used for execution. */
110 serviceAccount?: string;
111};
112
113export type ExecutionConfig = {
114 /**
115 * Usages this configuration applies to (`RENDER`, `DEPLOY`, `VERIFY`,
116 * `PREDEPLOY`, `POSTDEPLOY`, `ANALYSIS`).
117 */
118 usages?: Array<clouddeploy.ExecutionConfigUsagesItemEnum | (string & {})>;
119 /** Cloud Build execution timeout (10m–24h, seconds format). */
120 executionTimeout?: string;
121 /** Default Cloud Build pool. */
122 defaultPool?: DefaultPool;
123 /**
124 * Worker pool resource name. If omitted, the default Cloud Build pool
125 * is used.
126 */
127 workerPool?: string;
128 /** Cloud Storage location for execution outputs. */
129 artifactStorage?: string;
130 /** Private Cloud Build pool. */
131 privatePool?: PrivatePool;
132 /** Extra logging when running builds. */
133 verbose?: boolean;
134 /** Service account used for execution. */
135 serviceAccount?: string;
136};
137
138export type TargetProps = {
139 /**
140 * Target id (the `{target}` segment of
141 * `projects/{project}/locations/{location}/targets/{target}`). If
142 * omitted, a unique RFC1035 name is generated from the stack, stage,
143 * and logical id. Immutable — changing it replaces the target.
144 */
145 targetId?: string;
146 /**
147 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
148 * replaces the target. `US-CENTRAL1` is accepted and normalized to
149 * `us-central1`.
150 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
151 */
152 location?: string;
153 /**
154 * GKE cluster destination. Mutually exclusive with `run`,
155 * `anthosCluster`, `customTarget`, and `multiTarget`.
156 */
157 gke?: GkeCluster;
158 /**
159 * Cloud Run destination. Mutually exclusive with the other destination
160 * fields.
161 */
162 run?: CloudRunLocation;
163 /**
164 * Anthos / GKE Hub membership destination. Mutually exclusive with the
165 * other destination fields.
166 */
167 anthosCluster?: AnthosCluster;
168 /**
169 * Custom target destination. Mutually exclusive with the other
170 * destination fields.
171 */
172 customTarget?: CustomTarget;
173 /**
174 * Multi-target grouping child targets. Mutually exclusive with the
175 * other destination fields.
176 */
177 multiTarget?: MultiTarget;
178 /**
179 * Whether rollouts to this target require approval.
180 * @default false
181 */
182 requireApproval?: boolean;
183 /**
184 * Execution environments for render/deploy (and optional verify).
185 */
186 executionConfigs?: ExecutionConfig[];
187 /**
188 * Deploy parameters for this target.
189 */
190 deployParameters?: Record<string, string>;
191 /**
192 * Associated entities keyed by entity id (Gateway API canary, …).
193 */
194 associatedEntities?: clouddeploy.AssociatedEntitiesMap;
195 /**
196 * Human-readable description. Max length 255 characters.
197 */
198 description?: string;
199 /**
200 * User annotations (not used by Cloud Deploy).
201 */
202 annotations?: Record<string, string>;
203 /**
204 * User labels. Alchemy ownership labels are merged in automatically.
205 */
206 labels?: Record<string, string>;
207};
208
209export type Target = Resource<
210 "GCP.CloudDeploy.Target",
211 TargetProps,
212 {
213 /** Full resource name. */
214 name: string;
215 /** Target id (last path segment). */
216 targetId: string;
217 /** Project id. */
218 project: string;
219 /** Location id. */
220 location: string;
221 /** GKE destination, if configured. */
222 gke: GkeCluster | undefined;
223 /** Cloud Run destination, if configured. */
224 run: CloudRunLocation | undefined;
225 /** Anthos destination, if configured. */
226 anthosCluster: AnthosCluster | undefined;
227 /** Custom target destination, if configured. */
228 customTarget: CustomTarget | undefined;
229 /** Multi-target grouping, if configured. */
230 multiTarget: MultiTarget | undefined;
231 /** Whether rollouts require approval. */
232 requireApproval: boolean;
233 /** Execution environment configurations. */
234 executionConfigs: ExecutionConfig[] | undefined;
235 /** Deploy parameters. */
236 deployParameters: Record<string, string>;
237 /** Associated entities. */
238 associatedEntities: clouddeploy.AssociatedEntitiesMap | undefined;
239 /** Human-readable description. */
240 description: string | undefined;
241 /** User annotations. */
242 annotations: Record<string, string>;
243 /** User labels (Alchemy ownership labels stripped). */
244 labels: Record<string, string>;
245 /** Server-generated resource uid. */
246 uid: string | undefined;
247 /** RFC3339 creation timestamp. */
248 createTime: string | undefined;
249 /** RFC3339 last-update timestamp. */
250 updateTime: string | undefined;
251 /** Server-computed etag. */
252 etag: string | undefined;
253 },
254 never,
255 Providers
256>;
257
258/**
259 * A Cloud Deploy target — a location a Skaffold configuration can be
260 * deployed to (GKE, Cloud Run, Anthos, a custom target type, or a
261 * multi-target).
262 *
263 * Changing `targetId` or `location` replaces the target. Destination,
264 * approval, execution configs, description, labels, and annotations
265 * update in place.
266 *
267 * ### Creating a Target
268 * **Example:** Cloud Run
269 * ```typescript
270 * const target = yield* GCP.CloudDeploy.Target("Prod", {
271 * run: { location: "projects/my-project/locations/us-central1" },
272 * });
273 * ```
274 *
275 * **Example:** GKE cluster
276 * ```typescript
277 * const target = yield* GCP.CloudDeploy.Target("Prod", {
278 * gke: {
279 * cluster: "projects/my-project/locations/us-central1/clusters/app",
280 * },
281 * requireApproval: true,
282 * labels: { env: "prod" },
283 * });
284 * ```
285 *
286 * ### Updating a Target
287 * Re-declare the same logical id with changed props; the engine keeps the
288 * physical resource and updates it in place.
289 *
290 * **Example:** Description and labels
291 * ```typescript
292 * const target = yield* GCP.CloudDeploy.Target("Prod", {
293 * run: { location: "projects/my-project/locations/us-central1" },
294 * description: "prod run target v2",
295 * labels: { env: "prod", team: "platform" },
296 * });
297 * ```
298 *
299 * @resource
300 * @category CloudDeploy
301 */
302export const Target = Resource<Target>("GCP.CloudDeploy.Target");
303
304const resourceName = (project: string, location: string, targetId: string) =>
305 `projects/${project}/locations/${location}/targets/${targetId}`;
306
307const desiredDestination = (
308 news: TargetProps,
309 project: string,
310 location: string,
311) => {
312 const gke =
313 news.gke === undefined
314 ? undefined
315 : {
316 ...news.gke,
317 cluster:
318 news.gke.cluster === undefined
319 ? undefined
320 : gkeClusterName(news.gke.cluster, project, location),
321 };
322 const run =
323 news.run === undefined
324 ? undefined
325 : {
326 location:
327 news.run.location === undefined
328 ? undefined
329 : runLocationName(news.run.location, project, location),
330 };
331 const anthosCluster =
332 news.anthosCluster === undefined
333 ? undefined
334 : {
335 membership:
336 news.anthosCluster.membership === undefined
337 ? undefined
338 : membershipName(
339 news.anthosCluster.membership,
340 project,
341 location,
342 ),
343 };
344 const customTarget =
345 news.customTarget === undefined
346 ? undefined
347 : {
348 customTargetType:
349 news.customTarget.customTargetType === undefined
350 ? undefined
351 : expandParent(
352 news.customTarget.customTargetType,
353 project,
354 location,
355 "customTargetTypes",
356 ),
357 };
358 return {
359 gke,
360 run,
361 anthosCluster,
362 customTarget,
363 multiTarget: news.multiTarget,
364 };
365};
366
367const toGke = (
368 value: clouddeploy.GkeCluster | undefined,
369): GkeCluster | undefined =>
370 value === undefined
371 ? undefined
372 : {
373 cluster: value.cluster,
374 dnsEndpoint: value.dnsEndpoint,
375 internalIp: value.internalIp,
376 proxyUrl: value.proxyUrl,
377 };
378
379const toRun = (
380 value: clouddeploy.CloudRunLocation | undefined,
381): CloudRunLocation | undefined =>
382 value === undefined ? undefined : { location: value.location };
383
384const toAnthos = (
385 value: clouddeploy.AnthosCluster | undefined,
386): AnthosCluster | undefined =>
387 value === undefined ? undefined : { membership: value.membership };
388
389const toCustom = (
390 value: clouddeploy.CustomTarget | undefined,
391): CustomTarget | undefined =>
392 value === undefined
393 ? undefined
394 : { customTargetType: value.customTargetType };
395
396const toMulti = (
397 value: clouddeploy.MultiTarget | undefined,
398): MultiTarget | undefined =>
399 value === undefined ? undefined : { targetIds: value.targetIds };
400
401const toExecutionConfigs = (
402 value: clouddeploy.ExecutionConfigList | undefined,
403): ExecutionConfig[] | undefined =>
404 value === undefined
405 ? undefined
406 : value.map((config) => ({
407 usages: config.usages,
408 executionTimeout: config.executionTimeout,
409 defaultPool: config.defaultPool,
410 workerPool: config.workerPool,
411 artifactStorage: config.artifactStorage,
412 privatePool: config.privatePool,
413 verbose: config.verbose,
414 serviceAccount: config.serviceAccount,
415 }));
416
417const toAttrs = (item: clouddeploy.Target, project: string) => {
418 const name = item.name ?? "";
419 const parsed = parseName(name, "targets");
420 return {
421 name,
422 targetId: item.targetId ?? parsed.id,
423 project: parsed.project || project,
424 location: parsed.location,
425 gke: toGke(item.gke),
426 run: toRun(item.run),
427 anthosCluster: toAnthos(item.anthosCluster),
428 customTarget: toCustom(item.customTarget),
429 multiTarget: toMulti(item.multiTarget),
430 requireApproval: item.requireApproval === true,
431 executionConfigs: toExecutionConfigs(item.executionConfigs),
432 deployParameters: stringMap(item.deployParameters),
433 associatedEntities: item.associatedEntities,
434 description: item.description,
435 annotations: stringMap(item.annotations),
436 labels: userLabels(item.labels),
437 uid: item.uid,
438 createTime: item.createTime,
439 updateTime: item.updateTime,
440 etag: item.etag,
441 };
442};
443
444const getByName = (name: string) =>
445 clouddeploy
446 .getProjectsLocationsTargets({ name })
447 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
448
449const listOwned = (project: string, region: string) =>
450 listAtLocation(project, region, (parent) =>
451 listLabeledPages(
452 clouddeploy.listProjectsLocationsTargets.pages({
453 parent,
454 pageSize: 1000,
455 }),
456 (page) => page.targets,
457 (item) => item.labels,
458 ),
459 );
460
461export const TargetProvider = () =>
462 Provider.succeed(Target, {
463 stables: ["name", "targetId", "project", "location", "uid", "createTime"],
464
465 diff: Effect.fn(function* ({ news, olds, output }) {
466 if (!isResolved(news)) return undefined;
467 const env = yield* GcpEnvironment.current;
468 return replaceOnIdentity({
469 previousId: olds?.targetId ?? output?.targetId,
470 nextId: news.targetId ?? olds?.targetId ?? output?.targetId,
471 previousLocation: normalizeLocation(
472 olds?.location ?? output?.location ?? env.region,
473 ),
474 nextLocation: normalizeLocation(
475 news.location ?? olds?.location ?? output?.location ?? env.region,
476 ),
477 });
478 }),
479
480 read: Effect.fn(function* ({ id, olds, output }) {
481 const env = yield* GcpEnvironment.current;
482 const targetId = yield* toPhysicalId(
483 id,
484 olds?.targetId,
485 output?.targetId,
486 "target",
487 );
488 const location = normalizeLocation(
489 olds?.location ?? output?.location ?? env.region,
490 );
491 const name =
492 output?.name ?? resourceName(env.project, location, targetId);
493 const existing = yield* getByName(name);
494 if (existing === undefined) return undefined;
495 const attrs = toAttrs(existing, env.project);
496 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
497 ? attrs
498 : Unowned(attrs);
499 }),
500
501 list: () =>
502 Effect.gen(function* () {
503 const env = yield* GcpEnvironment.current;
504 const items = yield* listOwned(env.project, env.region);
505 return items.map((item) => toAttrs(item, env.project));
506 }),
507
508 reconcile: Effect.fn(function* ({ id, news, output }) {
509 const env = yield* GcpEnvironment.current;
510 const targetId = yield* toPhysicalId(
511 id,
512 news.targetId,
513 output?.targetId,
514 "target",
515 );
516 const location = normalizeLocation(
517 news.location ?? output?.location ?? env.region,
518 );
519 const name = resourceName(env.project, location, targetId);
520 const desiredLabels = {
521 ...toLabels(news.labels),
522 ...(yield* createInternalLabels(id)),
523 };
524 const desiredAnnotations = news.annotations ?? {};
525 const desiredParameters = news.deployParameters ?? {};
526 const desiredApproval = news.requireApproval === true;
527 const destination = desiredDestination(news, env.project, location);
528
529 let current = yield* getByName(output?.name ?? name);
530
531 if (current === undefined) {
532 const created = yield* clouddeploy
533 .createProjectsLocationsTargets({
534 parent: parentOf(env.project, location),
535 targetId,
536 body: {
537 description: news.description,
538 requireApproval: desiredApproval,
539 gke: destination.gke,
540 run: destination.run,
541 anthosCluster: destination.anthosCluster,
542 customTarget: destination.customTarget,
543 multiTarget: destination.multiTarget,
544 executionConfigs: news.executionConfigs,
545 deployParameters: desiredParameters,
546 associatedEntities: news.associatedEntities,
547 annotations: desiredAnnotations,
548 labels: desiredLabels,
549 },
550 })
551 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
552 if (created !== undefined) {
553 yield* waitForOperation(created);
554 }
555 current = yield* waitUntilExists(getByName(name), name);
556 }
557
558 if (current === undefined) {
559 return yield* new ResourceNotResolved({ name });
560 }
561
562 const observedLabels = tagRecord(current.labels);
563 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
564 const mask = fieldMask([
565 (upsert.length > 0 || removed.length > 0) && "labels",
566 fingerprint(stringMap(current.annotations)) !==
567 fingerprint(desiredAnnotations) && "annotations",
568 !sameText(current.description, news.description) && "description",
569 !sameBool(current.requireApproval, desiredApproval) &&
570 "requireApproval",
571 fingerprint(toGke(current.gke)) !== fingerprint(destination.gke) &&
572 "gke",
573 fingerprint(toRun(current.run)) !== fingerprint(destination.run) &&
574 "run",
575 fingerprint(toAnthos(current.anthosCluster)) !==
576 fingerprint(destination.anthosCluster) && "anthosCluster",
577 fingerprint(toCustom(current.customTarget)) !==
578 fingerprint(destination.customTarget) && "customTarget",
579 fingerprint(toMulti(current.multiTarget)) !==
580 fingerprint(destination.multiTarget) && "multiTarget",
581 fingerprint(toExecutionConfigs(current.executionConfigs)) !==
582 fingerprint(news.executionConfigs) && "executionConfigs",
583 fingerprint(stringMap(current.deployParameters)) !==
584 fingerprint(desiredParameters) && "deployParameters",
585 fingerprint(current.associatedEntities) !==
586 fingerprint(news.associatedEntities) && "associatedEntities",
587 ]);
588
589 if (mask.length > 0) {
590 const operation = yield* clouddeploy.patchProjectsLocationsTargets({
591 name: current.name ?? name,
592 updateMask: mask,
593 body: {
594 etag: current.etag,
595 description: news.description,
596 requireApproval: desiredApproval,
597 gke: destination.gke,
598 run: destination.run,
599 anthosCluster: destination.anthosCluster,
600 customTarget: destination.customTarget,
601 multiTarget: destination.multiTarget,
602 executionConfigs: news.executionConfigs,
603 deployParameters: desiredParameters,
604 associatedEntities: news.associatedEntities,
605 annotations: desiredAnnotations,
606 labels: desiredLabels,
607 },
608 });
609 yield* waitForOperation(operation);
610 current = yield* waitUntilExists(
611 getByName(current.name ?? name),
612 current.name ?? name,
613 );
614 }
615
616 return toAttrs(current, env.project);
617 }),
618
619 delete: Effect.fn(function* ({ output }) {
620 const operation = yield* clouddeploy
621 .deleteProjectsLocationsTargets({
622 name: output.name,
623 allowMissing: true,
624 })
625 .pipe(
626 Effect.retry({
627 while: (error) => error._tag === "Conflict",
628 times: 8,
629 schedule: Schedule.spaced("2 seconds"),
630 }),
631 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
632 );
633 if (operation !== undefined) {
634 yield* waitForOperation(operation, { notFoundOk: true });
635 }
636 yield* waitUntilGone(getByName(output.name), output.name);
637 }),
638 });