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 = "unitKinds";
38
39export type UnitKindBoundaryType =
40 | saasservicemgmt.UnitKindBoundaryTypeEnum
41 | (string & {});
42
43export type UnitKindDependency = {
44 /** UnitKind resource name this kind depends on. Immutable. */
45 unitKind?: string;
46 /** Alias used in variable mappings. */
47 alias?: string;
48};
49
50export type UnitKindFromMapping = {
51 outputVariable?: string;
52 dependency?: string;
53};
54
55export type UnitKindToMapping = {
56 inputVariable?: string;
57 ignoreForLookup?: boolean;
58 dependency?: string;
59};
60
61export type UnitKindVariableMapping = {
62 variable?: string;
63 from?: UnitKindFromMapping;
64 to?: UnitKindToMapping;
65};
66
67export type UnitKindProps = {
68 /**
69 * UnitKind id (the `{unitKind}` segment of
70 * `projects/{project}/locations/{location}/unitKinds/{unitKind}`). If
71 * omitted, a unique RFC1035 name is generated. Immutable — changing
72 * it replaces the unit kind.
73 */
74 unitKindId?: string;
75 /**
76 * Region of the unit kind (`us-central1`, …). Immutable — changing it
77 * replaces the unit kind. `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 * SaaS this unit kind belongs to. Accepts a SaaS id or a full
84 * resource name. Immutable — changing it replaces the unit kind.
85 */
86 saas: string;
87 /**
88 * Default Release used when creating new units of this kind. Accepts
89 * a release id or a full resource name.
90 */
91 defaultRelease?: string;
92 /**
93 * Default flag revisions applied to newly created units.
94 */
95 defaultFlagRevisions?: string[];
96 /**
97 * Other unit kinds this kind depends on. Immutable — changing them
98 * replaces the unit kind.
99 */
100 dependencies?: UnitKindDependency[];
101 /**
102 * Input variable mappings to or from dependencies. Maximum 100.
103 */
104 inputVariableMappings?: UnitKindVariableMapping[];
105 /**
106 * Output variable mappings from this unit kind. Maximum 100.
107 */
108 outputVariableMappings?: UnitKindVariableMapping[];
109 /**
110 * User labels. Alchemy ownership labels are merged in automatically.
111 */
112 labels?: Record<string, string>;
113 /**
114 * Unstructured annotations preserved across updates.
115 */
116 annotations?: Record<string, string>;
117};
118
119export type UnitKind = Resource<
120 "GCP.SaasServiceManagement.UnitKind",
121 UnitKindProps,
122 {
123 /** Full resource name `projects/{project}/locations/{location}/unitKinds/{unitKind}`. */
124 name: string;
125 /** UnitKind id (last path segment). */
126 unitKindId: string;
127 /** Project id. */
128 project: string;
129 /** Location id. */
130 location: string;
131 /** SaaS resource name. */
132 saas: string | undefined;
133 /** SaaS id (last path segment). */
134 saasId: string | undefined;
135 /** Default release resource name. */
136 defaultRelease: string | undefined;
137 /** Default flag revisions. */
138 defaultFlagRevisions: ReadonlyArray<string>;
139 /** Declared dependencies. */
140 dependencies: UnitKindDependency[];
141 /** Input variable mappings. */
142 inputVariableMappings: UnitKindVariableMapping[];
143 /** Output variable mappings. */
144 outputVariableMappings: UnitKindVariableMapping[];
145 /** Boundary type reported by the API. */
146 boundaryType: string | undefined;
147 /** User labels (Alchemy ownership labels stripped). */
148 labels: Record<string, string>;
149 /** User annotations. */
150 annotations: Record<string, string>;
151 /** Server UUID. */
152 uid: string | undefined;
153 /** Server etag. */
154 etag: string | undefined;
155 /** RFC3339 creation timestamp. */
156 createTime: string | undefined;
157 /** RFC3339 last-update timestamp. */
158 updateTime: string | undefined;
159 },
160 never,
161 Providers
162>;
163
164/**
165 * An App Lifecycle Manager unit kind — the blueprint family that Units
166 * of a SaaS share.
167 *
168 * Units of the same kind follow the same release model and are typically
169 * rolled out together. `unitKindId`, `location`, `saas`, and
170 * `dependencies` replace the unit kind. Default release, mappings,
171 * labels, and annotations update in place.
172 *
173 * ### Creating a UnitKind
174 * **Example:** Kind of a SaaS
175 * ```typescript
176 * const product = yield* GCP.SaasServiceManagement.Saa("Inventory", {
177 * locations: [{ name: "us-central1" }],
178 * });
179 * const kind = yield* GCP.SaasServiceManagement.UnitKind("Store", {
180 * saas: product.name,
181 * labels: { env: "prod" },
182 * });
183 * ```
184 *
185 * ### Updating a UnitKind
186 * **Example:** Point at a default release
187 * ```typescript
188 * const kind = yield* GCP.SaasServiceManagement.UnitKind("Store", {
189 * unitKindId: kind.unitKindId,
190 * saas: product.name,
191 * defaultRelease: release.name,
192 * labels: { env: "prod" },
193 * });
194 * ```
195 *
196 * @resource
197 * @category SaasServiceManagement
198 */
199export const UnitKind = Resource<UnitKind>(
200 "GCP.SaasServiceManagement.UnitKind",
201);
202
203const expandDependencies = (
204 dependencies: UnitKindDependency[] | undefined,
205 project: string,
206 location: string,
207): saasservicemgmt.Dependency[] | undefined =>
208 dependencies?.map((dependency) => ({
209 alias: dependency.alias,
210 unitKind: dependency.unitKind
211 ? expandName(dependency.unitKind, project, location, COLLECTION)
212 : undefined,
213 }));
214
215const toAttrs = (item: saasservicemgmt.UnitKind, project: string) => {
216 const name = item.name ?? "";
217 const parsed = parseName(name, COLLECTION);
218 return {
219 name,
220 unitKindId: parsed.id,
221 project: parsed.project || project,
222 location: parsed.location,
223 saas: item.saas,
224 saasId: item.saas ? lastSegment(item.saas) : undefined,
225 defaultRelease: item.defaultRelease,
226 defaultFlagRevisions: item.defaultFlagRevisions ?? [],
227 dependencies: (item.dependencies ?? []).map((dependency) => ({
228 unitKind: dependency.unitKind,
229 alias: dependency.alias,
230 })),
231 inputVariableMappings: item.inputVariableMappings ?? [],
232 outputVariableMappings: item.outputVariableMappings ?? [],
233 boundaryType: item.boundaryType,
234 labels: userLabels(item.labels),
235 annotations: userAnnotations(item.annotations),
236 uid: item.uid,
237 etag: item.etag,
238 createTime: item.createTime,
239 updateTime: item.updateTime,
240 };
241};
242
243const getByName = (name: string) =>
244 name.length === 0
245 ? Effect.succeed(undefined)
246 : saasservicemgmt
247 .getProjectsLocationsUnitKinds({ name })
248 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
249
250const listOwned = (project: string, location: string) =>
251 collectPages(
252 saasservicemgmt.listProjectsLocationsUnitKinds.pages({
253 parent: parentOf(project, "-"),
254 pageSize: 1000,
255 }),
256 (page) => page.unitKinds,
257 ).pipe(
258 Effect.map((items) =>
259 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
260 ),
261 Effect.flatMap((items) =>
262 items.length > 0
263 ? Effect.succeed(items)
264 : collectPages(
265 saasservicemgmt.listProjectsLocationsUnitKinds.pages({
266 parent: parentOf(project, location),
267 pageSize: 1000,
268 }),
269 (page) => page.unitKinds,
270 ).pipe(
271 Effect.map((fallback) =>
272 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
273 ),
274 ),
275 ),
276 );
277
278export const UnitKindProvider = () =>
279 Provider.succeed(UnitKind, {
280 stables: [
281 "name",
282 "unitKindId",
283 "project",
284 "location",
285 "saasId",
286 "uid",
287 "createTime",
288 ],
289
290 diff: Effect.fn(function* ({ news, olds, output }) {
291 if (!isResolved(news)) return undefined;
292 const env = yield* GcpEnvironment.current;
293 return replaceOnIdentity({
294 previousId: olds?.unitKindId ?? output?.unitKindId,
295 nextId: news.unitKindId ?? olds?.unitKindId ?? output?.unitKindId,
296 previousLocation: normalizeLocation(
297 olds?.location ?? output?.location ?? env.region,
298 ),
299 nextLocation: normalizeLocation(
300 news.location ?? olds?.location ?? output?.location ?? env.region,
301 ),
302 extra:
303 !sameRef(olds?.saas ?? output?.saas, news.saas) ||
304 (news.dependencies !== undefined &&
305 fingerprint(news.dependencies) !==
306 fingerprint(olds?.dependencies ?? output?.dependencies)),
307 });
308 }),
309
310 read: Effect.fn(function* ({ id, olds, output }) {
311 const env = yield* GcpEnvironment.current;
312 const unitKindId = yield* toPhysicalId(
313 id,
314 olds?.unitKindId,
315 output?.unitKindId,
316 "ukind",
317 );
318 const location = normalizeLocation(
319 olds?.location ?? output?.location ?? env.region,
320 );
321 const name =
322 output?.name ??
323 resourceName(env.project, location, COLLECTION, unitKindId);
324 const existing = yield* getByName(name);
325 if (existing === undefined) return undefined;
326 const attrs = toAttrs(existing, env.project);
327 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
328 ? attrs
329 : Unowned(attrs);
330 }),
331
332 list: () =>
333 Effect.gen(function* () {
334 const env = yield* GcpEnvironment.current;
335 const items = yield* listOwned(env.project, env.region);
336 return items.map((item) => toAttrs(item, env.project));
337 }),
338
339 reconcile: Effect.fn(function* ({ id, news, output }) {
340 const env = yield* GcpEnvironment.current;
341 const unitKindId = yield* toPhysicalId(
342 id,
343 news.unitKindId,
344 output?.unitKindId,
345 "ukind",
346 );
347 const location = normalizeLocation(
348 news.location ?? output?.location ?? env.region,
349 );
350 const name = resourceName(env.project, location, COLLECTION, unitKindId);
351 const desiredLabels = {
352 ...toLabels(news.labels),
353 ...(yield* createInternalLabels(id)),
354 };
355 const saas = expandName(news.saas, env.project, location, "saas");
356 const defaultRelease =
357 news.defaultRelease === undefined
358 ? undefined
359 : expandName(news.defaultRelease, env.project, location, "releases");
360 const dependencies = expandDependencies(
361 news.dependencies,
362 env.project,
363 location,
364 );
365 const annotations = news.annotations;
366
367 let current = yield* getByName(output?.name ?? name);
368
369 if (current === undefined) {
370 const created = yield* saasservicemgmt
371 .createProjectsLocationsUnitKinds({
372 parent: parentOf(env.project, location),
373 unitKindId,
374 body: {
375 saas,
376 defaultRelease,
377 defaultFlagRevisions: news.defaultFlagRevisions,
378 dependencies,
379 inputVariableMappings: news.inputVariableMappings,
380 outputVariableMappings: news.outputVariableMappings,
381 labels: desiredLabels,
382 annotations,
383 },
384 })
385 .pipe(
386 // A Saas created seconds earlier is unreadable here until it
387 // propagates.
388 Effect.retry({
389 while: (error) => error._tag === "SaasNotFound",
390 schedule: Schedule.spaced("2 seconds"),
391 times: 15,
392 }),
393 Effect.catchTag("Conflict", () => getByName(name)),
394 );
395 current = created ?? undefined;
396 }
397
398 if (current === undefined) {
399 return yield* new ResourceNotResolved({ name });
400 }
401
402 const observedLabels = tagRecord(current.labels);
403 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
404 const labelsChanged = upsert.length > 0 || removed.length > 0;
405 const annotationsChanged =
406 annotations !== undefined &&
407 fingerprint(userAnnotations(current.annotations)) !==
408 fingerprint(annotations);
409 const defaultReleaseChanged =
410 news.defaultRelease !== undefined &&
411 !sameRef(current.defaultRelease, defaultRelease);
412 const flagRevisionsChanged =
413 news.defaultFlagRevisions !== undefined &&
414 fingerprint(current.defaultFlagRevisions) !==
415 fingerprint(news.defaultFlagRevisions);
416 const inputChanged =
417 news.inputVariableMappings !== undefined &&
418 fingerprint(current.inputVariableMappings) !==
419 fingerprint(news.inputVariableMappings);
420 const outputChanged =
421 news.outputVariableMappings !== undefined &&
422 fingerprint(current.outputVariableMappings) !==
423 fingerprint(news.outputVariableMappings);
424 const mask = fieldMask([
425 labelsChanged && "labels",
426 annotationsChanged && "annotations",
427 defaultReleaseChanged && "defaultRelease",
428 flagRevisionsChanged && "defaultFlagRevisions",
429 inputChanged && "inputVariableMappings",
430 outputChanged && "outputVariableMappings",
431 ]);
432
433 if (mask.length > 0) {
434 current = yield* saasservicemgmt.patchProjectsLocationsUnitKinds({
435 name: current.name ?? name,
436 updateMask: mask,
437 body: {
438 name: current.name ?? name,
439 etag: current.etag,
440 defaultRelease,
441 defaultFlagRevisions: news.defaultFlagRevisions,
442 inputVariableMappings: news.inputVariableMappings,
443 outputVariableMappings: news.outputVariableMappings,
444 labels: desiredLabels,
445 annotations,
446 },
447 });
448 }
449
450 return toAttrs(current, env.project);
451 }),
452
453 delete: Effect.fn(function* ({ output }) {
454 yield* saasservicemgmt
455 .deleteProjectsLocationsUnitKinds({ name: output.name })
456 .pipe(
457 Effect.retry({
458 while: (error) => error._tag === "Conflict",
459 times: 8,
460 schedule: Schedule.spaced("2 seconds"),
461 }),
462 Effect.catchTag("NotFound", () => Effect.void),
463 );
464 yield* waitUntilGone(getByName(output.name), output.name);
465 }),
466 });