1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } 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 { tagRecord } from "../../Tags.ts";
13import { GcpEnvironment } from "../Environment.ts";
14import {
15 createInternalLabels,
16 diffLabels,
17 hasAlchemyLabels,
18 stripInternalLabels,
19 toLabels,
20} from "../Labels.ts";
21import type { Providers } from "../Providers.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 AddressProps = {
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 * Region to reserve the address in. Immutable — changing it replaces
37 * the address. `US-CENTRAL1` is accepted and normalized to `us-central1`.
38 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
39 */
40 region?: string;
41 /**
42 * Optional description. Immutable — changing it replaces the address.
43 */
44 description?: string;
45 /**
46 * Specific IP to reserve. If omitted, GCP assigns one. Immutable —
47 * changing it replaces the address.
48 */
49 address?: string;
50 /**
51 * `EXTERNAL` or `INTERNAL`. Immutable — changing it replaces the
52 * address.
53 * @default "EXTERNAL"
54 */
55 addressType?: compute.AddressAddressTypeEnum | (string & {});
56 /**
57 * `IPV4` or `IPV6`. Immutable — changing it replaces the address.
58 * @default "IPV4"
59 */
60 ipVersion?: compute.AddressIpVersionEnum | (string & {});
61 /**
62 * Prefix length when the resource is an IP range. Immutable — changing
63 * it replaces the address.
64 */
65 prefixLength?: number;
66 /**
67 * Purpose of an internal address (`GCE_ENDPOINT`, `DNS_RESOLVER`,
68 * `VPC_PEERING`, `NAT_AUTO`, `IPSEC_INTERCONNECT`,
69 * `SHARED_LOADBALANCER_VIP`, `PRIVATE_SERVICE_CONNECT`, `SERVERLESS`).
70 * Immutable — changing it replaces the address.
71 */
72 purpose?: compute.AddressPurposeEnum | (string & {});
73 /**
74 * Network URL for internal addresses (`VPC_PEERING` /
75 * `IPSEC_INTERCONNECT`). Immutable — changing it replaces the address.
76 */
77 network?: string;
78 /**
79 * Subnetwork URL for internal `GCE_ENDPOINT` / `DNS_RESOLVER`
80 * addresses. Immutable — changing it replaces the address.
81 */
82 subnetwork?: string;
83 /**
84 * Networking tier (`PREMIUM` or `STANDARD`). Internal addresses are
85 * always Premium. Immutable — changing it replaces the address.
86 * @default "PREMIUM"
87 */
88 networkTier?: compute.AddressNetworkTierEnum | (string & {});
89 /**
90 * Endpoint type for reserved IPv6 addresses (`VM` or `NETLB`).
91 * Immutable — changing it replaces the address.
92 */
93 ipv6EndpointType?: compute.AddressIpv6EndpointTypeEnum | (string & {});
94 /**
95 * Public delegated prefix used to draw a BYOIP address. Immutable —
96 * changing it replaces the address.
97 */
98 ipCollection?: string;
99 /**
100 * User labels. Alchemy ownership labels are merged in automatically
101 * and synced via `setLabels` (labels cannot be set on insert).
102 */
103 labels?: Record<string, string>;
104};
105
106export type Address = Resource<
107 "GCP.Compute.Address",
108 AddressProps,
109 {
110 /** Address name. */
111 addressName: string;
112 /** Project id. */
113 project: string;
114 /** Region short name (`us-central1`). */
115 region: string;
116 /** Reserved IP address or start of the range. */
117 address: string | undefined;
118 /** Server-assigned numeric id. */
119 addressId: string | undefined;
120 /** Resource self-link. */
121 selfLink: string | undefined;
122 /** `RESERVING`, `RESERVED`, or `IN_USE`. */
123 status: string | undefined;
124 /** `EXTERNAL` or `INTERNAL`. */
125 addressType: string | undefined;
126 /** `IPV4` or `IPV6`. */
127 ipVersion: string | undefined;
128 /** Prefix length of an IP range, if any. */
129 prefixLength: number | undefined;
130 /** Purpose of an internal address, if any. */
131 purpose: string | undefined;
132 /** Network URL for internal addresses, if any. */
133 network: string | undefined;
134 /** Subnetwork URL, if any. */
135 subnetwork: string | undefined;
136 /** Networking tier. */
137 networkTier: string | undefined;
138 /** IPv6 endpoint type, if any. */
139 ipv6EndpointType: string | undefined;
140 /** BYOIP public delegated prefix, if any. */
141 ipCollection: string | undefined;
142 /** Description. */
143 description: string | undefined;
144 /** User labels (Alchemy ownership labels stripped). */
145 labels: Record<string, string>;
146 /** RFC3339 creation timestamp. */
147 creationTimestamp: string | undefined;
148 /** Self-links of resources using this address. */
149 users: ReadonlyArray<string>;
150 },
151 never,
152 Providers
153>;
154
155/**
156 * A regional Compute Engine static IP address.
157 *
158 * Reserves a regional internal or external IP. Labels are the only
159 * in-place update (`addresses.setLabels`); name, region, IP, type,
160 * version, purpose, network, subnetwork, prefix length, and description
161 * replace the address.
162 *
163 * ### Creating an Address
164 * **Example:** Generated name
165 * ```typescript
166 * const ip = yield* GCP.Compute.Address("Ingress", {});
167 * ```
168 *
169 * **Example:** Named address with labels
170 * ```typescript
171 * const ip = yield* GCP.Compute.Address("Ingress", {
172 * addressName: "app-ingress",
173 * region: "us-central1",
174 * addressType: "EXTERNAL",
175 * networkTier: "PREMIUM",
176 * labels: { env: "prod" },
177 * });
178 * ```
179 *
180 * ### Internal Addresses
181 * **Example:** Regional internal IP in a subnetwork
182 * ```typescript
183 * const ip = yield* GCP.Compute.Address("ServiceIp", {
184 * region: "us-central1",
185 * addressType: "INTERNAL",
186 * subnetwork:
187 * "projects/my-project/regions/us-central1/subnetworks/default",
188 * purpose: "GCE_ENDPOINT",
189 * });
190 * ```
191 *
192 * @resource
193 * @category Compute
194 */
195export const Address = Resource<Address>("GCP.Compute.Address");
196
197export class AddressNotResolved extends Data.TaggedError(
198 "GCP.Compute.AddressNotResolved",
199)<{
200 addressName: string;
201 region: string;
202}> {}
203
204export class AddressPending extends Data.TaggedError(
205 "GCP.Compute.AddressPending",
206)<{
207 addressName: string;
208 status: string;
209}> {}
210
211const userLabels = (
212 labels: Record<string, string | undefined> | null | undefined,
213): Record<string, string> => stripInternalLabels(tagRecord(labels));
214
215const lastSegment = (value: string) => {
216 const trimmed = value.replace(/\/+$/, "");
217 const parts = trimmed.split("/");
218 return parts[parts.length - 1] || trimmed;
219};
220
221const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
222 lastSegment(region ?? defaultRegion).toLowerCase();
223
224const resourceRefOf = (value: string | undefined) => {
225 if (!value) return "";
226 return lastSegment(value);
227};
228
229const addressTypeOf = (value: string | undefined) =>
230 value && value !== "UNSPECIFIED_TYPE" ? value : DEFAULT_ADDRESS_TYPE;
231
232const ipVersionOf = (value: string | undefined) =>
233 value && value !== "UNSPECIFIED_VERSION" ? value : DEFAULT_IP_VERSION;
234
235const networkTierOf = (value: string | undefined) =>
236 value ?? DEFAULT_NETWORK_TIER;
237
238const toName = (id: string, name: string | undefined, existing?: string) =>
239 Effect.gen(function* () {
240 if (name !== undefined) return name;
241 if (existing !== undefined) return existing;
242 const generated = yield* createPhysicalName({
243 id,
244 maxLength: MAX_NAME_LENGTH,
245 lowercase: true,
246 });
247 return /^[a-z]/.test(generated)
248 ? generated
249 : `a${generated}`.slice(0, MAX_NAME_LENGTH);
250 });
251
252const toAttrs = (address: compute.Address, project: string) => ({
253 addressName: address.name ?? "",
254 project,
255 region: lastSegment(address.region ?? "").toLowerCase(),
256 address: address.address,
257 addressId: address.id,
258 selfLink: address.selfLink,
259 status: address.status,
260 addressType: address.addressType,
261 ipVersion: address.ipVersion,
262 prefixLength: address.prefixLength,
263 purpose: address.purpose,
264 network: address.network,
265 subnetwork: address.subnetwork,
266 networkTier: address.networkTier,
267 ipv6EndpointType: address.ipv6EndpointType,
268 ipCollection: address.ipCollection,
269 description: address.description,
270 labels: userLabels(address.labels),
271 creationTimestamp: address.creationTimestamp,
272 users: address.users ?? [],
273});
274
275const getByName = (project: string, region: string, address: string) =>
276 compute
277 .getAddresses({ project, region, address })
278 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
279
280const waitUntilReady = (project: string, region: string, addressName: string) =>
281 getByName(project, region, addressName).pipe(
282 Effect.flatMap((address) => {
283 if (address === undefined || address.status === "RESERVING") {
284 return Effect.fail(
285 new AddressPending({
286 addressName,
287 status: address?.status ?? "MISSING",
288 }),
289 );
290 }
291 return Effect.succeed(address);
292 }),
293 Effect.retry({
294 while: (e) => e._tag === "GCP.Compute.AddressPending",
295 times: 10,
296 schedule: Schedule.spaced("1 second"),
297 }),
298 );
299
300const requireAddress = (project: string, region: string, addressName: string) =>
301 getByName(project, region, addressName).pipe(
302 Effect.flatMap((address) =>
303 address
304 ? Effect.succeed(address)
305 : Effect.fail(new AddressNotResolved({ addressName, region })),
306 ),
307 Effect.retry({
308 while: (e) => e._tag === "GCP.Compute.AddressNotResolved",
309 schedule: Schedule.spaced("1 second"),
310 times: 8,
311 }),
312 );
313
314const waitUntilGone = (project: string, region: string, addressName: string) =>
315 getByName(project, region, addressName).pipe(
316 Effect.flatMap((address) =>
317 address === undefined
318 ? Effect.void
319 : Effect.fail(
320 new AddressPending({
321 addressName,
322 status: address.status ?? "EXISTS",
323 }),
324 ),
325 ),
326 Effect.retry({
327 while: (e) => e._tag === "GCP.Compute.AddressPending",
328 times: 10,
329 schedule: Schedule.spaced("2 seconds"),
330 }),
331 );
332
333export const AddressProvider = () =>
334 Provider.succeed(Address, {
335 stables: [
336 "addressName",
337 "project",
338 "region",
339 "address",
340 "addressId",
341 "selfLink",
342 "addressType",
343 "ipVersion",
344 "prefixLength",
345 "purpose",
346 "network",
347 "subnetwork",
348 "networkTier",
349 "creationTimestamp",
350 ],
351
352 diff: Effect.fn(function* ({ news, olds, output }) {
353 if (!isResolved(news)) return undefined;
354 const env = yield* GcpEnvironment.current;
355 const previousName = olds.addressName ?? output?.addressName;
356 const nextName = news.addressName ?? previousName;
357 const nameChanged =
358 previousName !== undefined &&
359 nextName !== undefined &&
360 nextName !== previousName;
361
362 const previousRegion = normalizeRegion(
363 olds.region ?? output?.region,
364 env.region,
365 );
366 const nextRegion = normalizeRegion(
367 news.region ?? output?.region,
368 env.region,
369 );
370 const regionChanged = previousRegion !== nextRegion;
371
372 const previousType = addressTypeOf(
373 olds.addressType ?? output?.addressType,
374 );
375 const previousVersion = ipVersionOf(olds.ipVersion ?? output?.ipVersion);
376 const previousTier = networkTierOf(
377 olds.networkTier ?? output?.networkTier,
378 );
379 const previousNetwork = resourceRefOf(olds.network ?? output?.network);
380 const previousSubnetwork = resourceRefOf(
381 olds.subnetwork ?? output?.subnetwork,
382 );
383 const previousPurpose = olds.purpose ?? output?.purpose ?? "";
384 const previousDescription = olds.description ?? output?.description ?? "";
385 const previousPrefix = olds.prefixLength ?? output?.prefixLength;
386 const previousIpv6 =
387 olds.ipv6EndpointType ?? output?.ipv6EndpointType ?? "";
388 const previousCollection = resourceRefOf(
389 olds.ipCollection ?? output?.ipCollection,
390 );
391
392 const immutableChanged =
393 (news.description !== undefined &&
394 (news.description ?? "") !== previousDescription) ||
395 (news.address !== undefined &&
396 output?.address !== undefined &&
397 news.address !== output.address) ||
398 (news.addressType !== undefined &&
399 addressTypeOf(news.addressType) !== previousType) ||
400 (news.ipVersion !== undefined &&
401 ipVersionOf(news.ipVersion) !== previousVersion) ||
402 (news.prefixLength !== undefined &&
403 news.prefixLength !== previousPrefix) ||
404 (news.purpose !== undefined &&
405 (news.purpose ?? "") !== previousPurpose) ||
406 (news.network !== undefined &&
407 resourceRefOf(news.network) !== previousNetwork) ||
408 (news.subnetwork !== undefined &&
409 resourceRefOf(news.subnetwork) !== previousSubnetwork) ||
410 (news.networkTier !== undefined &&
411 networkTierOf(news.networkTier) !== previousTier) ||
412 (news.ipv6EndpointType !== undefined &&
413 (news.ipv6EndpointType ?? "") !== previousIpv6) ||
414 (news.ipCollection !== undefined &&
415 resourceRefOf(news.ipCollection) !== previousCollection);
416
417 if (nameChanged || regionChanged) {
418 return { action: "replace" as const, deleteFirst: false };
419 }
420 if (immutableChanged) {
421 return { action: "replace" as const, deleteFirst: true };
422 }
423 return undefined;
424 }),
425
426 read: Effect.fn(function* ({ id, olds, output }) {
427 const env = yield* GcpEnvironment.current;
428 const addressName = yield* toName(
429 id,
430 olds?.addressName,
431 output?.addressName,
432 );
433 const region = normalizeRegion(
434 olds?.region ?? output?.region,
435 env.region,
436 );
437 const existing = yield* getByName(env.project, region, addressName);
438 if (existing === undefined) return undefined;
439 const attrs = toAttrs(existing, env.project);
440 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
441 ? attrs
442 : Unowned(attrs);
443 }),
444
445 list: () =>
446 Effect.gen(function* () {
447 const env = yield* GcpEnvironment.current;
448 const pages = yield* compute.aggregatedListAddresses
449 .pages({
450 project: env.project,
451 filter: "labels.alchemy-id:*",
452 returnPartialSuccess: true,
453 maxResults: 500,
454 })
455 .pipe(Stream.runCollect);
456 return Array.from(pages).flatMap((page) =>
457 Object.values(page.items ?? {}).flatMap((scoped) =>
458 (scoped?.addresses ?? [])
459 .filter((item) => item.region !== undefined)
460 .filter((item) =>
461 Object.keys(item.labels ?? {}).some((key) =>
462 key.startsWith("alchemy-"),
463 ),
464 )
465 .map((item) => toAttrs(item, env.project)),
466 ),
467 );
468 }),
469
470 reconcile: Effect.fn(function* ({ id, news, output }) {
471 const env = yield* GcpEnvironment.current;
472 const addressName = yield* toName(
473 id,
474 news.addressName,
475 output?.addressName,
476 );
477 const region = normalizeRegion(news.region ?? output?.region, env.region);
478 const desiredLabels = {
479 ...toLabels(news.labels),
480 ...(yield* createInternalLabels(id)),
481 };
482
483 let current = yield* getByName(env.project, region, addressName);
484
485 if (current === undefined) {
486 const created = yield* compute
487 .insertAddresses({
488 project: env.project,
489 region,
490 body: {
491 name: addressName,
492 description: news.description,
493 address: news.address,
494 addressType: news.addressType,
495 ipVersion: news.ipVersion,
496 prefixLength: news.prefixLength,
497 purpose: news.purpose,
498 network: news.network,
499 subnetwork: news.subnetwork,
500 networkTier: news.networkTier,
501 ipv6EndpointType: news.ipv6EndpointType,
502 ipCollection: news.ipCollection,
503 },
504 })
505 .pipe(
506 Effect.flatMap((operation) =>
507 waitRegionOperation(env.project, region, operation, {
508 ignore: ["RESOURCE_ALREADY_EXISTS"],
509 }).pipe(
510 Effect.flatMap(() =>
511 requireAddress(env.project, region, addressName),
512 ),
513 ),
514 ),
515 Effect.catchTag("Conflict", () =>
516 getByName(env.project, region, addressName),
517 ),
518 );
519 current = created ?? undefined;
520 }
521
522 if (current === undefined) {
523 return yield* new AddressNotResolved({ addressName, region });
524 }
525
526 if (current.status === "RESERVING") {
527 current = yield* waitUntilReady(env.project, region, addressName);
528 }
529
530 const resolved = current;
531 const observedLabels = tagRecord(resolved.labels);
532 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
533 if (upsert.length > 0 || removed.length > 0) {
534 yield* Effect.gen(function* () {
535 const latest =
536 (yield* getByName(env.project, region, addressName)) ?? resolved;
537 yield* compute
538 .setLabelsAddresses({
539 project: env.project,
540 region,
541 resource: addressName,
542 body: {
543 labels: desiredLabels,
544 labelFingerprint: latest.labelFingerprint,
545 },
546 })
547 .pipe(
548 Effect.flatMap((operation) =>
549 waitRegionOperation(env.project, region, operation),
550 ),
551 );
552 }).pipe(
553 Effect.retry({
554 while: (e) => e._tag === "Conflict",
555 times: 5,
556 schedule: Schedule.spaced("1 second"),
557 }),
558 );
559 current =
560 (yield* getByName(env.project, region, addressName)) ?? resolved;
561 }
562
563 return toAttrs(current ?? resolved, env.project);
564 }),
565
566 delete: Effect.fn(function* ({ output }) {
567 const env = yield* GcpEnvironment.current;
568 const project = output.project || env.project;
569 const region = normalizeRegion(output.region, env.region);
570 yield* compute
571 .deleteAddresses({
572 project,
573 region,
574 address: output.addressName,
575 })
576 .pipe(
577 Effect.flatMap((operation) =>
578 waitRegionOperation(project, region, operation, {
579 ignore: ["RESOURCE_NOT_FOUND"],
580 }),
581 ),
582 Effect.catchTag("NotFound", () => Effect.void),
583 Effect.retry({
584 while: (e) => e._tag === "Conflict",
585 times: 8,
586 schedule: Schedule.spaced("2 seconds"),
587 }),
588 );
589 yield* waitUntilGone(project, region, output.addressName);
590 }),
591 });