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 = "releases";
38
39export type ReleaseBlueprint = {
40 /**
41 * URI of the OCI blueprint package. Hostname may be omitted to use
42 * the regional Artifact Registry path. Immutable.
43 */
44 package?: string;
45 /** Actuation engine (`terraform`, `helm`, …). Output-only. */
46 engine?: string;
47 /** Version metadata from the image manifest. Output-only. */
48 version?: string;
49};
50
51export type ReleaseVariable = {
52 type?: saasservicemgmt.UnitVariableTypeEnum | (string & {});
53 value?: string;
54 variable?: string;
55};
56
57export type ReleaseProps = {
58 /**
59 * Release id (the `{release}` segment of
60 * `projects/{project}/locations/{location}/releases/{release}`). If
61 * omitted, a unique RFC1035 name is generated. Immutable — changing
62 * it replaces the release.
63 */
64 releaseId?: string;
65 /**
66 * Region of the release (`us-central1`, …). Immutable — changing it
67 * replaces the release. `US-CENTRAL1` is accepted and normalized to
68 * `us-central1`.
69 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
70 */
71 location?: string;
72 /**
73 * UnitKind this release belongs to. Accepts a unit kind id or a full
74 * resource name. Immutable — changing it replaces the release.
75 */
76 unitKind: string;
77 /**
78 * Blueprint package that actuates units of this release. `package` is
79 * immutable — changing it replaces the release.
80 */
81 blueprint?: ReleaseBlueprint;
82 /**
83 * Default values for input variables declared on the blueprint.
84 * Maximum 100.
85 */
86 inputVariableDefaults?: ReleaseVariable[];
87 /**
88 * Releases a unit may upgrade from to reach this one. Empty means no
89 * constraint.
90 */
91 upgradeableFromReleases?: string[];
92 /**
93 * User labels. Alchemy ownership labels are merged in automatically.
94 */
95 labels?: Record<string, string>;
96 /**
97 * Unstructured annotations preserved across updates.
98 */
99 annotations?: Record<string, string>;
100};
101
102export type Release = Resource<
103 "GCP.SaasServiceManagement.Release",
104 ReleaseProps,
105 {
106 /** Full resource name `projects/{project}/locations/{location}/releases/{release}`. */
107 name: string;
108 /** Release id (last path segment). */
109 releaseId: string;
110 /** Project id. */
111 project: string;
112 /** Location id. */
113 location: string;
114 /** UnitKind resource name. */
115 unitKind: string | undefined;
116 /** UnitKind id (last path segment). */
117 unitKindId: string | undefined;
118 /** Blueprint package. */
119 blueprint: ReleaseBlueprint | undefined;
120 /** Input variable defaults. */
121 inputVariableDefaults: ReleaseVariable[];
122 /** Input variables declared on the blueprint. */
123 inputVariables: ReleaseVariable[];
124 /** Output variables declared on the blueprint. */
125 outputVariables: ReleaseVariable[];
126 /** Releases this one may be upgraded from. */
127 upgradeableFromReleases: ReadonlyArray<string>;
128 /** User labels (Alchemy ownership labels stripped). */
129 labels: Record<string, string>;
130 /** User annotations. */
131 annotations: Record<string, string>;
132 /** Server UUID. */
133 uid: string | undefined;
134 /** Server etag. */
135 etag: string | undefined;
136 /** RFC3339 creation timestamp. */
137 createTime: string | undefined;
138 /** RFC3339 last-update timestamp. */
139 updateTime: string | undefined;
140 },
141 never,
142 Providers
143>;
144
145/**
146 * An App Lifecycle Manager release — a version of a UnitKind packaged
147 * as an OCI blueprint.
148 *
149 * `releaseId`, `location`, `unitKind`, and `blueprint.package` replace
150 * the release. Input variable defaults, upgrade constraints, labels,
151 * and annotations update in place.
152 *
153 * ### Creating a Release
154 * **Example:** Blueprint release
155 * ```typescript
156 * const release = yield* GCP.SaasServiceManagement.Release("V1", {
157 * unitKind: kind.name,
158 * blueprint: { package: "us-central1-docker.pkg.dev/proj/blueprints/store:v1" },
159 * labels: { env: "prod" },
160 * });
161 * ```
162 *
163 * ### Updating a Release
164 * **Example:** Default input variables
165 * ```typescript
166 * const release = yield* GCP.SaasServiceManagement.Release("V1", {
167 * releaseId: release.releaseId,
168 * unitKind: kind.name,
169 * blueprint: { package: "us-central1-docker.pkg.dev/proj/blueprints/store:v1" },
170 * inputVariableDefaults: [{ variable: "region", type: "STRING", value: "us-central1" }],
171 * });
172 * ```
173 *
174 * @resource
175 * @category SaasServiceManagement
176 */
177export const Release = Resource<Release>("GCP.SaasServiceManagement.Release");
178
179const expandUpgradeable = (
180 values: string[] | undefined,
181 project: string,
182 location: string,
183) => values?.map((value) => expandName(value, project, location, COLLECTION));
184
185const toAttrs = (item: saasservicemgmt.Release, project: string) => {
186 const name = item.name ?? "";
187 const parsed = parseName(name, COLLECTION);
188 return {
189 name,
190 releaseId: parsed.id,
191 project: parsed.project || project,
192 location: parsed.location,
193 unitKind: item.unitKind,
194 unitKindId: item.unitKind ? lastSegment(item.unitKind) : undefined,
195 blueprint: item.blueprint
196 ? {
197 package: item.blueprint.package,
198 engine: item.blueprint.engine,
199 version: item.blueprint.version,
200 }
201 : undefined,
202 inputVariableDefaults: item.inputVariableDefaults ?? [],
203 inputVariables: item.inputVariables ?? [],
204 outputVariables: item.outputVariables ?? [],
205 upgradeableFromReleases:
206 item.releaseRequirements?.upgradeableFromReleases ?? [],
207 labels: userLabels(item.labels),
208 annotations: userAnnotations(item.annotations),
209 uid: item.uid,
210 etag: item.etag,
211 createTime: item.createTime,
212 updateTime: item.updateTime,
213 };
214};
215
216const getByName = (name: string) =>
217 name.length === 0
218 ? Effect.succeed(undefined)
219 : saasservicemgmt
220 .getProjectsLocationsReleases({ name })
221 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
222
223const listOwned = (project: string, location: string) =>
224 collectPages(
225 saasservicemgmt.listProjectsLocationsReleases.pages({
226 parent: parentOf(project, "-"),
227 pageSize: 1000,
228 }),
229 (page) => page.releases,
230 ).pipe(
231 Effect.map((items) =>
232 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
233 ),
234 Effect.flatMap((items) =>
235 items.length > 0
236 ? Effect.succeed(items)
237 : collectPages(
238 saasservicemgmt.listProjectsLocationsReleases.pages({
239 parent: parentOf(project, location),
240 pageSize: 1000,
241 }),
242 (page) => page.releases,
243 ).pipe(
244 Effect.map((fallback) =>
245 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
246 ),
247 ),
248 ),
249 );
250
251export const ReleaseProvider = () =>
252 Provider.succeed(Release, {
253 stables: [
254 "name",
255 "releaseId",
256 "project",
257 "location",
258 "unitKindId",
259 "uid",
260 "createTime",
261 ],
262
263 diff: Effect.fn(function* ({ news, olds, output }) {
264 if (!isResolved(news)) return undefined;
265 const env = yield* GcpEnvironment.current;
266 const previousPackage =
267 olds?.blueprint?.package ?? output?.blueprint?.package;
268 const nextPackage = news.blueprint?.package ?? previousPackage;
269 return replaceOnIdentity({
270 previousId: olds?.releaseId ?? output?.releaseId,
271 nextId: news.releaseId ?? olds?.releaseId ?? output?.releaseId,
272 previousLocation: normalizeLocation(
273 olds?.location ?? output?.location ?? env.region,
274 ),
275 nextLocation: normalizeLocation(
276 news.location ?? olds?.location ?? output?.location ?? env.region,
277 ),
278 extra:
279 !sameRef(olds?.unitKind ?? output?.unitKind, news.unitKind) ||
280 (previousPackage !== undefined &&
281 nextPackage !== undefined &&
282 previousPackage !== nextPackage),
283 });
284 }),
285
286 read: Effect.fn(function* ({ id, olds, output }) {
287 const env = yield* GcpEnvironment.current;
288 const releaseId = yield* toPhysicalId(
289 id,
290 olds?.releaseId,
291 output?.releaseId,
292 "rel",
293 );
294 const location = normalizeLocation(
295 olds?.location ?? output?.location ?? env.region,
296 );
297 const name =
298 output?.name ??
299 resourceName(env.project, location, COLLECTION, releaseId);
300 const existing = yield* getByName(name);
301 if (existing === undefined) return undefined;
302 const attrs = toAttrs(existing, env.project);
303 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
304 ? attrs
305 : Unowned(attrs);
306 }),
307
308 list: () =>
309 Effect.gen(function* () {
310 const env = yield* GcpEnvironment.current;
311 const items = yield* listOwned(env.project, env.region);
312 return items.map((item) => toAttrs(item, env.project));
313 }),
314
315 reconcile: Effect.fn(function* ({ id, news, output }) {
316 const env = yield* GcpEnvironment.current;
317 const releaseId = yield* toPhysicalId(
318 id,
319 news.releaseId,
320 output?.releaseId,
321 "rel",
322 );
323 const location = normalizeLocation(
324 news.location ?? output?.location ?? env.region,
325 );
326 const name = resourceName(env.project, location, COLLECTION, releaseId);
327 const desiredLabels = {
328 ...toLabels(news.labels),
329 ...(yield* createInternalLabels(id)),
330 };
331 const unitKind = expandName(
332 news.unitKind,
333 env.project,
334 location,
335 "unitKinds",
336 );
337 const upgradeableFromReleases = expandUpgradeable(
338 news.upgradeableFromReleases,
339 env.project,
340 location,
341 );
342 const annotations = news.annotations;
343 const blueprint = news.blueprint
344 ? { package: news.blueprint.package }
345 : undefined;
346
347 let current = yield* getByName(output?.name ?? name);
348
349 if (current === undefined) {
350 const created = yield* saasservicemgmt
351 .createProjectsLocationsReleases({
352 parent: parentOf(env.project, location),
353 releaseId,
354 body: {
355 unitKind,
356 blueprint,
357 inputVariableDefaults: news.inputVariableDefaults,
358 releaseRequirements:
359 upgradeableFromReleases === undefined
360 ? undefined
361 : { upgradeableFromReleases },
362 labels: desiredLabels,
363 annotations,
364 },
365 })
366 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
367 current = created ?? undefined;
368 }
369
370 if (current === undefined) {
371 return yield* new ResourceNotResolved({ name });
372 }
373
374 const observedLabels = tagRecord(current.labels);
375 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
376 const labelsChanged = upsert.length > 0 || removed.length > 0;
377 const annotationsChanged =
378 annotations !== undefined &&
379 fingerprint(userAnnotations(current.annotations)) !==
380 fingerprint(annotations);
381 const defaultsChanged =
382 news.inputVariableDefaults !== undefined &&
383 fingerprint(current.inputVariableDefaults) !==
384 fingerprint(news.inputVariableDefaults);
385 const requirementsChanged =
386 news.upgradeableFromReleases !== undefined &&
387 fingerprint(current.releaseRequirements?.upgradeableFromReleases) !==
388 fingerprint(upgradeableFromReleases);
389 const mask = fieldMask([
390 labelsChanged && "labels",
391 annotationsChanged && "annotations",
392 defaultsChanged && "inputVariableDefaults",
393 requirementsChanged && "releaseRequirements",
394 ]);
395
396 if (mask.length > 0) {
397 current = yield* saasservicemgmt.patchProjectsLocationsReleases({
398 name: current.name ?? name,
399 updateMask: mask,
400 body: {
401 name: current.name ?? name,
402 etag: current.etag,
403 inputVariableDefaults: news.inputVariableDefaults,
404 releaseRequirements:
405 upgradeableFromReleases === undefined
406 ? undefined
407 : { upgradeableFromReleases },
408 labels: desiredLabels,
409 annotations,
410 },
411 });
412 }
413
414 return toAttrs(current, env.project);
415 }),
416
417 delete: Effect.fn(function* ({ output }) {
418 yield* saasservicemgmt
419 .deleteProjectsLocationsReleases({ name: output.name })
420 .pipe(
421 Effect.retry({
422 while: (error) => error._tag === "Conflict",
423 times: 8,
424 schedule: Schedule.spaced("2 seconds"),
425 }),
426 Effect.catchTag("NotFound", () => Effect.void),
427 );
428 yield* waitUntilGone(getByName(output.name), output.name);
429 }),
430 });