1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20export type GlobalPublicDelegatedPrefixProps = {
21 /**
22 * Prefix name (RFC1035, 1-63 chars). If omitted, a unique name is
23 * generated from the stack, stage, and logical id. Changing it replaces
24 * the prefix.
25 */
26 prefixName?: string;
27 /**
28 * Optional description. Public delegated prefixes have no labels field —
29 * Alchemy ownership is stored in a `[alchemy …]` prefix for `list` /
30 * nuke.
31 */
32 description?: string;
33 /**
34 * Parent Public Advertised Prefix or Public Delegated Prefix URL.
35 * Immutable — changing it replaces the prefix.
36 */
37 parentPrefix: string;
38 /**
39 * IP range in CIDR format allocated from the parent. Immutable —
40 * changing it replaces the prefix.
41 */
42 ipCidrRange: string;
43 /**
44 * IPv6 allocation mode (`DELEGATION`,
45 * `EXTERNAL_IPV6_FORWARDING_RULE_CREATION`, …). Immutable — changing
46 * it replaces the prefix.
47 */
48 mode?: compute.PublicDelegatedPrefixModeEnum | (string & {});
49 /**
50 * Allocatable prefix length for IPv6 prefixes that are not in
51 * `DELEGATION` mode.
52 */
53 allocatablePrefixLength?: number;
54 /**
55 * Whether this prefix is a live-migration prefix.
56 */
57 isLiveMigration?: boolean;
58};
59
60export type GlobalPublicDelegatedPrefix = Resource<
61 "GCP.Compute.GlobalPublicDelegatedPrefix",
62 GlobalPublicDelegatedPrefixProps,
63 {
64 /** Prefix name. */
65 prefixName: string;
66 /** Project id. */
67 project: string;
68 /** User description with the Alchemy ownership prefix stripped. */
69 description: string | undefined;
70 /** Parent prefix URL. */
71 parentPrefix: string | undefined;
72 /** Allocated CIDR. */
73 ipCidrRange: string | undefined;
74 /** Allocation mode. */
75 mode: string | undefined;
76 /** Allocatable prefix length. */
77 allocatablePrefixLength: number | undefined;
78 /** Live-migration flag. */
79 isLiveMigration: boolean | undefined;
80 /** Server-reported status. */
81 status: string | undefined;
82 /** IPv6 access type inherited from the parent. */
83 ipv6AccessType: string | undefined;
84 /** Server-assigned numeric id. */
85 prefixId: string | undefined;
86 /** Server-defined URL. */
87 selfLink: string | undefined;
88 /** RFC3339 creation timestamp. */
89 creationTimestamp: string | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A global Compute Engine public delegated prefix (BYOIP).
97 *
98 * Delegates a CIDR from a Public Advertised Prefix so addresses and
99 * sub-prefixes can be created. Creating one requires a parent advertised
100 * prefix (Bring Your Own IP). Name, parent, CIDR, and mode replace the
101 * resource; description updates in place via patch. Compute has no labels
102 * field, so Alchemy stamps ownership into the description.
103 *
104 * ### Creating a Global Public Delegated Prefix
105 * **Example:** Delegate a /24 from a PAP
106 * ```typescript
107 * const prefix = yield* GCP.Compute.GlobalPublicDelegatedPrefix("byoip", {
108 * parentPrefix:
109 * "projects/my-project/global/publicAdvertisedPrefixes/edge",
110 * ipCidrRange: "203.0.113.0/24",
111 * });
112 * ```
113 *
114 * @resource
115 * @category Compute
116 */
117export const GlobalPublicDelegatedPrefix =
118 Resource<GlobalPublicDelegatedPrefix>(
119 "GCP.Compute.GlobalPublicDelegatedPrefix",
120 );
121
122export class GlobalPublicDelegatedPrefixNotResolved extends Data.TaggedError(
123 "GCP.Compute.GlobalPublicDelegatedPrefixNotResolved",
124)<{
125 prefixName: string;
126}> {}
127
128const rfc1035 = (name: string): string => {
129 let next = name
130 .toLowerCase()
131 .replace(/[^a-z0-9-]/g, "-")
132 .replace(/-+/g, "-")
133 .replace(/^-+/, "")
134 .replace(/-+$/, "");
135 if (!/^[a-z]/.test(next)) {
136 next = `p${next}`;
137 }
138 next = next.slice(0, 63).replace(/-+$/, "");
139 return next.length > 0 ? next : "publicdelegatedprefix";
140};
141
142const lastSegment = (value: string | undefined): string => {
143 if (value === undefined || value.length === 0) return "";
144 const parts = value.replace(/\/+$/, "").split("/");
145 return parts[parts.length - 1] || value;
146};
147
148const toName = (id: string, name: string | undefined, existing?: string) =>
149 Effect.gen(function* () {
150 if (name !== undefined) return name;
151 if (existing !== undefined) return existing;
152 return rfc1035(
153 yield* createPhysicalName({
154 id,
155 maxLength: 63,
156 lowercase: true,
157 }),
158 );
159 });
160
161const encodeDescription = (
162 labels: Record<string, string>,
163 description: string | undefined,
164): string => {
165 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
166 return description ? `${marker}\n${description}` : marker;
167};
168
169const parseDescription = (
170 description: string | undefined,
171): {
172 labels: Record<string, string>;
173 description: string | undefined;
174} => {
175 if (!description?.startsWith("[alchemy ")) {
176 return { labels: {}, description };
177 }
178 const end = description.indexOf("]");
179 if (end < 0) return { labels: {}, description };
180 const labels: Record<string, string> = {};
181 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
182 const eq = part.indexOf("=");
183 if (eq > 0) {
184 labels[part.slice(0, eq)] = part.slice(eq + 1);
185 }
186 }
187 const rest = description.slice(end + 1).replace(/^\n/, "");
188 return { labels, description: rest.length > 0 ? rest : undefined };
189};
190
191const toAttrs = (
192 prefix: compute.PublicDelegatedPrefix,
193 project: string,
194): GlobalPublicDelegatedPrefix["Attributes"] => {
195 const parsed = parseDescription(prefix.description);
196 return {
197 prefixName: prefix.name ?? prefix.id ?? "",
198 project,
199 description: parsed.description,
200 parentPrefix: prefix.parentPrefix,
201 ipCidrRange: prefix.ipCidrRange,
202 mode: prefix.mode,
203 allocatablePrefixLength: prefix.allocatablePrefixLength,
204 isLiveMigration: prefix.isLiveMigration,
205 status: prefix.status,
206 ipv6AccessType: prefix.ipv6AccessType,
207 prefixId: prefix.id,
208 selfLink: prefix.selfLink,
209 creationTimestamp: prefix.creationTimestamp,
210 };
211};
212
213const getByName = (project: string, publicDelegatedPrefix: string) =>
214 compute
215 .getGlobalPublicDelegatedPrefixes({ project, publicDelegatedPrefix })
216 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
217
218const awaitResource = (project: string, prefixName: string) =>
219 getByName(project, prefixName).pipe(
220 Effect.repeat({
221 schedule: Schedule.spaced("1 second"),
222 until: (item) => item !== undefined,
223 times: 8,
224 }),
225 );
226
227export const GlobalPublicDelegatedPrefixProvider = () =>
228 Provider.succeed(GlobalPublicDelegatedPrefix, {
229 stables: [
230 "prefixName",
231 "project",
232 "parentPrefix",
233 "ipCidrRange",
234 "mode",
235 "prefixId",
236 "selfLink",
237 "creationTimestamp",
238 ],
239
240 diff: Effect.fn(function* ({ news, olds, output }) {
241 if (!isResolved(news)) return undefined;
242 const previousName = olds?.prefixName ?? output?.prefixName;
243 const nextName = news.prefixName;
244 const previousParent = lastSegment(
245 olds?.parentPrefix ?? output?.parentPrefix,
246 );
247 const nextParent = lastSegment(news.parentPrefix);
248 const previousRange = olds?.ipCidrRange ?? output?.ipCidrRange ?? "";
249 const nextRange = news.ipCidrRange;
250 const previousMode = olds?.mode ?? output?.mode ?? "";
251 const nextMode = news.mode ?? previousMode;
252 if (
253 (previousName !== undefined &&
254 nextName !== undefined &&
255 previousName !== nextName) ||
256 (nextParent.length > 0 &&
257 previousParent.length > 0 &&
258 previousParent !== nextParent) ||
259 (nextRange.length > 0 &&
260 previousRange.length > 0 &&
261 previousRange !== nextRange) ||
262 (news.mode !== undefined && previousMode !== nextMode)
263 ) {
264 return {
265 action: "replace" as const,
266 deleteFirst:
267 previousName !== undefined &&
268 nextName !== undefined &&
269 previousName === nextName,
270 };
271 }
272 return undefined;
273 }),
274
275 read: Effect.fn(function* ({ id, olds, output }) {
276 const env = yield* GcpEnvironment.current;
277 const prefixName = yield* toName(
278 id,
279 olds?.prefixName,
280 output?.prefixName,
281 );
282 const existing = yield* getByName(env.project, prefixName);
283 if (existing === undefined) return undefined;
284 const attrs = toAttrs(existing, env.project);
285 const { labels } = parseDescription(existing.description);
286 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
287 }),
288
289 list: () =>
290 Effect.gen(function* () {
291 const env = yield* GcpEnvironment.current;
292 return yield* compute.listGlobalPublicDelegatedPrefixes
293 .items({ project: env.project, maxResults: 500 })
294 .pipe(
295 Stream.filter((prefix) => {
296 const { labels } = parseDescription(prefix.description);
297 return Object.keys(labels).some((key) =>
298 key.startsWith("alchemy-"),
299 );
300 }),
301 Stream.map((prefix) => toAttrs(prefix, env.project)),
302 Stream.runCollect,
303 Effect.map((chunk) => Array.from(chunk)),
304 );
305 }),
306
307 reconcile: Effect.fn(function* ({ id, news, output }) {
308 const env = yield* GcpEnvironment.current;
309 const prefixName = yield* toName(id, news.prefixName, output?.prefixName);
310 const ownership = yield* createInternalLabels(id);
311 const description = encodeDescription(ownership, news.description);
312
313 let current = yield* getByName(env.project, prefixName);
314
315 if (current === undefined) {
316 yield* compute
317 .insertGlobalPublicDelegatedPrefixes({
318 project: env.project,
319 body: {
320 name: prefixName,
321 description,
322 parentPrefix: news.parentPrefix,
323 ipCidrRange: news.ipCidrRange,
324 mode: news.mode,
325 allocatablePrefixLength: news.allocatablePrefixLength,
326 isLiveMigration: news.isLiveMigration,
327 },
328 })
329 .pipe(
330 Effect.flatMap((operation) =>
331 waitGlobalOperation(env.project, operation, {
332 ignore: ["RESOURCE_ALREADY_EXISTS"],
333 }),
334 ),
335 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
336 );
337 current = yield* awaitResource(env.project, prefixName);
338 }
339
340 if (current === undefined) {
341 return yield* new GlobalPublicDelegatedPrefixNotResolved({
342 prefixName,
343 });
344 }
345
346 if ((current.description ?? "") !== description) {
347 yield* compute
348 .patchGlobalPublicDelegatedPrefixes({
349 project: env.project,
350 publicDelegatedPrefix: prefixName,
351 body: {
352 description,
353 fingerprint: current.fingerprint,
354 },
355 })
356 .pipe(
357 Effect.flatMap((operation) =>
358 waitGlobalOperation(env.project, operation),
359 ),
360 );
361 current = yield* getByName(env.project, prefixName);
362 if (current === undefined) {
363 return yield* new GlobalPublicDelegatedPrefixNotResolved({
364 prefixName,
365 });
366 }
367 }
368
369 return toAttrs(current, env.project);
370 }),
371
372 delete: Effect.fn(function* ({ output }) {
373 const env = yield* GcpEnvironment.current;
374 const operation = yield* compute
375 .deleteGlobalPublicDelegatedPrefixes({
376 project: env.project,
377 publicDelegatedPrefix: output.prefixName,
378 })
379 .pipe(
380 Effect.retry({
381 while: (error) => error._tag === "Conflict",
382 schedule: Schedule.spaced("2 seconds"),
383 times: 8,
384 }),
385 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
386 );
387 if (operation !== undefined) {
388 yield* waitGlobalOperation(env.project, operation).pipe(
389 Effect.catchTag("NotFound", () => Effect.void),
390 );
391 }
392 }),
393 });