1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 hasOwnershipExtension,
14 parseOwnershipExtension,
15 stripOwnershipExtension,
16 withOwnershipExtension,
17} from "./ownership.ts";
18import {
19 lastSegment,
20 orgParent,
21 resolveOrgId,
22 sameJson,
23 toPhysicalId,
24 waitForOperation,
25} from "./operations.ts";
26
27const MAX_NAME_LENGTH = 63;
28
29export type ApimNetworkConfig = {
30 /**
31 * Region for the PSC NEG.
32 */
33 region: string;
34 /**
35 * Subnet for the PSC NEG
36 * (`projects/{project}/regions/{region}/subnetworks/{subnet}`).
37 */
38 subnet: string;
39};
40
41export type ApimExtension = {
42 /**
43 * `LbTrafficExtension` resource name (RFC-1034, max 63 characters).
44 */
45 name: string;
46 /**
47 * Hostname of an Apigee environment group used to route traffic.
48 */
49 hostname: string;
50 /**
51 * Whether matching requests should fail open.
52 */
53 failOpen?: boolean;
54 /**
55 * CEL match condition.
56 */
57 matchCondition?: string;
58 /**
59 * Supported events. Empty means all events.
60 */
61 supportedEvents?: Array<
62 | apigee.GoogleCloudApigeeV1ApimServiceExtensionExtensionSupportedEventsItemEnum
63 | (string & {})
64 >;
65};
66
67export type ApimServiceExtensionProps = {
68 /**
69 * Apigee organization id. Defaults to the GCP project id. Immutable.
70 */
71 organizationId?: string;
72 /**
73 * Service extension id (RFC-1034, max 63 characters). If omitted, a
74 * unique name is generated. Immutable.
75 */
76 apimServiceExtensionId?: string;
77 /**
78 * Load balancer forwarding rule
79 * (`projects/{project}/regions/{region}/forwardingRules/{rule}` or
80 * global).
81 */
82 lbForwardingRule: string;
83 /**
84 * VPC network (`projects/{project}/global/networks/{network}`).
85 */
86 network: string;
87 /**
88 * Network configurations for the PSC NEG.
89 */
90 networkConfigs: ApimNetworkConfig[];
91 /**
92 * Name of the proxy deployed in the Apigee X instance.
93 */
94 extensionProcessor: string;
95 /**
96 * Extensions that make up this service extension. Alchemy prepends a
97 * never-matching ownership extension (`alchown`) so `list` / nuke can
98 * find the resource — the API has no labels field.
99 */
100 extensions?: ApimExtension[];
101};
102
103export type ApimServiceExtension = Resource<
104 "GCP.Apigee.ApimServiceExtension",
105 ApimServiceExtensionProps,
106 {
107 /** Full resource name `organizations/{org}/apimServiceExtensions/{id}`. */
108 name: string;
109 /** Service extension id. */
110 apimServiceExtensionId: string;
111 /** Organization id. */
112 organizationId: string;
113 /** Project id. */
114 project: string;
115 /** Load balancer forwarding rule. */
116 lbForwardingRule: string;
117 /** VPC network. */
118 network: string;
119 /** Network configurations. */
120 networkConfigs: ApimNetworkConfig[];
121 /** Extension processor proxy name. */
122 extensionProcessor: string;
123 /** User extensions (Alchemy ownership extension stripped). */
124 extensions: ApimExtension[];
125 /** Server-reported state. */
126 state: string | undefined;
127 /** RFC3339 creation timestamp. */
128 createTime: string | undefined;
129 /** RFC3339 last-update timestamp. */
130 updateTime: string | undefined;
131 },
132 never,
133 Providers
134>;
135
136/**
137 * An APIM service extension that routes load-balancer traffic to an
138 * existing Apigee X instance.
139 *
140 * Service extensions have no labels or description. Alchemy stamps
141 * ownership as a never-matching `alchown` extension so `list` / nuke
142 * can find them. Changing the id or organization replaces the resource.
143 *
144 * ### Creating a Service Extension
145 * **Example:** Route a regional forwarding rule
146 * ```typescript
147 * const extension = yield* GCP.Apigee.ApimServiceExtension("Edge", {
148 * lbForwardingRule:
149 * "projects/my-project/regions/us-central1/forwardingRules/https",
150 * network: "projects/my-project/global/networks/default",
151 * networkConfigs: [{
152 * region: "us-central1",
153 * subnet: "projects/my-project/regions/us-central1/subnetworks/default",
154 * }],
155 * extensionProcessor: "ext-processor",
156 * });
157 * ```
158 *
159 * @resource
160 * @category Apigee
161 */
162export const ApimServiceExtension = Resource<ApimServiceExtension>(
163 "GCP.Apigee.ApimServiceExtension",
164);
165
166export class ApimServiceExtensionNotResolved extends Data.TaggedError(
167 "GCP.Apigee.ApimServiceExtensionNotResolved",
168)<{
169 name: string;
170}> {}
171
172const resourceName = (organizationId: string, extensionId: string) =>
173 `${orgParent(organizationId)}/apimServiceExtensions/${extensionId}`;
174
175const toExtension = (
176 extension: apigee.GoogleCloudApigeeV1ApimServiceExtensionExtension,
177): ApimExtension => ({
178 name: extension.name ?? "",
179 hostname: extension.hostname ?? "",
180 failOpen: extension.failOpen,
181 matchCondition: extension.matchCondition,
182 supportedEvents: extension.supportedEvents
183 ? [...extension.supportedEvents]
184 : undefined,
185});
186
187const toAttrs = (
188 resource: apigee.GoogleCloudApigeeV1ApimServiceExtension,
189 project: string,
190 organizationId: string,
191) => {
192 const name = resource.name ?? "";
193 return {
194 name,
195 apimServiceExtensionId: lastSegment(name),
196 organizationId,
197 project,
198 lbForwardingRule: resource.lbForwardingRule ?? "",
199 network: resource.network ?? "",
200 networkConfigs: (resource.networkConfigs ?? []).map((config) => ({
201 region: config.region ?? "",
202 subnet: config.subnet ?? "",
203 })),
204 extensionProcessor: resource.extensionProcessor ?? "",
205 extensions: stripOwnershipExtension(resource.extensions).map(toExtension),
206 state: resource.state,
207 createTime: resource.createTime,
208 updateTime: resource.updateTime,
209 };
210};
211
212const getByName = (name: string) =>
213 apigee
214 .getOrganizationsApimServiceExtensions({ name })
215 .pipe(
216 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
217 Effect.succeed(undefined),
218 ),
219 );
220
221export const ApimServiceExtensionProvider = () =>
222 Provider.succeed(ApimServiceExtension, {
223 stables: [
224 "name",
225 "apimServiceExtensionId",
226 "organizationId",
227 "project",
228 "createTime",
229 ],
230
231 diff: Effect.fn(function* ({ news, olds, output }) {
232 if (!isResolved(news)) return undefined;
233 const previousId =
234 olds?.apimServiceExtensionId ?? output?.apimServiceExtensionId;
235 const previousOrg = olds?.organizationId ?? output?.organizationId;
236 if (
237 (previousId !== undefined &&
238 news.apimServiceExtensionId !== undefined &&
239 news.apimServiceExtensionId !== previousId) ||
240 (previousOrg !== undefined &&
241 news.organizationId !== undefined &&
242 news.organizationId !== previousOrg)
243 ) {
244 return { action: "replace" as const, deleteFirst: false };
245 }
246 return undefined;
247 }),
248
249 read: Effect.fn(function* ({ id, olds, output }) {
250 const env = yield* GcpEnvironment.current;
251 const organizationId =
252 olds?.organizationId ??
253 output?.organizationId ??
254 (yield* resolveOrgId(env.project));
255 const extensionId = yield* toPhysicalId(
256 id,
257 olds?.apimServiceExtensionId,
258 output?.apimServiceExtensionId,
259 MAX_NAME_LENGTH,
260 );
261 const name = output?.name ?? resourceName(organizationId, extensionId);
262 const existing = yield* getByName(name);
263 if (existing === undefined) return undefined;
264 const attrs = toAttrs(existing, env.project, organizationId);
265 const labels = parseOwnershipExtension(existing.extensions);
266 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
267 }),
268
269 list: () =>
270 Effect.gen(function* () {
271 const env = yield* GcpEnvironment.current;
272 const organizationId = yield* resolveOrgId(env.project);
273 return yield* apigee.listOrganizationsApimServiceExtensions
274 .pages({
275 parent: orgParent(organizationId),
276 pageSize: 100,
277 })
278 .pipe(
279 Stream.flatMap((page) =>
280 Stream.fromIterable(page.apimServiceExtensions ?? []),
281 ),
282 Stream.filter((resource) =>
283 hasOwnershipExtension(resource.extensions),
284 ),
285 Stream.map((resource) =>
286 toAttrs(resource, env.project, organizationId),
287 ),
288 Stream.runCollect,
289 Effect.map((chunk) => Array.from(chunk)),
290 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
291 Effect.succeed([]),
292 ),
293 );
294 }),
295
296 reconcile: Effect.fn(function* ({ id, news, output }) {
297 const env = yield* GcpEnvironment.current;
298 const organizationId =
299 news.organizationId ??
300 output?.organizationId ??
301 (yield* resolveOrgId(env.project));
302 const extensionId = yield* toPhysicalId(
303 id,
304 news.apimServiceExtensionId,
305 output?.apimServiceExtensionId,
306 MAX_NAME_LENGTH,
307 );
308 const name = resourceName(organizationId, extensionId);
309 const ownership = yield* createInternalLabels(id);
310 const desiredExtensions = withOwnershipExtension(
311 ownership,
312 news.extensions,
313 );
314 const body: apigee.GoogleCloudApigeeV1ApimServiceExtension = {
315 name: extensionId,
316 lbForwardingRule: news.lbForwardingRule,
317 network: news.network,
318 networkConfigs: news.networkConfigs,
319 extensionProcessor: news.extensionProcessor,
320 extensions: desiredExtensions,
321 };
322
323 let current = yield* getByName(output?.name ?? name);
324
325 if (current === undefined) {
326 yield* apigee
327 .createOrganizationsApimServiceExtensions({
328 parent: orgParent(organizationId),
329 apimServiceExtensionId: extensionId,
330 body,
331 })
332 .pipe(
333 Effect.flatMap((operation) => waitForOperation(operation)),
334 Effect.catchTag("Conflict", () => Effect.void),
335 );
336 current = yield* getByName(name);
337 }
338
339 if (current === undefined) {
340 return yield* new ApimServiceExtensionNotResolved({ name });
341 }
342
343 const needsUpdate =
344 (current.lbForwardingRule ?? "") !== news.lbForwardingRule ||
345 (current.network ?? "") !== news.network ||
346 (current.extensionProcessor ?? "") !== news.extensionProcessor ||
347 !sameJson(current.networkConfigs ?? [], news.networkConfigs) ||
348 !sameJson(current.extensions ?? [], desiredExtensions);
349
350 if (needsUpdate) {
351 const operation = yield* apigee.patchOrganizationsApimServiceExtensions(
352 {
353 name,
354 updateMask:
355 "lbForwardingRule,network,networkConfigs,extensionProcessor,extensions",
356 body,
357 },
358 );
359 yield* waitForOperation(operation);
360 current = (yield* getByName(name)) ?? current;
361 }
362
363 return toAttrs(current, env.project, organizationId);
364 }),
365
366 delete: Effect.fn(function* ({ output }) {
367 const operation = yield* apigee
368 .deleteOrganizationsApimServiceExtensions({ name: output.name })
369 .pipe(
370 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
371 Effect.succeed(undefined),
372 ),
373 );
374 if (operation !== undefined) {
375 yield* waitForOperation(operation, { notFoundOk: true });
376 }
377 }),
378 });