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 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 stripInternalLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const MAX_SERVICE_ID_LENGTH = 63;
21
22export type ServiceProps = {
23 /**
24 * Parent namespace. Full name
25 * `projects/{project}/locations/{location}/namespaces/{namespace}` or
26 * the namespace id (combined with `location`). Immutable — changing it
27 * replaces the service.
28 */
29 namespace: string;
30 /**
31 * Location of the service (e.g. `us-central1`). Used when `namespace`
32 * is a bare id. Immutable — changing it replaces the service.
33 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
34 * @default the stack's GCP region (`GCP.Region`, else the profile
35 * region, else `us-central1`)
36 */
37 location?: string;
38 /**
39 * Service id (the `{service}` segment of
40 * `projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}`).
41 * If omitted, a unique name is generated from the stack, stage, and
42 * logical id. Must be 1-63 characters and match
43 * `[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?`. Immutable — changing it
44 * replaces the service.
45 */
46 serviceId?: string;
47 /**
48 * User annotations consumed by service clients. Alchemy ownership
49 * keys (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) are merged
50 * in automatically — Service Directory services have no labels field.
51 * The entire map may contain at most 2000 characters.
52 */
53 annotations?: Record<string, string>;
54};
55
56export type Service = Resource<
57 "GCP.ServiceDirectory.Service",
58 ServiceProps,
59 {
60 /** Full resource name `projects/{project}/locations/{location}/namespaces/{namespace}/services/{service}`. */
61 name: string;
62 /** Service id (last path segment). */
63 serviceId: string;
64 /** Parent namespace resource name. */
65 namespace: string;
66 /** Namespace id (last path segment of the parent). */
67 namespaceId: string;
68 /** Project id. */
69 project: string;
70 /** Location id (e.g. `us-central1`). */
71 location: string;
72 /** User annotations (Alchemy ownership keys stripped). */
73 annotations: Record<string, string>;
74 /** Server-assigned UUID4. */
75 uid: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * A Service Directory service — a named registration that holds
83 * endpoints and client-facing annotations.
84 *
85 * Changing `serviceId`, `namespace`, or `location` replaces the service.
86 * Annotations update in place. Services have no labels field; Alchemy
87 * stamps ownership into annotations so `list` / `pnpm nuke:gcp` can find
88 * them.
89 *
90 * ### Creating a Service
91 * **Example:** Generated name in a namespace
92 * ```typescript
93 * const ns = yield* GCP.ServiceDirectory.Namespace("Services", {});
94 * const api = yield* GCP.ServiceDirectory.Service("Api", {
95 * namespace: ns.name,
96 * });
97 * ```
98 *
99 * **Example:** Explicit id and annotations
100 * ```typescript
101 * const api = yield* GCP.ServiceDirectory.Service("Api", {
102 * namespace: ns.name,
103 * serviceId: "checkout",
104 * annotations: { protocol: "http" },
105 * });
106 * ```
107 *
108 * ### Resolving Endpoints
109 * **Example:** Resolve the service at runtime
110 * ```typescript
111 * const resolve = yield* GCP.ServiceDirectory.Resolve(api);
112 * const { service } = yield* resolve();
113 * ```
114 *
115 * @resource
116 * @category ServiceDirectory
117 */
118export const Service = Resource<Service>("GCP.ServiceDirectory.Service");
119
120export class ServiceNotResolved extends Data.TaggedError(
121 "GCP.ServiceDirectory.ServiceNotResolved",
122)<{
123 name: string;
124}> {}
125
126const lastSegment = (value: string) => {
127 const trimmed = value.replace(/\/+$/, "");
128 const parts = trimmed.split("/");
129 return parts[parts.length - 1] || trimmed;
130};
131
132const normalizeLocation = (
133 location: string | undefined,
134 defaultLocation: string,
135) => lastSegment(location ?? defaultLocation).toLowerCase();
136
137const parseName = (name: string, defaultLocation: string) => {
138 const parts = name.split("/").filter((part) => part.length > 0);
139 const servicesAt = parts.lastIndexOf("services");
140 const namespacesAt = parts.lastIndexOf("namespaces");
141 const locationsAt = parts.lastIndexOf("locations");
142 const projectsAt = parts.lastIndexOf("projects");
143 const namespace =
144 namespacesAt >= 0 ? parts.slice(0, namespacesAt + 2).join("/") : "";
145 return {
146 project:
147 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
148 location:
149 locationsAt >= 0 && parts[locationsAt + 1]
150 ? parts[locationsAt + 1]!
151 : defaultLocation,
152 namespace,
153 namespaceId:
154 namespacesAt >= 0 && parts[namespacesAt + 1]
155 ? parts[namespacesAt + 1]!
156 : "",
157 serviceId:
158 servicesAt >= 0 && parts[servicesAt + 1]
159 ? parts[servicesAt + 1]!
160 : lastSegment(name),
161 };
162};
163
164const resolveParent = (
165 project: string,
166 namespace: string,
167 location: string | undefined,
168 region: string,
169) => {
170 if (namespace.includes("/")) {
171 const parsed = parseName(
172 namespace.includes("/services/") ? namespace : `${namespace}/services/_`,
173 region,
174 );
175 return {
176 parent: parsed.namespace,
177 location: parsed.location,
178 project: parsed.project || project,
179 namespaceId: parsed.namespaceId,
180 };
181 }
182 const loc = normalizeLocation(location, region);
183 return {
184 parent: `projects/${project}/locations/${loc}/namespaces/${namespace}`,
185 location: loc,
186 project,
187 namespaceId: namespace,
188 };
189};
190
191const resourceName = (parent: string, serviceId: string) =>
192 `${parent}/services/${serviceId}`;
193
194const locationParent = (project: string, location: string) =>
195 `projects/${project}/locations/${location}`;
196
197const userAnnotations = (
198 annotations: Record<string, string | undefined> | null | undefined,
199): Record<string, string> => stripInternalLabels(tagRecord(annotations));
200
201const toId = (id: string, serviceId: string | undefined, existing?: string) =>
202 Effect.gen(function* () {
203 if (serviceId !== undefined) return serviceId;
204 if (existing !== undefined) return existing;
205 const generated = yield* createPhysicalName({
206 id,
207 maxLength: MAX_SERVICE_ID_LENGTH,
208 lowercase: true,
209 });
210 const named = /^[a-z]/.test(generated) ? generated : `s${generated}`;
211 return named
212 .replace(/-+$/g, "")
213 .slice(0, MAX_SERVICE_ID_LENGTH)
214 .replace(/-+$/g, "");
215 });
216
217const toAttrs = (
218 service: servicedirectory.Service,
219 project: string,
220 region: string,
221) => {
222 const name = service.name ?? "";
223 const parsed = parseName(name, region);
224 return {
225 name,
226 serviceId: parsed.serviceId,
227 namespace: parsed.namespace,
228 namespaceId: parsed.namespaceId,
229 project: parsed.project || project,
230 location: parsed.location,
231 annotations: userAnnotations(service.annotations),
232 uid: service.uid,
233 };
234};
235
236const getByName = (name: string) =>
237 servicedirectory
238 .getProjectsLocationsNamespacesServices({ name })
239 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
240
241const hasAlchemyAnnotation = (
242 annotations: Record<string, string | undefined> | null | undefined,
243) => Object.keys(annotations ?? {}).some((key) => key.startsWith("alchemy-"));
244
245const listServicesAt = (parent: string, project: string, region: string) =>
246 Effect.gen(function* () {
247 const found: ReturnType<typeof toAttrs>[] = [];
248 let pageToken: string | undefined;
249 do {
250 const response =
251 yield* servicedirectory.listProjectsLocationsNamespacesServices({
252 parent,
253 pageSize: 1000,
254 pageToken,
255 });
256 for (const service of response.services ?? []) {
257 if (hasAlchemyAnnotation(service.annotations)) {
258 found.push(toAttrs(service, project, region));
259 }
260 }
261 pageToken = response.nextPageToken;
262 } while (pageToken !== undefined && pageToken !== "");
263 return found;
264 }).pipe(
265 Effect.catchTag("NotFound", () =>
266 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
267 ),
268 );
269
270const listNamespaceNamesAt = (parent: string) =>
271 Effect.gen(function* () {
272 const found: string[] = [];
273 let pageToken: string | undefined;
274 do {
275 const response = yield* servicedirectory.listProjectsLocationsNamespaces({
276 parent,
277 pageSize: 1000,
278 pageToken,
279 });
280 for (const namespace of response.namespaces ?? []) {
281 if (namespace.name) found.push(namespace.name);
282 }
283 pageToken = response.nextPageToken;
284 } while (pageToken !== undefined && pageToken !== "");
285 return found;
286 }).pipe(Effect.catchTag("NotFound", () => Effect.succeed([] as string[])));
287
288export const ServiceProvider = () =>
289 Provider.succeed(Service, {
290 stables: [
291 "name",
292 "serviceId",
293 "namespace",
294 "namespaceId",
295 "project",
296 "location",
297 "uid",
298 ],
299
300 diff: Effect.fn(function* ({ news, olds, output }) {
301 if (!isResolved(news)) return undefined;
302 const env = yield* GcpEnvironment.current;
303 const previousId = olds?.serviceId ?? output?.serviceId;
304 const nextId = news.serviceId ?? previousId;
305 const idChanged =
306 previousId !== undefined &&
307 nextId !== undefined &&
308 nextId !== previousId;
309
310 const previousParent = output?.namespace ?? olds?.namespace;
311 const previousParentId = previousParent
312 ? lastSegment(previousParent)
313 : undefined;
314 const nextParentId = lastSegment(news.namespace);
315 const parentChanged =
316 previousParentId !== undefined && previousParentId !== nextParentId;
317
318 const previousLocation = normalizeLocation(
319 olds?.location ?? output?.location,
320 env.region,
321 );
322 const nextLocation = news.namespace.includes("/")
323 ? parseName(
324 news.namespace.includes("/services/")
325 ? news.namespace
326 : `${news.namespace}/services/_`,
327 env.region,
328 ).location
329 : normalizeLocation(news.location ?? output?.location, env.region);
330
331 if (idChanged || parentChanged || previousLocation !== nextLocation) {
332 return { action: "replace" as const };
333 }
334 return undefined;
335 }),
336
337 read: Effect.fn(function* ({ id, olds, output }) {
338 const env = yield* GcpEnvironment.current;
339 const serviceId = yield* toId(id, olds?.serviceId, output?.serviceId);
340 const namespaceRef = olds?.namespace ?? output?.namespace;
341 const name =
342 output?.name ??
343 (namespaceRef
344 ? resourceName(
345 resolveParent(
346 env.project,
347 namespaceRef,
348 olds?.location ?? output?.location,
349 env.region,
350 ).parent,
351 serviceId,
352 )
353 : undefined);
354 if (name === undefined) return undefined;
355 const existing = yield* getByName(name);
356 if (existing === undefined) return undefined;
357 const attrs = toAttrs(existing, env.project, env.region);
358 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
359 ? attrs
360 : Unowned(attrs);
361 }),
362
363 list: () =>
364 Effect.gen(function* () {
365 const env = yield* GcpEnvironment.current;
366 const fallback = [locationParent(env.project, env.region)];
367 const found: ReturnType<typeof toAttrs>[] = [];
368 let pageToken: string | undefined;
369 do {
370 const response = yield* servicedirectory
371 .listProjectsLocations({
372 name: `projects/${env.project}`,
373 pageSize: 100,
374 pageToken,
375 })
376 .pipe(
377 Effect.catchTag("NotFound", () =>
378 Effect.succeed({
379 locations: [
380 {
381 name: fallback[0],
382 locationId: env.region,
383 } satisfies servicedirectory.Location,
384 ],
385 nextPageToken: undefined as string | undefined,
386 }),
387 ),
388 );
389 const parents = (response.locations ?? [])
390 .map((location) => location.name)
391 .filter((name): name is string => !!name);
392 const namespacePages = yield* Effect.forEach(
393 parents.length > 0 ? parents : fallback,
394 (parent) => listNamespaceNamesAt(parent),
395 { concurrency: 4 },
396 );
397 const namespaceNames = namespacePages.flat();
398 const servicePages = yield* Effect.forEach(
399 namespaceNames,
400 (parent) => listServicesAt(parent, env.project, env.region),
401 { concurrency: 4 },
402 );
403 for (const services of servicePages) {
404 found.push(...services);
405 }
406 pageToken = response.nextPageToken;
407 } while (pageToken !== undefined && pageToken !== "");
408 return found;
409 }),
410
411 reconcile: Effect.fn(function* ({ id, news, output }) {
412 const env = yield* GcpEnvironment.current;
413 const parent = resolveParent(
414 env.project,
415 news.namespace,
416 news.location ?? output?.location,
417 env.region,
418 );
419 const serviceId = yield* toId(id, news.serviceId, output?.serviceId);
420 const name = resourceName(parent.parent, serviceId);
421 const desiredAnnotations = {
422 ...tagRecord(news.annotations),
423 ...(yield* createInternalLabels(id)),
424 };
425
426 let current = yield* getByName(name);
427
428 if (current === undefined) {
429 const created = yield* servicedirectory
430 .createProjectsLocationsNamespacesServices({
431 parent: parent.parent,
432 serviceId,
433 body: {
434 annotations: desiredAnnotations,
435 },
436 })
437 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
438 current = created ?? undefined;
439 }
440
441 if (current === undefined) {
442 return yield* new ServiceNotResolved({ name });
443 }
444
445 const observedAnnotations = tagRecord(current.annotations);
446 const { upsert, removed } = diffLabels(
447 observedAnnotations,
448 desiredAnnotations,
449 );
450 if (upsert.length > 0 || removed.length > 0) {
451 current =
452 yield* servicedirectory.patchProjectsLocationsNamespacesServices({
453 name,
454 updateMask: "annotations",
455 body: {
456 name,
457 annotations: desiredAnnotations,
458 },
459 });
460 }
461
462 return toAttrs(current, env.project, env.region);
463 }),
464
465 delete: Effect.fn(function* ({ output }) {
466 yield* servicedirectory
467 .deleteProjectsLocationsNamespacesServices({ name: output.name })
468 .pipe(
469 Effect.retry({
470 while: (error) => error._tag === "Conflict",
471 times: 8,
472 schedule: Schedule.spaced("1 second"),
473 }),
474 Effect.catchTag("NotFound", () => Effect.void),
475 );
476 }),
477 });