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 NamedPort = {
21 /** RFC1035 name for this port mapping (e.g. `"http"`). */
22 name: string;
23 /** TCP port number (`1`–`65535`). */
24 port: number;
25};
26
27export type InstanceGroupProps = {
28 /**
29 * Name of the instance group. Must be 1–63 characters and comply with
30 * RFC1035. If omitted, a unique name is generated from the stack, stage,
31 * and logical id. Changing this replaces the group.
32 */
33 instanceGroupName?: string;
34 /**
35 * Zone of the instance group (e.g. `"us-central1-a"`). Immutable —
36 * changing it replaces the group.
37 * @default "us-central1-a"
38 */
39 zone?: string;
40 /**
41 * Optional description. Unmanaged instance groups have no labels API, so
42 * Alchemy stamps ownership (`alchemy-stack` / `alchemy-stage` /
43 * `alchemy-id`) into this field for `list` / nuke. Changing the
44 * user-facing description replaces the group.
45 */
46 description?: string;
47 /**
48 * VPC network URL or name (`default`, `global/networks/default`, or
49 * `projects/{project}/global/networks/{name}`). Immutable — changing it
50 * replaces the group.
51 */
52 network?: string;
53 /**
54 * Named ports applied to every instance in the group.
55 */
56 namedPorts?: NamedPort[];
57 /**
58 * Member instance URLs or names. When omitted, membership is left as-is.
59 * When set (including `[]`), observed members are added/removed to match.
60 */
61 instances?: string[];
62};
63
64export type InstanceGroup = Resource<
65 "GCP.Compute.InstanceGroup",
66 InstanceGroupProps,
67 {
68 /** Instance group name. */
69 instanceGroupName: string;
70 /** Zone (short name, e.g. `"us-central1-a"`). */
71 zone: string;
72 /** Project id. */
73 project: string;
74 /** User-facing description (ownership marker stripped). */
75 description: string | undefined;
76 /** Network URL, if set. */
77 network: string | undefined;
78 /** Subnetwork URL, if set. */
79 subnetwork: string | undefined;
80 /** Named ports currently configured on the group. */
81 namedPorts: NamedPort[];
82 /** Number of member instances. */
83 size: number;
84 /** Server-generated resource URL. */
85 selfLink: string | undefined;
86 /** Server-generated numeric id. */
87 id: string | undefined;
88 /** RFC3339 creation timestamp. */
89 creationTimestamp: string | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A zonal unmanaged Compute Engine instance group.
97 *
98 * Unmanaged groups hold an explicit list of VMs and optional named ports
99 * for load balancing. They have no labels API — Alchemy records ownership
100 * in the description so `list` / `pnpm nuke:gcp` can find them.
101 *
102 * ### Creating an Instance Group
103 * **Example:** Generated name
104 * ```typescript
105 * const group = yield* GCP.Compute.InstanceGroup("web", {
106 * namedPorts: [{ name: "http", port: 80 }],
107 * });
108 * ```
109 *
110 * **Example:** Explicit name, zone, and named ports
111 * ```typescript
112 * const group = yield* GCP.Compute.InstanceGroup("web", {
113 * instanceGroupName: "web-backends",
114 * zone: "us-central1-a",
115 * description: "HTTP backends",
116 * namedPorts: [
117 * { name: "http", port: 80 },
118 * { name: "https", port: 443 },
119 * ],
120 * });
121 * ```
122 *
123 * @resource
124 * @category Compute
125 */
126export const InstanceGroup = Resource<InstanceGroup>(
127 "GCP.Compute.InstanceGroup",
128);
129
130export class InstanceGroupNotResolved extends Data.TaggedError(
131 "GCP.Compute.InstanceGroupNotResolved",
132)<{
133 instanceGroupName: string;
134 zone: string;
135}> {}
136
137export class InstanceGroupStillExists extends Data.TaggedError(
138 "GCP.Compute.InstanceGroupStillExists",
139)<{
140 instanceGroupName: string;
141 zone: string;
142}> {}
143
144const DEFAULT_ZONE = "us-central1-a";
145
146const lastSegment = (value: string) => {
147 const parts = value.split("/").filter((part) => part.length > 0);
148 return parts[parts.length - 1] ?? value;
149};
150
151const rfc1035Name = (name: string) => {
152 let next = name
153 .toLowerCase()
154 .replace(/[^a-z0-9-]/g, "-")
155 .replace(/-+/g, "-")
156 .replace(/^-+|-+$/g, "");
157 if (!/^[a-z]/.test(next)) {
158 next = `g${next}`;
159 }
160 next = next.slice(0, 63).replace(/-+$/g, "");
161 return next.length > 0 ? next : "group";
162};
163
164const toName = (id: string, name: string | undefined, existing?: string) =>
165 Effect.gen(function* () {
166 return rfc1035Name(
167 name ??
168 existing ??
169 (yield* createPhysicalName({
170 id,
171 maxLength: 63,
172 lowercase: true,
173 })),
174 );
175 });
176
177const encodeDescription = (
178 user: string | undefined,
179 labels: Record<string, string>,
180) => {
181 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
182 const trimmed = user?.trim();
183 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
184};
185
186const parseDescription = (description: string | undefined) => {
187 if (!description?.startsWith("[alchemy ")) {
188 return { user: description, labels: {} as Record<string, string> };
189 }
190 const end = description.indexOf("]");
191 if (end < 0) {
192 return { user: description, labels: {} as Record<string, string> };
193 }
194 const labels: Record<string, string> = {};
195 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
196 const eq = part.indexOf("=");
197 if (eq > 0) {
198 labels[part.slice(0, eq)] = part.slice(eq + 1);
199 }
200 }
201 const rest = description
202 .slice(end + 1)
203 .replace(/^\n/, "")
204 .trim();
205 return {
206 user: rest.length > 0 ? rest : undefined,
207 labels,
208 };
209};
210
211const hasOwnershipMarker = (description: string | undefined) =>
212 Object.keys(parseDescription(description).labels).some((key) =>
213 key.startsWith("alchemy-"),
214 );
215
216const toNetworkUrl = (project: string, network: string | undefined) => {
217 if (network === undefined || network.length === 0) return undefined;
218 if (network.includes("/")) return network;
219 return `projects/${project}/global/networks/${network}`;
220};
221
222const toInstanceUrl = (project: string, zone: string, instance: string) =>
223 instance.includes("/")
224 ? instance
225 : `projects/${project}/zones/${zone}/instances/${instance}`;
226
227const canonPorts = (ports: readonly NamedPort[] | undefined): NamedPort[] =>
228 [...(ports ?? [])]
229 .map((port) => ({ name: port.name, port: port.port }))
230 .sort((a, b) => a.name.localeCompare(b.name) || a.port - b.port);
231
232const samePorts = (
233 left: readonly NamedPort[] | undefined,
234 right: readonly NamedPort[] | undefined,
235) => JSON.stringify(canonPorts(left)) === JSON.stringify(canonPorts(right));
236
237const fromApiPorts = (
238 ports: readonly compute.NamedPort[] | undefined,
239): NamedPort[] =>
240 canonPorts(
241 (ports ?? [])
242 .filter(
243 (port): port is { name: string; port: number } =>
244 typeof port.name === "string" && typeof port.port === "number",
245 )
246 .map((port) => ({ name: port.name, port: port.port })),
247 );
248
249const toAttrs = (group: compute.InstanceGroup, project: string) => {
250 const { user } = parseDescription(group.description);
251 return {
252 instanceGroupName: group.name ?? lastSegment(group.selfLink ?? ""),
253 zone: lastSegment(group.zone ?? DEFAULT_ZONE),
254 project,
255 description: user,
256 network: group.network,
257 subnetwork: group.subnetwork,
258 namedPorts: fromApiPorts(group.namedPorts),
259 size: group.size ?? 0,
260 selfLink: group.selfLink,
261 id: group.id,
262 creationTimestamp: group.creationTimestamp,
263 };
264};
265
266const getByName = (project: string, zone: string, instanceGroup: string) =>
267 compute
268 .getInstanceGroups({ project, zone, instanceGroup })
269 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
270
271const listMembers = (project: string, zone: string, instanceGroup: string) =>
272 compute.listInstancesInstanceGroups
273 .items({
274 project,
275 zone,
276 instanceGroup,
277 body: { instanceState: "ALL" },
278 })
279 .pipe(
280 Stream.runCollect,
281 Effect.map((chunk) =>
282 Array.from(chunk)
283 .map((item) => item.instance)
284 .filter((url): url is string => typeof url === "string"),
285 ),
286 Effect.catchTag("NotFound", () => Effect.succeed([] as string[])),
287 );
288
289const waitGone = (project: string, zone: string, instanceGroupName: string) =>
290 getByName(project, zone, instanceGroupName).pipe(
291 Effect.flatMap((group) =>
292 group === undefined
293 ? Effect.void
294 : Effect.fail(
295 new InstanceGroupStillExists({ instanceGroupName, zone }),
296 ),
297 ),
298 Effect.retry({
299 while: (error) => error._tag === "GCP.Compute.InstanceGroupStillExists",
300 times: 8,
301 schedule: Schedule.spaced("1 second"),
302 }),
303 );
304
305const runOp = <E, R>(
306 project: string,
307 zone: string,
308 start: Effect.Effect<compute.Operation, E, R>,
309) =>
310 start.pipe(
311 Effect.flatMap((operation) =>
312 waitZoneOperation(project, zone, operation, {
313 ignore: ["RESOURCE_ALREADY_EXISTS"],
314 }),
315 ),
316 );
317
318export const InstanceGroupProvider = () =>
319 Provider.succeed(InstanceGroup, {
320 stables: [
321 "instanceGroupName",
322 "zone",
323 "project",
324 "id",
325 "selfLink",
326 "creationTimestamp",
327 ],
328
329 diff: Effect.fn(function* ({ news, olds, output }) {
330 if (!isResolved(news)) return undefined;
331 const previousName = olds?.instanceGroupName ?? output?.instanceGroupName;
332 const nextName = news.instanceGroupName ?? previousName;
333 const previousZone = olds?.zone ?? output?.zone;
334 const nextZone = news.zone ?? DEFAULT_ZONE;
335 const previousNetwork = olds?.network ?? output?.network;
336 const nextNetwork = news.network;
337 const nameChanged =
338 previousName !== undefined &&
339 nextName !== undefined &&
340 previousName !== nextName;
341 const zoneChanged =
342 previousZone !== undefined &&
343 lastSegment(previousZone) !== lastSegment(nextZone);
344 const networkChanged =
345 nextNetwork !== undefined &&
346 previousNetwork !== undefined &&
347 lastSegment(previousNetwork) !== lastSegment(nextNetwork);
348 const descriptionChanged =
349 olds !== undefined &&
350 (olds.description ?? "") !== (news.description ?? "");
351 if (
352 !nameChanged &&
353 !zoneChanged &&
354 !networkChanged &&
355 !descriptionChanged
356 ) {
357 return undefined;
358 }
359 const sameIdentity =
360 previousName === nextName &&
361 lastSegment(previousZone ?? "") === lastSegment(nextZone);
362 return {
363 action: "replace" as const,
364 deleteFirst: sameIdentity,
365 };
366 }),
367
368 read: Effect.fn(function* ({ id, olds, output }) {
369 const env = yield* GcpEnvironment.current;
370 const instanceGroupName = yield* toName(
371 id,
372 olds?.instanceGroupName,
373 output?.instanceGroupName,
374 );
375 const zone = lastSegment(olds?.zone ?? output?.zone ?? DEFAULT_ZONE);
376 const existing = yield* getByName(env.project, zone, instanceGroupName);
377 if (existing === undefined) return undefined;
378 const attrs = toAttrs(existing, env.project);
379 const { labels } = parseDescription(existing.description);
380 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
381 }),
382
383 list: () =>
384 Effect.gen(function* () {
385 const env = yield* GcpEnvironment.current;
386 const pages = yield* compute.aggregatedListInstanceGroups
387 .pages({
388 project: env.project,
389 maxResults: 500,
390 returnPartialSuccess: true,
391 })
392 .pipe(Stream.runCollect);
393 return Array.from(pages).flatMap((page) =>
394 Object.values(page.items ?? {}).flatMap((scoped) =>
395 (scoped?.instanceGroups ?? [])
396 .filter((group) => hasOwnershipMarker(group.description))
397 .map((group) => toAttrs(group, env.project)),
398 ),
399 );
400 }),
401
402 reconcile: Effect.fn(function* ({ id, news, output }) {
403 const env = yield* GcpEnvironment.current;
404 const instanceGroupName = yield* toName(
405 id,
406 news.instanceGroupName,
407 output?.instanceGroupName,
408 );
409 const zone = lastSegment(news.zone ?? output?.zone ?? DEFAULT_ZONE);
410 const namedPorts = canonPorts(news.namedPorts);
411 const network = toNetworkUrl(env.project, news.network);
412 const desiredLabels = yield* createInternalLabels(id);
413 const description = encodeDescription(news.description, desiredLabels);
414
415 let current = yield* getByName(env.project, zone, instanceGroupName);
416
417 if (current === undefined) {
418 yield* compute
419 .insertInstanceGroups({
420 project: env.project,
421 zone,
422 body: {
423 name: instanceGroupName,
424 description,
425 namedPorts: namedPorts.length > 0 ? namedPorts : undefined,
426 network,
427 },
428 })
429 .pipe(
430 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
431 Effect.flatMap((operation) =>
432 operation === undefined
433 ? Effect.void
434 : waitZoneOperation(env.project, zone, operation, {
435 ignore: ["RESOURCE_ALREADY_EXISTS"],
436 }).pipe(Effect.asVoid),
437 ),
438 );
439 current = yield* getByName(env.project, zone, instanceGroupName).pipe(
440 Effect.flatMap((group) =>
441 group === undefined
442 ? Effect.fail(
443 new InstanceGroupNotResolved({ instanceGroupName, zone }),
444 )
445 : Effect.succeed(group),
446 ),
447 Effect.retry({
448 while: (error) =>
449 error._tag === "GCP.Compute.InstanceGroupNotResolved",
450 times: 8,
451 schedule: Schedule.exponential("250 millis"),
452 }),
453 Effect.catchTag("GCP.Compute.InstanceGroupNotResolved", () =>
454 Effect.succeed(undefined),
455 ),
456 );
457 }
458
459 if (current === undefined) {
460 return yield* new InstanceGroupNotResolved({
461 instanceGroupName,
462 zone,
463 });
464 }
465
466 if (!samePorts(fromApiPorts(current.namedPorts), namedPorts)) {
467 yield* runOp(
468 env.project,
469 zone,
470 compute.setNamedPortsInstanceGroups({
471 project: env.project,
472 zone,
473 instanceGroup: instanceGroupName,
474 body: {
475 namedPorts,
476 fingerprint: current.fingerprint,
477 },
478 }),
479 ).pipe(
480 Effect.retry({
481 while: (error) => error._tag === "Conflict",
482 times: 5,
483 schedule: Schedule.exponential("250 millis"),
484 }),
485 );
486 current =
487 (yield* getByName(env.project, zone, instanceGroupName)) ?? current;
488 }
489
490 if (news.instances !== undefined) {
491 const observed = yield* listMembers(
492 env.project,
493 zone,
494 instanceGroupName,
495 );
496 const desired = news.instances.map((instance) =>
497 toInstanceUrl(env.project, zone, instance),
498 );
499 const observedKeys = new Set(observed.map(lastSegment));
500 const desiredKeys = new Set(desired.map(lastSegment));
501 const toAdd = desired.filter(
502 (instance) => !observedKeys.has(lastSegment(instance)),
503 );
504 const toRemove = observed.filter(
505 (instance) => !desiredKeys.has(lastSegment(instance)),
506 );
507 if (toAdd.length > 0) {
508 yield* runOp(
509 env.project,
510 zone,
511 compute.addInstancesInstanceGroups({
512 project: env.project,
513 zone,
514 instanceGroup: instanceGroupName,
515 body: {
516 instances: toAdd.map((instance) => ({ instance })),
517 },
518 }),
519 );
520 }
521 if (toRemove.length > 0) {
522 yield* runOp(
523 env.project,
524 zone,
525 compute.removeInstancesInstanceGroups({
526 project: env.project,
527 zone,
528 instanceGroup: instanceGroupName,
529 body: {
530 instances: toRemove.map((instance) => ({ instance })),
531 },
532 }),
533 );
534 }
535 current =
536 (yield* getByName(env.project, zone, instanceGroupName)) ?? current;
537 }
538
539 return toAttrs(current, env.project);
540 }),
541
542 delete: Effect.fn(function* ({ output }) {
543 yield* compute
544 .deleteInstanceGroups({
545 project: output.project,
546 zone: output.zone,
547 instanceGroup: output.instanceGroupName,
548 })
549 .pipe(
550 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
551 Effect.flatMap((operation) =>
552 operation === undefined
553 ? Effect.void
554 : waitZoneOperation(output.project, output.zone, operation, {
555 ignore: ["RESOURCE_NOT_FOUND"],
556 }).pipe(Effect.asVoid),
557 ),
558 Effect.catchTag("NotFound", () => Effect.void),
559 );
560 yield* waitGone(output.project, output.zone, output.instanceGroupName);
561 }),
562 });