1import * as cnr from "@distilled.cloud/gcp/cloudnumberregistry_v1alpha";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_LOCATION,
19 ResourceNotResolved,
20 fieldMask,
21 listAtLocation,
22 listLabeledPages,
23 normalizeLocation,
24 parentOf,
25 parseName,
26 replaceOnIdentity,
27 resourceName,
28 sameStringList,
29 toPhysicalId,
30 userLabels,
31 waitForDeleteOperation,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35} from "./internal.ts";
36
37const COLLECTION = "registryBooks";
38
39export type RegistryBookAggregatedData = {
40 discoveredRangesCount: number | undefined;
41 uniqueScopesCount: number | undefined;
42 customRealmsCount: number | undefined;
43 customRangesCount: number | undefined;
44 discoveredRealmsCount: number | undefined;
45};
46
47export type RegistryBookProps = {
48 /**
49 * Registry book id (the `{registryBook}` segment of
50 * `projects/{project}/locations/{location}/registryBooks/{registryBook}`).
51 * If omitted, a unique RFC1035 name is generated. Immutable — changing
52 * it replaces the book.
53 */
54 registryBookId?: string;
55 /**
56 * Location of the registry book. Cloud Number Registry is global —
57 * `global` is the only supported value. Immutable — changing it
58 * replaces the book.
59 * @default "global"
60 */
61 location?: string;
62 /**
63 * Scopes claimed by this book. In Preview only project scopes are
64 * supported (`projects/{project}`). Each scope can be claimed once.
65 * Omit to organize only user-managed realms.
66 */
67 claimedScopes?: string[];
68 /**
69 * User labels. Alchemy ownership labels are merged in automatically.
70 */
71 labels?: Record<string, string>;
72};
73
74export type RegistryBook = Resource<
75 "GCP.CloudNumberRegistry.RegistryBook",
76 RegistryBookProps,
77 {
78 /** Full resource name `projects/{project}/locations/{location}/registryBooks/{registryBook}`. */
79 name: string;
80 /** Registry book id (last path segment). */
81 registryBookId: string;
82 /** Project id. */
83 project: string;
84 /** Location id of the resource. */
85 location: string;
86 /** Scopes claimed by this book. */
87 claimedScopes: string[];
88 /** User labels (Alchemy ownership labels stripped). */
89 labels: Record<string, string>;
90 /** Whether this is the default registry book created with the IPAM admin scope. */
91 isDefault: boolean | undefined;
92 /** Aggregated counts, populated when the view is AGGREGATE. */
93 aggregatedData: RegistryBookAggregatedData | undefined;
94 /** RFC3339 creation timestamp. */
95 createTime: string | undefined;
96 /** RFC3339 last-update timestamp. */
97 updateTime: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A Cloud Number Registry book — a container for IP address management
105 * information. Creating an IPAM admin scope also creates a default book;
106 * additional books organize discovered or user-managed realms.
107 *
108 * `registryBookId` and `location` replace the resource. `claimedScopes`
109 * and labels update in place.
110 *
111 * ### Creating a Registry Book
112 * **Example:** Generated name
113 * ```typescript
114 * const book = yield* GCP.CloudNumberRegistry.RegistryBook("Inventory", {});
115 * ```
116 *
117 * **Example:** Named book that claims a project
118 * ```typescript
119 * const book = yield* GCP.CloudNumberRegistry.RegistryBook("Inventory", {
120 * registryBookId: "inventory",
121 * claimedScopes: ["projects/my-project"],
122 * labels: { env: "prod" },
123 * });
124 * ```
125 *
126 * ### Updating a Registry Book
127 * **Example:** Labels
128 * ```typescript
129 * const book = yield* GCP.CloudNumberRegistry.RegistryBook("Inventory", {
130 * registryBookId: "inventory",
131 * labels: { env: "prod", role: "ipam" },
132 * });
133 * ```
134 *
135 * @resource
136 * @category CloudNumberRegistry
137 */
138export const RegistryBook = Resource<RegistryBook>(
139 "GCP.CloudNumberRegistry.RegistryBook",
140);
141
142const expandScopes = (scopes: readonly string[] | undefined, project: string) =>
143 (scopes ?? []).map((scope) =>
144 scope.includes("/") ? scope : `projects/${scope || project}`,
145 );
146
147const toAttrs = (item: cnr.RegistryBook, project: string) => {
148 const name = item.name ?? "";
149 const parsed = parseName(name, COLLECTION);
150 return {
151 name,
152 registryBookId: parsed.id,
153 project: parsed.project || project,
154 location: parsed.location || DEFAULT_LOCATION,
155 claimedScopes: item.claimedScopes ?? [],
156 labels: userLabels(item.labels),
157 isDefault: item.isDefault,
158 aggregatedData: item.aggregatedData
159 ? {
160 discoveredRangesCount: item.aggregatedData.discoveredRangesCount,
161 uniqueScopesCount: item.aggregatedData.uniqueScopesCount,
162 customRealmsCount: item.aggregatedData.customRealmsCount,
163 customRangesCount: item.aggregatedData.customRangesCount,
164 discoveredRealmsCount: item.aggregatedData.discoveredRealmsCount,
165 }
166 : undefined,
167 createTime: item.createTime,
168 updateTime: item.updateTime,
169 };
170};
171
172const getByName = (name: string) =>
173 name.length === 0
174 ? Effect.succeed(undefined)
175 : cnr
176 .getProjectsLocationsRegistryBooks({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179const listOwned = (project: string) =>
180 listAtLocation(project, (parent) =>
181 listLabeledPages(
182 cnr.listProjectsLocationsRegistryBooks.pages({
183 parent,
184 pageSize: 1000,
185 }),
186 (page) => page.registryBooks,
187 (item) => item.labels,
188 ),
189 );
190
191export const RegistryBookProvider = () =>
192 Provider.succeed(RegistryBook, {
193 stables: [
194 "name",
195 "registryBookId",
196 "project",
197 "location",
198 "isDefault",
199 "createTime",
200 ],
201
202 diff: Effect.fn(function* ({ news, olds, output }) {
203 if (!isResolved(news)) return undefined;
204 return replaceOnIdentity({
205 previousId: olds?.registryBookId ?? output?.registryBookId,
206 nextId:
207 news.registryBookId ?? olds?.registryBookId ?? output?.registryBookId,
208 previousLocation: normalizeLocation(olds?.location ?? output?.location),
209 nextLocation: normalizeLocation(
210 news.location ?? olds?.location ?? output?.location,
211 ),
212 });
213 }),
214
215 read: Effect.fn(function* ({ id, olds, output }) {
216 const env = yield* GcpEnvironment.current;
217 const registryBookId = yield* toPhysicalId(
218 id,
219 olds?.registryBookId,
220 output?.registryBookId,
221 "book",
222 );
223 const location = normalizeLocation(olds?.location ?? output?.location);
224 const name =
225 output?.name ??
226 resourceName(env.project, location, COLLECTION, registryBookId);
227 const existing = yield* getByName(name);
228 if (existing === undefined) return undefined;
229 const attrs = toAttrs(existing, env.project);
230 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
231 ? attrs
232 : Unowned(attrs);
233 }),
234
235 list: () =>
236 Effect.gen(function* () {
237 const env = yield* GcpEnvironment.current;
238 const items = yield* listOwned(env.project);
239 return items.map((item) => toAttrs(item, env.project));
240 }),
241
242 reconcile: Effect.fn(function* ({ id, news, output }) {
243 const env = yield* GcpEnvironment.current;
244 const registryBookId = yield* toPhysicalId(
245 id,
246 news.registryBookId,
247 output?.registryBookId,
248 "book",
249 );
250 const location = normalizeLocation(news.location ?? output?.location);
251 const name = resourceName(
252 env.project,
253 location,
254 COLLECTION,
255 registryBookId,
256 );
257 const desiredLabels = {
258 ...toLabels(news.labels),
259 ...(yield* createInternalLabels(id)),
260 };
261 const claimedScopes = expandScopes(news.claimedScopes, env.project);
262
263 let current = yield* getByName(output?.name ?? name);
264
265 if (current === undefined) {
266 const created = yield* cnr
267 .createProjectsLocationsRegistryBooks({
268 parent: parentOf(env.project, location),
269 registryBookId,
270 body: {
271 labels: desiredLabels,
272 claimedScopes:
273 claimedScopes.length > 0 ? claimedScopes : undefined,
274 },
275 })
276 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
277 if (created !== undefined) {
278 yield* waitForOperation(created);
279 }
280 current = yield* waitUntilExists(getByName(name), name);
281 }
282
283 if (current === undefined) {
284 return yield* new ResourceNotResolved({ name });
285 }
286
287 const observedLabels = tagRecord(current.labels);
288 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
289 const mask = fieldMask([
290 (upsert.length > 0 || removed.length > 0) && "labels",
291 news.claimedScopes !== undefined &&
292 !sameStringList(current.claimedScopes, claimedScopes) &&
293 "claimedScopes",
294 ]);
295
296 if (mask.length > 0) {
297 const operation = yield* cnr.patchProjectsLocationsRegistryBooks({
298 name: current.name ?? name,
299 updateMask: mask,
300 body: {
301 name: current.name ?? name,
302 labels: desiredLabels,
303 claimedScopes,
304 },
305 });
306 yield* waitForOperation(operation);
307 current = yield* waitUntilExists(
308 getByName(current.name ?? name),
309 current.name ?? name,
310 );
311 }
312
313 return toAttrs(current, env.project);
314 }),
315
316 delete: Effect.fn(function* ({ output }) {
317 const operation = yield* cnr
318 .deleteProjectsLocationsRegistryBooks({
319 name: output.name,
320 force: true,
321 })
322 .pipe(
323 Effect.retry({
324 while: (error) => error._tag === "Conflict",
325 times: 8,
326 schedule: Schedule.spaced("2 seconds"),
327 }),
328 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
329 );
330 if (operation !== undefined) {
331 yield* waitForDeleteOperation(operation);
332 }
333 yield* waitUntilGone(getByName(output.name), output.name);
334 }),
335 });