1import * as apigee from "@distilled.cloud/gcp/apigee_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 { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 lastSegment,
13 orgParent,
14 organizationFromName,
15 toResourceId,
16} from "./names.ts";
17import { waitForOperation } from "./operations.ts";
18import { listOwnedInstances } from "./org.ts";
19
20const MAX_NAME_LENGTH = 32;
21
22export type InstancesNatAddressProps = {
23 /**
24 * Apigee organization id. Defaults to the current GCP project id.
25 * Immutable — changing it replaces the NAT address.
26 */
27 organization?: string;
28 /**
29 * Parent instance id or full name
30 * (`organizations/{org}/instances/{instance}`). Immutable — changing it
31 * replaces the NAT address.
32 */
33 instance: string;
34 /**
35 * NAT address id (the `{nataddress}` segment of
36 * `organizations/{org}/instances/{instance}/natAddresses/{nataddress}`).
37 * If omitted, a unique name is generated from the stack, stage, and
38 * logical id. Immutable — changing it replaces the NAT address.
39 */
40 natAddressId?: string;
41 /**
42 * When true, activate the address after it reaches `RESERVED` so the
43 * instance uses it for Internet egress. Activation is one-way.
44 * @default false
45 */
46 activate?: boolean;
47};
48
49export type InstancesNatAddress = Resource<
50 "GCP.Apigee.InstancesNatAddress",
51 InstancesNatAddressProps,
52 {
53 /** Full resource name `organizations/{org}/instances/{instance}/natAddresses/{nataddress}`. */
54 name: string;
55 /** NAT address id (last path segment). */
56 natAddressId: string;
57 /** Parent instance id. */
58 instanceId: string;
59 /** Apigee organization id. */
60 organization: string;
61 /** Provisioned static IPv4 address, if assigned. */
62 ipAddress: string | undefined;
63 /** Server-reported state (`CREATING`, `RESERVED`, `ACTIVE`, `DELETING`). */
64 state: string | undefined;
65 },
66 never,
67 Providers
68>;
69
70/**
71 * A static NAT address on an Apigee runtime instance.
72 *
73 * NAT addresses have no labels or description. `list` enumerates
74 * addresses on alchemy-owned instances so `pnpm nuke:gcp` can find
75 * leaked rows. Organization, instance, and name are identity — changing
76 * them replaces the address. `activate` updates in place (RESERVED →
77 * ACTIVE) and cannot be reversed.
78 *
79 * ### Creating a NAT Address
80 * **Example:** Reserve a NAT address on a runtime instance
81 * ```typescript
82 * const runtime = yield* GCP.Apigee.Instance("Runtime", {});
83 * const nat = yield* GCP.Apigee.InstancesNatAddress("Egress", {
84 * instance: runtime.name,
85 * });
86 * ```
87 *
88 * **Example:** Reserve and activate
89 * ```typescript
90 * const nat = yield* GCP.Apigee.InstancesNatAddress("Egress", {
91 * instance: runtime.name,
92 * natAddressId: "app-egress",
93 * activate: true,
94 * });
95 * ```
96 *
97 * @resource
98 * @category Apigee
99 */
100export const InstancesNatAddress = Resource<InstancesNatAddress>(
101 "GCP.Apigee.InstancesNatAddress",
102);
103
104export class InstancesNatAddressNotResolved extends Data.TaggedError(
105 "GCP.Apigee.InstancesNatAddressNotResolved",
106)<{
107 name: string;
108}> {}
109
110export class InstancesNatAddressNotReady extends Data.TaggedError(
111 "GCP.Apigee.InstancesNatAddressNotReady",
112)<{
113 name: string;
114 state: string;
115}> {}
116
117const instanceIdOf = (instance: string) => lastSegment(instance);
118
119const instanceName = (organization: string, instance: string) =>
120 instance.includes("/")
121 ? instance
122 : `${orgParent(organization)}/instances/${instance}`;
123
124const resourceName = (
125 organization: string,
126 instance: string,
127 natAddressId: string,
128) => `${instanceName(organization, instance)}/natAddresses/${natAddressId}`;
129
130const toAttrs = (
131 address: apigee.GoogleCloudApigeeV1NatAddress,
132 organization: string,
133 instanceId: string,
134) => {
135 const raw = address.name ?? "";
136 const name = raw.includes("/")
137 ? raw
138 : resourceName(organization, instanceId, raw);
139 return {
140 name,
141 natAddressId: lastSegment(name),
142 instanceId,
143 organization: organizationFromName(name) ?? organization,
144 ipAddress: address.ipAddress,
145 state: address.state,
146 };
147};
148
149const getByName = (name: string) =>
150 apigee
151 .getOrganizationsInstancesNatAddresses({ name })
152 .pipe(
153 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
154 Effect.succeed(undefined),
155 ),
156 );
157
158const listByInstance = (parent: string) =>
159 apigee.listOrganizationsInstancesNatAddresses
160 .pages({
161 parent,
162 pageSize: 1000,
163 })
164 .pipe(
165 Stream.flatMap((page) => Stream.fromIterable(page.natAddresses ?? [])),
166 Stream.runCollect,
167 Effect.map((chunk) => Array.from(chunk)),
168 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
169 Effect.succeed([] as apigee.GoogleCloudApigeeV1NatAddress[]),
170 ),
171 );
172
173const waitUntilState = (name: string, allowed: ReadonlySet<string>) =>
174 getByName(name).pipe(
175 Effect.filterOrFail(
176 (address): address is apigee.GoogleCloudApigeeV1NatAddress =>
177 address !== undefined,
178 () => new InstancesNatAddressNotResolved({ name }),
179 ),
180 Effect.filterOrFail(
181 (address) => allowed.has(address.state ?? "STATE_UNSPECIFIED"),
182 (address) =>
183 new InstancesNatAddressNotReady({
184 name,
185 state: address.state ?? "STATE_UNSPECIFIED",
186 }),
187 ),
188 Effect.retry({
189 while: (error) =>
190 error._tag === "GCP.Apigee.InstancesNatAddressNotReady" ||
191 error._tag === "GCP.Apigee.InstancesNatAddressNotResolved",
192 times: 8,
193 schedule: Schedule.spaced("5 seconds"),
194 }),
195 );
196
197export const InstancesNatAddressProvider = () =>
198 Provider.succeed(InstancesNatAddress, {
199 stables: ["name", "natAddressId", "instanceId", "organization"],
200
201 diff: Effect.fn(function* ({ news, olds, output }) {
202 if (!isResolved(news)) return undefined;
203 const previousId = olds?.natAddressId ?? output?.natAddressId;
204 const previousInstance = olds?.instance ?? output?.instanceId;
205 const previousOrg = olds?.organization ?? output?.organization;
206 if (
207 (previousId !== undefined &&
208 news.natAddressId !== undefined &&
209 news.natAddressId !== previousId) ||
210 (previousInstance !== undefined &&
211 instanceIdOf(news.instance) !== instanceIdOf(previousInstance)) ||
212 (previousOrg !== undefined &&
213 news.organization !== undefined &&
214 news.organization !== previousOrg)
215 ) {
216 return { action: "replace" as const, deleteFirst: true };
217 }
218 return undefined;
219 }),
220
221 read: Effect.fn(function* ({ id, olds, output }) {
222 const env = yield* GcpEnvironment.current;
223 const organization =
224 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
225 const instanceId = instanceIdOf(
226 olds?.instance ?? output?.instanceId ?? "",
227 );
228 if (instanceId.length === 0) return undefined;
229 const natAddressId = yield* toResourceId(
230 id,
231 olds?.natAddressId,
232 output?.natAddressId,
233 MAX_NAME_LENGTH,
234 );
235 const name =
236 output?.name ?? resourceName(organization, instanceId, natAddressId);
237 const existing = yield* getByName(name);
238 if (existing === undefined) return undefined;
239 return toAttrs(existing, organization, instanceId);
240 }),
241
242 list: () =>
243 Effect.gen(function* () {
244 const env = yield* GcpEnvironment.current;
245 const instances = yield* listOwnedInstances(env.project);
246 const pages = yield* Effect.forEach(
247 instances,
248 (instance) => {
249 const parent = instance.name?.includes("/")
250 ? instance.name
251 : `${orgParent(env.project)}/instances/${lastSegment(instance.name ?? "")}`;
252 const instanceId = lastSegment(parent);
253 return listByInstance(parent).pipe(
254 Effect.map((addresses) =>
255 addresses.map((address) =>
256 toAttrs(address, env.project, instanceId),
257 ),
258 ),
259 );
260 },
261 { concurrency: 4 },
262 );
263 return pages.flat();
264 }),
265
266 reconcile: Effect.fn(function* ({ id, news, output }) {
267 const env = yield* GcpEnvironment.current;
268 const organization =
269 news.organization ?? output?.organization ?? env.project;
270 const instanceId = instanceIdOf(news.instance);
271 const natAddressId = yield* toResourceId(
272 id,
273 news.natAddressId,
274 output?.natAddressId,
275 MAX_NAME_LENGTH,
276 );
277 const parent = instanceName(organization, instanceId);
278 const name = resourceName(organization, instanceId, natAddressId);
279
280 let current = yield* getByName(output?.name ?? name);
281
282 if (current === undefined) {
283 const created = yield* apigee
284 .createOrganizationsInstancesNatAddresses({
285 parent,
286 body: { name: natAddressId },
287 })
288 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
289 if (created !== undefined) {
290 yield* waitForOperation(created, { alreadyExistsOk: true });
291 }
292 current = yield* getByName(name);
293 }
294
295 if (current === undefined) {
296 return yield* new InstancesNatAddressNotResolved({ name });
297 }
298
299 if ((current.state ?? "") === "CREATING") {
300 current =
301 (yield* waitUntilState(name, new Set(["RESERVED", "ACTIVE"])).pipe(
302 Effect.catchTag("GCP.Apigee.InstancesNatAddressNotReady", () =>
303 getByName(name),
304 ),
305 )) ?? current;
306 }
307
308 if (news.activate === true && (current.state ?? "") === "RESERVED") {
309 const activated =
310 yield* apigee.activateOrganizationsInstancesNatAddresses({
311 name: current.name?.includes("/") ? current.name : name,
312 body: {},
313 });
314 yield* waitForOperation(activated);
315 current = (yield* getByName(name)) ?? current;
316 }
317
318 return toAttrs(current, organization, instanceId);
319 }),
320
321 delete: Effect.fn(function* ({ output }) {
322 const deleted = yield* apigee
323 .deleteOrganizationsInstancesNatAddresses({ name: output.name })
324 .pipe(
325 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
326 Effect.succeed(undefined),
327 ),
328 );
329 if (deleted !== undefined) {
330 yield* waitForOperation(deleted, { notFoundOk: true });
331 }
332 }),
333 });