1import * as cloudcontrolspartner from "@distilled.cloud/gcp/cloudcontrolspartner_v1";
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 customerName,
12 encodeDisplayName,
13 findOwnedCustomer,
14 getCustomer,
15 hasOwnershipMarker,
16 listCustomers,
17 listLocationParents,
18 locationParent,
19 normalizeLocation,
20 organizationIdOf,
21 organizationParent,
22 ownedByAlchemy,
23 ownershipLabels,
24 parseName,
25 parseOwnership,
26 replaceOn,
27 resolveOrganization,
28 sameText,
29 toCustomerId,
30 toCustomerResourceId,
31 toDisplayName,
32 tryResolveOrganization,
33} from "./internal.ts";
34
35export type CustomerOnboardingState =
36 cloudcontrolspartner.CustomerOnboardingState;
37
38export type CustomerProps = {
39 /**
40 * Partner organization (`organizations/{organization}` or the numeric
41 * id). Defaults to `GOOGLE_CLOUDCONTROLSPARTNER_ORGANIZATION`,
42 * `GOOGLE_ORGANIZATION_ID`, or the project's Resource Manager
43 * ancestor. Immutable — changing it replaces the customer.
44 */
45 organization?: string;
46 /**
47 * Partner-operated Google Cloud location (`us-central1`, …).
48 * Immutable — changing it replaces the customer. `US-CENTRAL1` is
49 * accepted and normalized to `us-central1`.
50 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
51 */
52 location?: string;
53 /**
54 * Customer Google Cloud organization id. Becomes the last segment of
55 * `organizations/{organization}/locations/{location}/customers/{customer}`.
56 * Required on create and must be a valid organization id. Immutable —
57 * changing it replaces the customer.
58 */
59 customerId?: string;
60 /**
61 * Display name for the customer. Cloud Controls Partner customers
62 * have no labels field, so Alchemy ownership is stored in a
63 * `[alchemy …]` prefix and stripped from attributes.
64 */
65 displayName?: string;
66};
67
68export type Customer = Resource<
69 "GCP.CloudControlsPartner.Customer",
70 CustomerProps,
71 {
72 /** Resource name `organizations/{organization}/locations/{location}/customers/{customer}`. */
73 name: string;
74 /** Customer organization id (last path segment). */
75 customerId: string;
76 /** Partner organization resource name. */
77 organization: string;
78 /** Partner organization id. */
79 organizationId: string;
80 /** Location id. */
81 location: string;
82 /** Parent `organizations/{organization}/locations/{location}`. */
83 parent: string;
84 /** Project id of the deploying stack. */
85 project: string;
86 /** User display name with the Alchemy ownership prefix stripped. */
87 displayName: string | undefined;
88 /** Customer organization domain extracted from Resource Manager. */
89 organizationDomain: string | undefined;
90 /** Whether the customer has finished onboarding. */
91 isOnboarded: boolean;
92 /** Onboarding step state reported by the partner API. */
93 customerOnboardingState: CustomerOnboardingState | undefined;
94 },
95 never,
96 Providers
97>;
98
99/**
100 * A Cloud Controls Partner customer of a sovereign-controls partner.
101 *
102 * Customers have no labels field — Alchemy stamps ownership into
103 * `displayName` for `list` / nuke. Partner organization, location, and
104 * customer organization id are identity. Display name updates in place.
105 *
106 * Creating customers requires Cloud Controls Partner (Sovereign
107 * Controls by Partners) access. The `customerId` must be the customer's
108 * Google Cloud organization id.
109 *
110 * ### Creating a Customer
111 * **Example:** Generated display name
112 * ```typescript
113 * const customer = yield* GCP.CloudControlsPartner.Customer("Acme", {
114 * organization: "organizations/123",
115 * location: "us-central1",
116 * customerId: "456",
117 * });
118 * ```
119 *
120 * **Example:** Explicit display name
121 * ```typescript
122 * const customer = yield* GCP.CloudControlsPartner.Customer("Acme", {
123 * organization: "123",
124 * customerId: "456",
125 * displayName: "Acme Corp",
126 * });
127 * ```
128 *
129 * ### Updating a Customer
130 * Re-declare the same logical id with changed props; the engine keeps the
131 * physical resource and updates it in place.
132 *
133 * **Example:** Rename
134 * ```typescript
135 * const customer = yield* GCP.CloudControlsPartner.Customer("Acme", {
136 * organization: "organizations/123",
137 * customerId: "456",
138 * displayName: "Acme Corporation",
139 * });
140 * ```
141 *
142 * @resource
143 * @category CloudControlsPartner
144 */
145export const Customer = Resource<Customer>("GCP.CloudControlsPartner.Customer");
146
147export class CustomerNotResolved extends Data.TaggedError(
148 "GCP.CloudControlsPartner.CustomerNotResolved",
149)<{
150 name: string;
151}> {}
152
153const toAttrs = (
154 customer: cloudcontrolspartner.Customer,
155 organization: string,
156 project: string,
157) => {
158 const name = customer.name ?? "";
159 const parsed = parseName(name);
160 const org = parsed.organization || organizationParent(organization);
161 return {
162 name,
163 customerId: parsed.customerId || toCustomerId(name) || "",
164 organization: org,
165 organizationId: organizationIdOf(org),
166 location: parsed.location,
167 parent: parsed.parent || locationParent(org, parsed.location),
168 project,
169 displayName: parseOwnership(customer.displayName).text,
170 organizationDomain: customer.organizationDomain,
171 isOnboarded: customer.isOnboarded === true,
172 customerOnboardingState: customer.customerOnboardingState,
173 };
174};
175
176export const CustomerProvider = () =>
177 Provider.succeed(Customer, {
178 stables: [
179 "name",
180 "customerId",
181 "organization",
182 "organizationId",
183 "location",
184 "parent",
185 "project",
186 "organizationDomain",
187 ],
188
189 diff: Effect.fn(function* ({ news, olds, output }) {
190 if (!isResolved(news)) return undefined;
191 const env = yield* GcpEnvironment.current;
192 const previousLocation = normalizeLocation(
193 olds?.location ?? output?.location ?? env.region,
194 );
195 const nextLocation = normalizeLocation(
196 news.location ?? olds?.location ?? output?.location ?? env.region,
197 );
198 const previousOrg = olds?.organization ?? output?.organization;
199 const nextOrg = news.organization;
200 return (
201 replaceOn(
202 toCustomerId(olds?.customerId ?? output?.customerId),
203 toCustomerId(news.customerId),
204 ) ??
205 replaceOn(
206 previousOrg !== undefined
207 ? organizationParent(previousOrg)
208 : undefined,
209 nextOrg !== undefined ? organizationParent(nextOrg) : undefined,
210 ) ??
211 replaceOn(previousLocation, nextLocation)
212 );
213 }),
214
215 read: Effect.fn(function* ({ id, olds, output }) {
216 const env = yield* GcpEnvironment.current;
217 const organization = yield* resolveOrganization(
218 olds?.organization ?? output?.organization,
219 output?.organization,
220 );
221 const location = normalizeLocation(
222 olds?.location ?? output?.location ?? env.region,
223 );
224 const customerId = yield* toCustomerResourceId(
225 id,
226 olds?.customerId ?? output?.customerId,
227 output?.name,
228 );
229 const name =
230 output?.name ?? customerName(organization, location, customerId);
231 let existing = yield* getCustomer(name);
232 if (existing === undefined) {
233 existing = yield* findOwnedCustomer(
234 id,
235 locationParent(organization, location),
236 );
237 }
238 if (existing === undefined) return undefined;
239 const attrs = toAttrs(existing, organization, env.project);
240 return (yield* ownedByAlchemy(id, existing.displayName))
241 ? attrs
242 : Unowned(attrs);
243 }),
244
245 list: () =>
246 Effect.gen(function* () {
247 const env = yield* GcpEnvironment.current;
248 const organization = yield* tryResolveOrganization();
249 if (organization === undefined) return [];
250 const pages = yield* Effect.forEach(
251 listLocationParents(organization, env.region),
252 (parent) => listCustomers(parent),
253 { concurrency: 3 },
254 );
255 const byName = new Map<string, ReturnType<typeof toAttrs>>();
256 for (const customer of pages.flat()) {
257 if (!hasOwnershipMarker(customer.displayName)) continue;
258 const attrs = toAttrs(customer, organization, env.project);
259 if (attrs.name.length > 0) byName.set(attrs.name, attrs);
260 }
261 return [...byName.values()];
262 }),
263
264 reconcile: Effect.fn(function* ({ id, news, output }) {
265 const env = yield* GcpEnvironment.current;
266 const organization = yield* resolveOrganization(
267 news.organization,
268 output?.organization,
269 );
270 const location = normalizeLocation(
271 news.location ?? output?.location ?? env.region,
272 );
273 const customerId = yield* toCustomerResourceId(
274 id,
275 news.customerId,
276 output?.customerId ?? output?.name,
277 );
278 const parent = locationParent(organization, location);
279 const name = customerName(organization, location, customerId);
280 const ownership = yield* ownershipLabels(id);
281 const displayName = encodeDisplayName(
282 ownership,
283 yield* toDisplayName(id, news.displayName, output?.displayName),
284 );
285
286 let current = yield* getCustomer(output?.name ?? name);
287 if (current === undefined) {
288 current = yield* findOwnedCustomer(id, parent);
289 }
290
291 if (current === undefined) {
292 const created = yield* cloudcontrolspartner
293 .createOrganizationsLocationsCustomers({
294 parent,
295 customerId,
296 body: { displayName },
297 })
298 .pipe(
299 Effect.catchTag("Conflict", () =>
300 getCustomer(name).pipe(
301 Effect.flatMap((existing) =>
302 existing !== undefined
303 ? Effect.succeed(existing)
304 : findOwnedCustomer(id, parent),
305 ),
306 ),
307 ),
308 );
309 current = created ?? undefined;
310 }
311
312 if (current === undefined) {
313 return yield* new CustomerNotResolved({ name });
314 }
315
316 const currentName = current.name ?? name;
317 if (
318 !sameText(current.displayName, displayName) &&
319 currentName.length > 0
320 ) {
321 current =
322 yield* cloudcontrolspartner.patchOrganizationsLocationsCustomers({
323 name: currentName,
324 updateMask: "display_name",
325 body: { displayName },
326 });
327 }
328
329 const latest =
330 (yield* getCustomer(current.name ?? currentName)) ?? current;
331 return toAttrs(latest, organization, env.project);
332 }),
333
334 delete: Effect.fn(function* ({ output }) {
335 const name = output.name;
336 if (!name) return;
337 yield* cloudcontrolspartner
338 .deleteOrganizationsLocationsCustomers({ name })
339 .pipe(Effect.catchTag("NotFound", () => Effect.void));
340 }),
341 });