@intentius/chant-lexicon-gcp 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,701 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseYAML } from "@intentius/chant/yaml";
3
+ import { safeHeartbeat, sleep } from "@intentius/chant/op";
4
+
5
+ const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
6
+
7
+ // Ownership marker stamped as a GCP resource label so `prune` can identify what
8
+ // chant created (GCP label keys can't hold the k8s `app.kubernetes.io/managed-by`
9
+ // slash/dot form, so this is the GCP-valid equivalent).
10
+ const OWNERSHIP_LABEL_KEY = "managed-by";
11
+ const OWNERSHIP_LABEL_VALUE = "chant";
12
+
13
+ /** The GCP labels chant stamps on resources it creates. */
14
+ export function chantOwnershipLabels(): Record<string, string> {
15
+ return { [OWNERSHIP_LABEL_KEY]: OWNERSHIP_LABEL_VALUE };
16
+ }
17
+
18
+ /** True when a live resource's labels carry chant's ownership marker. */
19
+ export function isChantOwned(labels: Record<string, string> | null | undefined): boolean {
20
+ return labels?.[OWNERSHIP_LABEL_KEY] === OWNERSHIP_LABEL_VALUE;
21
+ }
22
+
23
+ /** Merge the ownership marker into a create/update body's `labels`. */
24
+ function stampOwnership<T extends object>(body: T): T & { labels: Record<string, string> } {
25
+ const existing = (body as { labels?: Record<string, string> }).labels ?? {};
26
+ return { ...body, labels: { ...existing, ...chantOwnershipLabels() } };
27
+ }
28
+
29
+ /** Minimal shape of a serialized CNRM resource (`*.cnrm.cloud.google.com`). */
30
+ export interface GcpResource {
31
+ apiVersion?: string;
32
+ kind?: string;
33
+ metadata?: {
34
+ name?: string;
35
+ labels?: Record<string, string>;
36
+ annotations?: Record<string, string>;
37
+ };
38
+ spec?: Record<string, unknown>;
39
+ }
40
+
41
+ /** A CNRM StorageBucket (`storage.cnrm.cloud.google.com`). */
42
+ export interface CnrmStorageBucket extends GcpResource {
43
+ spec?: {
44
+ location?: string;
45
+ storageClass?: string;
46
+ uniformBucketLevelAccess?: boolean;
47
+ versioning?: { enabled?: boolean };
48
+ lifecycleRule?: Array<{ action?: Record<string, unknown>; condition?: Record<string, unknown> }>;
49
+ };
50
+ }
51
+
52
+ /** GCS Buckets:insert request body (the subset chant emits). */
53
+ export interface BucketInsertBody {
54
+ name: string;
55
+ location?: string;
56
+ storageClass?: string;
57
+ iamConfiguration?: { uniformBucketLevelAccess: { enabled: boolean } };
58
+ versioning?: { enabled: boolean };
59
+ lifecycle?: { rule: Array<{ action: Record<string, unknown>; condition?: Record<string, unknown> }> };
60
+ }
61
+
62
+ /** Pub/Sub Topic resource body (subset). */
63
+ export interface PubSubTopicBody {
64
+ labels?: Record<string, string>;
65
+ messageRetentionDuration?: string;
66
+ }
67
+
68
+ // ── Resource mappers (kind → REST) ────────────────────────────────────────────
69
+
70
+ /**
71
+ * One HTTP plan: an idempotency `GET` (200 = exists), the create request, and —
72
+ * when the resource supports in-place reconcile — the update request. A mapper
73
+ * that omits `update` leaves an existing resource untouched (skip-if-exists).
74
+ */
75
+ export interface ApplyPlan {
76
+ getUrl: string;
77
+ create: { method: "POST" | "PUT"; url: string; body: unknown };
78
+ update?: { method: "PATCH" | "PUT"; url: string; body: unknown };
79
+ }
80
+
81
+ /**
82
+ * Handles an async create whose response is a long-running operation. Present
83
+ * only on mappers for resources that provision asynchronously (Cloud Run, GKE,
84
+ * Cloud SQL, …); synchronous resources (buckets, topics) omit it.
85
+ */
86
+ export interface OperationSpec {
87
+ /**
88
+ * URL to poll from the create response, or `undefined` when the create
89
+ * completed synchronously (returned the resource, not an operation).
90
+ */
91
+ pollUrl(createResponseBody: unknown, ctx: { base: string; project: string }): string | undefined;
92
+ /** True once the polled operation body reports completion. */
93
+ isDone(operationBody: unknown): boolean;
94
+ /** An error message from a completed-with-error operation, else undefined. */
95
+ error(operationBody: unknown): string | undefined;
96
+ }
97
+
98
+ /** How to list live resources of a kind, so `prune` can find chant-owned orphans. */
99
+ export interface ListSpec {
100
+ /** LIST endpoint for the kind. */
101
+ url(ctx: { base: string; project: string }): string;
102
+ /** Extract `{ name, labels }` for each item from the LIST response body. */
103
+ items(responseBody: unknown): Array<{ name: string; labels?: Record<string, string> | null }>;
104
+ }
105
+
106
+ /** Maps one CNRM kind to its GCP REST operations. The unit of the dispatch table. */
107
+ export interface ResourceMapper {
108
+ /** CNRM kind this handles. */
109
+ kind: string;
110
+ /** Real-GCP API host, used when no `endpoint` override is given. */
111
+ defaultHost: string;
112
+ /** Build the GET + create plan for one resource against `base`. */
113
+ plan(resource: GcpResource, ctx: { base: string; project: string }): ApplyPlan;
114
+ /** Present for async resources whose create returns a long-running operation. */
115
+ operation?: OperationSpec;
116
+ /** Present when the kind can be pruned (list live → delete chant-owned orphans). */
117
+ list?: ListSpec;
118
+ }
119
+
120
+ /**
121
+ * A standard `google.longrunning` operation spec: the create response carries an
122
+ * operation `name` (`projects/…/operations/…`), polled at `{base}/{version}/{name}`
123
+ * until `done: true`. Reused by every service that follows the pattern.
124
+ */
125
+ export function longRunningOperation(version: string): OperationSpec {
126
+ return {
127
+ pollUrl(createResponseBody, { base }) {
128
+ const opName = (createResponseBody as { name?: string })?.name;
129
+ if (!opName || !opName.includes("/operations/")) return undefined;
130
+ return `${base}/${version}/${opName}`;
131
+ },
132
+ isDone(operationBody) {
133
+ return (operationBody as { done?: boolean })?.done === true;
134
+ },
135
+ error(operationBody) {
136
+ return (operationBody as { error?: { message?: string } })?.error?.message;
137
+ },
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Map a CNRM StorageBucket to a GCS Buckets:insert body. Pure. CNRM mirrors the
143
+ * Terraform google provider which mirrors the GCS API, so most fields are
144
+ * field-for-field; the renames are `uniformBucketLevelAccess` →
145
+ * `iamConfiguration.uniformBucketLevelAccess.enabled` and `lifecycleRule` →
146
+ * `lifecycle.rule`.
147
+ */
148
+ export function bucketInsertBody(cnrm: CnrmStorageBucket): BucketInsertBody {
149
+ const name = cnrm.metadata?.name;
150
+ if (!name) throw new Error("StorageBucket has no metadata.name");
151
+ const spec = cnrm.spec ?? {};
152
+ const body: BucketInsertBody = { name };
153
+ if (spec.location) body.location = spec.location;
154
+ if (spec.storageClass) body.storageClass = spec.storageClass;
155
+ if (spec.uniformBucketLevelAccess !== undefined) {
156
+ body.iamConfiguration = { uniformBucketLevelAccess: { enabled: spec.uniformBucketLevelAccess } };
157
+ }
158
+ if (spec.versioning?.enabled !== undefined) {
159
+ body.versioning = { enabled: spec.versioning.enabled };
160
+ }
161
+ if (spec.lifecycleRule?.length) {
162
+ body.lifecycle = {
163
+ rule: spec.lifecycleRule.map((r) => ({
164
+ action: r.action ?? {},
165
+ ...(r.condition ? { condition: r.condition } : {}),
166
+ })),
167
+ };
168
+ }
169
+ return body;
170
+ }
171
+
172
+ /** Map a CNRM PubSubTopic to a Pub/Sub Topic body. Pure. Name travels in the URL. */
173
+ export function pubSubTopicBody(resource: GcpResource): PubSubTopicBody {
174
+ const spec = (resource.spec ?? {}) as { messageRetentionDuration?: string };
175
+ const body: PubSubTopicBody = {};
176
+ const labels = resource.metadata?.labels;
177
+ if (labels && Object.keys(labels).length) body.labels = labels;
178
+ if (spec.messageRetentionDuration) body.messageRetentionDuration = spec.messageRetentionDuration;
179
+ return body;
180
+ }
181
+
182
+ export const storageBucketMapper: ResourceMapper = {
183
+ kind: "StorageBucket",
184
+ defaultHost: "https://storage.googleapis.com",
185
+ plan(resource, { base, project }) {
186
+ const body = stampOwnership(bucketInsertBody(resource as CnrmStorageBucket));
187
+ const url = `${base}/storage/v1/b/${encodeURIComponent(body.name)}`;
188
+ // Reconcile in place with a PATCH of the desired mutable fields (the name is
189
+ // immutable and travels in the URL).
190
+ const { name: _name, ...patch } = body;
191
+ return {
192
+ getUrl: url,
193
+ create: { method: "POST", url: `${base}/storage/v1/b?project=${encodeURIComponent(project)}`, body },
194
+ update: { method: "PATCH", url, body: patch },
195
+ };
196
+ },
197
+ list: {
198
+ url: ({ base, project }) => `${base}/storage/v1/b?project=${encodeURIComponent(project)}`,
199
+ items: (body) => ((body as { items?: Array<{ name: string; labels?: Record<string, string> | null }> })?.items ?? []),
200
+ },
201
+ };
202
+
203
+ export const pubSubTopicMapper: ResourceMapper = {
204
+ kind: "PubSubTopic",
205
+ defaultHost: "https://pubsub.googleapis.com",
206
+ plan(resource, { base, project }) {
207
+ const topic = resource.metadata?.name;
208
+ if (!topic) throw new Error("PubSubTopic has no metadata.name");
209
+ // Pub/Sub creates a topic with an idempotent PUT to its resource URL; GET the
210
+ // same URL for the existence check.
211
+ const url = `${base}/v1/projects/${encodeURIComponent(project)}/topics/${encodeURIComponent(topic)}`;
212
+ const body = stampOwnership(pubSubTopicBody(resource));
213
+ // UpdateTopic uses the {topic, updateMask} envelope — the mask lists the
214
+ // fields being set (labels, messageRetentionDuration).
215
+ const update = {
216
+ method: "PATCH" as const,
217
+ url,
218
+ body: { topic: { name: `projects/${project}/topics/${topic}`, ...body }, updateMask: Object.keys(body).join(",") },
219
+ };
220
+ return { getUrl: url, create: { method: "PUT", url, body }, update };
221
+ },
222
+ list: {
223
+ url: ({ base, project }) => `${base}/v1/projects/${encodeURIComponent(project)}/topics`,
224
+ items: (body) =>
225
+ ((body as { topics?: Array<{ name: string; labels?: Record<string, string> | null }> })?.topics ?? []).map(
226
+ (t) => ({ name: t.name.split("/").pop() ?? t.name, labels: t.labels }),
227
+ ),
228
+ },
229
+ };
230
+
231
+ /** Map a CNRM RunService to a Cloud Run v2 Service body. Pure. */
232
+ export function cloudRunServiceBody(resource: GcpResource): { template?: unknown } {
233
+ const spec = (resource.spec ?? {}) as { template?: unknown };
234
+ const body: { template?: unknown } = {};
235
+ if (spec.template) body.template = spec.template;
236
+ return body;
237
+ }
238
+
239
+ export const cloudRunServiceMapper: ResourceMapper = {
240
+ kind: "RunService",
241
+ defaultHost: "https://run.googleapis.com",
242
+ // Cloud Run create is asynchronous — it returns a google.longrunning operation.
243
+ operation: longRunningOperation("v2"),
244
+ plan(resource, { base, project }) {
245
+ const name = resource.metadata?.name;
246
+ if (!name) throw new Error("RunService has no metadata.name");
247
+ const location = ((resource.spec ?? {}) as { location?: string }).location ?? "us-central1";
248
+ const services = `${base}/v2/projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/services`;
249
+ const url = `${services}/${encodeURIComponent(name)}`;
250
+ return {
251
+ getUrl: url,
252
+ create: {
253
+ method: "POST",
254
+ url: `${services}?serviceId=${encodeURIComponent(name)}`,
255
+ body: cloudRunServiceBody(resource),
256
+ },
257
+ // Cloud Run reconciles with a PATCH of the service; like create it returns
258
+ // a long-running operation, polled via the mapper's `operation`.
259
+ update: { method: "PATCH", url, body: cloudRunServiceBody(resource) },
260
+ };
261
+ },
262
+ };
263
+
264
+ /**
265
+ * Map a CNRM PubSubSubscription to a Pub/Sub Subscription body, resolving the
266
+ * `topicRef` to a full topic path. Pure.
267
+ */
268
+ export function pubSubSubscriptionBody(resource: GcpResource, project: string): Record<string, unknown> {
269
+ const spec = (resource.spec ?? {}) as {
270
+ topicRef?: { name?: string; external?: string };
271
+ ackDeadlineSeconds?: number;
272
+ };
273
+ const ref = spec.topicRef;
274
+ const topic = ref?.external ?? (ref?.name ? `projects/${project}/topics/${ref.name}` : undefined);
275
+ if (!topic) throw new Error("PubSubSubscription has no spec.topicRef.name");
276
+ const body: Record<string, unknown> = { topic };
277
+ if (spec.ackDeadlineSeconds !== undefined) body.ackDeadlineSeconds = spec.ackDeadlineSeconds;
278
+ return body;
279
+ }
280
+
281
+ export const pubSubSubscriptionMapper: ResourceMapper = {
282
+ kind: "PubSubSubscription",
283
+ defaultHost: "https://pubsub.googleapis.com",
284
+ plan(resource, { base, project }) {
285
+ const sub = resource.metadata?.name;
286
+ if (!sub) throw new Error("PubSubSubscription has no metadata.name");
287
+ const url = `${base}/v1/projects/${encodeURIComponent(project)}/subscriptions/${encodeURIComponent(sub)}`;
288
+ return { getUrl: url, create: { method: "PUT", url, body: pubSubSubscriptionBody(resource, project) } };
289
+ },
290
+ };
291
+
292
+ /** Map a CNRM SecretManagerSecret to a Secret Manager create body. Pure. */
293
+ export function secretBody(resource: GcpResource): Record<string, unknown> {
294
+ const spec = (resource.spec ?? {}) as { replication?: unknown };
295
+ return { replication: spec.replication ?? { automatic: {} } };
296
+ }
297
+
298
+ export const secretManagerSecretMapper: ResourceMapper = {
299
+ kind: "SecretManagerSecret",
300
+ defaultHost: "https://secretmanager.googleapis.com",
301
+ plan(resource, { base, project }) {
302
+ const name = resource.metadata?.name;
303
+ if (!name) throw new Error("SecretManagerSecret has no metadata.name");
304
+ const secrets = `${base}/v1/projects/${encodeURIComponent(project)}/secrets`;
305
+ const url = `${secrets}/${encodeURIComponent(name)}`;
306
+ const body = stampOwnership(secretBody(resource));
307
+ return {
308
+ getUrl: url,
309
+ create: { method: "POST", url: `${secrets}?secretId=${encodeURIComponent(name)}`, body },
310
+ // UpdateSecret carries the updateMask in the query; only labels are mutable here.
311
+ update: { method: "PATCH", url: `${url}?updateMask=labels`, body: { labels: body.labels } },
312
+ };
313
+ },
314
+ list: {
315
+ url: ({ base, project }) => `${base}/v1/projects/${encodeURIComponent(project)}/secrets`,
316
+ items: (body) =>
317
+ ((body as { secrets?: Array<{ name: string; labels?: Record<string, string> | null }> })?.secrets ?? []).map(
318
+ (s) => ({ name: s.name.split("/").pop() ?? s.name, labels: s.labels }),
319
+ ),
320
+ },
321
+ };
322
+
323
+ /** Map a CNRM IAMServiceAccount to a service-account create body. Pure. */
324
+ export function serviceAccountBody(resource: GcpResource, accountId: string): Record<string, unknown> {
325
+ const spec = (resource.spec ?? {}) as { displayName?: string };
326
+ return { accountId, serviceAccount: spec.displayName ? { displayName: spec.displayName } : {} };
327
+ }
328
+
329
+ export const gcpServiceAccountMapper: ResourceMapper = {
330
+ kind: "IAMServiceAccount",
331
+ defaultHost: "https://iam.googleapis.com",
332
+ plan(resource, { base, project }) {
333
+ const accountId = resource.metadata?.name;
334
+ if (!accountId) throw new Error("IAMServiceAccount has no metadata.name");
335
+ // The GCP identity is the derived email; IAM service accounts carry no labels,
336
+ // so there is no ownership stamping / prune for this kind.
337
+ const email = `${accountId}@${project}.iam.gserviceaccount.com`;
338
+ const accounts = `${base}/v1/projects/${encodeURIComponent(project)}/serviceAccounts`;
339
+ return {
340
+ getUrl: `${accounts}/${email}`,
341
+ create: { method: "POST", url: accounts, body: serviceAccountBody(resource, accountId) },
342
+ };
343
+ },
344
+ };
345
+
346
+ /** The kind → mapper dispatch table. Adding a resource type is a new entry here. */
347
+ export const MAPPERS: Record<string, ResourceMapper> = {
348
+ StorageBucket: storageBucketMapper,
349
+ PubSubTopic: pubSubTopicMapper,
350
+ PubSubSubscription: pubSubSubscriptionMapper,
351
+ SecretManagerSecret: secretManagerSecretMapper,
352
+ IAMServiceAccount: gcpServiceAccountMapper,
353
+ RunService: cloudRunServiceMapper,
354
+ };
355
+
356
+ /**
357
+ * Collect the local resource names this resource references via CNRM `*Ref`
358
+ * fields (`topicRef: { name }`, `subnetworkRefs: [{ name }]`, …). `external`
359
+ * refs point outside the manifest and are ignored for ordering. Pure.
360
+ */
361
+ export function referencedNames(resource: GcpResource): string[] {
362
+ const names: string[] = [];
363
+ const visit = (v: unknown, key?: string): void => {
364
+ if (Array.isArray(v)) {
365
+ for (const x of v) visit(x, key);
366
+ return;
367
+ }
368
+ if (v && typeof v === "object") {
369
+ const obj = v as Record<string, unknown>;
370
+ if (key && /Refs?$/.test(key) && typeof obj.name === "string") {
371
+ names.push(obj.name);
372
+ }
373
+ for (const [k, val] of Object.entries(obj)) visit(val, k);
374
+ }
375
+ };
376
+ visit(resource.spec);
377
+ return [...new Set(names)];
378
+ }
379
+
380
+ /**
381
+ * Topologically order resources so a referenced resource comes before the
382
+ * resource that references it (apply order; reverse for delete). Refs to names
383
+ * not in the manifest are left to exist already. Throws on a reference cycle.
384
+ * Pure — a DFS post-order.
385
+ */
386
+ export function orderByReferences(resources: GcpResource[]): GcpResource[] {
387
+ const byName = new Map<string, GcpResource>();
388
+ for (const r of resources) {
389
+ const n = r.metadata?.name;
390
+ if (n) byName.set(n, r);
391
+ }
392
+ const ordered: GcpResource[] = [];
393
+ const done = new Set<GcpResource>();
394
+ const active = new Set<GcpResource>();
395
+ const visit = (r: GcpResource): void => {
396
+ if (done.has(r)) return;
397
+ if (active.has(r)) throw new Error(`reference cycle involving ${r.metadata?.name ?? "?"}`);
398
+ active.add(r);
399
+ for (const ref of referencedNames(r)) {
400
+ const dep = byName.get(ref);
401
+ if (dep && dep !== r) visit(dep);
402
+ }
403
+ active.delete(r);
404
+ done.add(r);
405
+ ordered.push(r);
406
+ };
407
+ for (const r of resources) visit(r);
408
+ return ordered;
409
+ }
410
+
411
+ // ── Resolution + HTTP ─────────────────────────────────────────────────────────
412
+
413
+ /** Resolve the project: `GOOGLE_CLOUD_PROJECT` env, else the CNRM project-id annotation. Pure. */
414
+ export function resolveGcpProject(resource: GcpResource, env: NodeJS.ProcessEnv = process.env): string {
415
+ const project = env.GOOGLE_CLOUD_PROJECT || resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
416
+ if (!project) {
417
+ throw new Error(
418
+ `no GCP project — set GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`,
419
+ );
420
+ }
421
+ return project;
422
+ }
423
+
424
+ /** Injectable HTTP client — mirrors argo's injectable fetcher so tests avoid the network. */
425
+ export type GcpHttp = (
426
+ method: string,
427
+ url: string,
428
+ body?: unknown,
429
+ signal?: AbortSignal,
430
+ ) => Promise<{ status: number; text: string }>;
431
+
432
+ const defaultHttp: GcpHttp = async (method, url, body, signal) => {
433
+ const res = await fetch(url, {
434
+ method,
435
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
436
+ body: body === undefined ? undefined : JSON.stringify(body),
437
+ signal,
438
+ });
439
+ return { status: res.status, text: await res.text() };
440
+ };
441
+
442
+ /**
443
+ * Reconcile one resource via its mapper: `GET` it; if present, PATCH it to the
444
+ * desired state (when the mapper supports `update`, else leave it); if absent,
445
+ * create it. Async create/update return an operation that is polled to
446
+ * completion. `http` is injectable for tests.
447
+ */
448
+ export async function applyResource(
449
+ mapper: ResourceMapper,
450
+ resource: GcpResource,
451
+ ctx: { base: string; project: string },
452
+ http: GcpHttp = defaultHttp,
453
+ signal?: AbortSignal,
454
+ ): Promise<{ kind: string; name: string; created: boolean; updated: boolean }> {
455
+ const plan = mapper.plan(resource, ctx);
456
+ const name = resource.metadata?.name ?? "?";
457
+ const get = await http("GET", plan.getUrl, undefined, signal);
458
+
459
+ if (get.status === 200) {
460
+ // Exists. Reconcile to desired if the mapper supports it; otherwise leave it.
461
+ if (!plan.update) {
462
+ return { kind: mapper.kind, name, created: false, updated: false };
463
+ }
464
+ const res = await http(plan.update.method, plan.update.url, plan.update.body, signal);
465
+ if (res.status >= 300) {
466
+ throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
467
+ }
468
+ await pollIfAsync(mapper, res.text, ctx, http, signal);
469
+ return { kind: mapper.kind, name, created: false, updated: true };
470
+ }
471
+
472
+ const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
473
+ if (res.status >= 300) {
474
+ throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
475
+ }
476
+ await pollIfAsync(mapper, res.text, ctx, http, signal);
477
+ return { kind: mapper.kind, name, created: true, updated: false };
478
+ }
479
+
480
+ /**
481
+ * When a mapper is async, extract the long-running operation from a create/update
482
+ * response and poll it to completion so the step never reports success early.
483
+ */
484
+ async function pollIfAsync(
485
+ mapper: ResourceMapper,
486
+ responseText: string,
487
+ ctx: { base: string; project: string },
488
+ http: GcpHttp,
489
+ signal?: AbortSignal,
490
+ ): Promise<void> {
491
+ if (!mapper.operation) return;
492
+ const pollUrl = mapper.operation.pollUrl(parseJson(responseText), ctx);
493
+ if (pollUrl) {
494
+ await waitForOperation(mapper.operation, pollUrl, http, signal);
495
+ }
496
+ }
497
+
498
+ function parseJson(text: string): unknown {
499
+ try {
500
+ return JSON.parse(text);
501
+ } catch {
502
+ return undefined;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Poll a long-running operation until it reports done (or errors / times out).
508
+ * `http` and the interval are injectable so tests drive it without real waits.
509
+ */
510
+ export async function waitForOperation(
511
+ op: OperationSpec,
512
+ pollUrl: string,
513
+ http: GcpHttp = defaultHttp,
514
+ signal?: AbortSignal,
515
+ opts?: { intervalMs?: number; timeoutMs?: number },
516
+ ): Promise<void> {
517
+ const interval = opts?.intervalMs ?? 2_000;
518
+ const deadline = Date.now() + (opts?.timeoutMs ?? 300_000);
519
+ let attempt = 0;
520
+ while (Date.now() < deadline) {
521
+ if (signal?.aborted) throw new Error("waitForOperation aborted");
522
+ attempt++;
523
+ safeHeartbeat({ step: "waitForOperation", attempt });
524
+ const res = await http("GET", pollUrl, undefined, signal);
525
+ const body = res.status < 300 ? parseJson(res.text) : undefined;
526
+ if (body) {
527
+ const err = op.error(body);
528
+ if (err) throw new Error(`operation failed: ${err}`);
529
+ if (op.isDone(body)) return;
530
+ }
531
+ await sleep(interval, signal);
532
+ }
533
+ throw new Error(`operation did not complete within timeout: ${pollUrl}`);
534
+ }
535
+
536
+ /** Parse a built manifest into CNRM resource objects — YAML (multi-doc) or JSON. Pure. */
537
+ export function parseManifest(content: string, path: string): GcpResource[] {
538
+ if (/\.json$/i.test(path)) {
539
+ const parsed = JSON.parse(content) as unknown;
540
+ return (Array.isArray(parsed) ? parsed : [parsed]) as GcpResource[];
541
+ }
542
+ return content
543
+ .split(/^---\s*$/m)
544
+ .map((doc) => doc.trim())
545
+ .filter(Boolean)
546
+ .map((doc) => parseYAML(doc) as GcpResource);
547
+ }
548
+
549
+ export interface GcpApplyArgs {
550
+ /** Path to a built CNRM manifest (YAML multi-doc or JSON). */
551
+ manifestPath: string;
552
+ /** GCS/GCP endpoint override for all kinds (e.g. floci-gcp `:4588`). Default: each kind's real-GCP host. */
553
+ endpoint?: string;
554
+ /** Project override. Default: `GOOGLE_CLOUD_PROJECT` env / CNRM annotation. */
555
+ project?: string;
556
+ /**
557
+ * Delete chant-owned resources of a manifested kind that are no longer in the
558
+ * manifest (owned-only prune). Destructive — off by default. Only kinds with a
559
+ * `list` mapper are pruned; foreign (non-chant) resources are never touched.
560
+ */
561
+ prune?: boolean;
562
+ }
563
+
564
+ /**
565
+ * Owned-only prune: for each manifested kind with `list` support, delete the
566
+ * chant-owned live resources whose name is not in `desiredByKind`. Foreign
567
+ * resources (no ownership label) are left alone.
568
+ */
569
+ export async function pruneOrphans(
570
+ desired: GcpResource[],
571
+ resolve: (mapper: ResourceMapper, resource?: GcpResource) => { base: string; project: string },
572
+ http: GcpHttp = defaultHttp,
573
+ signal?: AbortSignal,
574
+ ): Promise<Array<{ kind: string; name: string; deleted: boolean }>> {
575
+ const desiredByKind = new Map<string, Set<string>>();
576
+ for (const r of desired) {
577
+ if (!r.kind) continue;
578
+ const names = desiredByKind.get(r.kind) ?? new Set<string>();
579
+ if (r.metadata?.name) names.add(r.metadata.name);
580
+ desiredByKind.set(r.kind, names);
581
+ }
582
+
583
+ const pruned: Array<{ kind: string; name: string; deleted: boolean }> = [];
584
+ for (const [kind, keep] of desiredByKind) {
585
+ const mapper = MAPPERS[kind];
586
+ if (!mapper?.list) continue;
587
+ const ctx = resolve(mapper, desired.find((r) => r.kind === kind));
588
+ const res = await http("GET", mapper.list.url(ctx), undefined, signal);
589
+ if (res.status >= 300) continue;
590
+ for (const item of mapper.list.items(parseJson(res.text))) {
591
+ if (!isChantOwned(item.labels) || keep.has(item.name)) continue;
592
+ safeHeartbeat({ step: "prune", kind, name: item.name });
593
+ const result = await deleteResource(mapper, { kind, metadata: { name: item.name } }, ctx, http, signal);
594
+ console.log(`pruned: ${kind}/${item.name} (${ctx.base})`);
595
+ pruned.push(result);
596
+ }
597
+ }
598
+ return pruned;
599
+ }
600
+
601
+ /**
602
+ * The native GCP applier (#706) — read a built CNRM manifest and apply each
603
+ * resource directly to its GCP REST API, targeting a local floci-gcp emulator or
604
+ * real GCP by endpoint override. Unlike AWS/Azure/k8s, GCP has no native deploy
605
+ * service to shell out to, so chant maps each `kind` to a REST call itself (the
606
+ * `MAPPERS` dispatch table). Unknown kinds are skipped. Uses longInfra profile.
607
+ * `http` is injectable for tests.
608
+ */
609
+ export async function gcpApply(
610
+ args: GcpApplyArgs,
611
+ signal?: AbortSignal,
612
+ http: GcpHttp = defaultHttp,
613
+ ): Promise<{
614
+ applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }>;
615
+ pruned: Array<{ kind: string; name: string; deleted: boolean }>;
616
+ }> {
617
+ const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
618
+ const resolve = (mapper: ResourceMapper, resource?: GcpResource) => ({
619
+ base: (args.endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
620
+ project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
621
+ });
622
+
623
+ const applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }> = [];
624
+ for (const r of resources) {
625
+ const mapper = r.kind ? MAPPERS[r.kind] : undefined;
626
+ if (!mapper) {
627
+ if (r.kind) console.log(`skip: no mapper for kind ${r.kind}`);
628
+ continue;
629
+ }
630
+ const ctx = resolve(mapper, r);
631
+ safeHeartbeat({ step: "gcpApply", kind: mapper.kind, name: r.metadata?.name });
632
+ const result = await applyResource(mapper, r, ctx, http, signal);
633
+ const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
634
+ console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
635
+ applied.push(result);
636
+ }
637
+
638
+ const pruned = args.prune ? await pruneOrphans(resources, resolve, http, signal) : [];
639
+ return { applied, pruned };
640
+ }
641
+
642
+ /**
643
+ * Idempotently delete one resource: `DELETE` its resource URL. A 404 means it is
644
+ * already gone. For async resources, `DELETE` returns a long-running operation
645
+ * that is polled to completion. `http` is injectable for tests.
646
+ */
647
+ export async function deleteResource(
648
+ mapper: ResourceMapper,
649
+ resource: GcpResource,
650
+ ctx: { base: string; project: string },
651
+ http: GcpHttp = defaultHttp,
652
+ signal?: AbortSignal,
653
+ ): Promise<{ kind: string; name: string; deleted: boolean }> {
654
+ const plan = mapper.plan(resource, ctx);
655
+ const name = resource.metadata?.name ?? "?";
656
+ const res = await http("DELETE", plan.getUrl, undefined, signal);
657
+ if (res.status === 404) {
658
+ return { kind: mapper.kind, name, deleted: false };
659
+ }
660
+ if (res.status >= 300) {
661
+ throw new Error(`${mapper.kind} ${name} delete failed (${res.status}): ${res.text}`);
662
+ }
663
+ if (mapper.operation) {
664
+ const pollUrl = mapper.operation.pollUrl(parseJson(res.text), ctx);
665
+ if (pollUrl) {
666
+ await waitForOperation(mapper.operation, pollUrl, http, signal);
667
+ }
668
+ }
669
+ return { kind: mapper.kind, name, deleted: true };
670
+ }
671
+
672
+ /**
673
+ * The inverse of {@link gcpApply} — read a built CNRM manifest and delete the
674
+ * resources it declares (in reverse order, so dependents go before their
675
+ * dependencies). Idempotent: already-absent resources are a no-op. Uses
676
+ * longInfra profile. `http` is injectable for tests.
677
+ */
678
+ export async function gcpDelete(
679
+ args: GcpApplyArgs,
680
+ signal?: AbortSignal,
681
+ http: GcpHttp = defaultHttp,
682
+ ): Promise<{ deleted: Array<{ kind: string; name: string; deleted: boolean }> }> {
683
+ // Delete in reverse dependency order: a referrer goes before the resource it
684
+ // references.
685
+ const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath)).reverse();
686
+ const deleted: Array<{ kind: string; name: string; deleted: boolean }> = [];
687
+ for (const r of resources) {
688
+ const mapper = r.kind ? MAPPERS[r.kind] : undefined;
689
+ if (!mapper) {
690
+ if (r.kind) console.log(`skip: no mapper for kind ${r.kind}`);
691
+ continue;
692
+ }
693
+ const base = (args.endpoint ?? mapper.defaultHost).replace(/\/$/, "");
694
+ const project = args.project ?? resolveGcpProject(r);
695
+ safeHeartbeat({ step: "gcpDelete", kind: mapper.kind, name: r.metadata?.name });
696
+ const result = await deleteResource(mapper, r, { base, project }, http, signal);
697
+ console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
698
+ deleted.push(result);
699
+ }
700
+ return { deleted };
701
+ }