@intentius/chant-lexicon-gcp 0.38.0 → 0.41.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.
Files changed (50) hide show
  1. package/dist/composites/cloud-function.d.ts +1 -1
  2. package/dist/composites/cloud-function.d.ts.map +1 -1
  3. package/dist/composites/cloud-run-service.d.ts +1 -1
  4. package/dist/composites/cloud-run-service.d.ts.map +1 -1
  5. package/dist/composites/cloud-sql-instance.d.ts +5 -1
  6. package/dist/composites/cloud-sql-instance.d.ts.map +1 -1
  7. package/dist/composites/gcs-bucket.d.ts +3 -1
  8. package/dist/composites/gcs-bucket.d.ts.map +1 -1
  9. package/dist/composites/gke-cluster.d.ts +5 -1
  10. package/dist/composites/gke-cluster.d.ts.map +1 -1
  11. package/dist/composites/managed-certificate.d.ts +1 -1
  12. package/dist/composites/managed-certificate.d.ts.map +1 -1
  13. package/dist/composites/memorystore-redis.d.ts +3 -1
  14. package/dist/composites/memorystore-redis.d.ts.map +1 -1
  15. package/dist/composites/private-service.d.ts +1 -1
  16. package/dist/composites/private-service.d.ts.map +1 -1
  17. package/dist/composites/pubsub-pipeline.d.ts +1 -1
  18. package/dist/composites/pubsub-pipeline.d.ts.map +1 -1
  19. package/dist/composites/secure-project.d.ts +1 -1
  20. package/dist/composites/secure-project.d.ts.map +1 -1
  21. package/dist/composites/vpc-network.d.ts +1 -1
  22. package/dist/composites/vpc-network.d.ts.map +1 -1
  23. package/dist/integrity.json +2 -2
  24. package/dist/manifest.json +1 -1
  25. package/dist/op/activities/floci-gcp.d.ts +20 -5
  26. package/dist/op/activities/floci-gcp.d.ts.map +1 -1
  27. package/dist/op/activities/gcp-apply.d.ts +101 -9
  28. package/dist/op/activities/gcp-apply.d.ts.map +1 -1
  29. package/dist/op/activities/index.d.ts +1 -1
  30. package/dist/op/activities/index.d.ts.map +1 -1
  31. package/dist/ownership.d.ts +36 -0
  32. package/dist/ownership.d.ts.map +1 -0
  33. package/package.json +2 -2
  34. package/src/composites/cloud-function.ts +1 -1
  35. package/src/composites/cloud-run-service.ts +1 -1
  36. package/src/composites/cloud-sql-instance.ts +1 -1
  37. package/src/composites/gcs-bucket.ts +1 -1
  38. package/src/composites/gke-cluster.ts +1 -1
  39. package/src/composites/managed-certificate.ts +1 -1
  40. package/src/composites/memorystore-redis.ts +1 -1
  41. package/src/composites/private-service.ts +1 -1
  42. package/src/composites/pubsub-pipeline.ts +1 -1
  43. package/src/composites/secure-project.ts +1 -1
  44. package/src/composites/vpc-network.ts +1 -1
  45. package/src/op/activities/floci-gcp.test.ts +25 -0
  46. package/src/op/activities/floci-gcp.ts +21 -6
  47. package/src/op/activities/gcp-apply.test.ts +234 -1
  48. package/src/op/activities/gcp-apply.ts +165 -24
  49. package/src/op/activities/index.ts +3 -0
  50. package/src/ownership.ts +41 -0
@@ -31,7 +31,7 @@ export interface PubSubPipelineProps {
31
31
  };
32
32
  }
33
33
 
34
- export const PubSubPipeline = Composite<PubSubPipelineProps>((props) => {
34
+ export const PubSubPipeline = Composite((props: PubSubPipelineProps) => {
35
35
  const {
36
36
  name,
37
37
  ackDeadlineSeconds = 10,
@@ -41,7 +41,7 @@ export interface SecureProjectProps {
41
41
  };
42
42
  }
43
43
 
44
- export const SecureProject = Composite<SecureProjectProps>((props) => {
44
+ export const SecureProject = Composite((props: SecureProjectProps) => {
45
45
  const {
46
46
  name,
47
47
  orgId,
@@ -68,7 +68,7 @@ export interface VpcNetworkProps {
68
68
  * });
69
69
  * ```
70
70
  */
71
- export const VpcNetwork = Composite<VpcNetworkProps>((props) => {
71
+ export const VpcNetwork = Composite((props: VpcNetworkProps) => {
72
72
  const {
73
73
  name,
74
74
  autoCreateSubnetworks = false,
@@ -5,7 +5,9 @@ import {
5
5
  flociGcpExistsCommand,
6
6
  flociGcpHealthUrl,
7
7
  flociGcpEndpoint,
8
+ FLOCI_GCP_EMULATOR,
8
9
  } from "./floci-gcp";
10
+ import { endpointEnvVars } from "@intentius/chant/op";
9
11
 
10
12
  describe("floci-gcp lifecycle commands (typed emulator, not shell)", () => {
11
13
  test("run command uses defaults and maps the port", () => {
@@ -27,3 +29,26 @@ describe("floci-gcp lifecycle commands (typed emulator, not shell)", () => {
27
29
  expect(flociGcpEndpoint(4588)).toBe("http://localhost:4588");
28
30
  });
29
31
  });
32
+
33
+ describe("floci-gcp emulator capability (#1431)", () => {
34
+ // The bug: `env` returned {}, so `chant emulator up --lexicon gcp` booted the
35
+ // emulator and left GCP_ENDPOINT_URL unset — pointing describeResources and
36
+ // observeResourcesDeep, which read that variable and nothing else, at real GCP.
37
+ test("injects the endpoint under the variable the READ path honours", () => {
38
+ expect(FLOCI_GCP_EMULATOR.env("http://localhost:4588")).toEqual({
39
+ GCP_ENDPOINT_URL: "http://localhost:4588",
40
+ });
41
+ });
42
+
43
+ test("injects whatever endpoint it is given, not a hardcoded port", () => {
44
+ expect(FLOCI_GCP_EMULATOR.env("http://localhost:4599")).toEqual({
45
+ GCP_ENDPOINT_URL: "http://localhost:4599",
46
+ });
47
+ });
48
+
49
+ // `endpointEnvVars` derives its list by probing `env`, so an empty `env` also
50
+ // meant core believed gcp had no endpoint variable at all.
51
+ test("core can now derive gcp's endpoint variable from the capability", () => {
52
+ expect(endpointEnvVars(FLOCI_GCP_EMULATOR)).toEqual(["GCP_ENDPOINT_URL"]);
53
+ });
54
+ });
@@ -30,16 +30,31 @@ export const FLOCI_GCP_SPEC: EmulatorSpec = {
30
30
  };
31
31
 
32
32
  /**
33
- * The gcp plugin's emulator capability (#1345).
33
+ * The gcp plugin's emulator capability (#1345, corrected in #1431).
34
34
  *
35
- * `env` is deliberately empty: `gcpApply` reaches the emulator through an
36
- * explicit `endpoint` argument rather than an ambient variable, so there is no
37
- * var to inject and claiming one would be worse than claiming none. The
38
- * endpoint is still reported by `chant emulator up --json`.
35
+ * The apply and read paths reach the emulator differently, and the capability
36
+ * exists for the one that cannot be told where to look:
37
+ *
38
+ * - **apply** `gcpApply` takes an explicit `endpoint` argument. Nothing to
39
+ * inject; the caller already holds it.
40
+ * - **read** — `describeResources` and `observeResourcesDeep` take it from
41
+ * `GCP_ENDPOINT_URL` and nothing else (`../../describe-resources.ts`,
42
+ * `../../deep-observe.ts`). A reader given no variable talks to real GCP.
43
+ *
44
+ * `env` was empty because when #1345 declared this capability the second bullet
45
+ * was not yet true: GCP was observed through Config Connector over kubectl, so
46
+ * there genuinely was no variable to inject. #1209 moved observation onto the
47
+ * applier's own direct-REST transport about two hours later, and nothing
48
+ * re-read this comment. The result was an emulator that booted, reported its
49
+ * endpoint, and left every `--live` read pointed at production.
50
+ *
51
+ * Injecting it makes `chant emulator up --lexicon gcp` mean the same thing for
52
+ * gcp that it already means for aws and azure: the estate's reads land on the
53
+ * emulator you just started.
39
54
  */
40
55
  export const FLOCI_GCP_EMULATOR: EmulatorCapability = {
41
56
  spec: FLOCI_GCP_SPEC,
42
- env: () => ({}),
57
+ env: (endpoint) => ({ GCP_ENDPOINT_URL: endpoint }),
43
58
  };
44
59
 
45
60
  const gcp = emulatorLifecycle(FLOCI_GCP_SPEC);
@@ -1,4 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
+ import { writeFileSync, unlinkSync } from "node:fs";
3
+ import { describeApplyConformance } from "@intentius/chant-test-utils";
2
4
  import {
3
5
  bucketInsertBody,
4
6
  pubSubTopicBody,
@@ -12,6 +14,9 @@ import {
12
14
  referencedNames,
13
15
  orderByReferences,
14
16
  pruneOrphans,
17
+ gcpApply,
18
+ gcpDelete,
19
+ toApplyResult,
15
20
  chantOwnershipLabels,
16
21
  isChantOwned,
17
22
  pubSubSubscriptionBody,
@@ -454,12 +459,47 @@ describe("ownership + prune (#706)", () => {
454
459
  return { status: 200, text: "" }; // DELETE
455
460
  };
456
461
  const resolve = () => ({ base: "http://x", project: "p" });
457
- const pruned = await pruneOrphans(desired, resolve, http);
462
+ const { pruned, notPrunable } = await pruneOrphans(desired, resolve, http);
458
463
  expect(pruned).toEqual([{ kind: "StorageBucket", name: "orphan", deleted: true }]);
464
+ expect(notPrunable).toEqual([]);
459
465
  expect(calls.filter((c) => c.method === "DELETE")).toEqual([
460
466
  { method: "DELETE", url: "http://x/storage/v1/b/orphan" },
461
467
  ]);
462
468
  });
469
+
470
+ // #1447. A kind the prune could not consider was `continue`, so `pruned: []`
471
+ // came back — which reads as "there was nothing to prune", not "I did not
472
+ // look at this kind". Same conflation the read path fixed in #1089/#1201.
473
+ test("a kind with no list capability is reported, not silently skipped", async () => {
474
+ // IAMServiceAccount is mapped but has no `list` spec.
475
+ const desired: GcpResource[] = [{ kind: "IAMServiceAccount", metadata: { name: "sa" } }];
476
+ const calls: string[] = [];
477
+ const http: GcpHttp = async (method) => {
478
+ calls.push(method);
479
+ return { status: 200, text: "{}" };
480
+ };
481
+ const { pruned, notPrunable } = await pruneOrphans(desired, () => ({ base: "http://x", project: "p" }), http);
482
+ expect(pruned).toEqual([]);
483
+ expect(notPrunable).toEqual([{ kind: "IAMServiceAccount", reason: "no-list-capability" }]);
484
+ // Reported without touching the transport at all.
485
+ expect(calls).toEqual([]);
486
+ });
487
+
488
+ // Not in #1447's list, same class: a failed LIST was also `continue`. A 403 or
489
+ // a 500 meant the kind went unconsidered and the result said nothing.
490
+ test("a failed list is reported, with the status, not silently skipped", async () => {
491
+ const desired: GcpResource[] = [{ kind: "StorageBucket", metadata: { name: "keep" } }];
492
+ const deletes: string[] = [];
493
+ const http: GcpHttp = async (method, url) => {
494
+ if (method === "DELETE") deletes.push(url);
495
+ return method === "GET" ? { status: 403, text: "forbidden" } : { status: 200, text: "" };
496
+ };
497
+ const { pruned, notPrunable } = await pruneOrphans(desired, () => ({ base: "http://x", project: "p" }), http);
498
+ expect(pruned).toEqual([]);
499
+ expect(notPrunable).toEqual([{ kind: "StorageBucket", reason: "list-failed", detail: "403: forbidden" }]);
500
+ // And nothing was deleted on the strength of a list that failed.
501
+ expect(deletes).toEqual([]);
502
+ });
463
503
  });
464
504
 
465
505
  describe("parseManifest (#711)", () => {
@@ -473,3 +513,196 @@ describe("parseManifest (#711)", () => {
473
513
  expect(docs.map((d) => d.kind)).toEqual(["StorageBucket", "PubSubTopic"]);
474
514
  });
475
515
  });
516
+
517
+ /**
518
+ * #1447 — a resource `MAPPERS` has no entry for was dropped with a
519
+ * `console.log`, and the return value looked exactly like a complete apply.
520
+ * `ApplyOp` reported success over a partial one.
521
+ *
522
+ * The GCP lexicon serializes many more kinds than the six `MAPPERS` covers, so
523
+ * this is not a hypothetical: anything else in the manifest was silently not
524
+ * applied.
525
+ */
526
+ describe("gcpApply / gcpDelete report what they did not attempt (#1447)", () => {
527
+ /** A manifest with one kind chant can apply and one it cannot. */
528
+ const manifest = (): string => {
529
+ const path = `/tmp/chant-1447-${process.pid}-${Math.random().toString(36).slice(2)}.json`;
530
+ writeFileSync(
531
+ path,
532
+ JSON.stringify([
533
+ { kind: "StorageBucket", metadata: { name: "mapped" }, spec: { location: "US" } },
534
+ { kind: "SQLInstance", metadata: { name: "unmapped" }, spec: {} },
535
+ ]),
536
+ );
537
+ return path;
538
+ };
539
+
540
+ const ok: GcpHttp = async (method) =>
541
+ method === "GET" ? { status: 404, text: "" } : { status: 200, text: "{}" };
542
+
543
+ test("an unmapped kind lands in notAttempted, not in applied", async () => {
544
+ const path = manifest();
545
+ try {
546
+ const res = await gcpApply({ manifestPath: path, endpoint: "http://x", project: "p" }, undefined, ok);
547
+ expect(res.applied.map((a) => a.name)).toEqual(["mapped"]);
548
+ expect(res.notAttempted).toEqual([
549
+ { kind: "SQLInstance", name: "unmapped", reason: "unsupported-kind" },
550
+ ]);
551
+ } finally {
552
+ unlinkSync(path);
553
+ }
554
+ });
555
+
556
+ // The distinction the old shape could not express: without notAttempted, this
557
+ // result and one from a manifest that never declared SQLInstance are identical.
558
+ test("a fully-applied manifest reports notAttempted empty", async () => {
559
+ const path = `/tmp/chant-1447-full-${process.pid}.json`;
560
+ writeFileSync(path, JSON.stringify([{ kind: "StorageBucket", metadata: { name: "only" }, spec: {} }]));
561
+ try {
562
+ const res = await gcpApply({ manifestPath: path, endpoint: "http://x", project: "p" }, undefined, ok);
563
+ expect(res.applied).toHaveLength(1);
564
+ expect(res.notAttempted).toEqual([]);
565
+ expect(res.notPrunable).toEqual([]);
566
+ } finally {
567
+ unlinkSync(path);
568
+ }
569
+ });
570
+
571
+ // The delete side matters more: a caller reading only `deleted` would believe
572
+ // the manifest was fully torn down.
573
+ test("gcpDelete reports the resources it never attempted to delete", async () => {
574
+ const path = manifest();
575
+ try {
576
+ const res = await gcpDelete({ manifestPath: path, endpoint: "http://x", project: "p" }, undefined, ok);
577
+ expect(res.deleted.map((d) => d.name)).toEqual(["mapped"]);
578
+ expect(res.notAttempted).toEqual([
579
+ { kind: "SQLInstance", name: "unmapped", reason: "unsupported-kind" },
580
+ ]);
581
+ } finally {
582
+ unlinkSync(path);
583
+ }
584
+ });
585
+
586
+ // A malformed manifest is a different fact from an unsupported kind — one is a
587
+ // bug in the input, the other a known gap in coverage. Only the second belongs
588
+ // in notAttempted.
589
+ test("a manifest entry with no kind throws instead of being skipped", async () => {
590
+ const path = `/tmp/chant-1447-nokind-${process.pid}.json`;
591
+ writeFileSync(path, JSON.stringify([{ metadata: { name: "kindless" } }]));
592
+ try {
593
+ await expect(
594
+ gcpApply({ manifestPath: path, endpoint: "http://x", project: "p" }, undefined, ok),
595
+ ).rejects.toThrow(/no "kind".*kindless.*malformed manifest/s);
596
+ } finally {
597
+ unlinkSync(path);
598
+ }
599
+ });
600
+ });
601
+
602
+ /**
603
+ * The shared apply-contract suite (#1446), run against gcp's own mocked
604
+ * transport. gcp is the reference adopter because #1447 already gave it the
605
+ * not-attempted facts; this proves the seam carries them.
606
+ *
607
+ * Assertion 3 ("accounts for every resource in the plan") is the one that fails
608
+ * on pre-#1447 gcp — an unmapped kind was in the plan and in no bucket.
609
+ */
610
+ describeApplyConformance({
611
+ lexicon: "gcp",
612
+ scenarios: [
613
+ {
614
+ name: "a manifest with one mapped and one unmapped kind",
615
+ plan: [
616
+ { kind: "StorageBucket", name: "mapped" },
617
+ { kind: "SQLInstance", name: "unmapped" },
618
+ ],
619
+ run: async () => {
620
+ const path = `/tmp/chant-1446-gcp-${process.pid}.json`;
621
+ writeFileSync(
622
+ path,
623
+ JSON.stringify([
624
+ { kind: "StorageBucket", metadata: { name: "mapped" }, spec: { location: "US" } },
625
+ { kind: "SQLInstance", metadata: { name: "unmapped" }, spec: {} },
626
+ ]),
627
+ );
628
+ try {
629
+ const http: GcpHttp = async (method) =>
630
+ method === "GET" ? { status: 404, text: "" } : { status: 200, text: "{}" };
631
+ return toApplyResult(
632
+ await gcpApply({ manifestPath: path, endpoint: "http://x", project: "p" }, undefined, http),
633
+ );
634
+ } finally {
635
+ unlinkSync(path);
636
+ }
637
+ },
638
+ expectApplied: ["StorageBucket/mapped"],
639
+ expectNotAttempted: ["SQLInstance/unmapped"],
640
+ },
641
+ ],
642
+ pruneScenarios: [
643
+ {
644
+ name: "an owned orphan beside a foreign resource in the same project",
645
+ ownedOrphan: "orphan",
646
+ foreign: "foreign",
647
+ run: async () => {
648
+ const deletes: string[] = [];
649
+ const live = {
650
+ items: [
651
+ { name: "keep", labels: { "managed-by": "chant" } },
652
+ { name: "orphan", labels: { "managed-by": "chant" } },
653
+ { name: "foreign", labels: { "managed-by": "terraform" } },
654
+ ],
655
+ };
656
+ const path = `/tmp/chant-1446-gcp-prune-${process.pid}.json`;
657
+ writeFileSync(
658
+ path,
659
+ JSON.stringify([{ kind: "StorageBucket", metadata: { name: "keep" }, spec: {} }]),
660
+ );
661
+ try {
662
+ const http: GcpHttp = async (method, url) => {
663
+ if (method === "DELETE") deletes.push(url);
664
+ if (method === "GET" && url.includes("/b?")) return { status: 200, text: JSON.stringify(live) };
665
+ if (method === "GET") return { status: 200, text: "{}" }; // `keep` already exists
666
+ return { status: 200, text: "{}" };
667
+ };
668
+ const result = toApplyResult(
669
+ await gcpApply(
670
+ { manifestPath: path, endpoint: "http://x", project: "p", prune: true },
671
+ undefined,
672
+ http,
673
+ ),
674
+ );
675
+ return { result, deletes };
676
+ } finally {
677
+ unlinkSync(path);
678
+ }
679
+ },
680
+ },
681
+ ],
682
+ idempotenceScenarios: [
683
+ {
684
+ name: "the same manifest applied twice",
685
+ run: async () => {
686
+ const path = `/tmp/chant-1446-gcp-idem-${process.pid}.json`;
687
+ writeFileSync(
688
+ path,
689
+ JSON.stringify([{ kind: "StorageBucket", metadata: { name: "b" }, spec: { location: "US" } }]),
690
+ );
691
+ try {
692
+ let exists = false;
693
+ const http: GcpHttp = async (method) => {
694
+ if (method === "GET") return exists ? { status: 200, text: "{}" } : { status: 404, text: "" };
695
+ exists = true;
696
+ return { status: 200, text: "{}" };
697
+ };
698
+ const args = { manifestPath: path, endpoint: "http://x", project: "p" };
699
+ const first = toApplyResult(await gcpApply(args, undefined, http));
700
+ const second = toApplyResult(await gcpApply(args, undefined, http));
701
+ return { first, second };
702
+ } finally {
703
+ unlinkSync(path);
704
+ }
705
+ },
706
+ },
707
+ ],
708
+ });
@@ -1,23 +1,40 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { parseYAML } from "@intentius/chant/yaml";
3
3
  import { safeHeartbeat, sleep } from "@intentius/chant/op";
4
+ import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
5
+ import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership";
6
+ import {
7
+ applyResult,
8
+ type ApplyResult,
9
+ type AppliedResource,
10
+ type NotAttemptedResource,
11
+ } from "@intentius/chant/apply";
4
12
 
5
13
  const PROJECT_ID_ANNOTATION = "cnrm.cloud.google.com/project-id";
6
14
 
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. */
15
+ /**
16
+ * The GCP labels chant stamps on resources it creates.
17
+ *
18
+ * Only the managed-by marker, not stack/env: this applier has no ownership
19
+ * marker to hand — the serializer stamps those from project config onto the
20
+ * Config Connector object. Same shape the fly serializer uses when no ownership
21
+ * context is set.
22
+ */
14
23
  export function chantOwnershipLabels(): Record<string, string> {
15
- return { [OWNERSHIP_LABEL_KEY]: OWNERSHIP_LABEL_VALUE };
24
+ return { [GCP_RESOURCE_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE };
16
25
  }
17
26
 
18
- /** True when a live resource's labels carry chant's ownership marker. */
27
+ /**
28
+ * True when a live resource's labels carry chant's ownership marker.
29
+ *
30
+ * Resolves through core's `hasOwnershipMarker` against the lexicon's declared
31
+ * GCP-resource channel (#1446) rather than comparing a string literal inline,
32
+ * so the stamp and the prune filter cannot drift. The key itself is unchanged
33
+ * and deliberately GCP-valid — see `../../ownership.ts` for why this surface
34
+ * cannot use core's `LABEL_OWNERSHIP_KEYS`.
35
+ */
19
36
  export function isChantOwned(labels: Record<string, string> | null | undefined): boolean {
20
- return labels?.[OWNERSHIP_LABEL_KEY] === OWNERSHIP_LABEL_VALUE;
37
+ return hasOwnershipMarker(labels ?? undefined, GCP_RESOURCE_OWNERSHIP_KEYS);
21
38
  }
22
39
 
23
40
  /** Merge the ownership marker into a create/update body's `labels`. */
@@ -533,6 +550,25 @@ export async function waitForOperation(
533
550
  throw new Error(`operation did not complete within timeout: ${pollUrl}`);
534
551
  }
535
552
 
553
+ /**
554
+ * A manifest entry with no `kind` is malformed, not unsupported (#1447).
555
+ *
556
+ * The old guard skipped it in silence, which put a hand-written or truncated
557
+ * manifest in the same bucket as a kind chant simply has no mapper for. One is
558
+ * a bug in the input and the other is a known gap in coverage; only the second
559
+ * belongs in `notAttempted`. Throws so the first surfaces.
560
+ */
561
+ function requireKind(resource: GcpResource, manifestPath: string): boolean {
562
+ if (!resource.kind) {
563
+ const name = resource.metadata?.name;
564
+ throw new Error(
565
+ `${manifestPath}: manifest entry has no "kind"${name ? ` (metadata.name: ${name})` : ""} — ` +
566
+ `a CNRM resource must declare one. This is a malformed manifest, not an unsupported kind.`,
567
+ );
568
+ }
569
+ return true;
570
+ }
571
+
536
572
  /** Parse a built manifest into CNRM resource objects — YAML (multi-doc) or JSON. Pure. */
537
573
  export function parseManifest(content: string, path: string): GcpResource[] {
538
574
  if (/\.json$/i.test(path)) {
@@ -561,17 +597,90 @@ export interface GcpApplyArgs {
561
597
  prune?: boolean;
562
598
  }
563
599
 
600
+ /**
601
+ * Normalize a gcp apply/delete result into core's apply envelope (#1446).
602
+ *
603
+ * The lexicon keeps its own richer shape — `created`/`updated` per resource,
604
+ * the CNRM `kind` — and this projects it onto the shared tri-state so a caller
605
+ * can read any applier's result the same way. Core's contract is the interface;
606
+ * it is not a replacement for what this applier knows.
607
+ */
608
+ export function toApplyResult(result: {
609
+ applied?: Array<{ kind: string; name: string; created: boolean; updated: boolean }>;
610
+ deleted?: Array<{ kind: string; name: string; deleted: boolean }>;
611
+ pruned?: Array<{ kind: string; name: string; deleted: boolean }>;
612
+ notAttempted?: GcpNotAttempted[];
613
+ notPrunable?: GcpNotPrunable[];
614
+ }): ApplyResult {
615
+ const applied: AppliedResource[] = (result.applied ?? []).map((a) => ({
616
+ kind: a.kind,
617
+ name: a.name,
618
+ action: a.created ? "created" : a.updated ? "updated" : "unchanged",
619
+ }));
620
+ const notAttempted: NotAttemptedResource[] = [
621
+ ...(result.notAttempted ?? []).map((n) => ({ kind: n.kind, name: n.name, reason: n.reason })),
622
+ // A kind the prune could not consider has no single resource name — the
623
+ // whole kind went unexamined, which `not-prunable` is the reason for.
624
+ ...(result.notPrunable ?? []).map((n) => ({
625
+ kind: n.kind,
626
+ name: "*",
627
+ reason: "not-prunable" as const,
628
+ ...(n.detail ? { detail: n.detail } : {}),
629
+ })),
630
+ ];
631
+ // `gcpDelete` reports deletions as `deleted`; they are prunes in the shared
632
+ // vocabulary — the applier removed something the plan no longer wants.
633
+ return applyResult(applied, result.pruned ?? result.deleted ?? [], notAttempted);
634
+ }
635
+
636
+ /**
637
+ * A resource this run did not attempt, and why (#1447).
638
+ *
639
+ * The read path learned this in #1089/#1201: a read that never happened is not
640
+ * the same fact as a resource that is not there, and collapsing the two made
641
+ * "absent" mean both. `observation.ts` puts it as "a warn on stderr is not a
642
+ * signal in a change set" — and a `console.log` on stdout is not a signal in an
643
+ * apply result, for the same reason. So the skips ride the return value.
644
+ */
645
+ export interface GcpNotAttempted {
646
+ kind: string;
647
+ name: string;
648
+ /** `MAPPERS` has no entry for this kind, so no REST call exists to make. */
649
+ reason: "unsupported-kind";
650
+ }
651
+
652
+ /** A kind an owned-only prune could not consider, and why (#1447). */
653
+ export interface GcpNotPrunable {
654
+ kind: string;
655
+ /**
656
+ * - `no-list-capability` — the mapper cannot enumerate live resources of this
657
+ * kind, so an owned orphan of it is never even a delete candidate.
658
+ * - `list-failed` — the list call itself failed. Transient or not, it means
659
+ * this kind was not considered, which `pruned: []` alone would report as
660
+ * "nothing to prune".
661
+ */
662
+ reason: "no-list-capability" | "list-failed";
663
+ /** Status and body for `list-failed`, so the caller can tell apart a 403 from a 500. */
664
+ detail?: string;
665
+ }
666
+
564
667
  /**
565
668
  * Owned-only prune: for each manifested kind with `list` support, delete the
566
669
  * chant-owned live resources whose name is not in `desiredByKind`. Foreign
567
670
  * resources (no ownership label) are left alone.
671
+ *
672
+ * Returns what it could not consider alongside what it deleted — a kind with no
673
+ * `list`, or whose list call failed, is reported rather than dropped (#1447).
568
674
  */
569
675
  export async function pruneOrphans(
570
676
  desired: GcpResource[],
571
677
  resolve: (mapper: ResourceMapper, resource?: GcpResource) => { base: string; project: string },
572
678
  http: GcpHttp = defaultHttp,
573
679
  signal?: AbortSignal,
574
- ): Promise<Array<{ kind: string; name: string; deleted: boolean }>> {
680
+ ): Promise<{
681
+ pruned: Array<{ kind: string; name: string; deleted: boolean }>;
682
+ notPrunable: GcpNotPrunable[];
683
+ }> {
575
684
  const desiredByKind = new Map<string, Set<string>>();
576
685
  for (const r of desired) {
577
686
  if (!r.kind) continue;
@@ -581,12 +690,21 @@ export async function pruneOrphans(
581
690
  }
582
691
 
583
692
  const pruned: Array<{ kind: string; name: string; deleted: boolean }> = [];
693
+ const notPrunable: GcpNotPrunable[] = [];
584
694
  for (const [kind, keep] of desiredByKind) {
585
695
  const mapper = MAPPERS[kind];
586
- if (!mapper?.list) continue;
696
+ if (!mapper?.list) {
697
+ console.log(`prune: cannot list kind ${kind} — owned orphans of it are not considered`);
698
+ notPrunable.push({ kind, reason: "no-list-capability" });
699
+ continue;
700
+ }
587
701
  const ctx = resolve(mapper, desired.find((r) => r.kind === kind));
588
702
  const res = await http("GET", mapper.list.url(ctx), undefined, signal);
589
- if (res.status >= 300) continue;
703
+ if (res.status >= 300) {
704
+ console.log(`prune: listing kind ${kind} failed (${res.status}) — not considered`);
705
+ notPrunable.push({ kind, reason: "list-failed", detail: `${res.status}: ${res.text}` });
706
+ continue;
707
+ }
590
708
  for (const item of mapper.list.items(parseJson(res.text))) {
591
709
  if (!isChantOwned(item.labels) || keep.has(item.name)) continue;
592
710
  safeHeartbeat({ step: "prune", kind, name: item.name });
@@ -595,7 +713,7 @@ export async function pruneOrphans(
595
713
  pruned.push(result);
596
714
  }
597
715
  }
598
- return pruned;
716
+ return { pruned, notPrunable };
599
717
  }
600
718
 
601
719
  /**
@@ -603,8 +721,13 @@ export async function pruneOrphans(
603
721
  * resource directly to its GCP REST API, targeting a local floci-gcp emulator or
604
722
  * real GCP by endpoint override. Unlike AWS/Azure/k8s, GCP has no native deploy
605
723
  * 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.
724
+ * `MAPPERS` dispatch table). Uses longInfra profile. `http` is injectable for
725
+ * tests.
726
+ *
727
+ * A kind `MAPPERS` does not cover is **reported, not dropped** — it comes back
728
+ * in `notAttempted` (#1447). Before that, it was a `console.log` and a
729
+ * populated `applied` array, so a caller could not tell a partial apply from a
730
+ * complete one and `ApplyOp` reported success either way.
608
731
  */
609
732
  export async function gcpApply(
610
733
  args: GcpApplyArgs,
@@ -613,6 +736,10 @@ export async function gcpApply(
613
736
  ): Promise<{
614
737
  applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }>;
615
738
  pruned: Array<{ kind: string; name: string; deleted: boolean }>;
739
+ /** Declared resources no REST call was made for. Empty on a complete apply. */
740
+ notAttempted: GcpNotAttempted[];
741
+ /** Kinds the prune could not consider. Empty when `prune` is off. */
742
+ notPrunable: GcpNotPrunable[];
616
743
  }> {
617
744
  const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath));
618
745
  const resolve = (mapper: ResourceMapper, resource?: GcpResource) => ({
@@ -621,10 +748,13 @@ export async function gcpApply(
621
748
  });
622
749
 
623
750
  const applied: Array<{ kind: string; name: string; created: boolean; updated: boolean }> = [];
751
+ const notAttempted: GcpNotAttempted[] = [];
624
752
  for (const r of resources) {
625
- const mapper = r.kind ? MAPPERS[r.kind] : undefined;
753
+ const mapper = requireKind(r, args.manifestPath) ? MAPPERS[r.kind as string] : undefined;
626
754
  if (!mapper) {
627
- if (r.kind) console.log(`skip: no mapper for kind ${r.kind}`);
755
+ const kind = r.kind as string;
756
+ console.log(`skip: no mapper for kind ${kind}`);
757
+ notAttempted.push({ kind, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
628
758
  continue;
629
759
  }
630
760
  const ctx = resolve(mapper, r);
@@ -635,8 +765,10 @@ export async function gcpApply(
635
765
  applied.push(result);
636
766
  }
637
767
 
638
- const pruned = args.prune ? await pruneOrphans(resources, resolve, http, signal) : [];
639
- return { applied, pruned };
768
+ const prune = args.prune
769
+ ? await pruneOrphans(resources, resolve, http, signal)
770
+ : { pruned: [], notPrunable: [] };
771
+ return { applied, pruned: prune.pruned, notAttempted, notPrunable: prune.notPrunable };
640
772
  }
641
773
 
642
774
  /**
@@ -679,15 +811,24 @@ export async function gcpDelete(
679
811
  args: GcpApplyArgs,
680
812
  signal?: AbortSignal,
681
813
  http: GcpHttp = defaultHttp,
682
- ): Promise<{ deleted: Array<{ kind: string; name: string; deleted: boolean }> }> {
814
+ ): Promise<{
815
+ deleted: Array<{ kind: string; name: string; deleted: boolean }>;
816
+ /** Declared resources no delete was attempted for — the delete-side twin of
817
+ * `gcpApply`'s field, and the more consequential one: a caller reading only
818
+ * `deleted` would believe the manifest was fully torn down (#1447). */
819
+ notAttempted: GcpNotAttempted[];
820
+ }> {
683
821
  // Delete in reverse dependency order: a referrer goes before the resource it
684
822
  // references.
685
823
  const resources = orderByReferences(parseManifest(readFileSync(args.manifestPath, "utf8"), args.manifestPath)).reverse();
686
824
  const deleted: Array<{ kind: string; name: string; deleted: boolean }> = [];
825
+ const notAttempted: GcpNotAttempted[] = [];
687
826
  for (const r of resources) {
688
- const mapper = r.kind ? MAPPERS[r.kind] : undefined;
827
+ const mapper = requireKind(r, args.manifestPath) ? MAPPERS[r.kind as string] : undefined;
689
828
  if (!mapper) {
690
- if (r.kind) console.log(`skip: no mapper for kind ${r.kind}`);
829
+ const kind = r.kind as string;
830
+ console.log(`skip: no mapper for kind ${kind}`);
831
+ notAttempted.push({ kind, name: r.metadata?.name ?? "?", reason: "unsupported-kind" });
691
832
  continue;
692
833
  }
693
834
  const base = (args.endpoint ?? mapper.defaultHost).replace(/\/$/, "");
@@ -697,5 +838,5 @@ export async function gcpDelete(
697
838
  console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`);
698
839
  deleted.push(result);
699
840
  }
700
- return { deleted };
841
+ return { deleted, notAttempted };
701
842
  }
@@ -27,6 +27,9 @@ export {
27
27
  referencedNames,
28
28
  orderByReferences,
29
29
  pruneOrphans,
30
+ toApplyResult,
31
+ type GcpNotAttempted,
32
+ type GcpNotPrunable,
30
33
  chantOwnershipLabels,
31
34
  isChantOwned,
32
35
  resolveGcpProject,