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 = "units";
38
39export type UnitManagementMode =
40 | saasservicemgmt.UnitManagementModeEnum
41 | (string & {});
42
43export type UnitMaintenanceSettings = {
44 /** Pin the unit to its current release until this RFC3339 timestamp. */
45 pinnedUntilTime?: string;
46};
47
48export type UnitVariable = {
49 type?: saasservicemgmt.UnitVariableTypeEnum | (string & {});
50 value?: string;
51 variable?: string;
52};
53
54export type UnitDependency = {
55 unit?: string;
56 alias?: string;
57};
58
59export type UnitCondition = {
60 lastTransitionTime: string | undefined;
61 status: string | undefined;
62 type: string | undefined;
63 message: string | undefined;
64 reason: string | undefined;
65};
66
67export type UnitProps = {
68 /**
69 * Unit id (the `{unit}` segment of
70 * `projects/{project}/locations/{location}/units/{unit}`). If omitted,
71 * a unique RFC1035 name is generated. Immutable — changing it
72 * replaces the unit.
73 */
74 unitId?: string;
75 /**
76 * Region of the unit (`us-central1`, …). Immutable — changing it
77 * replaces the unit. `US-CENTRAL1` is accepted and normalized to
78 * `us-central1`.
79 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
80 */
81 location?: string;
82 /**
83 * UnitKind this unit belongs to. Accepts a unit kind id or a full
84 * resource name. Immutable — changing it replaces the unit.
85 */
86 unitKind?: string;
87 /**
88 * Tenant this unit belongs to. Accepts a tenant id or a full resource
89 * name. Immutable — changing it replaces the unit.
90 */
91 tenant?: string;
92 /**
93 * Whether the unit lifecycle is user- or system-managed. Immutable —
94 * changing it replaces the unit.
95 */
96 managementMode?: UnitManagementMode;
97 /**
98 * Maintenance pin. Updates in place.
99 */
100 maintenance?: UnitMaintenanceSettings;
101 /**
102 * User labels. Alchemy ownership labels are merged in automatically.
103 */
104 labels?: Record<string, string>;
105 /**
106 * Unstructured annotations preserved across updates.
107 */
108 annotations?: Record<string, string>;
109};
110
111export type Unit = Resource<
112 "GCP.SaasServiceManagement.Unit",
113 UnitProps,
114 {
115 /** Full resource name `projects/{project}/locations/{location}/units/{unit}`. */
116 name: string;
117 /** Unit id (last path segment). */
118 unitId: string;
119 /** Project id. */
120 project: string;
121 /** Location id. */
122 location: string;
123 /** UnitKind resource name. */
124 unitKind: string | undefined;
125 /** UnitKind id (last path segment). */
126 unitKindId: string | undefined;
127 /** Tenant resource name. */
128 tenant: string | undefined;
129 /** Tenant id (last path segment). */
130 tenantId: string | undefined;
131 /** Management mode. */
132 managementMode: string | undefined;
133 /** System-managed state. */
134 systemManagedState: string | undefined;
135 /** Current release resource name. */
136 release: string | undefined;
137 /** Lifecycle state. */
138 state: string | undefined;
139 /** Maintenance pin. */
140 maintenance: UnitMaintenanceSettings | undefined;
141 /** Input variables currently deployed. */
142 inputVariables: UnitVariable[];
143 /** Output variables from actuation. */
144 outputVariables: UnitVariable[];
145 /** Dependencies of this unit. */
146 dependencies: UnitDependency[];
147 /** Units that depend on this unit. */
148 dependents: UnitDependency[];
149 /** Conditions. */
150 conditions: UnitCondition[];
151 /** Ongoing unit operations. */
152 ongoingOperations: ReadonlyArray<string>;
153 /** Pending unit operations. */
154 pendingOperations: ReadonlyArray<string>;
155 /** Flag revisions in use. */
156 flagRevisions: ReadonlyArray<string>;
157 /** User labels (Alchemy ownership labels stripped). */
158 labels: Record<string, string>;
159 /** User annotations. */
160 annotations: Record<string, string>;
161 /** Server UUID. */
162 uid: string | undefined;
163 /** Server etag. */
164 etag: string | undefined;
165 /** RFC3339 creation timestamp. */
166 createTime: string | undefined;
167 /** RFC3339 last-update timestamp. */
168 updateTime: string | undefined;
169 },
170 never,
171 Providers
172>;
173
174/**
175 * An App Lifecycle Manager unit — one deployable instance of a UnitKind.
176 *
177 * A unit is actuated by a blueprint engine (Terraform, Helm, …) under
178 * the hood. `unitId`, `location`, `unitKind`, `tenant`, and
179 * `managementMode` replace the unit. Maintenance pins, labels, and
180 * annotations update in place.
181 *
182 * ### Creating a Unit
183 * **Example:** User-managed unit
184 * ```typescript
185 * const unit = yield* GCP.SaasServiceManagement.Unit("Store1", {
186 * unitKind: kind.name,
187 * tenant: tenant.name,
188 * managementMode: "MANAGEMENT_MODE_USER",
189 * labels: { env: "prod" },
190 * });
191 * ```
192 *
193 * ### Updating a Unit
194 * **Example:** Pin maintenance
195 * ```typescript
196 * const unit = yield* GCP.SaasServiceManagement.Unit("Store1", {
197 * unitId: unit.unitId,
198 * unitKind: kind.name,
199 * maintenance: { pinnedUntilTime: "2030-01-01T00:00:00Z" },
200 * });
201 * ```
202 *
203 * @resource
204 * @category SaasServiceManagement
205 */
206export const Unit = Resource<Unit>("GCP.SaasServiceManagement.Unit");
207
208const toAttrs = (item: saasservicemgmt.Unit, project: string) => {
209 const name = item.name ?? "";
210 const parsed = parseName(name, COLLECTION);
211 return {
212 name,
213 unitId: parsed.id,
214 project: parsed.project || project,
215 location: parsed.location,
216 unitKind: item.unitKind,
217 unitKindId: item.unitKind ? lastSegment(item.unitKind) : undefined,
218 tenant: item.tenant,
219 tenantId: item.tenant ? lastSegment(item.tenant) : undefined,
220 managementMode: item.managementMode,
221 systemManagedState: item.systemManagedState,
222 release: item.release,
223 state: item.state,
224 maintenance: item.maintenance
225 ? { pinnedUntilTime: item.maintenance.pinnedUntilTime }
226 : undefined,
227 inputVariables: item.inputVariables ?? [],
228 outputVariables: item.outputVariables ?? [],
229 dependencies: item.dependencies ?? [],
230 dependents: item.dependents ?? [],
231 conditions: (item.conditions ?? []).map((condition) => ({
232 lastTransitionTime: condition.lastTransitionTime,
233 status: condition.status,
234 type: condition.type,
235 message: condition.message,
236 reason: condition.reason,
237 })),
238 ongoingOperations: item.ongoingOperations ?? [],
239 pendingOperations: item.pendingOperations ?? [],
240 flagRevisions: item.flagRevisions ?? [],
241 labels: userLabels(item.labels),
242 annotations: userAnnotations(item.annotations),
243 uid: item.uid,
244 etag: item.etag,
245 createTime: item.createTime,
246 updateTime: item.updateTime,
247 };
248};
249
250const getByName = (name: string) =>
251 name.length === 0
252 ? Effect.succeed(undefined)
253 : saasservicemgmt
254 .getProjectsLocationsUnits({ name })
255 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
256
257const listOwned = (project: string, location: string) =>
258 collectPages(
259 saasservicemgmt.listProjectsLocationsUnits.pages({
260 parent: parentOf(project, "-"),
261 pageSize: 1000,
262 }),
263 (page) => page.units,
264 ).pipe(
265 Effect.map((items) =>
266 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
267 ),
268 Effect.flatMap((items) =>
269 items.length > 0
270 ? Effect.succeed(items)
271 : collectPages(
272 saasservicemgmt.listProjectsLocationsUnits.pages({
273 parent: parentOf(project, location),
274 pageSize: 1000,
275 }),
276 (page) => page.units,
277 ).pipe(
278 Effect.map((fallback) =>
279 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
280 ),
281 ),
282 ),
283 );
284
285export const UnitProvider = () =>
286 Provider.succeed(Unit, {
287 stables: [
288 "name",
289 "unitId",
290 "project",
291 "location",
292 "unitKindId",
293 "tenantId",
294 "uid",
295 "createTime",
296 ],
297
298 diff: Effect.fn(function* ({ news, olds, output }) {
299 if (!isResolved(news)) return undefined;
300 const env = yield* GcpEnvironment.current;
301 const previousMode = olds?.managementMode ?? output?.managementMode;
302 const nextMode = news.managementMode ?? previousMode;
303 return replaceOnIdentity({
304 previousId: olds?.unitId ?? output?.unitId,
305 nextId: news.unitId ?? olds?.unitId ?? output?.unitId,
306 previousLocation: normalizeLocation(
307 olds?.location ?? output?.location ?? env.region,
308 ),
309 nextLocation: normalizeLocation(
310 news.location ?? olds?.location ?? output?.location ?? env.region,
311 ),
312 extra:
313 (news.unitKind !== undefined &&
314 !sameRef(olds?.unitKind ?? output?.unitKind, news.unitKind)) ||
315 (news.tenant !== undefined &&
316 !sameRef(olds?.tenant ?? output?.tenant, news.tenant)) ||
317 (previousMode !== undefined &&
318 nextMode !== undefined &&
319 previousMode !== nextMode),
320 });
321 }),
322
323 read: Effect.fn(function* ({ id, olds, output }) {
324 const env = yield* GcpEnvironment.current;
325 const unitId = yield* toPhysicalId(
326 id,
327 olds?.unitId,
328 output?.unitId,
329 "unit",
330 );
331 const location = normalizeLocation(
332 olds?.location ?? output?.location ?? env.region,
333 );
334 const name =
335 output?.name ?? resourceName(env.project, location, COLLECTION, unitId);
336 const existing = yield* getByName(name);
337 if (existing === undefined) return undefined;
338 const attrs = toAttrs(existing, env.project);
339 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
340 ? attrs
341 : Unowned(attrs);
342 }),
343
344 list: () =>
345 Effect.gen(function* () {
346 const env = yield* GcpEnvironment.current;
347 const items = yield* listOwned(env.project, env.region);
348 return items.map((item) => toAttrs(item, env.project));
349 }),
350
351 reconcile: Effect.fn(function* ({ id, news, output }) {
352 const env = yield* GcpEnvironment.current;
353 const unitId = yield* toPhysicalId(
354 id,
355 news.unitId,
356 output?.unitId,
357 "unit",
358 );
359 const location = normalizeLocation(
360 news.location ?? output?.location ?? env.region,
361 );
362 const name = resourceName(env.project, location, COLLECTION, unitId);
363 const desiredLabels = {
364 ...toLabels(news.labels),
365 ...(yield* createInternalLabels(id)),
366 };
367 const unitKind =
368 news.unitKind === undefined
369 ? undefined
370 : expandName(news.unitKind, env.project, location, "unitKinds");
371 const tenant =
372 news.tenant === undefined
373 ? undefined
374 : expandName(news.tenant, env.project, location, "tenants");
375 const annotations = news.annotations;
376
377 let current = yield* getByName(output?.name ?? name);
378
379 if (current === undefined) {
380 const created = yield* saasservicemgmt
381 .createProjectsLocationsUnits({
382 parent: parentOf(env.project, location),
383 unitId,
384 body: {
385 unitKind,
386 tenant,
387 managementMode: news.managementMode,
388 maintenance: news.maintenance,
389 labels: desiredLabels,
390 annotations,
391 },
392 })
393 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
394 current = created ?? undefined;
395 }
396
397 if (current === undefined) {
398 return yield* new ResourceNotResolved({ name });
399 }
400
401 const observedLabels = tagRecord(current.labels);
402 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
403 const labelsChanged = upsert.length > 0 || removed.length > 0;
404 const annotationsChanged =
405 annotations !== undefined &&
406 fingerprint(userAnnotations(current.annotations)) !==
407 fingerprint(annotations);
408 const maintenanceChanged =
409 news.maintenance !== undefined &&
410 fingerprint(current.maintenance) !== fingerprint(news.maintenance);
411 const mask = fieldMask([
412 labelsChanged && "labels",
413 annotationsChanged && "annotations",
414 maintenanceChanged && "maintenance",
415 ]);
416
417 if (mask.length > 0) {
418 current = yield* saasservicemgmt.patchProjectsLocationsUnits({
419 name: current.name ?? name,
420 updateMask: mask,
421 body: {
422 name: current.name ?? name,
423 etag: current.etag,
424 maintenance: news.maintenance,
425 labels: desiredLabels,
426 annotations,
427 },
428 });
429 }
430
431 return toAttrs(current, env.project);
432 }),
433
434 delete: Effect.fn(function* ({ output }) {
435 yield* saasservicemgmt
436 .deleteProjectsLocationsUnits({ name: output.name })
437 .pipe(
438 Effect.retry({
439 while: (error) => error._tag === "Conflict",
440 times: 8,
441 schedule: Schedule.spaced("2 seconds"),
442 }),
443 Effect.catchTag("NotFound", () => Effect.void),
444 );
445 yield* waitUntilGone(getByName(output.name), output.name);
446 }),
447 });