1import * as config from "@distilled.cloud/gcp/config_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 expandNamed,
19 fieldMask,
20 fingerprint,
21 listAtLocation,
22 listLabeledPages,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 replaceOnIdentity,
27 ResourceNotResolved,
28 stringMap,
29 toPhysicalId,
30 userLabels,
31 waitForOperation,
32 waitUntilExists,
33 waitUntilGone,
34} from "./internal.ts";
35
36export type DeploymentUnit = {
37 /**
38 * Unit id. Must be unique within the deployment group.
39 */
40 id?: string;
41 /**
42 * Existing deployment this unit points at, as
43 * `projects/{project}/locations/{location}/deployments/{deployment}`
44 * or a deployment id (expanded in the group's location).
45 */
46 deployment?: string;
47 /**
48 * IDs of other units in this group that must finish first.
49 */
50 dependencies?: string[];
51};
52
53export type DeploymentGroupProps = {
54 /**
55 * Deployment group id (the `{deploymentGroup}` segment of
56 * `projects/{project}/locations/{location}/deploymentGroups/{deploymentGroup}`).
57 * If omitted, a unique RFC1035 name is generated from the stack, stage,
58 * and logical id. Immutable — changing it replaces the group.
59 */
60 deploymentGroupId?: string;
61 /**
62 * Region (`us-central1`, `us-east1`, …). Immutable — changing it
63 * replaces the group. `US-CENTRAL1` is accepted and normalized to
64 * `us-central1`.
65 * @default the stack's GCP region (`GCP.Region`, else the profile
66 * region, else `us-central1`)
67 */
68 location?: string;
69 /**
70 * Deployment units arranged as a DAG. Units may reference an existing
71 * deployment or act as placeholders for a later provision call.
72 */
73 deploymentUnits?: DeploymentUnit[];
74 /**
75 * User annotations (not used by Infra Manager).
76 */
77 annotations?: Record<string, string>;
78 /**
79 * User labels. Alchemy ownership labels are merged in automatically.
80 */
81 labels?: Record<string, string>;
82};
83
84export type DeploymentGroup = Resource<
85 "GCP.Config.DeploymentGroup",
86 DeploymentGroupProps,
87 {
88 /** Full resource name. */
89 name: string;
90 /** Deployment group id (last path segment). */
91 deploymentGroupId: string;
92 /** Project id. */
93 project: string;
94 /** Location id. */
95 location: string;
96 /** Deployment units. */
97 deploymentUnits: DeploymentUnit[];
98 /** Server-reported resource state. */
99 state: string | undefined;
100 /** Extra state information. */
101 stateDescription: string | undefined;
102 /** Server-reported provisioning state. */
103 provisioningState: string | undefined;
104 /** Extra provisioning-state information. */
105 provisioningStateDescription: string | undefined;
106 /** User annotations. */
107 annotations: Record<string, string>;
108 /** User labels (Alchemy ownership labels stripped). */
109 labels: Record<string, string>;
110 /** RFC3339 creation timestamp. */
111 createTime: string | undefined;
112 /** RFC3339 last-update timestamp. */
113 updateTime: string | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * An Infrastructure Manager deployment group — a DAG of deployment units
121 * provisioned together.
122 *
123 * Changing `deploymentGroupId` or `location` replaces the group. Units,
124 * labels, and annotations update in place. Create, patch, and delete are
125 * long-running operations.
126 *
127 * ### Creating a Deployment Group
128 * **Example:** Generated name
129 * ```typescript
130 * const group = yield* GCP.Config.DeploymentGroup("App", {
131 * deploymentUnits: [{ id: "network", dependencies: [] }],
132 * });
133 * ```
134 *
135 * **Example:** Explicit id and labels
136 * ```typescript
137 * const group = yield* GCP.Config.DeploymentGroup("App", {
138 * deploymentGroupId: "app-group",
139 * deploymentUnits: [
140 * { id: "network", dependencies: [] },
141 * { id: "cluster", dependencies: ["network"] },
142 * ],
143 * labels: { env: "prod" },
144 * });
145 * ```
146 *
147 * ### Updating a Deployment Group
148 * **Example:** Labels and units
149 * ```typescript
150 * // Same logical id as before; only the changed props differ.
151 * const group = yield* GCP.Config.DeploymentGroup("App", {
152 * deploymentGroupId: "app-group",
153 * deploymentUnits: [
154 * { id: "network", dependencies: [] },
155 * { id: "cluster", dependencies: ["network"] },
156 * { id: "apps", dependencies: ["cluster"] },
157 * ],
158 * labels: { env: "prod", team: "platform" },
159 * });
160 * ```
161 *
162 * @resource
163 * @category Config
164 */
165export const DeploymentGroup = Resource<DeploymentGroup>(
166 "GCP.Config.DeploymentGroup",
167);
168
169const resourceName = (
170 project: string,
171 location: string,
172 deploymentGroupId: string,
173) =>
174 `projects/${project}/locations/${location}/deploymentGroups/${deploymentGroupId}`;
175
176const toUnits = (
177 units: readonly config.DeploymentUnit[] | undefined,
178 project: string,
179 location: string,
180): DeploymentUnit[] =>
181 (units ?? []).map((unit) => ({
182 id: unit.id,
183 deployment:
184 unit.deployment === undefined
185 ? undefined
186 : expandNamed(unit.deployment, project, location, "deployments"),
187 dependencies: unit.dependencies,
188 }));
189
190const desiredUnits = (
191 units: readonly DeploymentUnit[] | undefined,
192 project: string,
193 location: string,
194): config.DeploymentUnit[] =>
195 (units ?? []).map((unit) => ({
196 id: unit.id,
197 deployment:
198 unit.deployment === undefined
199 ? undefined
200 : expandNamed(unit.deployment, project, location, "deployments"),
201 dependencies: unit.dependencies,
202 }));
203
204const toAttrs = (
205 item: config.DeploymentGroup,
206 project: string,
207 region: string,
208) => {
209 const name = item.name ?? "";
210 const parsed = parseName(name, "deploymentGroups", region);
211 const resolvedProject = parsed.project || project;
212 return {
213 name,
214 deploymentGroupId: parsed.id,
215 project: resolvedProject,
216 location: parsed.location,
217 deploymentUnits: toUnits(
218 item.deploymentUnits,
219 resolvedProject,
220 parsed.location,
221 ),
222 state: item.state,
223 stateDescription: item.stateDescription,
224 provisioningState: item.provisioningState,
225 provisioningStateDescription: item.provisioningStateDescription,
226 annotations: stringMap(item.annotations),
227 labels: userLabels(item.labels),
228 createTime: item.createTime,
229 updateTime: item.updateTime,
230 };
231};
232
233const getByName = (name: string) =>
234 config
235 .getProjectsLocationsDeploymentGroups({ name })
236 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
237
238const listOwned = (project: string, region: string) =>
239 listAtLocation(project, region, (parent) =>
240 listLabeledPages(
241 config.listProjectsLocationsDeploymentGroups.pages({
242 parent,
243 pageSize: 1000,
244 }),
245 (page) => page.deploymentGroups,
246 (item) => item.labels,
247 ),
248 );
249
250/** Deployment group ids are at most 40 characters. */
251const MAX_DEPLOYMENT_GROUP_ID_LENGTH = 40;
252
253export const DeploymentGroupProvider = () =>
254 Provider.succeed(DeploymentGroup, {
255 stables: ["name", "deploymentGroupId", "project", "location", "createTime"],
256
257 diff: Effect.fn(function* ({ news, olds, output }) {
258 if (!isResolved(news)) return undefined;
259 const env = yield* GcpEnvironment.current;
260 return replaceOnIdentity({
261 previousId: olds?.deploymentGroupId ?? output?.deploymentGroupId,
262 nextId:
263 news.deploymentGroupId ??
264 olds?.deploymentGroupId ??
265 output?.deploymentGroupId,
266 previousLocation: normalizeLocation(
267 olds?.location ?? output?.location,
268 env.region,
269 ),
270 nextLocation: normalizeLocation(
271 news.location ?? olds?.location ?? output?.location,
272 env.region,
273 ),
274 });
275 }),
276
277 read: Effect.fn(function* ({ id, olds, output }) {
278 const env = yield* GcpEnvironment.current;
279 const deploymentGroupId = yield* toPhysicalId(
280 id,
281 olds?.deploymentGroupId,
282 output?.deploymentGroupId,
283 "deploymentgroup",
284 MAX_DEPLOYMENT_GROUP_ID_LENGTH,
285 );
286 const location = normalizeLocation(
287 olds?.location ?? output?.location,
288 env.region,
289 );
290 const name =
291 output?.name ?? resourceName(env.project, location, deploymentGroupId);
292 const existing = yield* getByName(name);
293 if (existing === undefined) return undefined;
294 const attrs = toAttrs(existing, env.project, env.region);
295 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
296 ? attrs
297 : Unowned(attrs);
298 }),
299
300 list: () =>
301 Effect.gen(function* () {
302 const env = yield* GcpEnvironment.current;
303 const items = yield* listOwned(env.project, env.region);
304 return items.map((item) => toAttrs(item, env.project, env.region));
305 }),
306
307 reconcile: Effect.fn(function* ({ id, news, output }) {
308 const env = yield* GcpEnvironment.current;
309 const deploymentGroupId = yield* toPhysicalId(
310 id,
311 news.deploymentGroupId,
312 output?.deploymentGroupId,
313 "deploymentgroup",
314 MAX_DEPLOYMENT_GROUP_ID_LENGTH,
315 );
316 const location = normalizeLocation(
317 news.location ?? output?.location,
318 env.region,
319 );
320 const name = resourceName(env.project, location, deploymentGroupId);
321 const desiredLabels = {
322 ...toLabels(news.labels),
323 ...(yield* createInternalLabels(id)),
324 };
325 const desiredAnnotations = news.annotations ?? {};
326 const units = desiredUnits(news.deploymentUnits, env.project, location);
327
328 let current = yield* getByName(output?.name ?? name);
329
330 if (current === undefined) {
331 const created = yield* config
332 .createProjectsLocationsDeploymentGroups({
333 parent: parentOf(env.project, location),
334 deploymentGroupId,
335 body: {
336 deploymentUnits: units,
337 annotations: desiredAnnotations,
338 labels: desiredLabels,
339 },
340 })
341 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
342 if (created !== undefined) {
343 yield* waitForOperation(created);
344 }
345 current = yield* waitUntilExists(getByName(name), name);
346 }
347
348 if (current === undefined) {
349 return yield* new ResourceNotResolved({ name });
350 }
351
352 const observedLabels = tagRecord(current.labels);
353 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
354 const mask = fieldMask([
355 (upsert.length > 0 || removed.length > 0) && "labels",
356 fingerprint(stringMap(current.annotations)) !==
357 fingerprint(desiredAnnotations) && "annotations",
358 fingerprint(toUnits(current.deploymentUnits, env.project, location)) !==
359 fingerprint(units) && "deploymentUnits",
360 ]);
361
362 if (mask.length > 0) {
363 const operation = yield* config.patchProjectsLocationsDeploymentGroups({
364 name: current.name ?? name,
365 updateMask: mask,
366 body: {
367 labels: desiredLabels,
368 annotations: desiredAnnotations,
369 deploymentUnits: units,
370 },
371 });
372 yield* waitForOperation(operation);
373 current = yield* waitUntilExists(
374 getByName(current.name ?? name),
375 current.name ?? name,
376 );
377 }
378
379 return toAttrs(current, env.project, env.region);
380 }),
381
382 delete: Effect.fn(function* ({ output }) {
383 const operation = yield* config
384 .deleteProjectsLocationsDeploymentGroups({
385 name: output.name,
386 force: true,
387 deploymentReferencePolicy: "IGNORE_DEPLOYMENT_REFERENCES",
388 })
389 .pipe(
390 Effect.retry({
391 while: (error) => error._tag === "Conflict",
392 times: 8,
393 schedule: Schedule.spaced("2 seconds"),
394 }),
395 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
396 );
397 if (operation !== undefined) {
398 yield* waitForOperation(operation, { notFoundOk: true });
399 }
400 yield* waitUntilGone(getByName(output.name), output.name);
401 }),
402 });