1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 lastSegment,
14 orgParent,
15 organizationFromName,
16 sameJson,
17 sameStringList,
18 toResourceId,
19} from "./names.ts";
20import { waitForOperation } from "./operations.ts";
21import {
22 createInternalLabels,
23 encodeOwnership,
24 hasAlchemyLabels,
25 hasOwnershipMarker,
26 parseOwnership,
27} from "./ownership.ts";
28
29const MAX_NAME_LENGTH = 32;
30
31export type AccessLoggingConfig = {
32 /**
33 * Whether access logging is enabled for this instance.
34 * @default false
35 */
36 enabled?: boolean;
37 /**
38 * CEL filter on `status_code` (for example
39 * `status_code >= 200 && status_code < 300`).
40 */
41 filter?: string;
42};
43
44export type MaintenanceWindow = {
45 /**
46 * Preferred day of week for maintenance.
47 */
48 day?:
49 | apigee.GoogleCloudApigeeV1MaintenanceUpdatePolicyMaintenanceWindowDayEnum
50 | (string & {});
51 /**
52 * UTC start time of the window.
53 */
54 startTime?: {
55 hours?: number;
56 minutes?: number;
57 seconds?: number;
58 nanos?: number;
59 };
60};
61
62export type MaintenanceUpdatePolicy = {
63 /**
64 * Preferred maintenance windows. The API currently accepts at most one.
65 */
66 maintenanceWindows?: MaintenanceWindow[];
67 /**
68 * Relative scheduling channel (`WEEK1` or `WEEK2`).
69 */
70 maintenanceChannel?:
71 | apigee.GoogleCloudApigeeV1MaintenanceUpdatePolicyMaintenanceChannelEnum
72 | (string & {});
73};
74
75export type InstanceProps = {
76 /**
77 * Apigee organization id. Defaults to the current GCP project id.
78 * Immutable — changing it replaces the instance.
79 */
80 organization?: string;
81 /**
82 * Instance id (the `{instance}` segment of
83 * `organizations/{org}/instances/{instance}`). If omitted, a unique
84 * RFC1035 name is generated from the stack, stage, and logical id.
85 * 2–32 characters, lowercase letters, digits, and hyphens; must start
86 * with a letter. Immutable — changing it replaces the instance.
87 */
88 instanceId?: string;
89 /**
90 * Compute Engine location where the instance resides (region, for
91 * example `us-central1`). Immutable.
92 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
93 */
94 location?: string;
95 /**
96 * Human-readable description. Apigee instances have no labels field, so
97 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
98 * from attributes.
99 */
100 description?: string;
101 /**
102 * Display name for the instance.
103 */
104 displayName?: string;
105 /**
106 * Size of the CIDR block reserved by the instance. Paid orgs support
107 * `SLASH_16`–`SLASH_20` (default `SLASH_16`); evaluation orgs support
108 * only `SLASH_23`. Immutable.
109 */
110 peeringCidrRange?:
111 | apigee.GoogleCloudApigeeV1InstancePeeringCidrRangeEnum
112 | (string & {});
113 /**
114 * Comma-separated `/22` and/or `/28` CIDR blocks used to create the
115 * instance. Immutable. Omit to let Apigee allocate ranges from Service
116 * Networking.
117 */
118 ipRange?: string;
119 /**
120 * Customer-managed encryption key for disk and volume encryption
121 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
122 * Immutable. Omit for a Google-managed key.
123 */
124 diskEncryptionKeyName?: string;
125 /**
126 * Projects (id or number) allowed to privately connect to the service
127 * attachment. The Apigee org project is always included.
128 */
129 consumerAcceptList?: string[];
130 /**
131 * Access logging configuration. Disabled by default.
132 */
133 accessLoggingConfig?: AccessLoggingConfig;
134 /**
135 * Preferred maintenance window.
136 */
137 maintenanceUpdatePolicy?: MaintenanceUpdatePolicy;
138};
139
140export type Instance = Resource<
141 "GCP.Apigee.Instance",
142 InstanceProps,
143 {
144 /** Full resource name `organizations/{org}/instances/{instance}`. */
145 name: string;
146 /** Instance id (last path segment). */
147 instanceId: string;
148 /** Apigee organization id. */
149 organization: string;
150 /** Compute Engine location. */
151 location: string;
152 /** User description with the Alchemy ownership prefix stripped. */
153 description: string | undefined;
154 /** Display name. */
155 displayName: string | undefined;
156 /** Peering CIDR range size. */
157 peeringCidrRange: string | undefined;
158 /** Configured IP ranges. */
159 ipRange: string | undefined;
160 /** CMEK resource name, if any. */
161 diskEncryptionKeyName: string | undefined;
162 /** Consumer accept list. */
163 consumerAcceptList: string[];
164 /** Access logging configuration. */
165 accessLoggingConfig: AccessLoggingConfig | undefined;
166 /** Maintenance update policy. */
167 maintenanceUpdatePolicy: MaintenanceUpdatePolicy | undefined;
168 /** Server-reported state (`CREATING`, `ACTIVE`, …). */
169 state: string | undefined;
170 /** Runtime system version. */
171 runtimeVersion: string | undefined;
172 /** Exposed Apigee endpoint hostname. */
173 host: string | undefined;
174 /** Exposed Apigee endpoint port. */
175 port: string | undefined;
176 /** PSC service attachment resource name. */
177 serviceAttachment: string | undefined;
178 /** Whether the instance is version-locked. */
179 isVersionLocked: boolean | undefined;
180 /** Scheduled maintenance start time, if any. */
181 scheduledMaintenance: string | undefined;
182 /** Creation time in milliseconds since epoch. */
183 createdAt: string | undefined;
184 /** Last modification time in milliseconds since epoch. */
185 lastModifiedAt: string | undefined;
186 },
187 never,
188 Providers
189>;
190
191/**
192 * An Apigee runtime instance.
193 *
194 * Apigee instances have no labels field, so Alchemy stamps ownership into
195 * the description for `list` / nuke. Name, organization, location,
196 * peering CIDR, IP range, and CMEK are identity — changing them replaces
197 * the instance. Description, display name, consumer accept list, access
198 * logging, and maintenance policy update in place.
199 *
200 * Provisioning typically takes 20–40 minutes. Live lifecycle tests are
201 * skipIf-gated (`GCP_TEST_APIGEE_INSTANCE`).
202 *
203 * ### Creating an Instance
204 * **Example:** Generated name in us-central1
205 * ```typescript
206 * const runtime = yield* GCP.Apigee.Instance("Runtime", {});
207 * ```
208 *
209 * **Example:** Explicit id, description, and access logging
210 * ```typescript
211 * const runtime = yield* GCP.Apigee.Instance("Runtime", {
212 * instanceId: "app-runtime",
213 * location: "us-central1",
214 * description: "production runtime",
215 * accessLoggingConfig: {
216 * enabled: true,
217 * filter: "status_code >= 400",
218 * },
219 * });
220 * ```
221 *
222 * ### Updating an Instance
223 * **Example:** Change display name and maintenance window
224 * ```typescript
225 * const runtime = yield* GCP.Apigee.Instance("Runtime", {
226 * displayName: "app-runtime-prod",
227 * maintenanceUpdatePolicy: {
228 * maintenanceWindows: [{
229 * day: "SUNDAY",
230 * startTime: { hours: 2, minutes: 0 },
231 * }],
232 * },
233 * });
234 * ```
235 *
236 * @resource
237 * @category Apigee
238 */
239export const Instance = Resource<Instance>("GCP.Apigee.Instance");
240
241export class InstanceNotResolved extends Data.TaggedError(
242 "GCP.Apigee.InstanceNotResolved",
243)<{
244 name: string;
245}> {}
246
247export class InstanceNotReady extends Data.TaggedError(
248 "GCP.Apigee.InstanceNotReady",
249)<{
250 name: string;
251 state: string;
252}> {}
253
254export class InstanceStillExists extends Data.TaggedError(
255 "GCP.Apigee.InstanceStillExists",
256)<{
257 name: string;
258}> {}
259
260const resourceName = (organization: string, instanceId: string) =>
261 `${orgParent(organization)}/instances/${instanceId}`;
262
263const instanceIdOf = (instance: apigee.GoogleCloudApigeeV1Instance) =>
264 lastSegment(instance.name ?? "");
265
266const locationOf = (location: string | undefined, defaultLocation: string) =>
267 (location ?? defaultLocation).toLowerCase();
268
269const accessLoggingOf = (
270 config:
271 | apigee.GoogleCloudApigeeV1AccessLoggingConfig
272 | AccessLoggingConfig
273 | undefined,
274): AccessLoggingConfig | undefined => {
275 if (config === undefined) return undefined;
276 return {
277 enabled: config.enabled === true,
278 filter: config.filter,
279 };
280};
281
282const maintenanceOf = (
283 policy:
284 | apigee.GoogleCloudApigeeV1MaintenanceUpdatePolicy
285 | MaintenanceUpdatePolicy
286 | undefined,
287): MaintenanceUpdatePolicy | undefined => {
288 if (policy === undefined) return undefined;
289 return {
290 maintenanceChannel: policy.maintenanceChannel,
291 maintenanceWindows: policy.maintenanceWindows?.map((window) => ({
292 day: window.day,
293 startTime: window.startTime
294 ? {
295 hours: window.startTime.hours,
296 minutes: window.startTime.minutes,
297 seconds: window.startTime.seconds,
298 nanos: window.startTime.nanos,
299 }
300 : undefined,
301 })),
302 };
303};
304
305const toAttrs = (
306 instance: apigee.GoogleCloudApigeeV1Instance,
307 organization: string,
308 region: string,
309) => {
310 const instanceId = instanceIdOf(instance);
311 const parsed = parseOwnership(instance.description);
312 const name = instance.name?.includes("/")
313 ? instance.name
314 : resourceName(organization, instanceId);
315 return {
316 name,
317 instanceId,
318 organization: organizationFromName(name) ?? organization,
319 location: instance.location ?? region,
320 description: parsed.text,
321 displayName: instance.displayName,
322 peeringCidrRange: instance.peeringCidrRange,
323 ipRange: instance.ipRange,
324 diskEncryptionKeyName: instance.diskEncryptionKeyName,
325 consumerAcceptList: [...(instance.consumerAcceptList ?? [])],
326 accessLoggingConfig: accessLoggingOf(instance.accessLoggingConfig),
327 maintenanceUpdatePolicy: maintenanceOf(instance.maintenanceUpdatePolicy),
328 state: instance.state,
329 runtimeVersion: instance.runtimeVersion,
330 host: instance.host,
331 port: instance.port,
332 serviceAttachment: instance.serviceAttachment,
333 isVersionLocked: instance.isVersionLocked,
334 scheduledMaintenance: instance.scheduledMaintenance?.startTime,
335 createdAt: instance.createdAt,
336 lastModifiedAt: instance.lastModifiedAt,
337 };
338};
339
340const getByName = (name: string) =>
341 apigee
342 .getOrganizationsInstances({ name })
343 .pipe(
344 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
345 Effect.succeed(undefined),
346 ),
347 );
348
349/** Instance provisioning and deletion take 30–60 minutes. */
350const INSTANCE_BUDGET = "75 minutes";
351
352const waitUntilExists = (name: string) =>
353 getByName(name).pipe(
354 Effect.flatMap((instance) =>
355 instance
356 ? Effect.succeed(instance)
357 : Effect.fail(new InstanceNotResolved({ name })),
358 ),
359 Effect.retry({
360 while: (error) => error._tag === "GCP.Apigee.InstanceNotResolved",
361 times: 8,
362 schedule: Schedule.spaced("1 second"),
363 }),
364 );
365
366const waitUntilActive = (name: string) =>
367 getByName(name).pipe(
368 Effect.filterOrFail(
369 (instance): instance is apigee.GoogleCloudApigeeV1Instance =>
370 instance !== undefined,
371 () => new InstanceNotResolved({ name }),
372 ),
373 Effect.filterOrFail(
374 (instance) => (instance.state ?? "STATE_UNSPECIFIED") === "ACTIVE",
375 (instance) =>
376 new InstanceNotReady({
377 name,
378 state: instance.state ?? "STATE_UNSPECIFIED",
379 }),
380 ),
381 Effect.retry({
382 while: (error) =>
383 error._tag === "GCP.Apigee.InstanceNotReady" ||
384 error._tag === "GCP.Apigee.InstanceNotResolved",
385 times: 8,
386 schedule: Schedule.spaced("5 seconds"),
387 }),
388 );
389
390const waitUntilGone = (name: string) =>
391 getByName(name).pipe(
392 Effect.flatMap((instance) =>
393 instance === undefined
394 ? Effect.void
395 : Effect.fail(new InstanceStillExists({ name })),
396 ),
397 Effect.retry({
398 while: (error) => error._tag === "GCP.Apigee.InstanceStillExists",
399 times: 8,
400 schedule: Schedule.spaced("5 seconds"),
401 }),
402 );
403
404const toBody = (
405 news: InstanceProps,
406 instanceId: string,
407 description: string,
408 location: string,
409): apigee.GoogleCloudApigeeV1Instance => ({
410 name: instanceId,
411 location,
412 description,
413 displayName: news.displayName,
414 peeringCidrRange: news.peeringCidrRange,
415 ipRange: news.ipRange,
416 diskEncryptionKeyName: news.diskEncryptionKeyName,
417 consumerAcceptList: news.consumerAcceptList,
418 accessLoggingConfig: news.accessLoggingConfig
419 ? {
420 enabled: news.accessLoggingConfig.enabled === true,
421 filter: news.accessLoggingConfig.filter,
422 }
423 : undefined,
424 maintenanceUpdatePolicy: news.maintenanceUpdatePolicy
425 ? {
426 maintenanceChannel: news.maintenanceUpdatePolicy.maintenanceChannel,
427 maintenanceWindows: news.maintenanceUpdatePolicy.maintenanceWindows,
428 }
429 : undefined,
430});
431
432export const InstanceProvider = () =>
433 Provider.succeed(Instance, {
434 stables: ["name", "instanceId", "organization", "createdAt"],
435
436 diff: Effect.fn(function* ({ news, olds, output }) {
437 if (!isResolved(news)) return undefined;
438 const previousId = olds?.instanceId ?? output?.instanceId;
439 const previousOrg = olds?.organization ?? output?.organization;
440 const previousLocation = olds?.location ?? output?.location;
441 const previousPeering =
442 olds?.peeringCidrRange ?? output?.peeringCidrRange;
443 const previousIpRange = olds?.ipRange ?? output?.ipRange;
444 const previousCmek =
445 olds?.diskEncryptionKeyName ?? output?.diskEncryptionKeyName;
446 if (
447 (previousId !== undefined &&
448 news.instanceId !== undefined &&
449 news.instanceId !== previousId) ||
450 (previousOrg !== undefined &&
451 news.organization !== undefined &&
452 news.organization !== previousOrg) ||
453 (previousLocation !== undefined &&
454 news.location !== undefined &&
455 news.location.toLowerCase() !== previousLocation.toLowerCase()) ||
456 (news.peeringCidrRange !== undefined &&
457 previousPeering !== undefined &&
458 news.peeringCidrRange !== previousPeering) ||
459 (news.ipRange !== undefined &&
460 previousIpRange !== undefined &&
461 news.ipRange !== previousIpRange) ||
462 (news.diskEncryptionKeyName !== undefined &&
463 previousCmek !== undefined &&
464 news.diskEncryptionKeyName !== previousCmek)
465 ) {
466 return { action: "replace" as const, deleteFirst: false };
467 }
468 return undefined;
469 }),
470
471 read: Effect.fn(function* ({ id, olds, output }) {
472 const env = yield* GcpEnvironment.current;
473 const organization =
474 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
475 const instanceId = yield* toResourceId(
476 id,
477 olds?.instanceId,
478 output?.instanceId,
479 MAX_NAME_LENGTH,
480 );
481 const name = output?.name ?? resourceName(organization, instanceId);
482 const existing = yield* getByName(name);
483 if (existing === undefined) return undefined;
484 const attrs = toAttrs(existing, organization, env.region);
485 const { labels } = parseOwnership(existing.description);
486 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
487 }),
488
489 list: () =>
490 Effect.gen(function* () {
491 const env = yield* GcpEnvironment.current;
492 return yield* apigee.listOrganizationsInstances
493 .pages({
494 parent: orgParent(env.project),
495 pageSize: 1000,
496 })
497 .pipe(
498 Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])),
499 Stream.filter((instance) =>
500 hasOwnershipMarker(instance.description),
501 ),
502 Stream.map((instance) =>
503 toAttrs(instance, env.project, env.region),
504 ),
505 Stream.runCollect,
506 Effect.map((chunk) => Array.from(chunk)),
507 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
508 Effect.succeed([] as Instance["Attributes"][]),
509 ),
510 );
511 }),
512
513 reconcile: Effect.fn(function* ({ id, news, output }) {
514 const env = yield* GcpEnvironment.current;
515 const organization =
516 news.organization ?? output?.organization ?? env.project;
517 const instanceId = yield* toResourceId(
518 id,
519 news.instanceId,
520 output?.instanceId,
521 MAX_NAME_LENGTH,
522 );
523 const location = locationOf(
524 news.location ?? output?.location,
525 env.region,
526 );
527 const name = resourceName(organization, instanceId);
528 const ownership = yield* createInternalLabels(id);
529 const desiredDescription = encodeOwnership(ownership, news.description);
530
531 let current = yield* getByName(output?.name ?? name);
532
533 if (current !== undefined && (current.state ?? "") === "DELETING") {
534 yield* waitUntilGone(current.name?.includes("/") ? current.name : name);
535 current = undefined;
536 }
537
538 if (current === undefined) {
539 const created = yield* apigee
540 .createOrganizationsInstances({
541 parent: orgParent(organization),
542 body: toBody(news, instanceId, desiredDescription, location),
543 })
544 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
545 if (created !== undefined) {
546 yield* waitForOperation(created, {
547 alreadyExistsOk: true,
548 budget: INSTANCE_BUDGET,
549 });
550 }
551 current = yield* waitUntilExists(name);
552 }
553
554 if (current === undefined) {
555 return yield* new InstanceNotResolved({ name });
556 }
557
558 if ((current.state ?? "") === "CREATING") {
559 current = (yield* waitUntilActive(name)) ?? current;
560 }
561
562 const desiredLogging = accessLoggingOf(news.accessLoggingConfig);
563 const desiredMaintenance = maintenanceOf(news.maintenanceUpdatePolicy);
564 const desiredAccept = news.consumerAcceptList;
565 const descriptionChanged =
566 (current.description ?? "") !== desiredDescription;
567 const displayChanged =
568 (current.displayName ?? "") !== (news.displayName ?? "");
569 const loggingChanged = !sameJson(
570 accessLoggingOf(current.accessLoggingConfig),
571 desiredLogging,
572 );
573 const maintenanceChanged = !sameJson(
574 maintenanceOf(current.maintenanceUpdatePolicy),
575 desiredMaintenance,
576 );
577 const acceptChanged =
578 desiredAccept !== undefined &&
579 !sameStringList(current.consumerAcceptList, desiredAccept);
580
581 const updateMask = [
582 descriptionChanged ? "description" : undefined,
583 displayChanged ? "displayName" : undefined,
584 loggingChanged ? "accessLoggingConfig" : undefined,
585 maintenanceChanged ? "maintenanceUpdatePolicy" : undefined,
586 acceptChanged ? "consumerAcceptList" : undefined,
587 ].filter((field): field is string => field !== undefined);
588
589 if (updateMask.length > 0) {
590 const patched = yield* apigee.patchOrganizationsInstances({
591 name: current.name?.includes("/") ? current.name : name,
592 updateMask: updateMask.join(","),
593 body: {
594 description: desiredDescription,
595 displayName: news.displayName,
596 accessLoggingConfig: news.accessLoggingConfig
597 ? {
598 enabled: news.accessLoggingConfig.enabled === true,
599 filter: news.accessLoggingConfig.filter,
600 }
601 : undefined,
602 maintenanceUpdatePolicy: news.maintenanceUpdatePolicy
603 ? {
604 maintenanceChannel:
605 news.maintenanceUpdatePolicy.maintenanceChannel,
606 maintenanceWindows:
607 news.maintenanceUpdatePolicy.maintenanceWindows,
608 }
609 : undefined,
610 consumerAcceptList: desiredAccept,
611 },
612 });
613 yield* waitForOperation(patched, { budget: INSTANCE_BUDGET });
614 current = (yield* getByName(name)) ?? current;
615 }
616
617 return toAttrs(current, organization, env.region);
618 }),
619
620 delete: Effect.fn(function* ({ output }) {
621 const deleted = yield* apigee
622 .deleteOrganizationsInstances({ name: output.name })
623 .pipe(
624 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
625 Effect.succeed(undefined),
626 ),
627 );
628 if (deleted !== undefined) {
629 yield* waitForOperation(deleted, {
630 notFoundOk: true,
631 budget: INSTANCE_BUDGET,
632 });
633 }
634 yield* waitUntilGone(output.name);
635 }),
636 });