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 fieldMask,
21 fingerprint,
22 hasAlchemyLabelKeys,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 replaceOnIdentity,
27 resourceName,
28 toPhysicalId,
29 userAnnotations,
30 userLabels,
31 waitUntilGone,
32} from "./internal.ts";
33
34const COLLECTION = "saas";
35
36export type SaasLocation = {
37 /** Location id, e.g. `us-central1`. */
38 name?: string;
39};
40
41export type SaasCondition = {
42 reason: string | undefined;
43 status: string | undefined;
44 type: string | undefined;
45 message: string | undefined;
46 lastTransitionTime: string | undefined;
47};
48
49export type SaaProps = {
50 /**
51 * SaaS id (the `{saas}` segment of
52 * `projects/{project}/locations/{location}/saas/{saas}`). If omitted, a
53 * unique RFC1035 name is generated. Immutable — changing it replaces
54 * the SaaS.
55 */
56 saasId?: string;
57 /**
58 * Region of the SaaS resource (`us-central1`, …). Immutable —
59 * changing it replaces the SaaS. `US-CENTRAL1` is accepted and
60 * normalized to `us-central1`.
61 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
62 */
63 location?: string;
64 /**
65 * Locations the product is offered in. Rollouts use this list to
66 * build a plan. Defaults to the resource location.
67 */
68 locations?: SaasLocation[];
69 /**
70 * User labels. Alchemy ownership labels are merged in automatically.
71 */
72 labels?: Record<string, string>;
73 /**
74 * Unstructured annotations preserved across updates.
75 */
76 annotations?: Record<string, string>;
77};
78
79export type Saa = Resource<
80 "GCP.SaasServiceManagement.Saa",
81 SaaProps,
82 {
83 /** Full resource name `projects/{project}/locations/{location}/saas/{saas}`. */
84 name: string;
85 /** SaaS id (last path segment). */
86 saasId: string;
87 /** Project id. */
88 project: string;
89 /** Location id of the resource. */
90 location: string;
91 /** Locations the product is offered in. */
92 locations: SaasLocation[];
93 /** User labels (Alchemy ownership labels stripped). */
94 labels: Record<string, string>;
95 /** User annotations. */
96 annotations: Record<string, string>;
97 /** Server-reported state (`STATE_ACTIVE`, …). */
98 state: string | undefined;
99 /** Server UUID. */
100 uid: string | undefined;
101 /** Server etag. */
102 etag: string | undefined;
103 /** Status conditions. */
104 conditions: SaasCondition[];
105 /** RFC3339 creation timestamp. */
106 createTime: string | undefined;
107 /** RFC3339 last-update timestamp. */
108 updateTime: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * An App Lifecycle Manager SaaS product definition.
116 *
117 * A SaaS is the producer-side product that Tenants, UnitKinds, and
118 * Units hang off. `saasId` and `location` replace the resource.
119 * `locations`, labels, and annotations update in place.
120 *
121 * ### Creating a SaaS
122 * **Example:** Generated name
123 * ```typescript
124 * const product = yield* GCP.SaasServiceManagement.Saa("Inventory", {
125 * locations: [{ name: "us-central1" }],
126 * });
127 * ```
128 *
129 * **Example:** Named SaaS with labels
130 * ```typescript
131 * const product = yield* GCP.SaasServiceManagement.Saa("Inventory", {
132 * saasId: "inventory",
133 * locations: [{ name: "us-central1" }, { name: "europe-west1" }],
134 * labels: { env: "prod" },
135 * });
136 * ```
137 *
138 * ### Updating a SaaS
139 * **Example:** Offer an extra region
140 * ```typescript
141 * const product = yield* GCP.SaasServiceManagement.Saa("Inventory", {
142 * saasId: "inventory",
143 * locations: [{ name: "us-central1" }, { name: "us-east1" }],
144 * labels: { env: "prod", role: "saas" },
145 * });
146 * ```
147 *
148 * @resource
149 * @category SaasServiceManagement
150 */
151export const Saa = Resource<Saa>("GCP.SaasServiceManagement.Saa");
152
153const desiredLocations = (
154 news: SaaProps,
155 location: string,
156): saasservicemgmt.Location[] => {
157 const listed = news.locations ?? [{ name: location }];
158 return listed.map((item) => ({
159 name: item.name ? normalizeLocation(item.name) : location,
160 }));
161};
162
163const toAttrs = (item: saasservicemgmt.Saas, project: string) => {
164 const name = item.name ?? "";
165 const parsed = parseName(name, COLLECTION);
166 return {
167 name,
168 saasId: parsed.id,
169 project: parsed.project || project,
170 location: parsed.location,
171 locations: (item.locations ?? []).map((entry) => ({ name: entry.name })),
172 labels: userLabels(item.labels),
173 annotations: userAnnotations(item.annotations),
174 state: item.state,
175 uid: item.uid,
176 etag: item.etag,
177 conditions: (item.conditions ?? []).map((condition) => ({
178 reason: condition.reason,
179 status: condition.status,
180 type: condition.type,
181 message: condition.message,
182 lastTransitionTime: condition.lastTransitionTime,
183 })),
184 createTime: item.createTime,
185 updateTime: item.updateTime,
186 };
187};
188
189const getByName = (name: string) =>
190 name.length === 0
191 ? Effect.succeed(undefined)
192 : saasservicemgmt
193 .getProjectsLocationsSaas({ name })
194 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
195
196const listOwned = (project: string, location: string) =>
197 collectPages(
198 saasservicemgmt.listProjectsLocationsSaas.pages({
199 parent: parentOf(project, "-"),
200 pageSize: 1000,
201 }),
202 (page) => page.saas,
203 ).pipe(
204 Effect.map((items) =>
205 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
206 ),
207 Effect.flatMap((items) =>
208 items.length > 0
209 ? Effect.succeed(items)
210 : collectPages(
211 saasservicemgmt.listProjectsLocationsSaas.pages({
212 parent: parentOf(project, location),
213 pageSize: 1000,
214 }),
215 (page) => page.saas,
216 ).pipe(
217 Effect.map((fallback) =>
218 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
219 ),
220 ),
221 ),
222 );
223
224export const SaaProvider = () =>
225 Provider.succeed(Saa, {
226 stables: ["name", "saasId", "project", "location", "uid", "createTime"],
227
228 diff: Effect.fn(function* ({ news, olds, output }) {
229 if (!isResolved(news)) return undefined;
230 const env = yield* GcpEnvironment.current;
231 return replaceOnIdentity({
232 previousId: olds?.saasId ?? output?.saasId,
233 nextId: news.saasId ?? olds?.saasId ?? output?.saasId,
234 previousLocation: normalizeLocation(
235 olds?.location ?? output?.location ?? env.region,
236 ),
237 nextLocation: normalizeLocation(
238 news.location ?? olds?.location ?? output?.location ?? env.region,
239 ),
240 });
241 }),
242
243 read: Effect.fn(function* ({ id, olds, output }) {
244 const env = yield* GcpEnvironment.current;
245 const saasId = yield* toPhysicalId(
246 id,
247 olds?.saasId,
248 output?.saasId,
249 "saa",
250 );
251 const location = normalizeLocation(
252 olds?.location ?? output?.location ?? env.region,
253 );
254 const name =
255 output?.name ?? resourceName(env.project, location, COLLECTION, saasId);
256 const existing = yield* getByName(name);
257 if (existing === undefined) return undefined;
258 const attrs = toAttrs(existing, env.project);
259 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
260 ? attrs
261 : Unowned(attrs);
262 }),
263
264 list: () =>
265 Effect.gen(function* () {
266 const env = yield* GcpEnvironment.current;
267 const items = yield* listOwned(env.project, env.region);
268 return items.map((item) => toAttrs(item, env.project));
269 }),
270
271 reconcile: Effect.fn(function* ({ id, news, output }) {
272 const env = yield* GcpEnvironment.current;
273 const saasId = yield* toPhysicalId(
274 id,
275 news.saasId,
276 output?.saasId,
277 "saa",
278 );
279 const location = normalizeLocation(
280 news.location ?? output?.location ?? env.region,
281 );
282 const name = resourceName(env.project, location, COLLECTION, saasId);
283 const desiredLabels = {
284 ...toLabels(news.labels),
285 ...(yield* createInternalLabels(id)),
286 };
287 const locations = desiredLocations(news, location);
288 const annotations = news.annotations;
289
290 let current = yield* getByName(output?.name ?? name);
291
292 if (current === undefined) {
293 const created = yield* saasservicemgmt
294 .createProjectsLocationsSaas({
295 parent: parentOf(env.project, location),
296 saasId,
297 body: {
298 locations,
299 labels: desiredLabels,
300 annotations,
301 },
302 })
303 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
304 // The create response omits server fields such as createTime.
305 current = created === undefined ? undefined : yield* getByName(name);
306 }
307
308 if (current === undefined) {
309 return yield* new ResourceNotResolved({ name });
310 }
311
312 const observedLabels = tagRecord(current.labels);
313 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
314 const labelsChanged = upsert.length > 0 || removed.length > 0;
315 const locationsChanged =
316 news.locations !== undefined &&
317 fingerprint(current.locations) !== fingerprint(locations);
318 const annotationsChanged =
319 annotations !== undefined &&
320 fingerprint(userAnnotations(current.annotations)) !==
321 fingerprint(annotations);
322 const mask = fieldMask([
323 labelsChanged && "labels",
324 locationsChanged && "locations",
325 annotationsChanged && "annotations",
326 ]);
327
328 if (mask.length > 0) {
329 current = yield* saasservicemgmt.patchProjectsLocationsSaas({
330 name: current.name ?? name,
331 updateMask: mask,
332 body: {
333 name: current.name ?? name,
334 etag: current.etag,
335 locations,
336 labels: desiredLabels,
337 annotations,
338 },
339 });
340 }
341
342 return toAttrs(current, env.project);
343 }),
344
345 delete: Effect.fn(function* ({ output }) {
346 yield* saasservicemgmt
347 .deleteProjectsLocationsSaas({ name: output.name })
348 .pipe(
349 Effect.retry({
350 while: (error) => error._tag === "Conflict",
351 times: 8,
352 schedule: Schedule.spaced("2 seconds"),
353 }),
354 Effect.catchTag("NotFound", () => Effect.void),
355 );
356 yield* waitUntilGone(getByName(output.name), output.name);
357 }),
358 });