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 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20
21const MAX_NAMESPACE_ID_LENGTH = 63;
22
23export type NamespaceProps = {
24 /**
25 * Namespace id (the `{namespace}` segment of
26 * `projects/{project}/locations/{location}/namespaces/{namespace}`).
27 * If omitted, a unique name is generated from the stack, stage, and
28 * logical id. Must be 1-63 characters and match
29 * `[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?`. Immutable — changing it
30 * replaces the namespace.
31 */
32 namespaceId?: string;
33 /**
34 * Location of the namespace (e.g. `us-central1`). Immutable — changing
35 * it replaces the namespace. `US-CENTRAL1` is accepted and normalized
36 * to `us-central1`.
37 * @default the stack's GCP region (`GCP.Region`, else the profile
38 * region, else `us-central1`)
39 */
40 location?: string;
41 /**
42 * User labels. Alchemy ownership labels are merged in automatically.
43 */
44 labels?: Record<string, string>;
45};
46
47export type Namespace = Resource<
48 "GCP.ServiceDirectory.Namespace",
49 NamespaceProps,
50 {
51 /** Full resource name `projects/{project}/locations/{location}/namespaces/{namespace}`. */
52 name: string;
53 /** Namespace id (last path segment). */
54 namespaceId: string;
55 /** Project id. */
56 project: string;
57 /** Location id (e.g. `us-central1`). */
58 location: string;
59 /** User labels (Alchemy ownership labels stripped). */
60 labels: Record<string, string>;
61 /** Server-assigned UUID4. */
62 uid: string | undefined;
63 },
64 never,
65 Providers
66>;
67
68/**
69 * A Service Directory namespace — a location-scoped container for
70 * services.
71 *
72 * Changing `namespaceId` or `location` replaces the namespace.
73 *
74 * ### Creating a Namespace
75 * **Example:** Generated name
76 * ```typescript
77 * const services = yield* GCP.ServiceDirectory.Namespace("Services", {});
78 * ```
79 *
80 * **Example:** Explicit id, location, and labels
81 * ```typescript
82 * const services = yield* GCP.ServiceDirectory.Namespace("Services", {
83 * namespaceId: "app-services",
84 * location: "us-central1",
85 * labels: { env: "prod" },
86 * });
87 * ```
88 *
89 * @resource
90 * @category ServiceDirectory
91 */
92export const Namespace = Resource<Namespace>("GCP.ServiceDirectory.Namespace");
93
94export class NamespaceNotResolved extends Data.TaggedError(
95 "GCP.ServiceDirectory.NamespaceNotResolved",
96)<{
97 name: string;
98}> {}
99
100const lastSegment = (value: string) => {
101 const trimmed = value.replace(/\/+$/, "");
102 const parts = trimmed.split("/");
103 return parts[parts.length - 1] || trimmed;
104};
105
106const normalizeLocation = (
107 location: string | undefined,
108 defaultLocation: string,
109) => lastSegment(location ?? defaultLocation).toLowerCase();
110
111const resourceName = (project: string, location: string, namespaceId: string) =>
112 `projects/${project}/locations/${location}/namespaces/${namespaceId}`;
113
114const locationParent = (project: string, location: string) =>
115 `projects/${project}/locations/${location}`;
116
117const parseName = (name: string, defaultLocation: string) => {
118 const parts = name.split("/").filter((part) => part.length > 0);
119 const namespacesAt = parts.lastIndexOf("namespaces");
120 const locationsAt = parts.lastIndexOf("locations");
121 const projectsAt = parts.lastIndexOf("projects");
122 return {
123 project:
124 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
125 location:
126 locationsAt >= 0 && parts[locationsAt + 1]
127 ? parts[locationsAt + 1]!
128 : defaultLocation,
129 namespaceId:
130 namespacesAt >= 0 && parts[namespacesAt + 1]
131 ? parts[namespacesAt + 1]!
132 : lastSegment(name),
133 };
134};
135
136const userLabels = (
137 labels: Record<string, string | undefined> | null | undefined,
138): Record<string, string> => stripInternalLabels(tagRecord(labels));
139
140const toId = (id: string, namespaceId: string | undefined, existing?: string) =>
141 Effect.gen(function* () {
142 if (namespaceId !== undefined) return namespaceId;
143 if (existing !== undefined) return existing;
144 const generated = yield* createPhysicalName({
145 id,
146 maxLength: MAX_NAMESPACE_ID_LENGTH,
147 lowercase: true,
148 forbiddenPrefixes: ["gcp"],
149 });
150 const named = /^[a-z]/.test(generated) ? generated : `n${generated}`;
151 return named
152 .replace(/-+$/g, "")
153 .slice(0, MAX_NAMESPACE_ID_LENGTH)
154 .replace(/-+$/g, "");
155 });
156
157const toAttrs = (
158 namespace: servicedirectory.Namespace,
159 project: string,
160 region: string,
161) => {
162 const name = namespace.name ?? "";
163 const parsed = parseName(name, region);
164 return {
165 name,
166 namespaceId: parsed.namespaceId,
167 project: parsed.project || project,
168 location: parsed.location,
169 labels: userLabels(namespace.labels),
170 uid: namespace.uid,
171 };
172};
173
174const getByName = (name: string) =>
175 servicedirectory
176 .getProjectsLocationsNamespaces({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179const listNamespacesAt = (parent: string, project: string, region: string) =>
180 Effect.gen(function* () {
181 const found: ReturnType<typeof toAttrs>[] = [];
182 let pageToken: string | undefined;
183 do {
184 const response = yield* servicedirectory.listProjectsLocationsNamespaces({
185 parent,
186 pageSize: 1000,
187 pageToken,
188 });
189 for (const namespace of response.namespaces ?? []) {
190 if (
191 Object.keys(namespace.labels ?? {}).some((key) =>
192 key.startsWith("alchemy-"),
193 )
194 ) {
195 found.push(toAttrs(namespace, project, region));
196 }
197 }
198 pageToken = response.nextPageToken;
199 } while (pageToken !== undefined && pageToken !== "");
200 return found;
201 }).pipe(
202 Effect.catchTag("NotFound", () =>
203 Effect.succeed([] as ReturnType<typeof toAttrs>[]),
204 ),
205 );
206
207export const NamespaceProvider = () =>
208 Provider.succeed(Namespace, {
209 stables: ["name", "namespaceId", "project", "location", "uid"],
210
211 diff: Effect.fn(function* ({ news, olds, output }) {
212 if (!isResolved(news)) return undefined;
213 const env = yield* GcpEnvironment.current;
214 const previousId = olds?.namespaceId ?? output?.namespaceId;
215 const nextId = news.namespaceId ?? previousId;
216 const idChanged =
217 previousId !== undefined &&
218 nextId !== undefined &&
219 nextId !== previousId;
220
221 const previousLocation = normalizeLocation(
222 olds?.location ?? output?.location,
223 env.region,
224 );
225 const nextLocation = normalizeLocation(
226 news.location ?? output?.location,
227 env.region,
228 );
229 if (idChanged || previousLocation !== nextLocation) {
230 return { action: "replace" as const };
231 }
232 return undefined;
233 }),
234
235 read: Effect.fn(function* ({ id, olds, output }) {
236 const env = yield* GcpEnvironment.current;
237 const namespaceId = yield* toId(
238 id,
239 olds?.namespaceId,
240 output?.namespaceId,
241 );
242 const location = normalizeLocation(
243 olds?.location ?? output?.location,
244 env.region,
245 );
246 const name =
247 output?.name ?? resourceName(env.project, location, namespaceId);
248 const existing = yield* getByName(name);
249 if (existing === undefined) return undefined;
250 const attrs = toAttrs(existing, env.project, env.region);
251 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
252 ? attrs
253 : Unowned(attrs);
254 }),
255
256 list: () =>
257 Effect.gen(function* () {
258 const env = yield* GcpEnvironment.current;
259 const fallback = [locationParent(env.project, env.region)];
260 const found: ReturnType<typeof toAttrs>[] = [];
261 let pageToken: string | undefined;
262 do {
263 const response = yield* servicedirectory
264 .listProjectsLocations({
265 name: `projects/${env.project}`,
266 pageSize: 100,
267 pageToken,
268 })
269 .pipe(
270 Effect.catchTag("NotFound", () =>
271 Effect.succeed({
272 locations: [
273 {
274 name: fallback[0],
275 locationId: env.region,
276 } satisfies servicedirectory.Location,
277 ],
278 nextPageToken: undefined as string | undefined,
279 }),
280 ),
281 );
282 const parents = (response.locations ?? [])
283 .map((location) => location.name)
284 .filter((name): name is string => !!name);
285 const pages = yield* Effect.forEach(
286 parents.length > 0 ? parents : fallback,
287 (parent) => listNamespacesAt(parent, env.project, env.region),
288 { concurrency: 4 },
289 );
290 for (const namespaces of pages) {
291 found.push(...namespaces);
292 }
293 pageToken = response.nextPageToken;
294 } while (pageToken !== undefined && pageToken !== "");
295 return found;
296 }),
297
298 reconcile: Effect.fn(function* ({ id, news, output }) {
299 const env = yield* GcpEnvironment.current;
300 const namespaceId = yield* toId(
301 id,
302 news.namespaceId,
303 output?.namespaceId,
304 );
305 const location = normalizeLocation(
306 news.location ?? output?.location,
307 env.region,
308 );
309 const name = resourceName(env.project, location, namespaceId);
310 const desiredLabels = {
311 ...toLabels(news.labels),
312 ...(yield* createInternalLabels(id)),
313 };
314
315 let current = yield* getByName(name);
316
317 if (current === undefined) {
318 const created = yield* servicedirectory
319 .createProjectsLocationsNamespaces({
320 parent: locationParent(env.project, location),
321 namespaceId,
322 body: {
323 labels: desiredLabels,
324 },
325 })
326 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
327 current = created ?? undefined;
328 }
329
330 if (current === undefined) {
331 return yield* new NamespaceNotResolved({ name });
332 }
333
334 const observedLabels = tagRecord(current.labels);
335 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
336 if (upsert.length > 0 || removed.length > 0) {
337 current = yield* servicedirectory.patchProjectsLocationsNamespaces({
338 name,
339 updateMask: "labels",
340 body: {
341 name,
342 labels: desiredLabels,
343 },
344 });
345 }
346
347 return toAttrs(current, env.project, env.region);
348 }),
349
350 delete: Effect.fn(function* ({ output }) {
351 yield* servicedirectory
352 .deleteProjectsLocationsNamespaces({ name: output.name })
353 .pipe(
354 Effect.retry({
355 while: (error) => error._tag === "Conflict",
356 times: 8,
357 schedule: Schedule.spaced("1 second"),
358 }),
359 Effect.catchTag("NotFound", () => Effect.void),
360 );
361 }),
362 });