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 = "ipamAdminScopes";
38const DEFAULT_PLATFORM: cnr.IpamAdminScopeEnabledAddonPlatformsItemEnum =
39 "COMPUTE_ENGINE";
40
41export type IpamAdminScopeProps = {
42 /**
43 * IPAM admin scope id (the `{ipamAdminScope}` segment of
44 * `projects/{project}/locations/{location}/ipamAdminScopes/{ipamAdminScope}`).
45 * If omitted, a unique RFC1035 name is generated. Immutable —
46 * changing it replaces the scope. Only one admin scope may exist per
47 * organization.
48 */
49 ipamAdminScopeId?: string;
50 /**
51 * Location of the scope. Cloud Number Registry is global — `global`
52 * is the only supported value. Immutable — changing it replaces the
53 * scope.
54 * @default "global"
55 */
56 location?: string;
57 /**
58 * Administrative scopes enabled for discovery. Preview allows a
59 * single organization (`organizations/{org}`). Immutable — changing
60 * it replaces the scope.
61 */
62 scopes: string[];
63 /**
64 * Platforms whose IP addresses Cloud Number Registry discovers.
65 * @default ["COMPUTE_ENGINE"]
66 */
67 enabledAddonPlatforms?: Array<
68 cnr.IpamAdminScopeEnabledAddonPlatformsItemEnum | (string & {})
69 >;
70 /**
71 * User labels. Alchemy ownership labels are merged in automatically.
72 */
73 labels?: Record<string, string>;
74};
75
76export type IpamAdminScope = Resource<
77 "GCP.CloudNumberRegistry.IpamAdminScope",
78 IpamAdminScopeProps,
79 {
80 /** Full resource name `projects/{project}/locations/{location}/ipamAdminScopes/{ipamAdminScope}`. */
81 name: string;
82 /** IPAM admin scope id (last path segment). */
83 ipamAdminScopeId: string;
84 /** Project id. */
85 project: string;
86 /** Location id of the resource. */
87 location: string;
88 /** Administrative scopes. */
89 scopes: string[];
90 /** Enabled discovery platforms. */
91 enabledAddonPlatforms: string[];
92 /** User labels (Alchemy ownership labels stripped). */
93 labels: Record<string, string>;
94 /** Discovery pipeline state (`SETUP_IN_PROGRESS`, `READY_FOR_USE`, …). */
95 state: string | undefined;
96 /** RFC3339 creation timestamp. */
97 createTime: string | undefined;
98 /** RFC3339 last-update timestamp. */
99 updateTime: string | undefined;
100 },
101 never,
102 Providers
103>;
104
105/**
106 * A Cloud Number Registry IPAM admin scope — the organization-wide
107 * boundary for IP address discovery. Only one project in an
108 * organization may own the admin scope. Creating it also creates a
109 * default registry book and starts discovery (up to 24 hours).
110 *
111 * `ipamAdminScopeId`, `location`, and `scopes` replace the resource.
112 * Platforms and labels update in place.
113 *
114 * ### Creating an IPAM Admin Scope
115 * **Example:** Organization scope
116 * ```typescript
117 * const scope = yield* GCP.CloudNumberRegistry.IpamAdminScope("Org", {
118 * scopes: ["organizations/1234567890"],
119 * enabledAddonPlatforms: ["COMPUTE_ENGINE"],
120 * labels: { env: "prod" },
121 * });
122 * ```
123 *
124 * ### Updating an IPAM Admin Scope
125 * **Example:** Labels
126 * ```typescript
127 * const scope = yield* GCP.CloudNumberRegistry.IpamAdminScope("Org", {
128 * scopes: ["organizations/1234567890"],
129 * labels: { env: "prod", role: "ipam" },
130 * });
131 * ```
132 *
133 * @resource
134 * @category CloudNumberRegistry
135 */
136export const IpamAdminScope = Resource<IpamAdminScope>(
137 "GCP.CloudNumberRegistry.IpamAdminScope",
138);
139
140const expandScopes = (scopes: readonly string[]) =>
141 scopes.map((scope) =>
142 scope.includes("/") ? scope : `organizations/${scope}`,
143 );
144
145const toAttrs = (item: cnr.IpamAdminScope, project: string) => {
146 const name = item.name ?? "";
147 const parsed = parseName(name, COLLECTION);
148 return {
149 name,
150 ipamAdminScopeId: parsed.id,
151 project: parsed.project || project,
152 location: parsed.location || DEFAULT_LOCATION,
153 scopes: item.scopes ?? [],
154 enabledAddonPlatforms: (item.enabledAddonPlatforms ?? []).map((value) =>
155 String(value),
156 ),
157 labels: userLabels(item.labels),
158 state: item.state,
159 createTime: item.createTime,
160 updateTime: item.updateTime,
161 };
162};
163
164const getByName = (name: string) =>
165 name.length === 0
166 ? Effect.succeed(undefined)
167 : cnr
168 .getProjectsLocationsIpamAdminScopes({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const listOwned = (project: string) =>
172 listAtLocation(project, (parent) =>
173 listLabeledPages(
174 cnr.listProjectsLocationsIpamAdminScopes.pages({
175 parent,
176 pageSize: 1000,
177 }),
178 (page) => page.ipamAdminScopes,
179 (item) => item.labels,
180 ),
181 );
182
183export const IpamAdminScopeProvider = () =>
184 Provider.succeed(IpamAdminScope, {
185 stables: [
186 "name",
187 "ipamAdminScopeId",
188 "project",
189 "location",
190 "scopes",
191 "createTime",
192 ],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 const previousScopes = olds?.scopes ?? output?.scopes;
197 const nextScopes = expandScopes(news.scopes);
198 return replaceOnIdentity({
199 previousId: olds?.ipamAdminScopeId ?? output?.ipamAdminScopeId,
200 nextId:
201 news.ipamAdminScopeId ??
202 olds?.ipamAdminScopeId ??
203 output?.ipamAdminScopeId,
204 previousLocation: normalizeLocation(olds?.location ?? output?.location),
205 nextLocation: normalizeLocation(
206 news.location ?? olds?.location ?? output?.location,
207 ),
208 extra:
209 previousScopes !== undefined &&
210 !sameStringList(previousScopes, nextScopes),
211 });
212 }),
213
214 read: Effect.fn(function* ({ id, olds, output }) {
215 const env = yield* GcpEnvironment.current;
216 const ipamAdminScopeId = yield* toPhysicalId(
217 id,
218 olds?.ipamAdminScopeId,
219 output?.ipamAdminScopeId,
220 "scope",
221 );
222 const location = normalizeLocation(olds?.location ?? output?.location);
223 const name =
224 output?.name ??
225 resourceName(env.project, location, COLLECTION, ipamAdminScopeId);
226 const existing = yield* getByName(name);
227 if (existing === undefined) return undefined;
228 const attrs = toAttrs(existing, env.project);
229 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
230 ? attrs
231 : Unowned(attrs);
232 }),
233
234 list: () =>
235 Effect.gen(function* () {
236 const env = yield* GcpEnvironment.current;
237 const items = yield* listOwned(env.project);
238 return items.map((item) => toAttrs(item, env.project));
239 }),
240
241 reconcile: Effect.fn(function* ({ id, news, output }) {
242 const env = yield* GcpEnvironment.current;
243 const ipamAdminScopeId = yield* toPhysicalId(
244 id,
245 news.ipamAdminScopeId,
246 output?.ipamAdminScopeId,
247 "scope",
248 );
249 const location = normalizeLocation(news.location ?? output?.location);
250 const name = resourceName(
251 env.project,
252 location,
253 COLLECTION,
254 ipamAdminScopeId,
255 );
256 const desiredLabels = {
257 ...toLabels(news.labels),
258 ...(yield* createInternalLabels(id)),
259 };
260 const scopes = expandScopes(news.scopes);
261 const enabledAddonPlatforms = news.enabledAddonPlatforms ?? [
262 DEFAULT_PLATFORM,
263 ];
264
265 let current = yield* getByName(output?.name ?? name);
266
267 if (current === undefined) {
268 const created = yield* cnr
269 .createProjectsLocationsIpamAdminScopes({
270 parent: parentOf(env.project, location),
271 ipamAdminScopeId,
272 body: {
273 scopes,
274 enabledAddonPlatforms,
275 labels: desiredLabels,
276 },
277 })
278 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
279 if (created !== undefined) {
280 yield* waitForOperation(created);
281 }
282 current = yield* waitUntilExists(getByName(name), name);
283 }
284
285 if (current === undefined) {
286 return yield* new ResourceNotResolved({ name });
287 }
288
289 const observedLabels = tagRecord(current.labels);
290 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
291 const mask = fieldMask([
292 (upsert.length > 0 || removed.length > 0) && "labels",
293 news.enabledAddonPlatforms !== undefined &&
294 !sameStringList(
295 current.enabledAddonPlatforms,
296 enabledAddonPlatforms.map((value) => String(value)),
297 ) &&
298 "enabledAddonPlatforms",
299 ]);
300
301 if (mask.length > 0) {
302 const operation = yield* cnr.patchProjectsLocationsIpamAdminScopes({
303 name: current.name ?? name,
304 updateMask: mask,
305 body: {
306 name: current.name ?? name,
307 labels: desiredLabels,
308 enabledAddonPlatforms,
309 },
310 });
311 yield* waitForOperation(operation);
312 current = yield* waitUntilExists(
313 getByName(current.name ?? name),
314 current.name ?? name,
315 );
316 }
317
318 return toAttrs(current, env.project);
319 }),
320
321 delete: Effect.fn(function* ({ output }) {
322 const disable = yield* cnr
323 .disableProjectsLocationsIpamAdminScopes({
324 name: output.name,
325 body: {},
326 })
327 .pipe(
328 Effect.catchTag(["NotFound", "BadRequest", "Conflict"], () =>
329 Effect.succeed(undefined),
330 ),
331 );
332 if (disable !== undefined) {
333 yield* waitForDeleteOperation(disable);
334 }
335
336 const operation = yield* cnr
337 .deleteProjectsLocationsIpamAdminScopes({
338 name: output.name,
339 force: true,
340 })
341 .pipe(
342 Effect.retry({
343 while: (error) => error._tag === "Conflict",
344 times: 8,
345 schedule: Schedule.spaced("2 seconds"),
346 }),
347 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
348 );
349 if (operation !== undefined) {
350 yield* waitForDeleteOperation(operation);
351 }
352 yield* waitUntilGone(getByName(output.name), output.name);
353 }),
354 });