1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitZoneOperation } 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 TargetInstanceNatPolicy = compute.TargetInstanceNatPolicyEnum;
21
22const DEFAULT_ZONE = "us-central1-a";
23const DEFAULT_NAT_POLICY: TargetInstanceNatPolicy = "NO_NAT";
24
25export type TargetInstanceProps = {
26 /**
27 * TargetInstance name (RFC1035, 1-63 characters). If omitted, a unique
28 * name is generated from the stack, stage, and logical id. Changing it
29 * replaces the resource.
30 */
31 targetInstanceName?: string;
32 /**
33 * Zone the target instance lives in (e.g. `us-central1-a`). Must match
34 * the backend VM's zone. Immutable — changing it replaces the resource.
35 * When omitted, Alchemy uses the zone from `instance` if that value is a
36 * URL, otherwise `us-central1-a`.
37 * @default "us-central1-a"
38 */
39 zone?: string;
40 /**
41 * Backend VM that terminates traffic. Accepts an instance name, a
42 * `projects/{project}/zones/{zone}/instances/{instance}` path, or a
43 * self-link. Immutable — changing it replaces the resource.
44 */
45 instance: string;
46 /**
47 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
48 * prefix so `list` / nuke can find resources (Compute TargetInstance has
49 * no labels field). Immutable — changing it replaces the resource.
50 */
51 description?: string;
52 /**
53 * Network used to forward traffic. Accepts a network name
54 * (`default`), a partial URL (`global/networks/default`), or a full
55 * self-link. When omitted, traffic is forwarded on the VM's default
56 * network interface. Immutable — changing it replaces the resource.
57 */
58 network?: string;
59 /**
60 * NAT policy applied to forwarded packets. Must be `NO_NAT` — protocol
61 * forwarding preserves the destination IP of the forwarding rule.
62 * Immutable — changing it replaces the resource.
63 * @default "NO_NAT"
64 */
65 natPolicy?: TargetInstanceNatPolicy | (string & {});
66 /**
67 * Cloud Armor security policy URL. Updated in place via
68 * `setSecurityPolicy`. Pass an empty string to detach.
69 */
70 securityPolicy?: string;
71};
72
73export type TargetInstance = Resource<
74 "GCP.Compute.TargetInstance",
75 TargetInstanceProps,
76 {
77 /** TargetInstance name. */
78 targetInstanceName: string;
79 /** Project id. */
80 project: string;
81 /** Zone short name (e.g. `us-central1-a`). */
82 zone: string;
83 /** User description with the Alchemy ownership prefix stripped. */
84 description: string | undefined;
85 /** URL of the backend VM. */
86 instance: string;
87 /** Network URL used for forwarding, if set. */
88 network: string | undefined;
89 /** NAT policy (`NO_NAT`). */
90 natPolicy: string | undefined;
91 /** Attached Cloud Armor security policy URL, if any. */
92 securityPolicy: string | undefined;
93 /** Server-defined URL. */
94 selfLink: string | undefined;
95 /** Server-assigned numeric id. */
96 targetInstanceId: string | undefined;
97 /** RFC3339 creation timestamp. */
98 creationTimestamp: string | undefined;
99 /** Resource kind. */
100 kind: string | undefined;
101 },
102 never,
103 Providers
104>;
105
106/**
107 * A zonal Compute Engine target instance.
108 *
109 * Target instances terminate protocol-forwarding traffic (ESP, AH, TCP,
110 * UDP) for one or more forwarding rules. The backend VM should enable IP
111 * forwarding (`canIpForward`). Compute TargetInstance has no labels field —
112 * Alchemy ownership is stored in the description so nuke can find leaked
113 * resources. Name, zone, instance, network, NAT policy, and description are
114 * immutable; only `securityPolicy` updates in place.
115 *
116 * ### Creating a Target Instance
117 * **Example:** Generated name in front of a VM
118 * ```typescript
119 * const vm = yield* GCP.Compute.Instance("web", {
120 * zone: "us-central1-a",
121 * canIpForward: true,
122 * });
123 * const target = yield* GCP.Compute.TargetInstance("protocol", {
124 * instance: vm.instanceName,
125 * zone: vm.zone,
126 * });
127 * ```
128 *
129 * **Example:** Explicit name, description, and network
130 * ```typescript
131 * const target = yield* GCP.Compute.TargetInstance("protocol", {
132 * targetInstanceName: "app-protocol",
133 * description: "esp frontend",
134 * instance: vm.selfLink,
135 * zone: "us-central1-a",
136 * network: "default",
137 * natPolicy: "NO_NAT",
138 * });
139 * ```
140 *
141 * @resource
142 * @category Compute
143 */
144export const TargetInstance = Resource<TargetInstance>(
145 "GCP.Compute.TargetInstance",
146);
147
148export class TargetInstanceNotResolved extends Data.TaggedError(
149 "GCP.Compute.TargetInstanceNotResolved",
150)<{
151 targetInstanceName: string;
152 zone: string;
153}> {}
154
155export class TargetInstanceStillExists extends Data.TaggedError(
156 "GCP.Compute.TargetInstanceStillExists",
157)<{
158 targetInstanceName: string;
159 zone: string;
160}> {}
161
162const lastSegment = (value: string | undefined): string => {
163 if (value === undefined || value.length === 0) return "";
164 const parts = value.split("/").filter((part) => part.length > 0);
165 return parts[parts.length - 1] ?? "";
166};
167
168const zoneFromUrl = (value: string | undefined): string | undefined => {
169 if (value === undefined || value.length === 0) return undefined;
170 const parts = value.split("/").filter((part) => part.length > 0);
171 const index = parts.indexOf("zones");
172 if (index >= 0 && parts[index + 1] !== undefined) {
173 return parts[index + 1];
174 }
175 return undefined;
176};
177
178const rfc1035 = (name: string): string => {
179 let next = name
180 .toLowerCase()
181 .replace(/[^a-z0-9-]/g, "-")
182 .replace(/-+/g, "-")
183 .replace(/^-+|-+$/g, "");
184 if (!/^[a-z]/.test(next)) next = `t${next}`;
185 next = next.slice(0, 63).replace(/-+$/g, "");
186 return next.length > 0 ? next : "target";
187};
188
189const toName = (id: string, name: string | undefined, existing?: string) =>
190 Effect.gen(function* () {
191 if (name !== undefined) return name;
192 if (existing !== undefined) return existing;
193 return rfc1035(
194 yield* createPhysicalName({
195 id,
196 maxLength: 63,
197 lowercase: true,
198 }),
199 );
200 });
201
202const encodeDescription = (
203 labels: Record<string, string>,
204 description: string | undefined,
205): string => {
206 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
207 return description ? `${marker}\n${description}` : marker;
208};
209
210const parseDescription = (
211 description: string | undefined,
212): {
213 labels: Record<string, string>;
214 description: string | undefined;
215} => {
216 if (!description?.startsWith("[alchemy ")) {
217 return { labels: {}, description };
218 }
219 const end = description.indexOf("]");
220 if (end < 0) return { labels: {}, description };
221 const labels: Record<string, string> = {};
222 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
223 const eq = part.indexOf("=");
224 if (eq > 0) {
225 labels[part.slice(0, eq)] = part.slice(eq + 1);
226 }
227 }
228 const rest = description.slice(end + 1).replace(/^\n/, "");
229 return { labels, description: rest.length > 0 ? rest : undefined };
230};
231
232const toInstanceRef = (project: string, zone: string, instance: string) => {
233 if (instance.includes("/")) return instance;
234 return `projects/${project}/zones/${zone}/instances/${instance}`;
235};
236
237const toNetworkRef = (project: string, network: string | undefined) => {
238 if (network === undefined || network.length === 0) return undefined;
239 if (network.includes("/")) {
240 return network.startsWith("projects/") || network.startsWith("http")
241 ? network
242 : `projects/${project}/${network.replace(/^\//, "")}`;
243 }
244 return `projects/${project}/global/networks/${network}`;
245};
246
247const resolveZone = (
248 news: { zone?: string; instance?: string },
249 fallback?: string,
250) =>
251 lastSegment(news.zone) ||
252 zoneFromUrl(news.instance) ||
253 lastSegment(fallback) ||
254 DEFAULT_ZONE;
255
256const toAttrs = (target: compute.TargetInstance, project: string) => {
257 const parsed = parseDescription(target.description);
258 return {
259 targetInstanceName: target.name ?? target.id ?? "",
260 project,
261 zone: lastSegment(target.zone) || zoneFromUrl(target.selfLink) || "",
262 description: parsed.description,
263 instance: target.instance ?? "",
264 network: target.network,
265 natPolicy: target.natPolicy,
266 securityPolicy: target.securityPolicy,
267 selfLink: target.selfLink,
268 targetInstanceId: target.id,
269 creationTimestamp: target.creationTimestamp,
270 kind: target.kind,
271 };
272};
273
274const getByName = (project: string, zone: string, targetInstance: string) =>
275 compute
276 .getTargetInstances({ project, zone, targetInstance })
277 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
278
279const waitTargetGone = (
280 project: string,
281 zone: string,
282 targetInstanceName: string,
283) =>
284 getByName(project, zone, targetInstanceName).pipe(
285 Effect.flatMap((existing) =>
286 existing === undefined
287 ? Effect.void
288 : Effect.fail(
289 new TargetInstanceStillExists({ targetInstanceName, zone }),
290 ),
291 ),
292 Effect.retry({
293 while: (error) => error._tag === "GCP.Compute.TargetInstanceStillExists",
294 times: 8,
295 schedule: Schedule.spaced("1 second"),
296 }),
297 );
298
299const requireTarget = (
300 project: string,
301 zone: string,
302 targetInstanceName: string,
303) =>
304 getByName(project, zone, targetInstanceName).pipe(
305 Effect.flatMap((existing) =>
306 existing !== undefined
307 ? Effect.succeed(existing)
308 : Effect.fail(
309 new TargetInstanceNotResolved({ targetInstanceName, zone }),
310 ),
311 ),
312 Effect.retry({
313 while: (error) => error._tag === "GCP.Compute.TargetInstanceNotResolved",
314 schedule: Schedule.spaced("1 second"),
315 times: 8,
316 }),
317 );
318
319export const TargetInstanceProvider = () =>
320 Provider.succeed(TargetInstance, {
321 stables: [
322 "targetInstanceName",
323 "project",
324 "zone",
325 "instance",
326 "network",
327 "natPolicy",
328 "targetInstanceId",
329 "selfLink",
330 "creationTimestamp",
331 ],
332
333 diff: Effect.fn(function* ({ news, olds, output }) {
334 if (!isResolved(news)) return undefined;
335 const previousName =
336 olds?.targetInstanceName ?? output?.targetInstanceName;
337 const nextName = news.targetInstanceName ?? previousName;
338 const previousZone = resolveZone(
339 { zone: olds?.zone ?? output?.zone, instance: olds?.instance },
340 output?.zone,
341 );
342 const nextZone = resolveZone(news, previousZone);
343 const previousInstance = lastSegment(olds?.instance ?? output?.instance);
344 const nextInstance = lastSegment(news.instance);
345 const previousNetwork = lastSegment(olds?.network ?? output?.network);
346 const nextNetwork =
347 news.network !== undefined
348 ? lastSegment(news.network)
349 : previousNetwork;
350 const previousNat =
351 olds?.natPolicy ?? output?.natPolicy ?? DEFAULT_NAT_POLICY;
352 const nextNat = news.natPolicy ?? DEFAULT_NAT_POLICY;
353 const previousDescription =
354 olds?.description ?? output?.description ?? "";
355 const nextDescription = news.description ?? "";
356
357 const replace =
358 (previousName !== undefined &&
359 nextName !== undefined &&
360 previousName !== nextName) ||
361 previousZone !== nextZone ||
362 (previousInstance.length > 0 &&
363 nextInstance.length > 0 &&
364 previousInstance !== nextInstance) ||
365 (news.network !== undefined && previousNetwork !== nextNetwork) ||
366 previousNat !== nextNat ||
367 previousDescription !== nextDescription;
368
369 if (!replace) return undefined;
370 return {
371 action: "replace" as const,
372 deleteFirst:
373 previousName !== undefined &&
374 nextName !== undefined &&
375 previousName === nextName,
376 };
377 }),
378
379 read: Effect.fn(function* ({ id, olds, output }) {
380 const env = yield* GcpEnvironment.current;
381 const targetInstanceName = yield* toName(
382 id,
383 olds?.targetInstanceName,
384 output?.targetInstanceName,
385 );
386 const zone = resolveZone(
387 { zone: olds?.zone ?? output?.zone, instance: olds?.instance },
388 output?.zone,
389 );
390 const existing = yield* getByName(env.project, zone, targetInstanceName);
391 if (existing === undefined) return undefined;
392 const attrs = toAttrs(existing, env.project);
393 const { labels } = parseDescription(existing.description);
394 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
395 }),
396
397 list: () =>
398 Effect.gen(function* () {
399 const env = yield* GcpEnvironment.current;
400 const pages = yield* compute.aggregatedListTargetInstances
401 .pages({
402 project: env.project,
403 returnPartialSuccess: true,
404 maxResults: 500,
405 })
406 .pipe(Stream.runCollect);
407 return Array.from(pages).flatMap((page) =>
408 Object.values(page.items ?? {}).flatMap((scoped) =>
409 (scoped?.targetInstances ?? [])
410 .filter((item) => {
411 const { labels } = parseDescription(item.description);
412 return Object.keys(labels).some((key) =>
413 key.startsWith("alchemy-"),
414 );
415 })
416 .map((item) => toAttrs(item, env.project)),
417 ),
418 );
419 }),
420
421 reconcile: Effect.fn(function* ({ id, news, output }) {
422 const env = yield* GcpEnvironment.current;
423 const targetInstanceName = yield* toName(
424 id,
425 news.targetInstanceName,
426 output?.targetInstanceName,
427 );
428 const zone = resolveZone(news, output?.zone);
429 const ownership = yield* createInternalLabels(id);
430 const desiredDescription = encodeDescription(ownership, news.description);
431 const desiredInstance = toInstanceRef(env.project, zone, news.instance);
432 const desiredNetwork = toNetworkRef(env.project, news.network);
433 const desiredNat = news.natPolicy ?? DEFAULT_NAT_POLICY;
434
435 let current = yield* getByName(env.project, zone, targetInstanceName);
436
437 if (current === undefined) {
438 const body: compute.TargetInstance = {
439 name: targetInstanceName,
440 description: desiredDescription,
441 instance: desiredInstance,
442 natPolicy: desiredNat,
443 };
444 if (desiredNetwork !== undefined) {
445 body.network = desiredNetwork;
446 }
447 yield* compute
448 .insertTargetInstances({
449 project: env.project,
450 zone,
451 body,
452 })
453 .pipe(
454 Effect.flatMap((operation) =>
455 waitZoneOperation(env.project, zone, operation),
456 ),
457 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
458 );
459 current = yield* requireTarget(env.project, zone, targetInstanceName);
460 }
461
462 if (current === undefined) {
463 return yield* new TargetInstanceNotResolved({
464 targetInstanceName,
465 zone,
466 });
467 }
468
469 if (news.securityPolicy !== undefined) {
470 const observedPolicy = lastSegment(current.securityPolicy);
471 const desiredPolicy = lastSegment(news.securityPolicy);
472 if (observedPolicy !== desiredPolicy) {
473 yield* compute
474 .setSecurityPolicyTargetInstances({
475 project: env.project,
476 zone,
477 targetInstance: targetInstanceName,
478 body: { securityPolicy: news.securityPolicy },
479 })
480 .pipe(
481 Effect.flatMap((operation) =>
482 waitZoneOperation(env.project, zone, operation),
483 ),
484 );
485 current = yield* requireTarget(env.project, zone, targetInstanceName);
486 }
487 }
488
489 return toAttrs(current, env.project);
490 }),
491
492 delete: Effect.fn(function* ({ output }) {
493 const env = yield* GcpEnvironment.current;
494 const zone = lastSegment(output.zone) || DEFAULT_ZONE;
495 const operation = yield* compute
496 .deleteTargetInstances({
497 project: env.project,
498 zone,
499 targetInstance: output.targetInstanceName,
500 })
501 .pipe(
502 Effect.retry({
503 while: (error) => error._tag === "Conflict",
504 times: 8,
505 schedule: Schedule.spaced("2 seconds"),
506 }),
507 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
508 );
509 if (operation !== undefined) {
510 yield* waitZoneOperation(env.project, zone, operation, {
511 ignore: ["RESOURCE_NOT_FOUND"],
512 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
513 }
514 yield* waitTargetGone(env.project, zone, output.targetInstanceName);
515 }),
516 });