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 = "tenants";
38
39export type TenantProps = {
40 /**
41 * Tenant id (the `{tenant}` segment of
42 * `projects/{project}/locations/{location}/tenants/{tenant}`). If
43 * omitted, a unique RFC1035 name is generated. Immutable — changing
44 * it replaces the tenant.
45 */
46 tenantId?: string;
47 /**
48 * Region of the tenant (`us-central1`, …). Immutable — changing it
49 * replaces the tenant. `US-CENTRAL1` is accepted and normalized to
50 * `us-central1`.
51 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
52 */
53 location?: string;
54 /**
55 * SaaS this tenant belongs to. Accepts a SaaS id or a full resource
56 * name. Immutable — changing it replaces the tenant.
57 */
58 saas: string;
59 /**
60 * Consumer resource this tenant represents, e.g. a Cloud resource
61 * handed to the customer. Immutable — changing it replaces the
62 * tenant.
63 */
64 consumerResource?: string;
65 /**
66 * User labels. Alchemy ownership labels are merged in automatically.
67 */
68 labels?: Record<string, string>;
69 /**
70 * Unstructured annotations preserved across updates.
71 */
72 annotations?: Record<string, string>;
73};
74
75export type Tenant = Resource<
76 "GCP.SaasServiceManagement.Tenant",
77 TenantProps,
78 {
79 /** Full resource name `projects/{project}/locations/{location}/tenants/{tenant}`. */
80 name: string;
81 /** Tenant id (last path segment). */
82 tenantId: string;
83 /** Project id. */
84 project: string;
85 /** Location id. */
86 location: string;
87 /** SaaS resource name. */
88 saas: string | undefined;
89 /** SaaS id (last path segment). */
90 saasId: string | undefined;
91 /** Consumer resource this tenant represents. */
92 consumerResource: string | undefined;
93 /** User labels (Alchemy ownership labels stripped). */
94 labels: Record<string, string>;
95 /** User annotations. */
96 annotations: Record<string, string>;
97 /** Server UUID. */
98 uid: string | undefined;
99 /** Server etag. */
100 etag: string | undefined;
101 /** RFC3339 creation timestamp. */
102 createTime: string | undefined;
103 /** RFC3339 last-update timestamp. */
104 updateTime: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * An App Lifecycle Manager tenant — the producer-side instance of a
112 * SaaS created for one consumer.
113 *
114 * `tenantId`, `location`, `saas`, and `consumerResource` replace the
115 * tenant. Labels and annotations update in place.
116 *
117 * ### Creating a Tenant
118 * **Example:** Tenant of a SaaS
119 * ```typescript
120 * const product = yield* GCP.SaasServiceManagement.Saa("Inventory", {
121 * locations: [{ name: "us-central1" }],
122 * });
123 * const tenant = yield* GCP.SaasServiceManagement.Tenant("Acme", {
124 * saas: product.name,
125 * labels: { env: "prod" },
126 * });
127 * ```
128 *
129 * ### Updating a Tenant
130 * **Example:** Relabel
131 * ```typescript
132 * const tenant = yield* GCP.SaasServiceManagement.Tenant("Acme", {
133 * tenantId: "acme",
134 * saas: product.name,
135 * labels: { env: "prod", customer: "acme" },
136 * });
137 * ```
138 *
139 * @resource
140 * @category SaasServiceManagement
141 */
142export const Tenant = Resource<Tenant>("GCP.SaasServiceManagement.Tenant");
143
144const toAttrs = (item: saasservicemgmt.Tenant, project: string) => {
145 const name = item.name ?? "";
146 const parsed = parseName(name, COLLECTION);
147 return {
148 name,
149 tenantId: parsed.id,
150 project: parsed.project || project,
151 location: parsed.location,
152 saas: item.saas,
153 saasId: item.saas ? lastSegment(item.saas) : undefined,
154 consumerResource: item.consumerResource,
155 labels: userLabels(item.labels),
156 annotations: userAnnotations(item.annotations),
157 uid: item.uid,
158 etag: item.etag,
159 createTime: item.createTime,
160 updateTime: item.updateTime,
161 };
162};
163
164const getByName = (name: string) =>
165 name.length === 0
166 ? Effect.succeed(undefined)
167 : saasservicemgmt
168 .getProjectsLocationsTenants({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const listOwned = (project: string, location: string) =>
172 collectPages(
173 saasservicemgmt.listProjectsLocationsTenants.pages({
174 parent: parentOf(project, "-"),
175 pageSize: 1000,
176 }),
177 (page) => page.tenants,
178 ).pipe(
179 Effect.map((items) =>
180 items.filter((item) => hasAlchemyLabelKeys(item.labels)),
181 ),
182 Effect.flatMap((items) =>
183 items.length > 0
184 ? Effect.succeed(items)
185 : collectPages(
186 saasservicemgmt.listProjectsLocationsTenants.pages({
187 parent: parentOf(project, location),
188 pageSize: 1000,
189 }),
190 (page) => page.tenants,
191 ).pipe(
192 Effect.map((fallback) =>
193 fallback.filter((item) => hasAlchemyLabelKeys(item.labels)),
194 ),
195 ),
196 ),
197 );
198
199export const TenantProvider = () =>
200 Provider.succeed(Tenant, {
201 stables: [
202 "name",
203 "tenantId",
204 "project",
205 "location",
206 "saasId",
207 "uid",
208 "createTime",
209 ],
210
211 diff: Effect.fn(function* ({ news, olds, output }) {
212 if (!isResolved(news)) return undefined;
213 const env = yield* GcpEnvironment.current;
214 return replaceOnIdentity({
215 previousId: olds?.tenantId ?? output?.tenantId,
216 nextId: news.tenantId ?? olds?.tenantId ?? output?.tenantId,
217 previousLocation: normalizeLocation(
218 olds?.location ?? output?.location ?? env.region,
219 ),
220 nextLocation: normalizeLocation(
221 news.location ?? olds?.location ?? output?.location ?? env.region,
222 ),
223 extra:
224 !sameRef(olds?.saas ?? output?.saas, news.saas) ||
225 (olds?.consumerResource ?? output?.consumerResource ?? "") !==
226 (news.consumerResource ??
227 olds?.consumerResource ??
228 output?.consumerResource ??
229 ""),
230 });
231 }),
232
233 read: Effect.fn(function* ({ id, olds, output }) {
234 const env = yield* GcpEnvironment.current;
235 const tenantId = yield* toPhysicalId(
236 id,
237 olds?.tenantId,
238 output?.tenantId,
239 "tnt",
240 );
241 const location = normalizeLocation(
242 olds?.location ?? output?.location ?? env.region,
243 );
244 const name =
245 output?.name ??
246 resourceName(env.project, location, COLLECTION, tenantId);
247 const existing = yield* getByName(name);
248 if (existing === undefined) return undefined;
249 const attrs = toAttrs(existing, env.project);
250 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
251 ? attrs
252 : Unowned(attrs);
253 }),
254
255 list: () =>
256 Effect.gen(function* () {
257 const env = yield* GcpEnvironment.current;
258 const items = yield* listOwned(env.project, env.region);
259 return items.map((item) => toAttrs(item, env.project));
260 }),
261
262 reconcile: Effect.fn(function* ({ id, news, output }) {
263 const env = yield* GcpEnvironment.current;
264 const tenantId = yield* toPhysicalId(
265 id,
266 news.tenantId,
267 output?.tenantId,
268 "tnt",
269 );
270 const location = normalizeLocation(
271 news.location ?? output?.location ?? env.region,
272 );
273 const name = resourceName(env.project, location, COLLECTION, tenantId);
274 const desiredLabels = {
275 ...toLabels(news.labels),
276 ...(yield* createInternalLabels(id)),
277 };
278 const saas = expandName(news.saas, env.project, location, "saas");
279 const annotations = news.annotations;
280
281 let current = yield* getByName(output?.name ?? name);
282
283 if (current === undefined) {
284 const created = yield* saasservicemgmt
285 .createProjectsLocationsTenants({
286 parent: parentOf(env.project, location),
287 tenantId,
288 body: {
289 saas,
290 consumerResource: news.consumerResource,
291 labels: desiredLabels,
292 annotations,
293 },
294 })
295 .pipe(
296 // A Saas created seconds earlier is unreadable here until it
297 // propagates.
298 Effect.retry({
299 while: (error) => error._tag === "SaasNotFound",
300 schedule: Schedule.spaced("2 seconds"),
301 times: 15,
302 }),
303 Effect.catchTag("Conflict", () => getByName(name)),
304 );
305 current = created ?? undefined;
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 annotationsChanged =
316 annotations !== undefined &&
317 fingerprint(userAnnotations(current.annotations)) !==
318 fingerprint(annotations);
319 const mask = fieldMask([
320 labelsChanged && "labels",
321 annotationsChanged && "annotations",
322 ]);
323
324 if (mask.length > 0) {
325 current = yield* saasservicemgmt.patchProjectsLocationsTenants({
326 name: current.name ?? name,
327 updateMask: mask,
328 body: {
329 name: current.name ?? name,
330 etag: current.etag,
331 labels: desiredLabels,
332 annotations,
333 },
334 });
335 }
336
337 return toAttrs(current, env.project);
338 }),
339
340 delete: Effect.fn(function* ({ output }) {
341 yield* saasservicemgmt
342 .deleteProjectsLocationsTenants({ name: output.name })
343 .pipe(
344 Effect.retry({
345 while: (error) => error._tag === "Conflict",
346 times: 8,
347 schedule: Schedule.spaced("2 seconds"),
348 }),
349 Effect.catchTag("NotFound", () => Effect.void),
350 );
351 yield* waitUntilGone(getByName(output.name), output.name);
352 }),
353 });