1import * as apigee from "@distilled.cloud/gcp/apigee_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 { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeDescription,
13 hasOwnershipMarker,
14 parseDescription,
15} from "./ownership.ts";
16import {
17 lastSegment,
18 orgName,
19 sameJson,
20 waitForOperation,
21} from "./operations.ts";
22
23const DEFAULT_RUNTIME_TYPE = "CLOUD";
24const DEFAULT_BILLING_TYPE = "EVALUATION";
25/** Organization provisioning and deletion take tens of minutes. */
26const ORGANIZATION_BUDGET = "60 minutes";
27
28export type OrganizationAddonsConfig = {
29 /** API Security add-on. */
30 apiSecurityConfig?: { enabled?: boolean };
31 /** Monetization add-on. */
32 monetizationConfig?: { enabled?: boolean };
33 /** Connectors Platform add-on. */
34 connectorsPlatformConfig?: { enabled?: boolean };
35 /** Analytics add-on. */
36 analyticsConfig?: { enabled?: boolean };
37 /** Integration add-on. */
38 integrationConfig?: { enabled?: boolean };
39 /** Advanced API Ops add-on. */
40 advancedApiOpsConfig?: { enabled?: boolean };
41};
42
43export type OrganizationProps = {
44 /**
45 * Apigee organization id. Defaults to the current GCP project id —
46 * Apigee X organizations are 1:1 with a Google Cloud project.
47 * Immutable.
48 */
49 organizationId?: string;
50 /**
51 * Primary Google Cloud region for analytics data storage.
52 * Immutable.
53 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
54 */
55 analyticsRegion?: string;
56 /**
57 * Runtime type purchased for this organization (`CLOUD` or `HYBRID`).
58 * Immutable.
59 * @default "CLOUD"
60 */
61 runtimeType?:
62 | apigee.GoogleCloudApigeeV1OrganizationRuntimeTypeEnum
63 | (string & {});
64 /**
65 * Billing type. See Apigee pricing. Immutable after create.
66 * @default "EVALUATION"
67 */
68 billingType?:
69 | apigee.GoogleCloudApigeeV1OrganizationBillingTypeEnum
70 | (string & {});
71 /**
72 * Human-readable description. Apigee organizations have no labels, so
73 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
74 * from attributes.
75 */
76 description?: string;
77 /**
78 * Display name. Unused by Apigee today; reserved for future use.
79 */
80 displayName?: string;
81 /**
82 * Disable VPC peering through Private Google Access. Required when
83 * `authorizedNetwork` is omitted for `CLOUD` runtime.
84 * @default true
85 */
86 disableVpcPeering?: boolean;
87 /**
88 * Compute Engine network peered with Apigee runtime instances.
89 */
90 authorizedNetwork?: string;
91 /**
92 * Restrict internet egress for VPC Service Controls. Valid only when
93 * `runtimeType` is `CLOUD` and `disableVpcPeering` is true.
94 */
95 networkEgressRestricted?: boolean;
96 /**
97 * Cloud KMS key for runtime database encryption. Immutable.
98 */
99 runtimeDatabaseEncryptionKeyName?: string;
100 /**
101 * Cloud KMS key for control-plane data. Immutable.
102 */
103 controlPlaneEncryptionKeyName?: string;
104 /**
105 * Cloud KMS key for API consumer data. Immutable.
106 */
107 apiConsumerDataEncryptionKeyName?: string;
108 /**
109 * Single-region location for control-plane data (data residency).
110 */
111 apiConsumerDataLocation?: string;
112 /**
113 * Disable the integrated developer portal.
114 */
115 portalDisabled?: boolean;
116 /**
117 * Add-on configurations.
118 */
119 addonsConfig?: OrganizationAddonsConfig;
120};
121
122export type Organization = Resource<
123 "GCP.Apigee.Organization",
124 OrganizationProps,
125 {
126 /** Full resource name `organizations/{org}`. */
127 name: string;
128 /** Organization id (last path segment). */
129 organizationId: string;
130 /** Project id associated with the organization. */
131 project: string;
132 /** Analytics region. */
133 analyticsRegion: string | undefined;
134 /** Runtime type. */
135 runtimeType: string | undefined;
136 /** Billing type. */
137 billingType: string | undefined;
138 /** User description with the Alchemy ownership prefix stripped. */
139 description: string | undefined;
140 /** Display name. */
141 displayName: string | undefined;
142 /** Whether VPC peering is disabled. */
143 disableVpcPeering: boolean;
144 /** Authorized VPC network, if any. */
145 authorizedNetwork: string | undefined;
146 /** Whether internet egress is restricted. */
147 networkEgressRestricted: boolean;
148 /** Runtime database CMEK, if any. */
149 runtimeDatabaseEncryptionKeyName: string | undefined;
150 /** Control-plane CMEK, if any. */
151 controlPlaneEncryptionKeyName: string | undefined;
152 /** API consumer data CMEK, if any. */
153 apiConsumerDataEncryptionKeyName: string | undefined;
154 /** Control-plane data location. */
155 apiConsumerDataLocation: string | undefined;
156 /** Whether the portal is disabled. */
157 portalDisabled: boolean;
158 /** Add-on configurations. */
159 addonsConfig: OrganizationAddonsConfig | undefined;
160 /** Server-reported state (`ACTIVE`, `CREATING`, …). */
161 state: string | undefined;
162 /** Apigee project id used for PSC allowlisting. */
163 apigeeProjectId: string | undefined;
164 /** Subscription plan. */
165 subscriptionPlan: string | undefined;
166 /** Subscription type. */
167 subscriptionType: string | undefined;
168 /** Environments in the organization. */
169 environments: string[];
170 /** Creation time in milliseconds since epoch. */
171 createdAt: string | undefined;
172 /** Last modification time in milliseconds since epoch. */
173 lastModifiedAt: string | undefined;
174 },
175 never,
176 Providers
177>;
178
179/**
180 * An Apigee X organization, 1:1 with a Google Cloud project.
181 *
182 * Organizations have no labels field — Alchemy stamps ownership into
183 * the description so `list` / nuke can find them. Creating an
184 * organization is slow and entitlement-gated; live tests skip unless
185 * `GCP_TEST_APIGEE=1`.
186 *
187 * Changing `organizationId`, `analyticsRegion`, `runtimeType`,
188 * `billingType`, or CMEK keys replaces the organization.
189 *
190 * ### Creating an Organization
191 * **Example:** Evaluation org in us-central1
192 * ```typescript
193 * const org = yield* GCP.Apigee.Organization("Org", {
194 * analyticsRegion: "us-central1",
195 * runtimeType: "CLOUD",
196 * billingType: "EVALUATION",
197 * disableVpcPeering: true,
198 * });
199 * ```
200 *
201 * **Example:** Description and add-ons
202 * ```typescript
203 * const org = yield* GCP.Apigee.Organization("Org", {
204 * description: "api platform",
205 * addonsConfig: { monetizationConfig: { enabled: true } },
206 * });
207 * ```
208 *
209 * @resource
210 * @category Apigee
211 */
212export const Organization = Resource<Organization>("GCP.Apigee.Organization");
213
214export class OrganizationNotResolved extends Data.TaggedError(
215 "GCP.Apigee.OrganizationNotResolved",
216)<{
217 name: string;
218}> {}
219
220const toOrgId = (
221 project: string,
222 organizationId: string | undefined,
223 existing?: string,
224) => organizationId ?? existing ?? project;
225
226const toAttrs = (
227 organization: apigee.GoogleCloudApigeeV1Organization,
228 project: string,
229) => {
230 const organizationId = lastSegment(organization.name ?? project);
231 const parsed = parseDescription(organization.description);
232 const addons = organization.addonsConfig;
233 return {
234 name: orgName(organizationId),
235 organizationId,
236 project: organization.projectId ?? project,
237 analyticsRegion: organization.analyticsRegion,
238 runtimeType: organization.runtimeType,
239 billingType: organization.billingType,
240 description: parsed.description,
241 displayName: organization.displayName,
242 disableVpcPeering: organization.disableVpcPeering === true,
243 authorizedNetwork: organization.authorizedNetwork,
244 networkEgressRestricted: organization.networkEgressRestricted === true,
245 runtimeDatabaseEncryptionKeyName:
246 organization.runtimeDatabaseEncryptionKeyName,
247 controlPlaneEncryptionKeyName: organization.controlPlaneEncryptionKeyName,
248 apiConsumerDataEncryptionKeyName:
249 organization.apiConsumerDataEncryptionKeyName,
250 apiConsumerDataLocation: organization.apiConsumerDataLocation,
251 portalDisabled: organization.portalDisabled === true,
252 addonsConfig: addons,
253 state: organization.state,
254 apigeeProjectId: organization.apigeeProjectId,
255 subscriptionPlan: organization.subscriptionPlan,
256 subscriptionType: organization.subscriptionType,
257 environments: [...(organization.environments ?? [])],
258 createdAt: organization.createdAt,
259 lastModifiedAt: organization.lastModifiedAt,
260 };
261};
262
263const getByName = (name: string) =>
264 apigee
265 .getOrganizations({ name })
266 .pipe(
267 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
268 Effect.succeed(undefined),
269 ),
270 );
271
272const toBody = (
273 props: OrganizationProps,
274 description: string,
275 analyticsRegion: string,
276): apigee.GoogleCloudApigeeV1Organization => ({
277 analyticsRegion,
278 runtimeType: props.runtimeType ?? DEFAULT_RUNTIME_TYPE,
279 billingType: props.billingType ?? DEFAULT_BILLING_TYPE,
280 description,
281 displayName: props.displayName,
282 disableVpcPeering: props.disableVpcPeering ?? true,
283 authorizedNetwork: props.authorizedNetwork,
284 networkEgressRestricted: props.networkEgressRestricted,
285 runtimeDatabaseEncryptionKeyName: props.runtimeDatabaseEncryptionKeyName,
286 controlPlaneEncryptionKeyName: props.controlPlaneEncryptionKeyName,
287 apiConsumerDataEncryptionKeyName: props.apiConsumerDataEncryptionKeyName,
288 apiConsumerDataLocation: props.apiConsumerDataLocation,
289 portalDisabled: props.portalDisabled,
290 addonsConfig: props.addonsConfig,
291});
292
293export const OrganizationProvider = () =>
294 Provider.succeed(Organization, {
295 stables: [
296 "name",
297 "organizationId",
298 "project",
299 "createdAt",
300 "apigeeProjectId",
301 ],
302
303 diff: Effect.fn(function* ({ news, olds, output }) {
304 if (!isResolved(news)) return undefined;
305 const env = yield* GcpEnvironment.current;
306 const previousId = olds?.organizationId ?? output?.organizationId;
307 const previousRegion = olds?.analyticsRegion ?? output?.analyticsRegion;
308 const previousRuntime = olds?.runtimeType ?? output?.runtimeType;
309 const previousBilling = olds?.billingType ?? output?.billingType;
310 const nextId = news.organizationId ?? previousId;
311 const nextRegion = news.analyticsRegion ?? previousRegion ?? env.region;
312 const nextRuntime = news.runtimeType ?? DEFAULT_RUNTIME_TYPE;
313 const nextBilling = news.billingType ?? DEFAULT_BILLING_TYPE;
314 if (
315 (previousId !== undefined && nextId !== previousId) ||
316 (previousRegion !== undefined &&
317 previousRegion.toLowerCase() !== nextRegion.toLowerCase()) ||
318 (previousRuntime !== undefined && previousRuntime !== nextRuntime) ||
319 (previousBilling !== undefined && previousBilling !== nextBilling)
320 ) {
321 return { action: "replace" as const, deleteFirst: true };
322 }
323 return undefined;
324 }),
325
326 read: Effect.fn(function* ({ id, olds, output }) {
327 const env = yield* GcpEnvironment.current;
328 const organizationId = toOrgId(
329 env.project,
330 olds?.organizationId,
331 output?.organizationId,
332 );
333 const name = output?.name ?? orgName(organizationId);
334 const existing = yield* getByName(name);
335 if (existing === undefined) return undefined;
336 const attrs = toAttrs(existing, env.project);
337 const { labels } = parseDescription(existing.description);
338 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
339 }),
340
341 list: () =>
342 Effect.gen(function* () {
343 const env = yield* GcpEnvironment.current;
344 const page = yield* apigee
345 .listOrganizations({ parent: "organizations" })
346 .pipe(
347 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
348 Effect.succeed({ organizations: [] }),
349 ),
350 );
351 const mapped = (page.organizations ?? []).filter(
352 (item) =>
353 item.projectId === env.project ||
354 (item.projectIds ?? []).includes(env.project),
355 );
356 const attrs = [];
357 for (const mapping of mapped) {
358 const organizationId = lastSegment(
359 mapping.organization ?? env.project,
360 );
361 const existing = yield* getByName(orgName(organizationId));
362 if (
363 existing !== undefined &&
364 hasOwnershipMarker(existing.description)
365 ) {
366 attrs.push(toAttrs(existing, env.project));
367 }
368 }
369 return attrs;
370 }),
371
372 reconcile: Effect.fn(function* ({ id, news, output }) {
373 const env = yield* GcpEnvironment.current;
374 const organizationId = toOrgId(
375 env.project,
376 news.organizationId,
377 output?.organizationId,
378 );
379 const name = orgName(organizationId);
380 const ownership = yield* createInternalLabels(id);
381 const desiredDescription = encodeDescription(ownership, news.description);
382 const analyticsRegion =
383 news.analyticsRegion ?? output?.analyticsRegion ?? env.region;
384
385 let current = yield* getByName(output?.name ?? name);
386
387 if (current === undefined) {
388 const created = yield* apigee
389 .createOrganizations({
390 parent: `projects/${env.project}`,
391 body: toBody(news, desiredDescription, analyticsRegion),
392 })
393 .pipe(
394 Effect.flatMap((operation) =>
395 waitForOperation(operation, { budget: ORGANIZATION_BUDGET }),
396 ),
397 Effect.flatMap(() => getByName(name)),
398 Effect.catchTag("Conflict", () => getByName(name)),
399 );
400 current = created ?? undefined;
401 }
402
403 if (current === undefined) {
404 return yield* new OrganizationNotResolved({ name });
405 }
406
407 const desired = toBody(news, desiredDescription, analyticsRegion);
408 const needsUpdate =
409 (current.description ?? "") !== desiredDescription ||
410 (current.displayName ?? "") !== (news.displayName ?? "") ||
411 (current.disableVpcPeering === true) !==
412 (desired.disableVpcPeering === true) ||
413 (current.authorizedNetwork ?? "") !== (news.authorizedNetwork ?? "") ||
414 (current.networkEgressRestricted === true) !==
415 (news.networkEgressRestricted === true) ||
416 (current.portalDisabled === true) !== (news.portalDisabled === true) ||
417 (current.apiConsumerDataLocation ?? "") !==
418 (news.apiConsumerDataLocation ?? "") ||
419 !sameJson(current.addonsConfig ?? {}, news.addonsConfig ?? {});
420
421 if (needsUpdate) {
422 current = yield* apigee.updateOrganizations({
423 name,
424 body: {
425 ...current,
426 ...desired,
427 description: desiredDescription,
428 },
429 });
430 }
431
432 return toAttrs(current, env.project);
433 }),
434
435 delete: Effect.fn(function* ({ output }) {
436 const operation = yield* apigee
437 .deleteOrganizations({
438 name: output.name,
439 retention: "MINIMUM",
440 })
441 .pipe(
442 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
443 Effect.succeed(undefined),
444 ),
445 );
446 if (operation !== undefined) {
447 yield* waitForOperation(operation, {
448 notFoundOk: true,
449 budget: ORGANIZATION_BUDGET,
450 });
451 }
452 }),
453 });