1import * as servicedirectory from "@distilled.cloud/gcp/servicedirectory_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 ALCHEMY_LABEL_PREFIX,
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const MAX_ENDPOINT_ID_LENGTH = 63;
22const DEFAULT_PORT = 0;
23
24export type EndpointProps = {
25 /**
26 * Parent Service Directory service. Full name
27 * `projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}`
28 * or the service id (combined with `namespace`). Immutable — changing
29 * it replaces the endpoint.
30 */
31 service: string;
32 /**
33 * Parent namespace. Full name
34 * `projects/{project}/locations/{location}/namespaces/{namespace}` or
35 * the namespace id (combined with `location`). Used when `service` is
36 * a bare id. Immutable — changing it replaces the endpoint.
37 */
38 namespace?: string;
39 /**
40 * Location of the endpoint (e.g. `us-central1`). Used when `service`
41 * is a bare id. Immutable — changing it replaces the endpoint.
42 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
43 * @default the stack's GCP region (`GCP.Region`, else the profile
44 * region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * Endpoint id (the `{endpoint}` segment of
49 * `.../services/{service}/endpoints/{endpoint}`). If omitted, a unique
50 * name is generated from the stack, stage, and logical id. Must be
51 * 1-63 characters and match `[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?`.
52 * Immutable — changing it replaces the endpoint.
53 */
54 endpointId?: string;
55 /**
56 * IPv4 or IPv6 address. Host:port, IPv6 brackets, and truncated
57 * addresses are rejected. Limited to 45 characters.
58 */
59 address?: string;
60 /**
61 * Port in `[0, 65535]`.
62 * @default 0
63 */
64 port?: number;
65 /**
66 * User annotations consumed by service clients. Alchemy ownership
67 * (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is merged in
68 * automatically. The entire annotations map may contain at most 512
69 * characters across all key-value pairs.
70 */
71 annotations?: Record<string, string>;
72 /**
73 * Google Compute Engine network of the endpoint, as
74 * `projects/{projectNumber}/locations/global/networks/{network}`. The
75 * project must be a project *number*. Immutable — changing it
76 * replaces the endpoint.
77 */
78 network?: string;
79};
80
81export type Endpoint = Resource<
82 "GCP.ServiceDirectory.Endpoint",
83 EndpointProps,
84 {
85 /** Full resource name `projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}/endpoints/{endpoint}`. */
86 name: string;
87 /** Endpoint id (last path segment). */
88 endpointId: string;
89 /** Parent service resource name. */
90 service: string;
91 /** Parent namespace resource name. */
92 namespace: string;
93 /** Project id. */
94 project: string;
95 /** Location id (e.g. `us-central1`). */
96 location: string;
97 /** IPv4 or IPv6 address, if set. */
98 address: string | undefined;
99 /** Port currently on the endpoint. */
100 port: number;
101 /** User annotations (Alchemy ownership annotations stripped). */
102 annotations: Record<string, string>;
103 /** VPC network resource name, if set. */
104 network: string | undefined;
105 /** Server-assigned UUID4. */
106 uid: string | undefined;
107 },
108 never,
109 Providers
110>;
111
112/**
113 * A Service Directory endpoint — an address/port registration on a
114 * service. The parent service must already exist.
115 *
116 * Changing `endpointId`, `service`, `namespace`, `location`, or
117 * `network` replaces the endpoint. `address`, `port`, and `annotations`
118 * update in place.
119 *
120 * Endpoints have no labels field. Alchemy stamps ownership into
121 * `annotations` so `list` / `pnpm nuke:gcp` can find owned endpoints
122 * under alchemy-labeled namespaces.
123 *
124 * ### Creating an Endpoint
125 * **Example:** Address and port on a service
126 * ```typescript
127 * const ns = yield* GCP.ServiceDirectory.Namespace("Services", {});
128 * const api = yield* GCP.ServiceDirectory.Service("Api", {
129 * namespace: ns.name,
130 * });
131 * const endpoint = yield* GCP.ServiceDirectory.Endpoint("Https", {
132 * service: api.name,
133 * address: "10.0.0.1",
134 * port: 443,
135 * });
136 * ```
137 *
138 * **Example:** Explicit id and annotations
139 * ```typescript
140 * const endpoint = yield* GCP.ServiceDirectory.Endpoint("Https", {
141 * service: api.name,
142 * endpointId: "api-https",
143 * address: "10.0.0.1",
144 * port: 443,
145 * annotations: { protocol: "https" },
146 * });
147 * ```
148 *
149 * @resource
150 * @category ServiceDirectory
151 */
152export const Endpoint = Resource<Endpoint>("GCP.ServiceDirectory.Endpoint");
153
154export class EndpointNotResolved extends Data.TaggedError(
155 "GCP.ServiceDirectory.EndpointNotResolved",
156)<{
157 name: string;
158}> {}
159
160export class EndpointParentMissing extends Data.TaggedError(
161 "GCP.ServiceDirectory.EndpointParentMissing",
162)<{
163 service: string;
164}> {}
165
166const lastSegment = (value: string) => {
167 const trimmed = value.replace(/\/+$/, "");
168 const parts = trimmed.split("/");
169 return parts[parts.length - 1] || trimmed;
170};
171
172const normalizeLocation = (
173 location: string | undefined,
174 defaultLocation: string,
175) => lastSegment(location ?? defaultLocation).toLowerCase();
176
177const normalizeOptional = (value: string | undefined) =>
178 value === undefined || value === "" ? undefined : value;
179
180const locationParent = (project: string, location: string) =>
181 `projects/${project}/locations/${location}`;
182
183const parseName = (name: string, defaultLocation: string) => {
184 const parts = name.split("/").filter((part) => part.length > 0);
185 const endpointsAt = parts.lastIndexOf("endpoints");
186 const servicesAt = parts.lastIndexOf("services");
187 const namespacesAt = parts.lastIndexOf("namespaces");
188 const locationsAt = parts.lastIndexOf("locations");
189 const projectsAt = parts.lastIndexOf("projects");
190 const namespace =
191 namespacesAt >= 0 ? parts.slice(0, namespacesAt + 2).join("/") : "";
192 const service =
193 servicesAt >= 0 ? parts.slice(0, servicesAt + 2).join("/") : "";
194 return {
195 project:
196 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
197 location:
198 locationsAt >= 0 && parts[locationsAt + 1]
199 ? parts[locationsAt + 1]!
200 : defaultLocation,
201 namespace,
202 namespaceId:
203 namespacesAt >= 0 && parts[namespacesAt + 1]
204 ? parts[namespacesAt + 1]!
205 : "",
206 service,
207 serviceId:
208 servicesAt >= 0 && parts[servicesAt + 1] ? parts[servicesAt + 1]! : "",
209 endpointId:
210 endpointsAt >= 0 && parts[endpointsAt + 1]
211 ? parts[endpointsAt + 1]!
212 : lastSegment(name),
213 };
214};
215
216const resolveParent = (
217 project: string,
218 service: string,
219 namespace: string | undefined,
220 location: string | undefined,
221 region: string,
222) => {
223 if (service.includes("/")) {
224 const parsed = parseName(
225 service.includes("/endpoints/")
226 ? service
227 : `${service.replace(/\/+$/, "")}/endpoints/_`,
228 region,
229 );
230 return {
231 parent: parsed.service,
232 location: parsed.location,
233 project: parsed.project || project,
234 namespace: parsed.namespace,
235 };
236 }
237 const loc = normalizeLocation(location, region);
238 if (namespace === undefined || namespace === "") {
239 return undefined;
240 }
241 const namespaceName = namespace.includes("/namespaces/")
242 ? namespace.replace(/\/+$/, "")
243 : `projects/${project}/locations/${loc}/namespaces/${namespace}`;
244 const parsed = parseName(`${namespaceName}/services/_/endpoints/_`, region);
245 return {
246 parent: `${parsed.namespace}/services/${service}`,
247 location: parsed.location,
248 project: parsed.project || project,
249 namespace: parsed.namespace,
250 };
251};
252
253const parentKey = (
254 service: string | undefined,
255 namespace: string | undefined,
256 location: string | undefined,
257 region: string,
258) => {
259 if (service === undefined || service === "") return undefined;
260 const resolved = resolveParent("_", service, namespace, location, region);
261 if (resolved === undefined) return undefined;
262 return `${resolved.location}/${lastSegment(resolved.namespace)}/${lastSegment(resolved.parent)}`;
263};
264
265const resourceName = (parent: string, endpointId: string) =>
266 `${parent}/endpoints/${endpointId}`;
267
268const userAnnotations = (
269 annotations: Record<string, string | undefined> | null | undefined,
270): Record<string, string> => stripInternalLabels(tagRecord(annotations));
271
272const hasAlchemyAnnotation = (
273 annotations: Record<string, string | undefined> | null | undefined,
274) =>
275 Object.keys(annotations ?? {}).some((key) =>
276 key.startsWith(ALCHEMY_LABEL_PREFIX),
277 );
278
279const hasAlchemyNamespaceLabels = (
280 labels: Record<string, string | undefined> | null | undefined,
281) =>
282 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
283
284const toId = (id: string, endpointId: string | undefined, existing?: string) =>
285 Effect.gen(function* () {
286 if (endpointId !== undefined) return endpointId;
287 if (existing !== undefined) return existing;
288 const generated = yield* createPhysicalName({
289 id,
290 maxLength: MAX_ENDPOINT_ID_LENGTH,
291 lowercase: true,
292 forbiddenPrefixes: ["gcp"],
293 });
294 const named = /^[a-z]/.test(generated) ? generated : `e${generated}`;
295 return named
296 .replace(/-+$/g, "")
297 .slice(0, MAX_ENDPOINT_ID_LENGTH)
298 .replace(/-+$/g, "");
299 });
300
301const toAttrs = (
302 endpoint: servicedirectory.Endpoint,
303 project: string,
304 region: string,
305) => {
306 const name = endpoint.name ?? "";
307 const parsed = parseName(name, region);
308 return {
309 name,
310 endpointId: parsed.endpointId,
311 service: parsed.service,
312 namespace: parsed.namespace,
313 project: parsed.project || project,
314 location: parsed.location,
315 address: normalizeOptional(endpoint.address),
316 port: endpoint.port ?? DEFAULT_PORT,
317 annotations: userAnnotations(endpoint.annotations),
318 network: normalizeOptional(endpoint.network),
319 uid: endpoint.uid,
320 };
321};
322
323const getByName = (name: string) =>
324 servicedirectory
325 .getProjectsLocationsNamespacesServicesEndpoints({ name })
326 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
327
328const deleteByName = (name: string) =>
329 servicedirectory
330 .deleteProjectsLocationsNamespacesServicesEndpoints({ name })
331 .pipe(
332 Effect.retry({
333 while: (error) => error._tag === "Conflict",
334 times: 8,
335 schedule: Schedule.spaced("1 second"),
336 }),
337 Effect.catchTag("NotFound", () => Effect.void),
338 );
339
340const paginate = <A, E, R>(
341 fetch: (
342 pageToken: string | undefined,
343 ) => Effect.Effect<{ items: A[]; nextPageToken?: string }, E, R>,
344) =>
345 Effect.gen(function* () {
346 const found: A[] = [];
347 let pageToken: string | undefined;
348 do {
349 const response = yield* fetch(pageToken);
350 found.push(...response.items);
351 pageToken = response.nextPageToken;
352 } while (pageToken !== undefined && pageToken !== "");
353 return found;
354 });
355
356const listNamespacesAt = (parent: string) =>
357 paginate((pageToken) =>
358 servicedirectory
359 .listProjectsLocationsNamespaces({
360 parent,
361 pageSize: 1000,
362 pageToken,
363 })
364 .pipe(
365 Effect.map((response) => ({
366 items: (response.namespaces ?? []).filter((namespace) =>
367 hasAlchemyNamespaceLabels(namespace.labels),
368 ),
369 nextPageToken: response.nextPageToken,
370 })),
371 Effect.catchTag("NotFound", () =>
372 Effect.succeed({
373 items: [] as servicedirectory.Namespace[],
374 nextPageToken: undefined,
375 }),
376 ),
377 ),
378 );
379
380const listServicesAt = (parent: string) =>
381 paginate((pageToken) =>
382 servicedirectory
383 .listProjectsLocationsNamespacesServices({
384 parent,
385 pageSize: 1000,
386 pageToken,
387 })
388 .pipe(
389 Effect.map((response) => ({
390 items: response.services ?? [],
391 nextPageToken: response.nextPageToken,
392 })),
393 Effect.catchTag("NotFound", () =>
394 Effect.succeed({
395 items: [] as servicedirectory.Service[],
396 nextPageToken: undefined,
397 }),
398 ),
399 ),
400 );
401
402const listEndpointsAt = (parent: string) =>
403 paginate((pageToken) =>
404 servicedirectory
405 .listProjectsLocationsNamespacesServicesEndpoints({
406 parent,
407 pageSize: 1000,
408 pageToken,
409 })
410 .pipe(
411 Effect.map((response) => ({
412 items: response.endpoints ?? [],
413 nextPageToken: response.nextPageToken,
414 })),
415 Effect.catchTag("NotFound", () =>
416 Effect.succeed({
417 items: [] as servicedirectory.Endpoint[],
418 nextPageToken: undefined,
419 }),
420 ),
421 ),
422 );
423
424const listLocations = (project: string) =>
425 paginate((pageToken) =>
426 servicedirectory
427 .listProjectsLocations({
428 name: `projects/${project}`,
429 pageSize: 100,
430 pageToken,
431 })
432 .pipe(
433 Effect.map((response) => ({
434 items: (response.locations ?? [])
435 .map((location) => location.name)
436 .filter((name): name is string => !!name),
437 nextPageToken: response.nextPageToken,
438 })),
439 Effect.catchTag("NotFound", () =>
440 Effect.succeed({
441 items: [] as string[],
442 nextPageToken: undefined,
443 }),
444 ),
445 ),
446 );
447
448export const EndpointProvider = () =>
449 Provider.succeed(Endpoint, {
450 stables: [
451 "name",
452 "endpointId",
453 "service",
454 "namespace",
455 "project",
456 "location",
457 "uid",
458 ],
459
460 diff: Effect.fn(function* ({ news, olds, output }) {
461 if (!isResolved(news)) return undefined;
462 const env = yield* GcpEnvironment.current;
463 const previousId = olds?.endpointId ?? output?.endpointId;
464 const nextId = news.endpointId ?? previousId;
465 const idChanged =
466 previousId !== undefined &&
467 nextId !== undefined &&
468 nextId !== previousId;
469
470 const previousParent = parentKey(
471 olds?.service ?? output?.service,
472 olds?.namespace ?? output?.namespace,
473 olds?.location ?? output?.location,
474 env.region,
475 );
476 const nextParent = parentKey(
477 news.service,
478 news.namespace ?? olds?.namespace ?? output?.namespace,
479 news.location ?? olds?.location ?? output?.location,
480 env.region,
481 );
482 const parentChanged =
483 previousParent !== undefined &&
484 nextParent !== undefined &&
485 previousParent !== nextParent;
486
487 const previousNetwork = normalizeOptional(
488 olds?.network ?? output?.network,
489 );
490 const nextNetwork = normalizeOptional(news.network ?? previousNetwork);
491 const networkChanged = previousNetwork !== nextNetwork;
492
493 if (!idChanged && !parentChanged && !networkChanged) return undefined;
494 return {
495 action: "replace" as const,
496 deleteFirst:
497 networkChanged &&
498 !idChanged &&
499 !parentChanged &&
500 previousId !== undefined &&
501 nextId === previousId,
502 };
503 }),
504
505 read: Effect.fn(function* ({ id, olds, output }) {
506 const env = yield* GcpEnvironment.current;
507 const endpointId = yield* toId(id, olds?.endpointId, output?.endpointId);
508 const parent = resolveParent(
509 env.project,
510 olds?.service ?? output?.service ?? "",
511 olds?.namespace ?? output?.namespace,
512 olds?.location ?? output?.location,
513 env.region,
514 );
515 const name =
516 output?.name ??
517 (parent !== undefined
518 ? resourceName(parent.parent, endpointId)
519 : undefined);
520 if (name === undefined) return undefined;
521 const existing = yield* getByName(name);
522 if (existing === undefined) return undefined;
523 const attrs = toAttrs(existing, env.project, env.region);
524 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
525 ? attrs
526 : Unowned(attrs);
527 }),
528
529 list: () =>
530 Effect.gen(function* () {
531 const env = yield* GcpEnvironment.current;
532 const fallback = [locationParent(env.project, env.region)];
533 const locations = yield* listLocations(env.project);
534 const parents = locations.length > 0 ? locations : fallback;
535 const namespacePages = yield* Effect.forEach(
536 parents,
537 (parent) => listNamespacesAt(parent),
538 { concurrency: 4 },
539 );
540 const namespaces = namespacePages.flat();
541 const servicePages = yield* Effect.forEach(
542 namespaces,
543 (namespace) =>
544 namespace.name
545 ? listServicesAt(namespace.name)
546 : Effect.succeed([] as servicedirectory.Service[]),
547 { concurrency: 4 },
548 );
549 const services = servicePages.flat();
550 const endpointPages = yield* Effect.forEach(
551 services,
552 (service) =>
553 service.name
554 ? listEndpointsAt(service.name)
555 : Effect.succeed([] as servicedirectory.Endpoint[]),
556 { concurrency: 4 },
557 );
558 return endpointPages
559 .flat()
560 .filter((endpoint) => hasAlchemyAnnotation(endpoint.annotations))
561 .map((endpoint) => toAttrs(endpoint, env.project, env.region));
562 }),
563
564 reconcile: Effect.fn(function* ({ id, news, output }) {
565 const env = yield* GcpEnvironment.current;
566 const endpointId = yield* toId(id, news.endpointId, output?.endpointId);
567 const parent = resolveParent(
568 env.project,
569 news.service,
570 news.namespace ?? output?.namespace,
571 news.location ?? output?.location,
572 env.region,
573 );
574 if (parent === undefined) {
575 return yield* new EndpointParentMissing({ service: news.service });
576 }
577 const name = resourceName(parent.parent, endpointId);
578 const desiredAnnotations = {
579 ...tagRecord(news.annotations),
580 ...(yield* createInternalLabels(id)),
581 };
582 const desiredAddress = news.address;
583 const desiredPort = news.port ?? DEFAULT_PORT;
584 const desiredNetwork = normalizeOptional(news.network);
585
586 let current = yield* getByName(output?.name ?? name);
587
588 if (
589 current !== undefined &&
590 normalizeOptional(current.network) !== desiredNetwork
591 ) {
592 yield* deleteByName(current.name ?? name);
593 current = undefined;
594 }
595
596 if (current === undefined) {
597 const created = yield* servicedirectory
598 .createProjectsLocationsNamespacesServicesEndpoints({
599 parent: parent.parent,
600 endpointId,
601 body: {
602 address: desiredAddress,
603 port: desiredPort,
604 annotations: desiredAnnotations,
605 network: desiredNetwork,
606 },
607 })
608 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
609 current = created ?? undefined;
610 }
611
612 if (current === undefined) {
613 return yield* new EndpointNotResolved({ name });
614 }
615
616 const observedAnnotations = tagRecord(current.annotations);
617 const { upsert, removed } = diffLabels(
618 observedAnnotations,
619 desiredAnnotations,
620 );
621 const annotationsChanged = upsert.length > 0 || removed.length > 0;
622 const addressChanged = (current.address ?? "") !== (desiredAddress ?? "");
623 const portChanged = (current.port ?? DEFAULT_PORT) !== desiredPort;
624
625 if (annotationsChanged || addressChanged || portChanged) {
626 const updateMask = [
627 annotationsChanged ? "annotations" : undefined,
628 addressChanged ? "address" : undefined,
629 portChanged ? "port" : undefined,
630 ]
631 .filter((field): field is string => field !== undefined)
632 .join(",");
633 current =
634 yield* servicedirectory.patchProjectsLocationsNamespacesServicesEndpoints(
635 {
636 name,
637 updateMask,
638 body: {
639 name,
640 annotations: desiredAnnotations,
641 address: desiredAddress,
642 port: desiredPort,
643 },
644 },
645 );
646 }
647
648 return toAttrs(current, env.project, env.region);
649 }),
650
651 delete: Effect.fn(function* ({ output }) {
652 yield* deleteByName(output.name);
653 }),
654 });