1import * as jobs from "@distilled.cloud/gcp/jobs_v4";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 deleteTenant,
12 encodeOwnershipLine,
13 findOwnedTenant,
14 getTenant,
15 listOwnedTenants,
16 MAX_EXTERNAL_ID_LENGTH,
17 ownedByAlchemy,
18 ownershipLabels,
19 parseOwnership,
20 parseTenantName,
21 projectParent,
22 replaceOnIdentity,
23 sameText,
24 tenantNameOf,
25 toGeneratedName,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type TenantProps = {
30 /**
31 * Server-assigned tenant id (the `{tenant}` segment of
32 * `projects/{project}/tenants/{tenant}`). Leave blank on create.
33 * Immutable — changing it replaces the tenant.
34 */
35 tenantId?: string;
36 /**
37 * Client-side tenant identifier (max 255 characters). Cloud Talent
38 * tenants have no labels field, so Alchemy ownership is stored in a
39 * `[alchemy …]` prefix and stripped from attributes.
40 */
41 externalId?: string;
42};
43
44export type Tenant = Resource<
45 "GCP.Jobs.Tenant",
46 TenantProps,
47 {
48 /** Full resource name `projects/{project}/tenants/{tenant}`. */
49 name: string;
50 /** Server-assigned tenant id. */
51 tenantId: string;
52 /** Project id. */
53 project: string;
54 /** Client identifier with the Alchemy ownership prefix stripped. */
55 externalId: string | undefined;
56 },
57 never,
58 Providers
59>;
60
61/**
62 * A Cloud Talent Solution tenant. Tenants isolate jobs and companies
63 * for different customer groups.
64 *
65 * Tenants have no labels field, so Alchemy stamps ownership into
66 * `externalId` for `list` / nuke. The server-assigned tenant id is
67 * identity — changing `tenantId` replaces the tenant. `externalId`
68 * updates in place.
69 *
70 * ### Creating a Tenant
71 * **Example:** Generated identifier
72 * ```typescript
73 * const tenant = yield* GCP.Jobs.Tenant("Acme", {});
74 * ```
75 *
76 * **Example:** Explicit external id
77 * ```typescript
78 * const tenant = yield* GCP.Jobs.Tenant("Acme", {
79 * externalId: "acme-corp",
80 * });
81 * ```
82 *
83 * ### Updating a Tenant
84 * Re-declare the same logical id with changed props; the engine keeps the
85 * physical resource and updates it in place.
86 *
87 * **Example:** Change the client identifier
88 * ```typescript
89 * const tenant = yield* GCP.Jobs.Tenant("Acme", {
90 * externalId: "acme-holdings",
91 * });
92 * ```
93 *
94 * @resource
95 * @category Jobs
96 */
97export const Tenant = Resource<Tenant>("GCP.Jobs.Tenant");
98
99export class TenantNotResolved extends Data.TaggedError(
100 "GCP.Jobs.TenantNotResolved",
101)<{
102 name: string;
103}> {}
104
105const toAttrs = (tenant: jobs.Tenant, project: string) => {
106 const name = tenant.name ?? "";
107 const parsed = parseTenantName(name, project);
108 return {
109 name,
110 tenantId: parsed.tenantId,
111 project: parsed.project || project,
112 externalId: parseOwnership(tenant.externalId).text,
113 };
114};
115
116export const TenantProvider = () =>
117 Provider.succeed(Tenant, {
118 stables: ["name", "tenantId", "project"],
119
120 diff: Effect.fn(function* ({ news, olds, output }) {
121 if (!isResolved(news)) return undefined;
122 return replaceOnIdentity({
123 previousId: olds?.tenantId ?? output?.tenantId,
124 nextId: news.tenantId,
125 });
126 }),
127
128 read: Effect.fn(function* ({ id, olds, output }) {
129 const env = yield* GcpEnvironment.current;
130 const name =
131 output?.name ??
132 tenantNameOf(env.project, olds?.tenantId ?? output?.tenantId ?? "");
133 let existing = yield* getTenant(name);
134 if (existing === undefined) {
135 existing = yield* findOwnedTenant(env.project, id);
136 }
137 if (existing === undefined) return undefined;
138 const attrs = toAttrs(existing, env.project);
139 return (yield* ownedByAlchemy(id, existing.externalId))
140 ? attrs
141 : Unowned(attrs);
142 }),
143
144 list: () =>
145 Effect.gen(function* () {
146 const env = yield* GcpEnvironment.current;
147 const tenants = yield* listOwnedTenants(env.project);
148 return tenants.map((tenant) => toAttrs(tenant, env.project));
149 }),
150
151 reconcile: Effect.fn(function* ({ id, news, output }) {
152 const env = yield* GcpEnvironment.current;
153 const ownership = yield* ownershipLabels(id);
154 const clientId = yield* toGeneratedName(
155 id,
156 news.externalId,
157 output?.externalId,
158 );
159 const externalId = encodeOwnershipLine(
160 ownership,
161 clientId,
162 MAX_EXTERNAL_ID_LENGTH,
163 );
164 const name =
165 output?.name ?? tenantNameOf(env.project, news.tenantId ?? "");
166
167 let current = yield* getTenant(name);
168 if (current === undefined) {
169 current = yield* findOwnedTenant(env.project, id);
170 }
171
172 if (current === undefined) {
173 const created = yield* jobs
174 .createProjectsTenants({
175 parent: projectParent(env.project),
176 body: { externalId },
177 })
178 .pipe(
179 Effect.catchTag("Conflict", () => findOwnedTenant(env.project, id)),
180 );
181 current = created ?? undefined;
182 }
183
184 if (current === undefined) {
185 return yield* new TenantNotResolved({
186 name: name || `projects/${env.project}/tenants/${clientId}`,
187 });
188 }
189
190 const currentName = current.name ?? name;
191 if (!sameText(current.externalId, externalId)) {
192 current = yield* jobs.patchProjectsTenants({
193 name: currentName,
194 updateMask: "externalId",
195 body: { name: currentName, externalId },
196 });
197 }
198
199 return toAttrs(current, env.project);
200 }),
201
202 delete: Effect.fn(function* ({ output }) {
203 if (!output.name) return;
204 yield* deleteTenant(output.name);
205 yield* waitUntilGone(getTenant(output.name));
206 }),
207 });