1import * as saasservicemgmt from "@distilled.cloud/gcp/saasservicemgmt_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 ResourceNotResolved,
19 collectPages,
20 expandName,
21 fieldMask,
22 fingerprint,
23 hasAlchemyLabelKeys,
24 lastSegment,
25 normalizeLocation,
26 parentOf,
27 parseName,
28 replaceOnIdentity,
29 resourceName,
30 sameRef,
31 toPhysicalId,
32 userAnnotations,
33 userLabels,
34 waitUntilGone,
35} from "./internal.ts";
36
37const COLLECTION = "unitOperations";
38
39export type UnitOperationVariable = {
40 type?: saasservicemgmt.UnitVariableTypeEnum | (string & {});
41 value?: string;
42 variable?: string;
43};
44
45export type UnitOperationProvision = {
46 /** Release to provision. Accepts a release id or full resource name. */
47 release?: string;
48 /** Input variables. Maximum 100. */
49 inputVariables?: UnitOperationVariable[];
50};
51
52export type UnitOperationFlagUpdate = {
53 /** Flag release to apply. */
54 flagRelease?: string;
55};
56
57export type UnitOperationSchedule = {
58 /** RFC3339 start time. If omitted, the next maintenance window is used. */
59 startTime?: string;
60};
61
62export type UnitOperationCondition = {
63 status: string | undefined;
64 type: string | undefined;
65 message: string | undefined;
66 reason: string | undefined;
67 lastTransitionTime: string | undefined;
68};
69
70export type UnitOperationProps = {
71 /**
72 * UnitOperation id (the `{unitOperation}` segment of
73 * `projects/{project}/locations/{location}/unitOperations/{unitOperation}`).
74 * If omitted, a unique RFC1035 name is generated. Immutable —
75 * changing it replaces the operation.
76 */
77 unitOperationId?: string;
78 /**
79 * Region of the operation (`us-central1`, …). Immutable — changing it
80 * replaces the operation. `US-CENTRAL1` is accepted and normalized to
81 * `us-central1`.
82 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
83 */
84 location?: string;
85 /**
86 * Unit this operation acts on. Accepts a unit id or a full resource
87 * name. Immutable — changing it replaces the operation.
88 */
89 unit: string;
90 /**
91 * Provision the unit. Mutually exclusive with `upgrade`,
92 * `deprovision`, and `flagUpdate`. Immutable.
93 */
94 provision?: UnitOperationProvision;
95 /**
96 * Upgrade a provisioned unit. Mutually exclusive with the other
97 * operation types. Immutable.
98 */
99 upgrade?: UnitOperationProvision;
100 /**
101 * Deprovision a provisioned unit. Mutually exclusive with the other
102 * operation types. Immutable.
103 */
104 deprovision?: boolean;
105 /**
106 * Push a flag release. Mutually exclusive with the other operation
107 * types. Immutable.
108 */
109 flagUpdate?: UnitOperationFlagUpdate;
110 /**
111 * Parent unit operation, for tracing child work.
112 */
113 parentUnitOperation?: string;
114 /**
115 * Rollout that created this operation. Filtering only.
116 */
117 rollout?: string;
118 /**
119 * When to run the operation.
120 */
121 schedule?: UnitOperationSchedule;
122 /**
123 * Request cancellation. May fail if the operation is already
124 * executing.
125 */
126 cancel?: boolean;
127 /**
128 * User labels. Alchemy ownership labels are merged in automatically.
129 */
130 labels?: Record<string, string>;
131 /**
132 * Unstructured annotations preserved across updates.
133 */
134 annotations?: Record<string, string>;
135};
136
137export type UnitOperation = Resource<
138 "GCP.SaasServiceManagement.UnitOperation",
139 UnitOperationProps,
140 {
141 /** Full resource name `projects/{project}/locations/{location}/unitOperations/{unitOperation}`. */
142 name: string;
143 /** UnitOperation id (last path segment). */
144 unitOperationId: string;
145 /** Project id. */
146 project: string;
147 /** Location id. */
148 location: string;
149 /** Unit resource name. */
150 unit: string | undefined;
151 /** Unit id (last path segment). */
152 unitId: string | undefined;
153 /** Provision payload, if this is a provision. */
154 provision: UnitOperationProvision | undefined;
155 /** Upgrade payload, if this is an upgrade. */
156 upgrade: UnitOperationProvision | undefined;
157 /** Whether this is a deprovision. */
158 deprovision: boolean;
159 /** Flag-update payload, if this is a flag update. */
160 flagUpdate: UnitOperationFlagUpdate | undefined;
161 /** Parent unit operation. */
162 parentUnitOperation: string | undefined;
163 /** Creating rollout. */
164 rollout: string | undefined;
165 /** Scheduled start. */
166 schedule: UnitOperationSchedule | undefined;
167 /** Cancellation requested. */
168 cancel: boolean;
169 /** Lifecycle state. */
170 state: string | undefined;
171 /** Engine state (opaque). */
172 engineState: string | undefined;
173 /** Error category. */
174 errorCategory: string | undefined;
175 /** Conditions. */
176 conditions: UnitOperationCondition[];
177 /** User labels (Alchemy ownership labels stripped). */
178 labels: Record<string, string>;
179 /** User annotations. */
180 annotations: Record<string, string>;
181 /** Server UUID. */
182 uid: string | undefined;
183 /** Server etag. */
184 etag: string | undefined;
185 /** RFC3339 creation timestamp. */
186 createTime: string | undefined;
187 /** RFC3339 last-update timestamp. */
188 updateTime: string | undefined;
189 },
190 never,
191 Providers
192>;
193
194/**
195 * An App Lifecycle Manager unit operation — one provision, upgrade,
196 * deprovision, or flag-update of a Unit.
197 *
198 * `unitOperationId`, `location`, `unit`, and the chosen operation type
199 * replace the resource. `cancel`, labels, and annotations update in
200 * place. Only one mutating unit operation runs on a unit at a time.
201 *
202 * ### Creating a UnitOperation
203 * **Example:** Provision a unit
204 * ```typescript
205 * const op = yield* GCP.SaasServiceManagement.UnitOperation("Provision", {
206 * unit: unit.name,
207 * provision: { release: release.name },
208 * labels: { env: "prod" },
209 * });
210 * ```
211 *
212 * **Example:** Deprovision
213 * ```typescript
214 * const op = yield* GCP.SaasServiceManagement.UnitOperation("Teardown", {
215 * unit: unit.name,
216 * deprovision: true,
217 * });
218 * ```
219 *
220 * ### Updating a UnitOperation
221 * **Example:** Cancel
222 * ```typescript
223 * const op = yield* GCP.SaasServiceManagement.UnitOperation("Provision", {
224 * unitOperationId: op.unitOperationId,
225 * unit: unit.name,
226 * provision: { release: release.name },
227 * cancel: true,
228 * });
229 * ```
230 *
231 * @resource
232 * @category SaasServiceManagement
233 */
234export const UnitOperation = Resource<UnitOperation>(
235 "GCP.SaasServiceManagement.UnitOperation",
236);
237
238const operationKind = (props: {
239 provision?: UnitOperationProvision;
240 upgrade?: UnitOperationProvision;
241 deprovision?: boolean;
242 flagUpdate?: UnitOperationFlagUpdate;
243}) => {
244 if (props.provision !== undefined) return "provision";
245 if (props.upgrade !== undefined) return "upgrade";
246 if (props.deprovision === true) return "deprovision";
247 if (props.flagUpdate !== undefined) return "flagUpdate";
248 return "";
249};
250
251const expandProvision = (
252 provision: UnitOperationProvision | undefined,
253 project: string,
254 location: string,
255): saasservicemgmt.Provision | undefined => {
256 if (provision === undefined) return undefined;
257 return {
258 release:
259 provision.release === undefined
260 ? undefined
261 : expandName(provision.release, project, location, "releases"),
262 inputVariables: provision.inputVariables,
263 };
264};
265
266const toAttrs = (item: saasservicemgmt.UnitOperation, project: string) => {
267 const name = item.name ?? "";
268 const parsed = parseName(name, COLLECTION);
269 return {
270 name,
271 unitOperationId: parsed.id,
272 project: parsed.project || project,
273 location: parsed.location,
274 unit: item.unit,
275 unitId: item.unit ? lastSegment(item.unit) : undefined,
276 provision: item.provision,
277 upgrade: item.upgrade,
278 deprovision: item.deprovision !== undefined,
279 flagUpdate: item.flagUpdate,
280 parentUnitOperation: item.parentUnitOperation,
281 rollout: item.rollout,
282 schedule: item.schedule,
283 cancel: item.cancel === true,
284 state: item.state,
285 engineState: item.engineState,
286 errorCategory: item.errorCategory,
287 conditions: (item.conditions ?? []).map((condition) => ({
288 status: condition.status,
289 type: condition.type,
290 message: condition.message,
291 reason: condition.reason,
292 lastTransitionTime: condition.lastTransitionTime,
293 })),
294 labels: userLabels(item.labels),
295 annotations: userAnnotations(item.annotations),
296 uid: item.uid,
297 etag: item.etag,
298 createTime: item.createTime,
299 updateTime: item.updateTime,
300 };
301};
302
303const getByName = (name: string) =>
304 name.length === 0
305 ? Effect.succeed(undefined)
306 : saasservicemgmt
307 .getProjectsLocationsUnitOperations({ name })
308 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
309
310const listOwned = (project: string, location: string) =>
311 collectPages(
312 saasservicemgmt.listProjectsLocationsUnitOperations.pages({
313 parent: parentOf(project, "-"),
314 pageSize: 1000,
315 }),
316 (page) => page.unitOperations,
317 ).pipe(
318 Effect.map((items) =>
319 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
320 ),
321 Effect.flatMap((items) =>
322 items.length > 0
323 ? Effect.succeed(items)
324 : collectPages(
325 saasservicemgmt.listProjectsLocationsUnitOperations.pages({
326 parent: parentOf(project, location),
327 pageSize: 1000,
328 }),
329 (page) => page.unitOperations,
330 ).pipe(
331 Effect.map((fallback) =>
332 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
333 ),
334 ),
335 ),
336 );
337
338export const UnitOperationProvider = () =>
339 Provider.succeed(UnitOperation, {
340 stables: [
341 "name",
342 "unitOperationId",
343 "project",
344 "location",
345 "unitId",
346 "uid",
347 "createTime",
348 ],
349
350 diff: Effect.fn(function* ({ news, olds, output }) {
351 if (!isResolved(news)) return undefined;
352 const env = yield* GcpEnvironment.current;
353 const previousKind = operationKind({
354 provision: olds?.provision ?? output?.provision,
355 upgrade: olds?.upgrade ?? output?.upgrade,
356 deprovision: olds?.deprovision ?? output?.deprovision,
357 flagUpdate: olds?.flagUpdate ?? output?.flagUpdate,
358 });
359 const nextKind = operationKind(news) || previousKind;
360 return replaceOnIdentity({
361 previousId: olds?.unitOperationId ?? output?.unitOperationId,
362 nextId:
363 news.unitOperationId ??
364 olds?.unitOperationId ??
365 output?.unitOperationId,
366 previousLocation: normalizeLocation(
367 olds?.location ?? output?.location ?? env.region,
368 ),
369 nextLocation: normalizeLocation(
370 news.location ?? olds?.location ?? output?.location ?? env.region,
371 ),
372 extra:
373 !sameRef(olds?.unit ?? output?.unit, news.unit) ||
374 (previousKind.length > 0 &&
375 nextKind.length > 0 &&
376 previousKind !== nextKind),
377 });
378 }),
379
380 read: Effect.fn(function* ({ id, olds, output }) {
381 const env = yield* GcpEnvironment.current;
382 const unitOperationId = yield* toPhysicalId(
383 id,
384 olds?.unitOperationId,
385 output?.unitOperationId,
386 "uop",
387 );
388 const location = normalizeLocation(
389 olds?.location ?? output?.location ?? env.region,
390 );
391 const name =
392 output?.name ??
393 resourceName(env.project, location, COLLECTION, unitOperationId);
394 const existing = yield* getByName(name);
395 if (existing === undefined) return undefined;
396 const attrs = toAttrs(existing, env.project);
397 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
398 ? attrs
399 : Unowned(attrs);
400 }),
401
402 list: () =>
403 Effect.gen(function* () {
404 const env = yield* GcpEnvironment.current;
405 const items = yield* listOwned(env.project, env.region);
406 return items.map((item) => toAttrs(item, env.project));
407 }),
408
409 reconcile: Effect.fn(function* ({ id, news, output }) {
410 const env = yield* GcpEnvironment.current;
411 const unitOperationId = yield* toPhysicalId(
412 id,
413 news.unitOperationId,
414 output?.unitOperationId,
415 "uop",
416 );
417 const location = normalizeLocation(
418 news.location ?? output?.location ?? env.region,
419 );
420 const name = resourceName(
421 env.project,
422 location,
423 COLLECTION,
424 unitOperationId,
425 );
426 const desiredLabels = {
427 ...toLabels(news.labels),
428 ...(yield* createInternalLabels(id)),
429 };
430 const unit = expandName(news.unit, env.project, location, "units");
431 const provision = expandProvision(news.provision, env.project, location);
432 const upgrade = expandProvision(news.upgrade, env.project, location);
433 const parentUnitOperation =
434 news.parentUnitOperation === undefined
435 ? undefined
436 : expandName(
437 news.parentUnitOperation,
438 env.project,
439 location,
440 COLLECTION,
441 );
442 const annotations = news.annotations;
443
444 let current = yield* getByName(output?.name ?? name);
445
446 if (current === undefined) {
447 const created = yield* saasservicemgmt
448 .createProjectsLocationsUnitOperations({
449 parent: parentOf(env.project, location),
450 unitOperationId,
451 body: {
452 unit,
453 provision,
454 upgrade,
455 deprovision: news.deprovision === true ? {} : undefined,
456 flagUpdate: news.flagUpdate,
457 parentUnitOperation,
458 rollout: news.rollout,
459 schedule: news.schedule,
460 cancel: news.cancel,
461 labels: desiredLabels,
462 annotations,
463 },
464 })
465 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
466 current = created ?? undefined;
467 }
468
469 if (current === undefined) {
470 return yield* new ResourceNotResolved({ name });
471 }
472
473 const observedLabels = tagRecord(current.labels);
474 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
475 const labelsChanged = upsert.length > 0 || removed.length > 0;
476 const annotationsChanged =
477 annotations !== undefined &&
478 fingerprint(userAnnotations(current.annotations)) !==
479 fingerprint(annotations);
480 const cancelChanged =
481 news.cancel !== undefined && (current.cancel === true) !== news.cancel;
482 const scheduleChanged =
483 news.schedule !== undefined &&
484 fingerprint(current.schedule) !== fingerprint(news.schedule);
485 const mask = fieldMask([
486 labelsChanged && "labels",
487 annotationsChanged && "annotations",
488 cancelChanged && "cancel",
489 scheduleChanged && "schedule",
490 ]);
491
492 if (mask.length > 0) {
493 current = yield* saasservicemgmt.patchProjectsLocationsUnitOperations({
494 name: current.name ?? name,
495 updateMask: mask,
496 body: {
497 name: current.name ?? name,
498 etag: current.etag,
499 cancel: news.cancel,
500 schedule: news.schedule,
501 labels: desiredLabels,
502 annotations,
503 },
504 });
505 }
506
507 return toAttrs(current, env.project);
508 }),
509
510 delete: Effect.fn(function* ({ output }) {
511 yield* saasservicemgmt
512 .deleteProjectsLocationsUnitOperations({ name: output.name })
513 .pipe(
514 Effect.retry({
515 while: (error) => error._tag === "Conflict",
516 times: 8,
517 schedule: Schedule.spaced("2 seconds"),
518 }),
519 Effect.catchTag("NotFound", () => Effect.void),
520 );
521 yield* waitUntilGone(getByName(output.name), output.name);
522 }),
523 });