@intentius/chant-lexicon-gcp 0.124.30 → 0.124.32

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.
Files changed (103) hide show
  1. package/dist/actions/index.js +45 -0
  2. package/dist/api/read-client.js +105 -0
  3. package/dist/codegen/docs-cli.js +6 -0
  4. package/dist/codegen/docs.js +115 -0
  5. package/dist/codegen/generate-cli.js +17 -0
  6. package/dist/codegen/generate.js +214 -0
  7. package/dist/codegen/naming.js +164 -0
  8. package/dist/codegen/package.js +43 -0
  9. package/dist/composites/catalog.js +1245 -0
  10. package/dist/composites/cloud-function.js +65 -0
  11. package/dist/composites/cloud-run-service.js +71 -0
  12. package/dist/composites/cloud-sql-instance.js +85 -0
  13. package/dist/composites/gcs-bucket.js +64 -0
  14. package/dist/composites/gke-cluster.js +113 -0
  15. package/dist/composites/gke-crdb-region.js +137 -0
  16. package/dist/composites/gpu-node-pool.js +74 -0
  17. package/dist/composites/index.js +15 -0
  18. package/dist/composites/landing-zone.js +89 -0
  19. package/dist/composites/managed-certificate.js +46 -0
  20. package/dist/composites/memorystore-redis.js +52 -0
  21. package/dist/composites/multi-region-vpc.js +108 -0
  22. package/dist/composites/private-service.js +50 -0
  23. package/dist/composites/pubsub-pipeline.js +63 -0
  24. package/dist/composites/secure-project.js +85 -0
  25. package/dist/composites/vpc-network.js +113 -0
  26. package/dist/coverage.js +28 -0
  27. package/dist/deep-observe-hooks.js +152 -0
  28. package/dist/deep-observe.js +273 -0
  29. package/dist/default-labels.js +67 -0
  30. package/dist/describe-resources.js +264 -0
  31. package/dist/detect.js +16 -0
  32. package/dist/export-resources.js +59 -0
  33. package/dist/generated/index.js +1804 -0
  34. package/dist/generated/runtime.js +4 -0
  35. package/dist/governance.js +98 -0
  36. package/dist/import/generator.js +68 -0
  37. package/dist/import/live-export.js +73 -0
  38. package/dist/import/parser.js +61 -0
  39. package/dist/index.js +36 -0
  40. package/dist/integrity.json +3 -3
  41. package/dist/lint/audit-catalog.js +38 -0
  42. package/dist/lint/audit-lineage.js +63 -0
  43. package/dist/lint/post-synth/gcp-helpers.js +91 -0
  44. package/dist/lint/post-synth/index.js +54 -0
  45. package/dist/lint/post-synth/schema-registry.d.ts.map +1 -1
  46. package/dist/lint/post-synth/schema-registry.js +69 -0
  47. package/dist/lint/post-synth/wgc101.js +47 -0
  48. package/dist/lint/post-synth/wgc102.js +29 -0
  49. package/dist/lint/post-synth/wgc103.js +37 -0
  50. package/dist/lint/post-synth/wgc104.js +34 -0
  51. package/dist/lint/post-synth/wgc105.js +39 -0
  52. package/dist/lint/post-synth/wgc106.js +30 -0
  53. package/dist/lint/post-synth/wgc107.js +33 -0
  54. package/dist/lint/post-synth/wgc108.js +34 -0
  55. package/dist/lint/post-synth/wgc109.js +33 -0
  56. package/dist/lint/post-synth/wgc110.js +32 -0
  57. package/dist/lint/post-synth/wgc111.js +47 -0
  58. package/dist/lint/post-synth/wgc112.js +46 -0
  59. package/dist/lint/post-synth/wgc113.js +35 -0
  60. package/dist/lint/post-synth/wgc201.js +30 -0
  61. package/dist/lint/post-synth/wgc202.js +33 -0
  62. package/dist/lint/post-synth/wgc203.js +35 -0
  63. package/dist/lint/post-synth/wgc204.js +33 -0
  64. package/dist/lint/post-synth/wgc301.js +28 -0
  65. package/dist/lint/post-synth/wgc302.js +28 -0
  66. package/dist/lint/post-synth/wgc303.js +29 -0
  67. package/dist/lint/post-synth/wgc401.js +50 -0
  68. package/dist/lint/post-synth/wgc402.js +46 -0
  69. package/dist/lint/post-synth/wgc403.js +74 -0
  70. package/dist/lint/post-synth/wgc501.js +63 -0
  71. package/dist/lint/post-synth/wgc502.js +37 -0
  72. package/dist/lint/post-synth/wgc503.js +49 -0
  73. package/dist/lint/rules/hardcoded-project.js +51 -0
  74. package/dist/lint/rules/hardcoded-region.js +51 -0
  75. package/dist/lint/rules/index.js +3 -0
  76. package/dist/lint/rules/public-iam.js +37 -0
  77. package/dist/lsp/completions.d.ts.map +1 -1
  78. package/dist/lsp/completions.js +15 -0
  79. package/dist/lsp/hover.d.ts.map +1 -1
  80. package/dist/lsp/hover.js +46 -0
  81. package/dist/manifest.json +1 -1
  82. package/dist/op/activities/floci-gcp.js +49 -0
  83. package/dist/op/activities/gcp-apply.js +618 -0
  84. package/dist/op/activities/index.js +10 -0
  85. package/dist/op/builders.js +50 -0
  86. package/dist/ownership.js +38 -0
  87. package/dist/package-cli.js +20 -0
  88. package/dist/plugin.js +371 -0
  89. package/dist/pseudo.js +15 -0
  90. package/dist/rules/schema-registry.ts +2 -1
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/serializer.js +233 -0
  93. package/dist/spec/fetch.js +107 -0
  94. package/dist/spec/parse.js +323 -0
  95. package/dist/validate-cli.js +11 -0
  96. package/dist/validate.js +24 -0
  97. package/dist/variables.js +15 -0
  98. package/package.json +8 -8
  99. package/src/codegen/package.ts +2 -2
  100. package/src/lint/post-synth/schema-registry.ts +2 -1
  101. package/src/lsp/completions.ts +2 -1
  102. package/src/lsp/hover.ts +2 -1
  103. package/src/serializer.ts +2 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcp",
3
- "version": "0.124.30",
3
+ "version": "0.124.32",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GCP",
6
6
  "intrinsics": [],
@@ -0,0 +1,49 @@
1
+ import { emulatorLifecycle } from "@intentius/chant/op";
2
+ // floci-gcp is a bespoke GCP-REST fake (not LocalStack) — a plain 200 on its
3
+ // health endpoint means ready. Shared lifecycle: emulatorLifecycle (#746).
4
+ // Pinned rather than `:latest` (#1345), same reasoning as the other emulators.
5
+ export const FLOCI_GCP_SPEC = {
6
+ name: "chant-floci-gcp",
7
+ image: "floci/floci-gcp:0.5.0",
8
+ containerPort: 4588,
9
+ healthPath: "/_floci-gcp/health",
10
+ upstream: { repo: "floci-io/floci-gcp" },
11
+ };
12
+ /**
13
+ * The gcp plugin's emulator capability (#1345, corrected in #1431).
14
+ *
15
+ * The apply and read paths reach the emulator differently, and the capability
16
+ * exists for the one that cannot be told where to look:
17
+ *
18
+ * - **apply** — `gcpApply` takes an explicit `endpoint` argument. Nothing to
19
+ * inject; the caller already holds it.
20
+ * - **read** — `describeResources` and `observeResourcesDeep` take it from
21
+ * `GCP_ENDPOINT_URL` and nothing else (`../../describe-resources.ts`,
22
+ * `../../deep-observe.ts`). A reader given no variable talks to real GCP.
23
+ *
24
+ * `env` was empty because when #1345 declared this capability the second bullet
25
+ * was not yet true: GCP was observed through Config Connector over kubectl, so
26
+ * there genuinely was no variable to inject. #1209 moved observation onto the
27
+ * applier's own direct-REST transport about two hours later, and nothing
28
+ * re-read this comment. The result was an emulator that booted, reported its
29
+ * endpoint, and left every `--live` read pointed at production.
30
+ *
31
+ * Injecting it makes `chant emulator up --lexicon gcp` mean the same thing for
32
+ * gcp that it already means for aws and azure: the estate's reads land on the
33
+ * emulator you just started.
34
+ */
35
+ export const FLOCI_GCP_EMULATOR = {
36
+ spec: FLOCI_GCP_SPEC,
37
+ env: (endpoint) => ({ GCP_ENDPOINT_URL: endpoint }),
38
+ };
39
+ const gcp = emulatorLifecycle(FLOCI_GCP_SPEC);
40
+ export const flociGcpExistsCommand = gcp.existsCommand;
41
+ export const flociGcpRmCommand = gcp.rmCommand;
42
+ export const flociGcpHealthUrl = gcp.healthUrl;
43
+ /** The GCP endpoint URL (what `gcpApply`'s `endpoint` should point at). */
44
+ export const flociGcpEndpoint = gcp.endpoint;
45
+ export const flociGcpRunCommand = (args = {}) => gcp.runCommand(args);
46
+ /** Boot a local floci-gcp (GCP emulator) in Docker and return its endpoint. */
47
+ export const flociGcpUp = (args = {}, signal) => gcp.up(args, signal);
48
+ /** Stop and remove the local floci-gcp container (no-op if already gone). */
49
+ export const flociGcpDown = (args = {}, signal) => gcp.down(args, signal);
@@ -0,0 +1,618 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parseYAML } from "@intentius/chant/yaml";
3
+ import { sleep } from "@intentius/chant/op";
4
+ import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
5
+ import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership.js";
6
+ import { applyResult, } from "@intentius/chant/apply";
7
+ const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
8
+ /**
9
+ * The GCP labels chant stamps on resources it creates.
10
+ *
11
+ * Only the managed-by marker, not stack/env: this applier has no ownership
12
+ * marker to hand — the serializer stamps those from project config onto the
13
+ * Config Connector object. Same shape the fly serializer uses when no ownership
14
+ * context is set.
15
+ */
16
+ export function chantOwnershipLabels() {
17
+ return { [GCP_RESOURCE_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
18
+ }
19
+ /**
20
+ * True when a live resource's labels carry chant's ownership marker.
21
+ *
22
+ * Resolves through core's `hasOwnershipMarker` against the lexicon's declared
23
+ * GCP-resource channel (#1446) rather than comparing a string literal inline,
24
+ * so the stamp and the prune filter cannot drift. The key itself is unchanged
25
+ * and deliberately GCP-valid — see `../../ownership.ts` for why this surface
26
+ * cannot use core's `LABEL_OWNERSHIP_KEYS`.
27
+ */
28
+ export function isChantOwned(labels) {
29
+ return hasOwnershipMarker(labels ?? undefined, GCP_RESOURCE_OWNERSHIP_KEYS);
30
+ }
31
+ /** Merge the ownership marker into a create/update body's `labels`. */
32
+ function stampOwnership(body) {
33
+ const existing = body.labels ?? {};
34
+ return { ...body, labels: { ...existing, ...chantOwnershipLabels() } };
35
+ }
36
+ /**
37
+ * A standard `google.longrunning` operation spec: the create response carries an
38
+ * operation `name` (`projects/…/operations/…`), polled at `{base}/{version}/{name}`
39
+ * until `done: true`. Reused by every service that follows the pattern.
40
+ */
41
+ export function longRunningOperation(version) {
42
+ return {
43
+ pollUrl(createResponseBody, { base }) {
44
+ const opName = createResponseBody?.name;
45
+ if (!opName || !opName.includes("/operations/"))
46
+ return undefined;
47
+ return `${base}/${version}/${opName}`;
48
+ },
49
+ isDone(operationBody) {
50
+ return operationBody?.done === true;
51
+ },
52
+ error(operationBody) {
53
+ return operationBody?.error?.message;
54
+ },
55
+ };
56
+ }
57
+ /**
58
+ * Map a CNRM StorageBucket to a GCS Buckets:insert body. Pure. CNRM mirrors the
59
+ * Terraform google provider which mirrors the GCS API, so most fields are
60
+ * field-for-field; the renames are `uniformBucketLevelAccess` →
61
+ * `iamConfiguration.uniformBucketLevelAccess.enabled` and `lifecycleRule` →
62
+ * `lifecycle.rule`.
63
+ */
64
+ export function bucketInsertBody(cnrm) {
65
+ const name = cnrm.metadata?.name;
66
+ if (!name)
67
+ throw new Error("StorageBucket has no metadata.name");
68
+ const spec = cnrm.spec ?? {};
69
+ const body = { name };
70
+ if (spec.location)
71
+ body.location = spec.location;
72
+ if (spec.storageClass)
73
+ body.storageClass = spec.storageClass;
74
+ if (spec.uniformBucketLevelAccess !== undefined) {
75
+ body.iamConfiguration = { uniformBucketLevelAccess: { enabled: spec.uniformBucketLevelAccess } };
76
+ }
77
+ if (spec.versioning?.enabled !== undefined) {
78
+ body.versioning = { enabled: spec.versioning.enabled };
79
+ }
80
+ if (spec.lifecycleRule?.length) {
81
+ body.lifecycle = {
82
+ rule: spec.lifecycleRule.map((r) => ({
83
+ action: r.action ?? {},
84
+ ...(r.condition ? { condition: r.condition } : {}),
85
+ })),
86
+ };
87
+ }
88
+ return body;
89
+ }
90
+ /** Map a CNRM PubSubTopic to a Pub/Sub Topic body. Pure. Name travels in the URL. */
91
+ export function pubSubTopicBody(resource) {
92
+ const spec = (resource.spec ?? {});
93
+ const body = {};
94
+ const labels = resource.metadata?.labels;
95
+ if (labels && Object.keys(labels).length)
96
+ body.labels = labels;
97
+ if (spec.messageRetentionDuration)
98
+ body.messageRetentionDuration = spec.messageRetentionDuration;
99
+ return body;
100
+ }
101
+ export const storageBucketMapper = {
102
+ kind: "StorageBucket",
103
+ defaultHost: "https://storage.googleapis.com",
104
+ plan(resource, { base, project }) {
105
+ const body = stampOwnership(bucketInsertBody(resource));
106
+ const url = `${base}/storage/v1/b/${encodeURIComponent(body.name)}`;
107
+ // Reconcile in place with a PATCH of the desired mutable fields (the name is
108
+ // immutable and travels in the URL).
109
+ const { name: _name, ...patch } = body;
110
+ return {
111
+ getUrl: url,
112
+ create: { method: "POST", url: `${base}/storage/v1/b?project=${encodeURIComponent(project)}`, body },
113
+ update: { method: "PATCH", url, body: patch },
114
+ };
115
+ },
116
+ list: {
117
+ url: ({ base, project }) => `${base}/storage/v1/b?project=${encodeURIComponent(project)}`,
118
+ items: (body) => (body?.items ?? []),
119
+ },
120
+ };
121
+ export const pubSubTopicMapper = {
122
+ kind: "PubSubTopic",
123
+ defaultHost: "https://pubsub.googleapis.com",
124
+ plan(resource, { base, project }) {
125
+ const topic = resource.metadata?.name;
126
+ if (!topic)
127
+ throw new Error("PubSubTopic has no metadata.name");
128
+ // Pub/Sub creates a topic with an idempotent PUT to its resource URL; GET the
129
+ // same URL for the existence check.
130
+ const url = `${base}/v1/projects/${encodeURIComponent(project)}/topics/${encodeURIComponent(topic)}`;
131
+ const body = stampOwnership(pubSubTopicBody(resource));
132
+ // UpdateTopic uses the {topic, updateMask} envelope — the mask lists the
133
+ // fields being set (labels, messageRetentionDuration).
134
+ const update = {
135
+ method: "PATCH",
136
+ url,
137
+ body: { topic: { name: `projects/${project}/topics/${topic}`, ...body }, updateMask: Object.keys(body).join(",") },
138
+ };
139
+ return { getUrl: url, create: { method: "PUT", url, body }, update };
140
+ },
141
+ list: {
142
+ url: ({ base, project }) => `${base}/v1/projects/${encodeURIComponent(project)}/topics`,
143
+ items: (body) => (body?.topics ?? []).map((t) => ({ name: t.name.split("/").pop() ?? t.name, labels: t.labels })),
144
+ },
145
+ };
146
+ /** Map a CNRM RunService to a Cloud Run v2 Service body. Pure. */
147
+ export function cloudRunServiceBody(resource) {
148
+ const spec = (resource.spec ?? {});
149
+ const body = {};
150
+ if (spec.template)
151
+ body.template = spec.template;
152
+ return body;
153
+ }
154
+ export const cloudRunServiceMapper = {
155
+ kind: "RunService",
156
+ defaultHost: "https://run.googleapis.com",
157
+ // Cloud Run create is asynchronous — it returns a google.longrunning operation.
158
+ operation: longRunningOperation("v2"),
159
+ plan(resource, { base, project }) {
160
+ const name = resource.metadata?.name;
161
+ if (!name)
162
+ throw new Error("RunService has no metadata.name");
163
+ const location = (resource.spec ?? {}).location ?? "us-central1";
164
+ const services = `${base}/v2/projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/services`;
165
+ const url = `${services}/${encodeURIComponent(name)}`;
166
+ return {
167
+ getUrl: url,
168
+ create: {
169
+ method: "POST",
170
+ url: `${services}?serviceId=${encodeURIComponent(name)}`,
171
+ body: cloudRunServiceBody(resource),
172
+ },
173
+ // Cloud Run reconciles with a PATCH of the service; like create it returns
174
+ // a long-running operation, polled via the mapper's `operation`.
175
+ update: { method: "PATCH", url, body: cloudRunServiceBody(resource) },
176
+ };
177
+ },
178
+ };
179
+ /**
180
+ * Map a CNRM PubSubSubscription to a Pub/Sub Subscription body, resolving the
181
+ * `topicRef` to a full topic path. Pure.
182
+ */
183
+ export function pubSubSubscriptionBody(resource, project) {
184
+ const spec = (resource.spec ?? {});
185
+ const ref = spec.topicRef;
186
+ const topic = ref?.external ?? (ref?.name ? `projects/${project}/topics/${ref.name}` : undefined);
187
+ if (!topic)
188
+ throw new Error("PubSubSubscription has no spec.topicRef.name");
189
+ const body = { topic };
190
+ if (spec.ackDeadlineSeconds !== undefined)
191
+ body.ackDeadlineSeconds = spec.ackDeadlineSeconds;
192
+ return body;
193
+ }
194
+ export const pubSubSubscriptionMapper = {
195
+ kind: "PubSubSubscription",
196
+ defaultHost: "https://pubsub.googleapis.com",
197
+ plan(resource, { base, project }) {
198
+ const sub = resource.metadata?.name;
199
+ if (!sub)
200
+ throw new Error("PubSubSubscription has no metadata.name");
201
+ const url = `${base}/v1/projects/${encodeURIComponent(project)}/subscriptions/${encodeURIComponent(sub)}`;
202
+ return { getUrl: url, create: { method: "PUT", url, body: pubSubSubscriptionBody(resource, project) } };
203
+ },
204
+ };
205
+ /** Map a CNRM SecretManagerSecret to a Secret Manager create body. Pure. */
206
+ export function secretBody(resource) {
207
+ const spec = (resource.spec ?? {});
208
+ return { replication: spec.replication ?? { automatic: {} } };
209
+ }
210
+ export const secretManagerSecretMapper = {
211
+ kind: "SecretManagerSecret",
212
+ defaultHost: "https://secretmanager.googleapis.com",
213
+ plan(resource, { base, project }) {
214
+ const name = resource.metadata?.name;
215
+ if (!name)
216
+ throw new Error("SecretManagerSecret has no metadata.name");
217
+ const secrets = `${base}/v1/projects/${encodeURIComponent(project)}/secrets`;
218
+ const url = `${secrets}/${encodeURIComponent(name)}`;
219
+ const body = stampOwnership(secretBody(resource));
220
+ return {
221
+ getUrl: url,
222
+ create: { method: "POST", url: `${secrets}?secretId=${encodeURIComponent(name)}`, body },
223
+ // UpdateSecret carries the updateMask in the query; only labels are mutable here.
224
+ update: { method: "PATCH", url: `${url}?updateMask=labels`, body: { labels: body.labels } },
225
+ };
226
+ },
227
+ list: {
228
+ url: ({ base, project }) => `${base}/v1/projects/${encodeURIComponent(project)}/secrets`,
229
+ items: (body) => (body?.secrets ?? []).map((s) => ({ name: s.name.split("/").pop() ?? s.name, labels: s.labels })),
230
+ },
231
+ };
232
+ /** Map a CNRM IAMServiceAccount to a service-account create body. Pure. */
233
+ export function serviceAccountBody(resource, accountId) {
234
+ const spec = (resource.spec ?? {});
235
+ return { accountId, serviceAccount: spec.displayName ? { displayName: spec.displayName } : {} };
236
+ }
237
+ export const gcpServiceAccountMapper = {
238
+ kind: "IAMServiceAccount",
239
+ defaultHost: "https://iam.googleapis.com",
240
+ plan(resource, { base, project }) {
241
+ const accountId = resource.metadata?.name;
242
+ if (!accountId)
243
+ throw new Error("IAMServiceAccount has no metadata.name");
244
+ // The GCP identity is the derived email; IAM service accounts carry no labels,
245
+ // so there is no ownership stamping / prune for this kind.
246
+ const email = `${accountId}@${project}.iam.gserviceaccount.com`;
247
+ const accounts = `${base}/v1/projects/${encodeURIComponent(project)}/serviceAccounts`;
248
+ return {
249
+ getUrl: `${accounts}/${email}`,
250
+ create: { method: "POST", url: accounts, body: serviceAccountBody(resource, accountId) },
251
+ };
252
+ },
253
+ };
254
+ /** The kind → mapper dispatch table. Adding a resource type is a new entry here. */
255
+ export const MAPPERS = {
256
+ StorageBucket: storageBucketMapper,
257
+ PubSubTopic: pubSubTopicMapper,
258
+ PubSubSubscription: pubSubSubscriptionMapper,
259
+ SecretManagerSecret: secretManagerSecretMapper,
260
+ IAMServiceAccount: gcpServiceAccountMapper,
261
+ RunService: cloudRunServiceMapper,
262
+ };
263
+ /**
264
+ * Collect the local resource names this resource references via CNRM `*Ref`
265
+ * fields (`topicRef: { name }`, `subnetworkRefs: [{ name }]`, …). `external`
266
+ * refs point outside the manifest and are ignored for ordering. Pure.
267
+ */
268
+ export function referencedNames(resource) {
269
+ const names = [];
270
+ const visit = (v, key) => {
271
+ if (Array.isArray(v)) {
272
+ for (const x of v)
273
+ visit(x, key);
274
+ return;
275
+ }
276
+ if (v && typeof v === "object") {
277
+ const obj = v;
278
+ if (key && /Refs?$/.test(key) && typeof obj.name === "string") {
279
+ names.push(obj.name);
280
+ }
281
+ for (const [k, val] of Object.entries(obj))
282
+ visit(val, k);
283
+ }
284
+ };
285
+ visit(resource.spec);
286
+ return [...new Set(names)];
287
+ }
288
+ /**
289
+ * Topologically order resources so a referenced resource comes before the
290
+ * resource that references it (apply order; reverse for delete). Refs to names
291
+ * not in the manifest are left to exist already. Throws on a reference cycle.
292
+ * Pure — a DFS post-order.
293
+ */
294
+ export function orderByReferences(resources) {
295
+ const byName = new Map();
296
+ for (const r of resources) {
297
+ const n = r.metadata?.name;
298
+ if (n)
299
+ byName.set(n, r);
300
+ }
301
+ const ordered = [];
302
+ const done = new Set();
303
+ const active = new Set();
304
+ const visit = (r) => {
305
+ if (done.has(r))
306
+ return;
307
+ if (active.has(r))
308
+ throw new Error(`reference cycle involving ${r.metadata?.name ?? "?"}`);
309
+ active.add(r);
310
+ for (const ref of referencedNames(r)) {
311
+ const dep = byName.get(ref);
312
+ if (dep && dep !== r)
313
+ visit(dep);
314
+ }
315
+ active.delete(r);
316
+ done.add(r);
317
+ ordered.push(r);
318
+ };
319
+ for (const r of resources)
320
+ visit(r);
321
+ return ordered;
322
+ }
323
+ // ── Resolution + HTTP ─────────────────────────────────────────────────────────
324
+ /**
325
+ * Resolve the endpoint override (#1449): an explicit `endpoint` arg wins, then
326
+ * the `GCP_ENDPOINT_URL` env — the same variable the read path honours
327
+ * (`describe-resources`, floci-gcp), so an apply lands wherever `--live` is
328
+ * already looking. `undefined` means no override: each kind falls back to its
329
+ * real-GCP host. The same rule the aws applier applies to `AWS_ENDPOINT_URL`
330
+ * (#1694) and the fly applier to `FLY_FLAPS_BASE_URL`. Pure.
331
+ */
332
+ export function resolveGcpEndpoint(args = {}, env = process.env) {
333
+ return args.endpoint || env.GCP_ENDPOINT_URL || undefined;
334
+ }
335
+ /** Resolve the project: `GOOGLE_CLOUD_PROJECT` env, else the CNRM project-id annotation. Pure. */
336
+ export function resolveGcpProject(resource, env = process.env) {
337
+ const project = env.GOOGLE_CLOUD_PROJECT || resource.metadata?.annotations?.[PROJECT_ID_ANNOTATION];
338
+ if (!project) {
339
+ throw new Error(`no GCP project — set GOOGLE_CLOUD_PROJECT or the ${PROJECT_ID_ANNOTATION} annotation`);
340
+ }
341
+ return project;
342
+ }
343
+ const defaultHttp = async (method, url, body, signal) => {
344
+ const res = await fetch(url, {
345
+ method,
346
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
347
+ body: body === undefined ? undefined : JSON.stringify(body),
348
+ signal,
349
+ });
350
+ return { status: res.status, text: await res.text() };
351
+ };
352
+ /**
353
+ * Reconcile one resource via its mapper: `GET` it; if present, PATCH it to the
354
+ * desired state (when the mapper supports `update`, else leave it); if absent,
355
+ * create it. Async create/update return an operation that is polled to
356
+ * completion. `http` is injectable for tests.
357
+ */
358
+ export async function applyResource(mapper, resource, ctx, http = defaultHttp, signal) {
359
+ const plan = mapper.plan(resource, ctx);
360
+ const name = resource.metadata?.name ?? "?";
361
+ const get = await http("GET", plan.getUrl, undefined, signal);
362
+ if (get.status === 200) {
363
+ // Exists. Reconcile to desired if the mapper supports it; otherwise leave it.
364
+ if (!plan.update) {
365
+ return { kind: mapper.kind, name, created: false, updated: false };
366
+ }
367
+ const res = await http(plan.update.method, plan.update.url, plan.update.body, signal);
368
+ if (res.status >= 300) {
369
+ throw new Error(`${mapper.kind} ${name} update failed (${res.status}): ${res.text}`);
370
+ }
371
+ await pollIfAsync(mapper, res.text, ctx, http, signal);
372
+ return { kind: mapper.kind, name, created: false, updated: true };
373
+ }
374
+ const res = await http(plan.create.method, plan.create.url, plan.create.body, signal);
375
+ if (res.status >= 300) {
376
+ throw new Error(`${mapper.kind} ${name} create failed (${res.status}): ${res.text}`);
377
+ }
378
+ await pollIfAsync(mapper, res.text, ctx, http, signal);
379
+ return { kind: mapper.kind, name, created: true, updated: false };
380
+ }
381
+ /**
382
+ * When a mapper is async, extract the long-running operation from a create/update
383
+ * response and poll it to completion so the step never reports success early.
384
+ */
385
+ async function pollIfAsync(mapper, responseText, ctx, http, signal) {
386
+ if (!mapper.operation)
387
+ return;
388
+ const pollUrl = mapper.operation.pollUrl(parseJson(responseText), ctx);
389
+ if (pollUrl) {
390
+ await waitForOperation(mapper.operation, pollUrl, http, signal);
391
+ }
392
+ }
393
+ function parseJson(text) {
394
+ try {
395
+ return JSON.parse(text);
396
+ }
397
+ catch {
398
+ return undefined;
399
+ }
400
+ }
401
+ /**
402
+ * Poll a long-running operation until it reports done (or errors / times out).
403
+ * `http` and the interval are injectable so tests drive it without real waits.
404
+ */
405
+ export async function waitForOperation(op, pollUrl, http = defaultHttp, signal, opts) {
406
+ const interval = opts?.intervalMs ?? 2000;
407
+ const deadline = Date.now() + (opts?.timeoutMs ?? 300000);
408
+ let attempt = 0;
409
+ while (Date.now() < deadline) {
410
+ if (signal?.aborted)
411
+ throw new Error("waitForOperation aborted");
412
+ attempt++;
413
+ const res = await http("GET", pollUrl, undefined, signal);
414
+ const body = res.status < 300 ? parseJson(res.text) : undefined;
415
+ if (body) {
416
+ const err = op.error(body);
417
+ if (err)
418
+ throw new Error(`operation failed: ${err}`);
419
+ if (op.isDone(body))
420
+ return;
421
+ }
422
+ await sleep(interval, signal);
423
+ }
424
+ throw new Error(`operation did not complete within timeout: ${pollUrl}`);
425
+ }
426
+ /**
427
+ * A manifest entry with no `kind` is malformed, not unsupported (#1447).
428
+ *
429
+ * The old guard skipped it in silence, which put a hand-written or truncated
430
+ * manifest in the same bucket as a kind chant simply has no mapper for. One is
431
+ * a bug in the input and the other is a known gap in coverage; only the second
432
+ * belongs in `notAttempted`. Throws so the first surfaces.
433
+ */
434
+ function requireKind(resource, manifestPath) {
435
+ if (!resource.kind) {
436
+ const name = resource.metadata?.name;
437
+ throw new Error(`${manifestPath}: manifest entry has no "kind"${name ? ` (metadata.name: ${name})` : ""} — ` +
438
+ `a CNRM resource must declare one. This is a malformed manifest, not an unsupported kind.`);
439
+ }
440
+ return true;
441
+ }
442
+ /** Parse a built manifest into CNRM resource objects — YAML (multi-doc) or JSON. Pure. */
443
+ export function parseManifest(content, path) {
444
+ if (/\.json$/i.test(path)) {
445
+ const parsed = JSON.parse(content);
446
+ return (Array.isArray(parsed) ? parsed : [parsed]);
447
+ }
448
+ return content
449
+ .split(/^---\s*$/m)
450
+ .map((doc) => doc.trim())
451
+ .filter(Boolean)
452
+ .map((doc) => parseYAML(doc));
453
+ }
454
+ /**
455
+ * Normalize a gcp apply/delete result into core's apply envelope (#1446).
456
+ *
457
+ * The lexicon keeps its own richer shape — `created`/`updated` per resource,
458
+ * the CNRM `kind` — and this projects it onto the shared tri-state so a caller
459
+ * can read any applier's result the same way. Core's contract is the interface;
460
+ * it is not a replacement for what this applier knows.
461
+ */
462
+ export function toApplyResult(result) {
463
+ const applied = (result.applied ?? []).map((a) => ({
464
+ kind: a.kind,
465
+ name: a.name,
466
+ action: a.created ? "created" : a.updated ? "updated" : "unchanged",
467
+ }));
468
+ const notAttempted = [
469
+ ...(result.notAttempted ?? []).map((n) => ({ kind: n.kind, name: n.name, reason: n.reason })),
470
+ // A kind the prune could not consider has no single resource name — the
471
+ // whole kind went unexamined, which `not-prunable` is the reason for.
472
+ ...(result.notPrunable ?? []).map((n) => ({
473
+ kind: n.kind,
474
+ name: "*",
475
+ reason: "not-prunable",
476
+ ...(n.detail ? { detail: n.detail } : {}),
477
+ })),
478
+ ];
479
+ // `gcpDelete` reports deletions as `deleted`; they are prunes in the shared
480
+ // vocabulary — the applier removed something the plan no longer wants.
481
+ return applyResult(applied, result.pruned ?? result.deleted ?? [], notAttempted);
482
+ }
483
+ /**
484
+ * Owned-only prune: for each manifested kind with `list` support, delete the
485
+ * chant-owned live resources whose name is not in `desiredByKind`. Foreign
486
+ * resources (no ownership label) are left alone.
487
+ *
488
+ * Returns what it could not consider alongside what it deleted — a kind with no
489
+ * `list`, or whose list call failed, is reported rather than dropped (#1447).
490
+ */
491
+ export async function pruneOrphans(desired, resolve, http = defaultHttp, signal) {
492
+ const desiredByKind = new Map();
493
+ for (const r of desired) {
494
+ if (!r.kind)
495
+ continue;
496
+ const names = desiredByKind.get(r.kind) ?? new Set();
497
+ if (r.metadata?.name)
498
+ names.add(r.metadata.name);
499
+ desiredByKind.set(r.kind, names);
500
+ }
501
+ const pruned = [];
502
+ const notPrunable = [];
503
+ for (const [kind, keep] of desiredByKind) {
504
+ const mapper = MAPPERS[kind];
505
+ if (!mapper?.list) {
506
+ console.log(`prune: cannot list kind ${kind} — owned orphans of it are not considered`);
507
+ notPrunable.push({ kind, reason: "no-list-capability" });
508
+ continue;
509
+ }
510
+ const ctx = resolve(mapper, desired.find((r) => r.kind === kind));
511
+ const res = await http("GET", mapper.list.url(ctx), undefined, signal);
512
+ if (res.status >= 300) {
513
+ console.log(`prune: listing kind ${kind} failed (${res.status}) — not considered`);
514
+ notPrunable.push({ kind, reason: "list-failed", detail: `${res.status}: ${res.text}` });
515
+ continue;
516
+ }
517
+ for (const item of mapper.list.items(parseJson(res.text))) {
518
+ if (!isChantOwned(item.labels) || keep.has(item.name))
519
+ continue;
520
+ const result = await deleteResource(mapper, { kind, metadata: { name: item.name } }, ctx, http, signal);
521
+ console.log(`pruned: ${kind}/${item.name} (${ctx.base})`);
522
+ pruned.push(result);
523
+ }
524
+ }
525
+ return { pruned, notPrunable };
526
+ }
527
+ /**
528
+ * The native GCP applier (#706) — read a built CNRM manifest and apply each
529
+ * resource directly to its GCP REST API, targeting a local floci-gcp emulator or
530
+ * real GCP by endpoint override. Unlike AWS/Azure/k8s, GCP has no native deploy
531
+ * service to shell out to, so chant maps each `kind` to a REST call itself (the
532
+ * `MAPPERS` dispatch table). Uses longInfra profile. `http` is injectable for
533
+ * tests.
534
+ *
535
+ * A kind `MAPPERS` does not cover is **reported, not dropped** — it comes back
536
+ * in `notAttempted` (#1447). Before that, it was a `console.log` and a
537
+ * populated `applied` array, so a caller could not tell a partial apply from a
538
+ * complete one and `ApplyOp` reported success either way.
539
+ */
540
+ export async function gcpApply(args, signal, http = defaultHttp) {
541
+ const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
542
+ const endpoint = resolveGcpEndpoint(args);
543
+ const resolve = (mapper, resource) => ({
544
+ base: (endpoint ?? mapper.defaultHost).replace(/\/$/, ""),
545
+ project: args.project ?? (resource ? resolveGcpProject(resource) : ""),
546
+ });
547
+ const applied = [];
548
+ const notAttempted = [];
549
+ for (const r of resources) {
550
+ const mapper = requireKind(r, args.manifestPath) ? MAPPERS[r.kind] : undefined;
551
+ if (!mapper) {
552
+ const kind = r.kind;
553
+ console.log(`skip: no mapper for kind ${kind}`);
554
+ notAttempted.push({ kind, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
555
+ continue;
556
+ }
557
+ const ctx = resolve(mapper, r);
558
+ const result = await applyResource(mapper, r, ctx, http, signal);
559
+ const verb = result.created ? "created" : result.updated ? "updated" : "unchanged";
560
+ console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`);
561
+ applied.push(result);
562
+ }
563
+ const prune = args.prune
564
+ ? await pruneOrphans(resources, resolve, http, signal)
565
+ : { pruned: [], notPrunable: [] };
566
+ return { applied, pruned: prune.pruned, notAttempted, notPrunable: prune.notPrunable };
567
+ }
568
+ /**
569
+ * Idempotently delete one resource: `DELETE` its resource URL. A 404 means it is
570
+ * already gone. For async resources, `DELETE` returns a long-running operation
571
+ * that is polled to completion. `http` is injectable for tests.
572
+ */
573
+ export async function deleteResource(mapper, resource, ctx, http = defaultHttp, signal) {
574
+ const plan = mapper.plan(resource, ctx);
575
+ const name = resource.metadata?.name ?? "?";
576
+ const res = await http("DELETE", plan.getUrl, undefined, signal);
577
+ if (res.status === 404) {
578
+ return { kind: mapper.kind, name, deleted: false };
579
+ }
580
+ if (res.status >= 300) {
581
+ throw new Error(`${mapper.kind} ${name} delete failed (${res.status}): ${res.text}`);
582
+ }
583
+ if (mapper.operation) {
584
+ const pollUrl = mapper.operation.pollUrl(parseJson(res.text), ctx);
585
+ if (pollUrl) {
586
+ await waitForOperation(mapper.operation, pollUrl, http, signal);
587
+ }
588
+ }
589
+ return { kind: mapper.kind, name, deleted: true };
590
+ }
591
+ /**
592
+ * The inverse of {@link gcpApply} — read a built CNRM manifest and delete the
593
+ * resources it declares (in reverse order, so dependents go before their
594
+ * dependencies). Idempotent: already-absent resources are a no-op. Uses
595
+ * longInfra profile. `http` is injectable for tests.
596
+ */
597
+ export async function gcpDelete(args, signal, http = defaultHttp) {
598
+ // Delete in reverse dependency order: a referrer goes before the resource it
599
+ // references.
600
+ const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath)).reverse();
601
+ const deleted = [];
602
+ const notAttempted = [];
603
+ for (const r of resources) {
604
+ const mapper = requireKind(r, args.manifestPath) ? MAPPERS[r.kind] : undefined;
605
+ if (!mapper) {
606
+ const kind = r.kind;
607
+ console.log(`skip: no mapper for kind ${kind}`);
608
+ notAttempted.push({ kind, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
609
+ continue;
610
+ }
611
+ const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, "");
612
+ const project = args.project ?? resolveGcpProject(r);
613
+ const result = await deleteResource(mapper, r, { base, project }, http, signal);
614
+ console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
615
+ deleted.push(result);
616
+ }
617
+ return { deleted, notAttempted };
618
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * GCP Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `gcp` lexicon. Contributes the native GCP applier
4
+ * (`gcpApply`), which maps CNRM resources to GCP REST calls since GCP has no
5
+ * native deployment service to shell out to.
6
+ */
7
+ export { gcpApply, gcpDelete, applyResource, deleteResource, waitForOperation, longRunningOperation, bucketInsertBody, pubSubTopicBody, pubSubSubscriptionBody, cloudRunServiceBody, secretBody, serviceAccountBody, storageBucketMapper, pubSubTopicMapper, pubSubSubscriptionMapper, cloudRunServiceMapper, secretManagerSecretMapper, gcpServiceAccountMapper, MAPPERS, referencedNames, orderByReferences, pruneOrphans, toApplyResult, chantOwnershipLabels, isChantOwned, resolveGcpEndpoint, resolveGcpProject, parseManifest, } from "./gcp-apply.js";
8
+ // floci-gcp (GCP emulator) lifecycle — the typed twin of aws's flociUp/Down, so
9
+ // the trio's GCP op boots/tears down the emulator as a modeled step, not a shell.
10
+ export { flociGcpUp, flociGcpDown, flociGcpRunCommand, flociGcpRmCommand, flociGcpExistsCommand, flociGcpHealthUrl, flociGcpEndpoint, } from "./floci-gcp.js";