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 expandName,
21 fieldMask,
22 fingerprint,
23 listAtLocation,
24 listLabeledPages,
25 normalizeLocation,
26 parentOf,
27 parseName,
28 replaceOnIdentity,
29 resourceName,
30 sameRef,
31 sameText,
32 toPhysicalId,
33 userLabels,
34 waitForDeleteOperation,
35 waitForOperation,
36 waitUntilExists,
37 waitUntilGone,
38} from "./internal.ts";
39
40const COLLECTION = "customRanges";
41
42export type CustomRangeAttribute = {
43 /** Attribute key. */
44 key?: string;
45 /** Attribute value. */
46 value?: string;
47};
48
49export type CustomRangeProps = {
50 /**
51 * Custom range id (the `{customRange}` segment of
52 * `projects/{project}/locations/{location}/customRanges/{customRange}`).
53 * If omitted, a unique RFC1035 name is generated. Immutable —
54 * changing it replaces the range.
55 */
56 customRangeId?: string;
57 /**
58 * Location of the range. Cloud Number Registry is global — `global`
59 * is the only supported value. Immutable — changing it replaces the
60 * range.
61 * @default "global"
62 */
63 location?: string;
64 /**
65 * Parent realm. Accepts a realm id or a full resource name. Mutually
66 * exclusive with `parentRange`. Immutable — changing it replaces the
67 * range.
68 */
69 realm?: string;
70 /**
71 * Parent custom range. Accepts a range id or a full resource name.
72 * Mutually exclusive with `realm` — the child inherits the parent's
73 * realm. Immutable — changing it replaces the range.
74 */
75 parentRange?: string;
76 /**
77 * IPv4 CIDR (for example `10.0.0.0/22`). Mutually exclusive with
78 * `ipv6CidrRange`. Immutable — changing it replaces the range.
79 */
80 ipv4CidrRange?: string;
81 /**
82 * IPv6 CIDR. Mutually exclusive with `ipv4CidrRange`. Immutable —
83 * changing it replaces the range.
84 */
85 ipv6CidrRange?: string;
86 /**
87 * Human-readable description.
88 */
89 description?: string;
90 /**
91 * Searchable attributes (`key` / `value` pairs).
92 */
93 attributes?: CustomRangeAttribute[];
94 /**
95 * User labels. Alchemy ownership labels are merged in automatically.
96 */
97 labels?: Record<string, string>;
98};
99
100export type CustomRange = Resource<
101 "GCP.CloudNumberRegistry.CustomRange",
102 CustomRangeProps,
103 {
104 /** Full resource name `projects/{project}/locations/{location}/customRanges/{customRange}`. */
105 name: string;
106 /** Custom range id (last path segment). */
107 customRangeId: string;
108 /** Project id. */
109 project: string;
110 /** Location id of the resource. */
111 location: string;
112 /** Parent realm, if this range is attached directly to a realm. */
113 realm: string | undefined;
114 /** Parent custom range, if this is a child range. */
115 parentRange: string | undefined;
116 /** Inherited registry book. */
117 registryBook: string | undefined;
118 /** IPv4 CIDR, if this is an IPv4 range. */
119 ipv4CidrRange: string | undefined;
120 /** IPv6 CIDR, if this is an IPv6 range. */
121 ipv6CidrRange: string | undefined;
122 /** Human-readable description. */
123 description: string | undefined;
124 /** Searchable attributes. */
125 attributes: CustomRangeAttribute[];
126 /** User labels (Alchemy ownership labels stripped). */
127 labels: Record<string, string>;
128 },
129 never,
130 Providers
131>;
132
133/**
134 * A user-defined Cloud Number Registry IP range. Custom ranges track
135 * address space that discovery does not cover (on-prem subnets, and so
136 * on) and can only be added to user-managed realms.
137 *
138 * `customRangeId`, `location`, `realm`, `parentRange`, and the CIDR
139 * replace the resource. Description, attributes, and labels update in
140 * place.
141 *
142 * ### Creating a Custom Range
143 * **Example:** Range in a realm
144 * ```typescript
145 * const book = yield* GCP.CloudNumberRegistry.RegistryBook("Inventory", {});
146 * const realm = yield* GCP.CloudNumberRegistry.Realm("Private", {
147 * registryBook: book.name,
148 * });
149 * const range = yield* GCP.CloudNumberRegistry.CustomRange("OnPrem", {
150 * realm: realm.name,
151 * ipv4CidrRange: "10.0.0.0/22",
152 * description: "on-prem campus",
153 * });
154 * ```
155 *
156 * **Example:** Child range
157 * ```typescript
158 * const child = yield* GCP.CloudNumberRegistry.CustomRange("Building", {
159 * parentRange: parent.name,
160 * ipv4CidrRange: "10.0.0.0/24",
161 * });
162 * ```
163 *
164 * ### Updating a Custom Range
165 * **Example:** Description and labels
166 * ```typescript
167 * const range = yield* GCP.CloudNumberRegistry.CustomRange("OnPrem", {
168 * realm: realm.name,
169 * ipv4CidrRange: "10.0.0.0/22",
170 * description: "on-prem campus v2",
171 * labels: { env: "prod" },
172 * });
173 * ```
174 *
175 * @resource
176 * @category CloudNumberRegistry
177 */
178export const CustomRange = Resource<CustomRange>(
179 "GCP.CloudNumberRegistry.CustomRange",
180);
181
182const toAttributes = (
183 attributes: readonly cnr.Attribute[] | undefined,
184): CustomRangeAttribute[] =>
185 (attributes ?? []).map((attribute) => ({
186 key: attribute.key,
187 value: attribute.value,
188 }));
189
190const toAttrs = (item: cnr.CustomRange, project: string) => {
191 const name = item.name ?? "";
192 const parsed = parseName(name, COLLECTION);
193 return {
194 name,
195 customRangeId: parsed.id,
196 project: parsed.project || project,
197 location: parsed.location || DEFAULT_LOCATION,
198 realm: item.realm,
199 parentRange: item.parentRange,
200 registryBook: item.registryBook,
201 ipv4CidrRange: item.ipv4CidrRange,
202 ipv6CidrRange: item.ipv6CidrRange,
203 description: item.description,
204 attributes: toAttributes(item.attributes),
205 labels: userLabels(item.labels),
206 };
207};
208
209const getByName = (name: string) =>
210 name.length === 0
211 ? Effect.succeed(undefined)
212 : cnr
213 .getProjectsLocationsCustomRanges({ name })
214 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
215
216const listOwned = (project: string) =>
217 listAtLocation(project, (parent) =>
218 listLabeledPages(
219 cnr.listProjectsLocationsCustomRanges.pages({
220 parent,
221 pageSize: 1000,
222 }),
223 (page) => page.customRanges,
224 (item) => item.labels,
225 ),
226 );
227
228export const CustomRangeProvider = () =>
229 Provider.succeed(CustomRange, {
230 stables: [
231 "name",
232 "customRangeId",
233 "project",
234 "location",
235 "realm",
236 "parentRange",
237 "registryBook",
238 "ipv4CidrRange",
239 "ipv6CidrRange",
240 ],
241
242 diff: Effect.fn(function* ({ news, olds, output }) {
243 if (!isResolved(news)) return undefined;
244 const previousRealm = olds?.realm ?? output?.realm;
245 const previousParent = olds?.parentRange ?? output?.parentRange;
246 const previousV4 = olds?.ipv4CidrRange ?? output?.ipv4CidrRange;
247 const nextV4 = news.ipv4CidrRange ?? previousV4;
248 const previousV6 = olds?.ipv6CidrRange ?? output?.ipv6CidrRange;
249 const nextV6 = news.ipv6CidrRange ?? previousV6;
250 return replaceOnIdentity({
251 previousId: olds?.customRangeId ?? output?.customRangeId,
252 nextId:
253 news.customRangeId ?? olds?.customRangeId ?? output?.customRangeId,
254 previousLocation: normalizeLocation(olds?.location ?? output?.location),
255 nextLocation: normalizeLocation(
256 news.location ?? olds?.location ?? output?.location,
257 ),
258 extra:
259 (news.realm !== undefined &&
260 previousRealm !== undefined &&
261 !sameRef(previousRealm, news.realm)) ||
262 (news.parentRange !== undefined &&
263 previousParent !== undefined &&
264 !sameRef(previousParent, news.parentRange)) ||
265 (news.realm !== undefined && previousParent !== undefined) ||
266 (news.parentRange !== undefined && previousRealm !== undefined) ||
267 (previousV4 !== undefined &&
268 nextV4 !== undefined &&
269 previousV4 !== nextV4) ||
270 (previousV6 !== undefined &&
271 nextV6 !== undefined &&
272 previousV6 !== nextV6),
273 });
274 }),
275
276 read: Effect.fn(function* ({ id, olds, output }) {
277 const env = yield* GcpEnvironment.current;
278 const customRangeId = yield* toPhysicalId(
279 id,
280 olds?.customRangeId,
281 output?.customRangeId,
282 "range",
283 );
284 const location = normalizeLocation(olds?.location ?? output?.location);
285 const name =
286 output?.name ??
287 resourceName(env.project, location, COLLECTION, customRangeId);
288 const existing = yield* getByName(name);
289 if (existing === undefined) return undefined;
290 const attrs = toAttrs(existing, env.project);
291 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
292 ? attrs
293 : Unowned(attrs);
294 }),
295
296 list: () =>
297 Effect.gen(function* () {
298 const env = yield* GcpEnvironment.current;
299 const items = yield* listOwned(env.project);
300 return items.map((item) => toAttrs(item, env.project));
301 }),
302
303 reconcile: Effect.fn(function* ({ id, news, output }) {
304 const env = yield* GcpEnvironment.current;
305 const customRangeId = yield* toPhysicalId(
306 id,
307 news.customRangeId,
308 output?.customRangeId,
309 "range",
310 );
311 const location = normalizeLocation(news.location ?? output?.location);
312 const name = resourceName(
313 env.project,
314 location,
315 COLLECTION,
316 customRangeId,
317 );
318 const desiredLabels = {
319 ...toLabels(news.labels),
320 ...(yield* createInternalLabels(id)),
321 };
322 const realm =
323 news.parentRange === undefined && news.realm !== undefined
324 ? expandName(news.realm, env.project, location, "realms")
325 : undefined;
326 const parentRange =
327 news.parentRange !== undefined
328 ? expandName(news.parentRange, env.project, location, COLLECTION)
329 : undefined;
330 const attributes = news.attributes;
331
332 let current = yield* getByName(output?.name ?? name);
333
334 if (current === undefined) {
335 const created = yield* cnr
336 .createProjectsLocationsCustomRanges({
337 parent: parentOf(env.project, location),
338 customRangeId,
339 body: {
340 realm,
341 parentRange,
342 ipv4CidrRange: news.ipv4CidrRange,
343 ipv6CidrRange: news.ipv6CidrRange,
344 description: news.description,
345 attributes,
346 labels: desiredLabels,
347 },
348 })
349 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
350 if (created !== undefined) {
351 yield* waitForOperation(created);
352 }
353 current = yield* waitUntilExists(getByName(name), name);
354 }
355
356 if (current === undefined) {
357 return yield* new ResourceNotResolved({ name });
358 }
359
360 const observedLabels = tagRecord(current.labels);
361 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
362 const mask = fieldMask([
363 (upsert.length > 0 || removed.length > 0) && "labels",
364 !sameText(current.description, news.description) && "description",
365 attributes !== undefined &&
366 fingerprint(toAttributes(current.attributes)) !==
367 fingerprint(attributes) &&
368 "attributes",
369 ]);
370
371 if (mask.length > 0) {
372 const operation = yield* cnr.patchProjectsLocationsCustomRanges({
373 name: current.name ?? name,
374 updateMask: mask,
375 body: {
376 name: current.name ?? name,
377 labels: desiredLabels,
378 description: news.description,
379 attributes,
380 },
381 });
382 yield* waitForOperation(operation);
383 current = yield* waitUntilExists(
384 getByName(current.name ?? name),
385 current.name ?? name,
386 );
387 }
388
389 return toAttrs(current, env.project);
390 }),
391
392 delete: Effect.fn(function* ({ output }) {
393 const operation = yield* cnr
394 .deleteProjectsLocationsCustomRanges({
395 name: output.name,
396 force: true,
397 })
398 .pipe(
399 Effect.retry({
400 while: (error) => error._tag === "Conflict",
401 times: 8,
402 schedule: Schedule.spaced("2 seconds"),
403 }),
404 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
405 );
406 if (operation !== undefined) {
407 yield* waitForDeleteOperation(operation);
408 }
409 yield* waitUntilGone(getByName(output.name), output.name);
410 }),
411 });