1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { tagRecord } from "../../Tags.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 createInternalLabels,
15 diffLabels,
16 hasAlchemyLabels,
17 stripInternalLabels,
18 toLabels,
19} from "../Labels.ts";
20import type { Providers } from "../Providers.ts";
21import { waitGlobalOperation } from "./operations.ts";
22
23const DEFAULT_ADDRESS_TYPE = "EXTERNAL";
24const DEFAULT_IP_VERSION = "IPV4";
25const DEFAULT_NETWORK_TIER = "PREMIUM";
26const MAX_NAME_LENGTH = 63;
27
28export type GlobalAddressProps = {
29 /**
30 * Name of the address. If omitted, a unique RFC1035 name is generated
31 * from the stack, stage, and logical id. Immutable — changing it
32 * replaces the address.
33 */
34 addressName?: string;
35 /**
36 * Optional description. Immutable — changing it replaces the address.
37 */
38 description?: string;
39 /**
40 * Specific IP to reserve. If omitted, GCP assigns one. Immutable —
41 * changing it replaces the address.
42 */
43 address?: string;
44 /**
45 * `EXTERNAL` (global anycast) or `INTERNAL` (VPC peering / PSC).
46 * Immutable — changing it replaces the address.
47 * @default "EXTERNAL"
48 */
49 addressType?: compute.AddressAddressTypeEnum | (string & {});
50 /**
51 * `IPV4` or `IPV6`. Immutable — changing it replaces the address.
52 * @default "IPV4"
53 */
54 ipVersion?: compute.AddressIpVersionEnum | (string & {});
55 /**
56 * Prefix length when the resource is an IP range (VPC peering /
57 * PSC). Immutable — changing it replaces the address.
58 */
59 prefixLength?: number;
60 /**
61 * Purpose of an internal address (`VPC_PEERING`,
62 * `PRIVATE_SERVICE_CONNECT`, …). Immutable — changing it replaces
63 * the address.
64 */
65 purpose?: compute.AddressPurposeEnum | (string & {});
66 /**
67 * Network URL for internal addresses (`VPC_PEERING` /
68 * `PRIVATE_SERVICE_CONNECT`). Immutable — changing it replaces the
69 * address.
70 */
71 network?: string;
72 /**
73 * Networking tier. Global external addresses are always `PREMIUM`.
74 * Immutable — changing it replaces the address.
75 * @default "PREMIUM"
76 */
77 networkTier?: compute.AddressNetworkTierEnum | (string & {});
78 /**
79 * Endpoint type for reserved IPv6 addresses (`VM` or `NETLB`).
80 * Immutable — changing it replaces the address.
81 */
82 ipv6EndpointType?: compute.AddressIpv6EndpointTypeEnum | (string & {});
83 /**
84 * User labels. Alchemy ownership labels are merged in automatically
85 * and synced via `setLabels` (labels cannot be set on insert).
86 */
87 labels?: Record<string, string>;
88};
89
90export type GlobalAddress = Resource<
91 "GCP.Compute.GlobalAddress",
92 GlobalAddressProps,
93 {
94 /** Address name. */
95 addressName: string;
96 /** Project id. */
97 project: string;
98 /** Reserved IP address or start of the range. */
99 address: string | undefined;
100 /** Server-assigned numeric id. */
101 id: string | undefined;
102 /** Resource self-link. */
103 selfLink: string | undefined;
104 /** `RESERVING`, `RESERVED`, or `IN_USE`. */
105 status: string | undefined;
106 /** `EXTERNAL` or `INTERNAL`. */
107 addressType: string | undefined;
108 /** `IPV4` or `IPV6`. */
109 ipVersion: string | undefined;
110 /** Prefix length of an IP range, if any. */
111 prefixLength: number | undefined;
112 /** Purpose of an internal address, if any. */
113 purpose: string | undefined;
114 /** Network URL for internal addresses, if any. */
115 network: string | undefined;
116 /** Networking tier. */
117 networkTier: string | undefined;
118 /** IPv6 endpoint type, if any. */
119 ipv6EndpointType: string | undefined;
120 /** Description. */
121 description: string | undefined;
122 /** User labels (Alchemy ownership labels stripped). */
123 labels: Record<string, string>;
124 /** RFC3339 creation timestamp. */
125 creationTimestamp: string | undefined;
126 /** Self-links of resources using this address. */
127 users: ReadonlyArray<string>;
128 },
129 never,
130 Providers
131>;
132
133/**
134 * A global Compute Engine address — a reserved anycast IP for global
135 * forwarding rules, or an internal range for VPC peering / Private
136 * Service Connect.
137 *
138 * Labels are the only in-place update. Name, IP, type, version,
139 * purpose, network, prefix length, and description replace the
140 * address.
141 *
142 * ### Creating a Global Address
143 * **Example:** Generated name
144 * ```typescript
145 * const ip = yield* GCP.Compute.GlobalAddress("FrontendIp", {});
146 * ```
147 *
148 * **Example:** Named address with labels
149 * ```typescript
150 * const ip = yield* GCP.Compute.GlobalAddress("FrontendIp", {
151 * addressName: "app-lb-ip",
152 * description: "Global anycast IP for the HTTPS load balancer",
153 * ipVersion: "IPV4",
154 * labels: { env: "prod" },
155 * });
156 * ```
157 *
158 * ### Internal Ranges
159 * **Example:** VPC peering range
160 * ```typescript
161 * const range = yield* GCP.Compute.GlobalAddress("PsaRange", {
162 * addressType: "INTERNAL",
163 * purpose: "VPC_PEERING",
164 * network: "projects/my-project/global/networks/main",
165 * prefixLength: 16,
166 * });
167 * ```
168 *
169 * @resource
170 * @category Compute
171 */
172export const GlobalAddress = Resource<GlobalAddress>(
173 "GCP.Compute.GlobalAddress",
174);
175
176export class GlobalAddressNotResolved extends Data.TaggedError(
177 "GCP.Compute.GlobalAddressNotResolved",
178)<{
179 addressName: string;
180}> {}
181
182export class GlobalAddressPending extends Data.TaggedError(
183 "GCP.Compute.GlobalAddressPending",
184)<{
185 addressName: string;
186 status: string;
187}> {}
188
189const userLabels = (
190 labels: Record<string, string | undefined> | null | undefined,
191): Record<string, string> => stripInternalLabels(tagRecord(labels));
192
193const toName = (id: string, name: string | undefined, existing?: string) =>
194 Effect.gen(function* () {
195 if (name !== undefined) return name;
196 if (existing !== undefined) return existing;
197 const generated = yield* createPhysicalName({
198 id,
199 maxLength: MAX_NAME_LENGTH,
200 lowercase: true,
201 });
202 return /^[a-z]/.test(generated)
203 ? generated
204 : `a${generated}`.slice(0, MAX_NAME_LENGTH);
205 });
206
207const toAttrs = (address: compute.Address, project: string) => ({
208 addressName: address.name ?? "",
209 project,
210 address: address.address,
211 id: address.id,
212 selfLink: address.selfLink,
213 status: address.status,
214 addressType: address.addressType,
215 ipVersion: address.ipVersion,
216 prefixLength: address.prefixLength,
217 purpose: address.purpose,
218 network: address.network,
219 networkTier: address.networkTier,
220 ipv6EndpointType: address.ipv6EndpointType,
221 description: address.description,
222 labels: userLabels(address.labels),
223 creationTimestamp: address.creationTimestamp,
224 users: address.users ?? [],
225});
226
227const addressTypeOf = (value: string | undefined) =>
228 value && value !== "UNSPECIFIED_TYPE" ? value : DEFAULT_ADDRESS_TYPE;
229
230const ipVersionOf = (value: string | undefined) =>
231 value && value !== "UNSPECIFIED_VERSION" ? value : DEFAULT_IP_VERSION;
232
233const networkTierOf = (value: string | undefined) =>
234 value ?? DEFAULT_NETWORK_TIER;
235
236const resourceRefOf = (value: string | undefined) => {
237 if (!value) return "";
238 const parts = value.split("/");
239 return parts[parts.length - 1] ?? value;
240};
241
242const getByName = (project: string, address: string) =>
243 compute
244 .getGlobalAddresses({ project, address })
245 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
246
247const waitUntilReady = (project: string, addressName: string) =>
248 getByName(project, addressName).pipe(
249 Effect.flatMap((address) => {
250 if (address === undefined || address.status === "RESERVING") {
251 return Effect.fail(
252 new GlobalAddressPending({
253 addressName,
254 status: address?.status ?? "MISSING",
255 }),
256 );
257 }
258 return Effect.succeed(address);
259 }),
260 Effect.retry({
261 while: (e) => e._tag === "GCP.Compute.GlobalAddressPending",
262 times: 10,
263 schedule: Schedule.spaced("1 second"),
264 }),
265 );
266
267const waitUntilGone = (project: string, addressName: string) =>
268 getByName(project, addressName).pipe(
269 Effect.flatMap((address) =>
270 address === undefined
271 ? Effect.void
272 : Effect.fail(
273 new GlobalAddressPending({
274 addressName,
275 status: address.status ?? "EXISTS",
276 }),
277 ),
278 ),
279 Effect.retry({
280 while: (e) => e._tag === "GCP.Compute.GlobalAddressPending",
281 times: 10,
282 schedule: Schedule.spaced("1 second"),
283 }),
284 Effect.catchTag("GCP.Compute.GlobalAddressPending", () => Effect.void),
285 );
286
287export const GlobalAddressProvider = () =>
288 Provider.succeed(GlobalAddress, {
289 stables: [
290 "addressName",
291 "project",
292 "address",
293 "id",
294 "selfLink",
295 "addressType",
296 "ipVersion",
297 "prefixLength",
298 "purpose",
299 "network",
300 "networkTier",
301 "creationTimestamp",
302 ],
303
304 diff: Effect.fn(function* ({ news, olds, output }) {
305 if (!isResolved(news)) return undefined;
306 const previousName = olds.addressName ?? output?.addressName;
307 const nextName = news.addressName ?? previousName;
308 const nameChanged =
309 previousName !== undefined &&
310 nextName !== undefined &&
311 nextName !== previousName;
312
313 const previousType = addressTypeOf(
314 olds.addressType ?? output?.addressType,
315 );
316 const previousVersion = ipVersionOf(olds.ipVersion ?? output?.ipVersion);
317 const previousTier = networkTierOf(
318 olds.networkTier ?? output?.networkTier,
319 );
320 const previousNetwork = resourceRefOf(olds.network ?? output?.network);
321 const previousPurpose = olds.purpose ?? output?.purpose ?? "";
322 const previousDescription = olds.description ?? output?.description ?? "";
323 const previousPrefix = olds.prefixLength ?? output?.prefixLength;
324 const previousIpv6 =
325 olds.ipv6EndpointType ?? output?.ipv6EndpointType ?? "";
326
327 const immutableChanged =
328 (news.description !== undefined &&
329 (news.description ?? "") !== previousDescription) ||
330 (news.address !== undefined &&
331 output?.address !== undefined &&
332 news.address !== output.address) ||
333 (news.addressType !== undefined &&
334 addressTypeOf(news.addressType) !== previousType) ||
335 (news.ipVersion !== undefined &&
336 ipVersionOf(news.ipVersion) !== previousVersion) ||
337 (news.prefixLength !== undefined &&
338 news.prefixLength !== previousPrefix) ||
339 (news.purpose !== undefined &&
340 (news.purpose ?? "") !== previousPurpose) ||
341 (news.network !== undefined &&
342 resourceRefOf(news.network) !== previousNetwork) ||
343 (news.networkTier !== undefined &&
344 networkTierOf(news.networkTier) !== previousTier) ||
345 (news.ipv6EndpointType !== undefined &&
346 (news.ipv6EndpointType ?? "") !== previousIpv6);
347
348 if (nameChanged) {
349 return { action: "replace" as const, deleteFirst: false };
350 }
351 if (immutableChanged) {
352 return { action: "replace" as const, deleteFirst: true };
353 }
354 return undefined;
355 }),
356
357 read: Effect.fn(function* ({ id, olds, output }) {
358 const env = yield* GcpEnvironment.current;
359 const addressName = yield* toName(
360 id,
361 olds?.addressName,
362 output?.addressName,
363 );
364 const existing = yield* getByName(env.project, addressName);
365 if (existing === undefined) return undefined;
366 const attrs = toAttrs(existing, env.project);
367 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
368 ? attrs
369 : Unowned(attrs);
370 }),
371
372 list: () =>
373 Effect.gen(function* () {
374 const env = yield* GcpEnvironment.current;
375 return yield* compute.listGlobalAddresses
376 .items({ project: env.project, maxResults: 500 })
377 .pipe(
378 Stream.filter((address) =>
379 Object.keys(address.labels ?? {}).some((key) =>
380 key.startsWith("alchemy-"),
381 ),
382 ),
383 Stream.map((address) => toAttrs(address, env.project)),
384 Stream.runCollect,
385 Effect.map((chunk) => Array.from(chunk)),
386 );
387 }),
388
389 reconcile: Effect.fn(function* ({ id, news, output }) {
390 const env = yield* GcpEnvironment.current;
391 const addressName = yield* toName(
392 id,
393 news.addressName,
394 output?.addressName,
395 );
396 const desiredLabels = {
397 ...toLabels(news.labels),
398 ...(yield* createInternalLabels(id)),
399 };
400
401 let current = yield* getByName(env.project, addressName);
402
403 if (current === undefined) {
404 const created = yield* compute
405 .insertGlobalAddresses({
406 project: env.project,
407 body: {
408 name: addressName,
409 description: news.description,
410 address: news.address,
411 addressType: news.addressType,
412 ipVersion: news.ipVersion,
413 prefixLength: news.prefixLength,
414 purpose: news.purpose,
415 network: news.network,
416 networkTier: news.networkTier,
417 ipv6EndpointType: news.ipv6EndpointType,
418 },
419 })
420 .pipe(
421 Effect.flatMap((operation) =>
422 waitGlobalOperation(env.project, operation, {
423 ignore: ["RESOURCE_ALREADY_EXISTS"],
424 }).pipe(
425 Effect.flatMap(() => getByName(env.project, addressName)),
426 ),
427 ),
428 Effect.catchTag("Conflict", () =>
429 getByName(env.project, addressName),
430 ),
431 );
432 current = created ?? undefined;
433 }
434
435 if (current === undefined) {
436 return yield* new GlobalAddressNotResolved({ addressName });
437 }
438
439 if (current.status === "RESERVING") {
440 current = yield* waitUntilReady(env.project, addressName);
441 }
442
443 const observedLabels = tagRecord(current.labels);
444 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
445 if (upsert.length > 0 || removed.length > 0) {
446 yield* compute
447 .setLabelsGlobalAddresses({
448 project: env.project,
449 resource: addressName,
450 body: {
451 labels: desiredLabels,
452 labelFingerprint: current.labelFingerprint,
453 },
454 })
455 .pipe(
456 Effect.flatMap((operation) =>
457 waitGlobalOperation(env.project, operation),
458 ),
459 );
460 current = (yield* getByName(env.project, addressName)) ?? current;
461 }
462
463 return toAttrs(current, env.project);
464 }),
465
466 delete: Effect.fn(function* ({ output }) {
467 const env = yield* GcpEnvironment.current;
468 yield* compute
469 .deleteGlobalAddresses({
470 project: env.project,
471 address: output.addressName,
472 })
473 .pipe(
474 Effect.flatMap((operation) =>
475 waitGlobalOperation(env.project, operation, {
476 ignore: ["RESOURCE_NOT_FOUND"],
477 }),
478 ),
479 Effect.catchTag("NotFound", () => Effect.void),
480 Effect.retry({
481 while: (e) => e._tag === "Conflict",
482 times: 8,
483 schedule: Schedule.spaced("2 seconds"),
484 }),
485 );
486 yield* waitUntilGone(env.project, output.addressName);
487 }),
488 });