@intentius/chant-lexicon-gcp 0.30.0 → 0.32.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,39 @@
1
+ /**
2
+ * The gcp lexicon's *static* deep-observation noise rules (#1087, epic #1073).
3
+ *
4
+ * Split out from `./deep-observe.ts` for the same reason chant #1076 split
5
+ * the k8s lexicon's rules into `lexicons/k8s/src/deep-observe-hooks.ts`: this
6
+ * file must be safe to import from `plugin.ts` at module load time, because
7
+ * `LexiconPlugin.deepNormalizationHooks` is plain data core reads to
8
+ * normalize the *declared* tree — the half of the contract that runs whether
9
+ * or not a cluster is ever touched (`lifecycle diff` without `--live`,
10
+ * `chant build`, tests that only exercise normalization). `./deep-observe.ts`
11
+ * imports `./describe-resources.ts` for the live kubectl transport, which
12
+ * pulls in `node:child_process`; nothing this file exports may pull that in,
13
+ * directly or transitively, so `chant build` never resolves a process-
14
+ * spawning module just to normalize a declared tree.
15
+ *
16
+ * What lives here is entirely reused from `@intentius/chant/managed-fields`
17
+ * (chant #1087's module doc explains why that lives in core rather than the
18
+ * k8s lexicon) plus GCP/Config-Connector-specific annotation noise. Nothing
19
+ * here needs a live object in hand — the per-resource managed-fields prune
20
+ * (which does) is computed in `./deep-observe.ts` and layered on top.
21
+ */
22
+ import type { DeepNormalizationHooks } from "@intentius/chant/lexicon";
23
+ /**
24
+ * GCP's static deep-observation noise rules — the entity-agnostic half,
25
+ * applied to the *declared* tree by core (`gcpPlugin.deepNormalizationHooks`)
26
+ * exactly like the other three rows, and layered under the per-resource
27
+ * managed-fields prune in `./deep-observe.ts` for the live tree.
28
+ *
29
+ * A Config Connector custom resource *is* a Kubernetes object (see
30
+ * `./deep-observe.ts`'s module doc), so the generic envelope
31
+ * (`K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS`) and Kubernetes' own list-map-key
32
+ * ordering conventions (`k8sListMapOrderKey` — needed because some CNRM kinds
33
+ * embed genuinely k8s-shaped substructures, e.g. Cloud Run's `RunService`)
34
+ * are reused verbatim from `@intentius/chant/managed-fields` rather than
35
+ * restated. The one GCP-specific rule is CNRM's own observed-state
36
+ * annotation noise.
37
+ */
38
+ export declare const gcpDeepNormalizationHooks: DeepNormalizationHooks;
39
+ //# sourceMappingURL=deep-observe-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-observe-hooks.d.ts","sourceRoot":"","sources":["../src/deep-observe-hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAY,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAmCjF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,yBAAyB,EAAE,sBAMvC,CAAC"}
@@ -0,0 +1,141 @@
1
+ /**
2
+ * GCP deep observation (#1087) — the GCP row of the deep-observe contract
3
+ * (#1014), reusing the k8s row (#1076) rather than writing a second one, per
4
+ * the issue and #1173's own per-row checklist: "A Config Connector resource
5
+ * *is* a Kubernetes object, so it carries managed-fields for the same
6
+ * reason. Share the hooks rather than writing a second implementation."
7
+ *
8
+ * ## A Config Connector CR is a Kubernetes object
9
+ *
10
+ * `kubectl get <kind>.<group> -o json` against a Config Connector-enabled
11
+ * cluster (`./describe-resources.ts`'s own transport, reused here) returns a
12
+ * real Kubernetes API object: `metadata.managedFields`, `status`,
13
+ * `metadata.{uid,resourceVersion,generation,creationTimestamp}` are all
14
+ * present for the identical reason they're present on a Deployment — the API
15
+ * server that served the read is the same kind of API server. The epic's own
16
+ * table lists `metadata.managedFields` under GCP's "field noise" to prune; it
17
+ * is the opposite of noise, and it is available here for exactly the reason
18
+ * chant #1076 reads it on the k8s row.
19
+ *
20
+ * So this reader reuses, unchanged, from `@intentius/chant/managed-fields`:
21
+ *
22
+ * - `K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS` — the generic Kubernetes object
23
+ * envelope (`status`, `metadata.{uid,resourceVersion,generation,
24
+ * creationTimestamp,managedFields,selfLink}`).
25
+ * - `k8sListMapOrderKey` — Kubernetes' own list-map-key conventions, needed
26
+ * because some CNRM kinds embed genuinely k8s-shaped substructures (Cloud
27
+ * Run's `RunService` wraps a Knative pod spec with
28
+ * `containers`/`env`/`ports`, keyed the same way a Deployment's are).
29
+ * - `buildOwnershipSets`/`pruneByOwnership` — the managed-fields ownership
30
+ * walk itself: chant #1076's actual contribution, and the piece this row
31
+ * exists to reuse rather than reimplement.
32
+ *
33
+ * These live in core, not in the k8s lexicon, specifically so this file can
34
+ * reuse them without adding a dependency on the k8s lexicon package or (never,
35
+ * per the issue) on `@intentius/chant-k8s-client` — this reader shells
36
+ * `kubectl` exactly like `./describe-resources.ts` already does. Chant
37
+ * #1177/#1180 kept GCP off the typed client deliberately ("a separate
38
+ * lexicon and a separate decision"); this row does not revisit that.
39
+ *
40
+ * ## The GCP twist: who is "chant" on this path?
41
+ *
42
+ * Chant #1076's rule treats a field as always-diffable when *any* chant field
43
+ * manager (`chant`, `chant:<stack>` — chant #1075's SSA identity) owns it.
44
+ * That identity is only ever set by the k8s lexicon's typed client, applying
45
+ * with an explicit `--field-manager`. GCP has no equivalent apply activity:
46
+ * every gcp example's own deploy script (e.g.
47
+ * `examples/cockroachdb-multi-region-gke/scripts/deploy.sh`) runs plain
48
+ * `kubectl apply -f dist/*.yaml` — classic client-side apply, no
49
+ * `--server-side`, no `--field-manager`. kubectl's own hardcoded default
50
+ * field manager for that command is `kubectl-client-side-apply`, identical
51
+ * to what a human's `kubectl apply -f their-own-file.yaml` would record.
52
+ * **There is no field-manager name on GCP's real deploy path that
53
+ * distinguishes "chant applied this" from "a person applied this".**
54
+ * (Config Connector's own controller is distinguishable — its reconciler
55
+ * records itself as `cnrm-controller-manager`, per the k8s-config-connector
56
+ * source's own `ControllerManagedFieldManager` constant — just chant isn't,
57
+ * on this path.)
58
+ *
59
+ * That sounds like it breaks question 1 of the three-question rule
60
+ * (`isChantManager`) below. It doesn't, because question 3 already covers
61
+ * it: chant's own kubectl apply always applies the *exact* built manifest, so
62
+ * any field chant's own most recent apply set is, by construction, also in
63
+ * `declaredRoot` (`props`) on the very next read — chant never authors a
64
+ * field it does not also declare. Working through what that means per field:
65
+ *
66
+ * - A field only chant's own applies have ever touched: not recognized as
67
+ * `chantOwned` (its manager is `kubectl-client-side-apply`, not `chant`),
68
+ * but foreign-owned *and* declared → contested → kept diffable anyway.
69
+ * Same outcome as being chant-owned would have been.
70
+ * - A field CNRM's controller sets and chant never declares: foreign-owned,
71
+ * undeclared → pruned. Correct regardless of whether "chant" is recognized.
72
+ * - A field a human sets by hand (`kubectl edit`, or their own `kubectl apply
73
+ * -f`) that chant *does* declare: foreign-owned (manager is `kubectl-edit`
74
+ * or `kubectl-client-side-apply`) and declared → contested → surfaces as
75
+ * drift. This is #1087's acceptance criterion 1.
76
+ *
77
+ * `isGcpChantFieldManager` below still checks the `chant`/`chant:<stack>`
78
+ * family, so a future GCP apply path that *does* route through server-side
79
+ * apply with an explicit chant field manager (matching the k8s lexicon's own
80
+ * path) is recognized with no change needed here. On today's plain-apply
81
+ * path it is inert — proven inert by this module's own test suite (the
82
+ * "managers-specific case") — and the contested-field rule (question 3) is
83
+ * what actually keeps GCP's drift semantics correct without it.
84
+ *
85
+ * ## What's genuinely GCP-specific
86
+ *
87
+ * - No typed operation surface (chant #1177's `operationFor` is a k8s
88
+ * lexicon/typed-client concept, generated from cluster discovery this
89
+ * reader never touches) — `./describe-resources.ts`'s `deriveGVK` stands in.
90
+ * - CNRM's own observed-state annotations (`./deep-observe-hooks.ts`'s
91
+ * `gcpDeepNormalizationHooks`) — bookkeeping the controller writes into
92
+ * `metadata.annotations` for GCP-side properties the CRD schema has no
93
+ * field for. See that module's doc for why the list is narrow.
94
+ *
95
+ * ## Deliberately out of scope
96
+ *
97
+ * `status.conditions` staleness as its own signal — the issue's own
98
+ * "Proposed" section floats this as worth *deciding*, not worth doing here.
99
+ * `status` is pruned outright (matching k8s, and the acceptance criteria),
100
+ * and a CNRM projection lagging real GCP state is a genuine gap, but
101
+ * detecting it means reading the actual GCP API, not the CR — a different
102
+ * read than this contract's "normalize what Kubernetes reports" scope.
103
+ * Flagged, not papered over.
104
+ *
105
+ * ## The build-path boundary
106
+ *
107
+ * This module imports `./describe-resources.ts` for the live kubectl
108
+ * transport, which imports `node:child_process`. `gcpPlugin.ts` reaches this
109
+ * file only via `await import("./deep-observe.js")` inside
110
+ * `observeResourcesDeep` — never statically — the same way it already
111
+ * dynamic-imports `describeResources`/`exportResources`, so `chant build`
112
+ * never resolves a process-spawning module just to synthesize a template.
113
+ * `deepNormalizationHooks` is plain data with no such dependency
114
+ * (`./deep-observe-hooks.ts`) and is imported statically from `plugin.ts`,
115
+ * because core normalizes the *declared* tree with it whether or not a
116
+ * cluster read ever happens.
117
+ */
118
+ import type { DeepObservationResult } from "@intentius/chant/lexicon";
119
+ import { gcpDeepNormalizationHooks } from "./deep-observe-hooks.js";
120
+ export { gcpDeepNormalizationHooks };
121
+ export interface GcpDeepObserveOptions {
122
+ environment: string;
123
+ buildOutput?: string;
124
+ entityNames: string[];
125
+ entities: Map<string, {
126
+ entityType: string;
127
+ props: Record<string, unknown>;
128
+ }>;
129
+ stack?: string;
130
+ owned?: boolean;
131
+ }
132
+ /**
133
+ * Read the live property tree for each declared entity via `kubectl get
134
+ * <kind>.<group> -o json`, pruning by `metadata.managedFields` (see the
135
+ * module doc). Reuses `./describe-resources.ts`'s exact cluster-binding
136
+ * resolution and kubectl mechanics — the binding check (chant #1100) still
137
+ * refuses before any resource is touched, and a connect failure still
138
+ * becomes NOT-OBSERVED for every declared entity rather than an empty result.
139
+ */
140
+ export declare function observeResourcesDeepGcp(options: GcpDeepObserveOptions): Promise<DeepObservationResult>;
141
+ //# sourceMappingURL=deep-observe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-observe.d.ts","sourceRoot":"","sources":["../src/deep-observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoHG;AAEH,OAAO,KAAK,EAEV,qBAAqB,EAGtB,MAAM,0BAA0B,CAAC;AAMlC,OAAO,EAAE,yBAAyB,EAAE,MAAM,sBAAsB,CAAC;AAMjE,OAAO,EAAE,yBAAyB,EAAE,CAAC;AAErC,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAkDD;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyE5G"}
@@ -20,8 +20,31 @@
20
20
  * `chant.config.ts` (see `lexicons/k8s/src/config.ts`), not a separate
21
21
  * `gcp.profiles` key, because it is fundamentally the same kubectl context a
22
22
  * project's K8s entities would use against the same cluster.
23
+ *
24
+ * `resolveGcpKubectlContext` and `execConfigConnectorGet` below are exported
25
+ * so `./deep-observe.ts` (chant #1087) shares this exact cluster-binding
26
+ * resolution and `kubectl get -o json` mechanics rather than restating them —
27
+ * the two readers differ only in how much of the response each one keeps.
23
28
  */
24
29
  import type { ObservationResult } from "@intentius/chant/lexicon";
30
+ /**
31
+ * Resolve this environment's cluster binding once and return the `--context`
32
+ * argv fragment every subsequent kubectl call should append (empty when
33
+ * unbound — ambient context, unchanged behavior). Throws on a bound-but-
34
+ * mismatched context (chant #1100), the same loud refusal both readers rely
35
+ * on core to turn into NOT-OBSERVED for every declared entity.
36
+ */
37
+ export declare function resolveGcpKubectlContext(environment: string): Promise<string[]>;
38
+ /**
39
+ * `kubectl get <kind>.<group> <name> [-n <namespace>] [--context <ctx>] -o
40
+ * json`, parsed. Throws on any kubectl failure — callers classify it with
41
+ * `classifyKubectlFailure` exactly like today, so a NotFound and an
42
+ * auth/connectivity failure are told apart at the call site, not here.
43
+ */
44
+ export declare function execConfigConnectorGet(gvk: {
45
+ group: string;
46
+ kind: string;
47
+ }, name: string, namespace: string | undefined, ctxArg: readonly string[]): Promise<Record<string, unknown>>;
25
48
  /**
26
49
  * Mirror of `lexicons/gcp/src/serializer.ts:deriveGVKFromType` — keeping the
27
50
  * derivation logic local so describeResources can compute the kubectl resource
@@ -1 +1 @@
1
- {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,MAAM,0BAA0B,CAAC;AAuBtG;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CASpF;AA4BD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CA8E7B"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,MAAM,0BAA0B,CAAC;AAQtG;;;;;;GAMG;AACH,wBAAsB,wBAAwB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAIrF;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACpC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAMlC;AAiBD;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CASpF;AA4BD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAuE7B"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "934b2e465f4a0be499267e8b23f84e1df1353c29eb599046ccb50bc20b3c2bd8",
4
+ "manifest.json": "364da7aab0d1cf639b4bb9b4da365272cd1e9fbebc1490237c12f0b3570473da",
5
5
  "meta.json": "475f10909854d059a2e2d4fb541c058803422e38b9c64bd4d6858c0623eedb8c",
6
6
  "types/index.d.ts": "5433b54c04b57d6fda8f3d81ef92ed0aca87f7e8e5dd3ea92d8de6e62a3ccfab",
7
7
  "rules/hardcoded-project.ts": "228631d3159e1ffcce2359c66073d4ae59bb0285f378e46e446f416aec50481c",
@@ -37,5 +37,5 @@
37
37
  "skills/chant-gcp-patterns.md": "a7ef31c1eb2f7244d3f73952c300472ef94c1eb09bd7a1003281b89299b6b704",
38
38
  "skills/chant-gcp-gke.md": "be277019da9a722c851e47cd2dfb9c9536668948c3535fb20db7697e934c4e2b"
39
39
  },
40
- "composite": "9b0f2abc33da4def1bad8cc078ec2d56039630e59fe397152d17d1a317f80b30"
40
+ "composite": "ee170703110b77abd20502d2047a75cf91c12c2e70da5203b0e057ff6d4788c7"
41
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcp",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "GCP",
6
6
  "intrinsics": [],
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAoBjG,eAAO,MAAM,SAAS,EAAE,aAuWvB,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAqBjG,eAAO,MAAM,SAAS,EAAE,aAuXvB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-gcp",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Google Cloud lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -76,7 +76,7 @@
76
76
  "typescript": "^5.9.3"
77
77
  },
78
78
  "peerDependencies": {
79
- "@intentius/chant": "^0.30.0",
79
+ "@intentius/chant": "^0.32.0",
80
80
  "typescript": "^5.9.3"
81
81
  }
82
82
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The gcp lexicon's *static* deep-observation noise rules (#1087, epic #1073).
3
+ *
4
+ * Split out from `./deep-observe.ts` for the same reason chant #1076 split
5
+ * the k8s lexicon's rules into `lexicons/k8s/src/deep-observe-hooks.ts`: this
6
+ * file must be safe to import from `plugin.ts` at module load time, because
7
+ * `LexiconPlugin.deepNormalizationHooks` is plain data core reads to
8
+ * normalize the *declared* tree — the half of the contract that runs whether
9
+ * or not a cluster is ever touched (`lifecycle diff` without `--live`,
10
+ * `chant build`, tests that only exercise normalization). `./deep-observe.ts`
11
+ * imports `./describe-resources.ts` for the live kubectl transport, which
12
+ * pulls in `node:child_process`; nothing this file exports may pull that in,
13
+ * directly or transitively, so `chant build` never resolves a process-
14
+ * spawning module just to normalize a declared tree.
15
+ *
16
+ * What lives here is entirely reused from `@intentius/chant/managed-fields`
17
+ * (chant #1087's module doc explains why that lives in core rather than the
18
+ * k8s lexicon) plus GCP/Config-Connector-specific annotation noise. Nothing
19
+ * here needs a live object in hand — the per-resource managed-fields prune
20
+ * (which does) is computed in `./deep-observe.ts` and layered on top.
21
+ */
22
+
23
+ import type { DeepNode, DeepNormalizationHooks } from "@intentius/chant/lexicon";
24
+ import { K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS, k8sListMapOrderKey } from "@intentius/chant/managed-fields";
25
+
26
+ /**
27
+ * Config Connector's own observed-state annotations — written by
28
+ * `cnrm-controller-manager` after reconciling, mirroring GCP-side state the
29
+ * CRD schema has no field for (e.g. secret version pins). Never authored by
30
+ * a chant manifest, so pruning them unconditionally cannot hide declared
31
+ * config the way blindly pruning every `cnrm.cloud.google.com/*` annotation
32
+ * would — several of that family (`state-into-spec`, `deletion-policy`,
33
+ * `management-conflict-prevention-policy`) are themselves user-authored
34
+ * configuration, not observed state, and pruning those would hide genuine
35
+ * drift on them.
36
+ *
37
+ * Sparse and evidence-based (Config Connector's own docs), the same posture
38
+ * every other lexicon's noise table takes. Widening it is additive and needs
39
+ * no contract change.
40
+ *
41
+ * Most CNRM-controller noise doesn't need this list at all — it's already
42
+ * pruned by the reused managed-fields ownership rule in `./deep-observe.ts`,
43
+ * since `cnrm-controller-manager` is a foreign, undeclared owner of whatever
44
+ * it touches. This list only covers the narrow, evidence-based case worth
45
+ * pruning even were managedFields attribution ever missing for it.
46
+ */
47
+ const CNRM_OBSERVED_STATE_ANNOTATION_KEYS: ReadonlySet<string> = new Set([
48
+ "cnrm.cloud.google.com/observed-secret-versions",
49
+ ]);
50
+
51
+ const ANNOTATIONS_PREFIX = "metadata.annotations.";
52
+
53
+ function isCnrmObservedStateAnnotation(node: DeepNode): boolean {
54
+ if (!node.pattern.startsWith(ANNOTATIONS_PREFIX)) return false;
55
+ return CNRM_OBSERVED_STATE_ANNOTATION_KEYS.has(node.pattern.slice(ANNOTATIONS_PREFIX.length));
56
+ }
57
+
58
+ /**
59
+ * GCP's static deep-observation noise rules — the entity-agnostic half,
60
+ * applied to the *declared* tree by core (`gcpPlugin.deepNormalizationHooks`)
61
+ * exactly like the other three rows, and layered under the per-resource
62
+ * managed-fields prune in `./deep-observe.ts` for the live tree.
63
+ *
64
+ * A Config Connector custom resource *is* a Kubernetes object (see
65
+ * `./deep-observe.ts`'s module doc), so the generic envelope
66
+ * (`K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS`) and Kubernetes' own list-map-key
67
+ * ordering conventions (`k8sListMapOrderKey` — needed because some CNRM kinds
68
+ * embed genuinely k8s-shaped substructures, e.g. Cloud Run's `RunService`)
69
+ * are reused verbatim from `@intentius/chant/managed-fields` rather than
70
+ * restated. The one GCP-specific rule is CNRM's own observed-state
71
+ * annotation noise.
72
+ */
73
+ export const gcpDeepNormalizationHooks: DeepNormalizationHooks = {
74
+ prune(node: DeepNode): boolean {
75
+ if (K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has(node.pattern)) return true;
76
+ return isCnrmObservedStateAnnotation(node);
77
+ },
78
+ orderKey: k8sListMapOrderKey,
79
+ };
@@ -0,0 +1,522 @@
1
+ /**
2
+ * GCP deep observation (#1087) — the GCP row of the deep-observe contract
3
+ * (#1014), and the managed-fields reuse from the k8s row (#1076).
4
+ *
5
+ * Every case here drives the real reader (`observeResourcesDeepGcp`) with
6
+ * `node:child_process`'s `exec` mocked — the same harness
7
+ * `describe-resources.test.ts` uses for the thin read. No ambient kubectl
8
+ * config is read and no cluster is contacted.
9
+ *
10
+ * The end-to-end acceptance test drives `observeResourcesDeepGcp`'s real
11
+ * output through core's real `diffDeepObservation`, with `gcpPlugin`'s real,
12
+ * exported `deepNormalizationHooks` — the same three pieces
13
+ * `lexicons/k8s/src/deep-observe.test.ts` exercises for the k8s row.
14
+ */
15
+
16
+ import { describe, test, expect, vi, beforeEach } from "vitest";
17
+
18
+ const execMock = vi.fn();
19
+ vi.mock("node:child_process", async () => {
20
+ const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
21
+ return {
22
+ ...actual,
23
+ exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
24
+ Promise.resolve(execMock(cmd)).then(
25
+ (out) => cb(null, out),
26
+ (err) => cb(err as Error, { stdout: "", stderr: "" }),
27
+ );
28
+ },
29
+ };
30
+ });
31
+
32
+ const loadChantConfigMock = vi.fn();
33
+ vi.mock("@intentius/chant/config", () => ({
34
+ loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args),
35
+ }));
36
+
37
+ const { gcpPlugin } = await import("./plugin");
38
+ const { observeResourcesDeepGcp } = await import("./deep-observe");
39
+ const { gcpDeepNormalizationHooks } = await import("./deep-observe-hooks");
40
+ const { diffDeepObservation, observeDeep } = await import("@intentius/chant/lifecycle/deep-observe");
41
+ const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
42
+
43
+ type Entity = { name: string; entityType: string; props: Record<string, unknown> };
44
+ function makeEntities(records: Entity[]): Map<string, { entityType: string; props: Record<string, unknown> }> {
45
+ return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
46
+ }
47
+
48
+ /** Route a mocked `kubectl get <resource> <name> ... -o json` command to a canned JSON body, by matching on `<resource> <name>`. */
49
+ function respondTo(bodies: Record<string, unknown>): (cmd: string) => { stdout: string; stderr: string } {
50
+ return (cmd: string) => {
51
+ for (const [needle, body] of Object.entries(bodies)) {
52
+ if (cmd.includes(needle)) return { stdout: JSON.stringify(body), stderr: "" };
53
+ }
54
+ throw new Error(`unexpected kubectl invocation: ${cmd}`);
55
+ };
56
+ }
57
+
58
+ beforeEach(() => {
59
+ execMock.mockReset();
60
+ loadChantConfigMock.mockReset();
61
+ loadChantConfigMock.mockResolvedValue({ config: {} });
62
+ });
63
+
64
+ describe("gcpPlugin wiring (#1087)", () => {
65
+ test("the plugin exposes the deep-observe contract, and the hooks are the shared static instance", () => {
66
+ expect(typeof gcpPlugin.observeResourcesDeep).toBe("function");
67
+ expect(gcpPlugin.deepNormalizationHooks).toBe(gcpDeepNormalizationHooks);
68
+ });
69
+ });
70
+
71
+ describe("gcpDeepNormalizationHooks — the static rules", () => {
72
+ test("prunes status and the server-minted metadata fields, reused verbatim from the k8s row", () => {
73
+ const out = normalizeDeepProperties(
74
+ {
75
+ status: { conditions: [{ type: "Ready", status: "True" }] },
76
+ metadata: {
77
+ name: "data-bucket",
78
+ uid: "u-1",
79
+ resourceVersion: "7",
80
+ generation: 3,
81
+ creationTimestamp: "2026-01-01T00:00:00Z",
82
+ managedFields: [{ manager: "cnrm-controller-manager" }],
83
+ labels: { app: "web" },
84
+ },
85
+ },
86
+ { entityType: "GCP::Storage::Bucket", side: "live", hooks: gcpDeepNormalizationHooks },
87
+ );
88
+ expect(out).toEqual({ metadata: { name: "data-bucket", labels: { app: "web" } } });
89
+ });
90
+
91
+ test("prunes Config Connector's own observed-state annotation, but not a user-authored cnrm.cloud.google.com annotation", () => {
92
+ const out = normalizeDeepProperties(
93
+ {
94
+ metadata: {
95
+ annotations: {
96
+ "cnrm.cloud.google.com/observed-secret-versions": '{"db-password":"1"}',
97
+ "cnrm.cloud.google.com/deletion-policy": "abandon",
98
+ },
99
+ },
100
+ },
101
+ { entityType: "GCP::Storage::Bucket", side: "live", hooks: gcpDeepNormalizationHooks },
102
+ );
103
+ expect(out).toEqual({ metadata: { annotations: { "cnrm.cloud.google.com/deletion-policy": "abandon" } } });
104
+ });
105
+
106
+ test("orders containers by name — reused for CNRM kinds embedding a k8s-shaped pod spec (e.g. Cloud Run's RunService)", () => {
107
+ const out = normalizeDeepProperties(
108
+ { spec: { template: { spec: { containers: [{ name: "sidecar" }, { name: "app" }] } } } },
109
+ { entityType: "GCP::Run::Service", side: "live", hooks: gcpDeepNormalizationHooks },
110
+ );
111
+ expect(
112
+ (out.spec as { template: { spec: { containers: Array<{ name: string }> } } }).template.spec.containers.map((c) => c.name),
113
+ ).toEqual(["app", "sidecar"]);
114
+ });
115
+ });
116
+
117
+ describe("observeResourcesDeepGcp — reading through kubectl (#1087)", () => {
118
+ test("a hand-edited field surfaces with its path; an unsupported entity type is unsupported-kind", async () => {
119
+ execMock.mockImplementation(
120
+ respondTo({
121
+ "storagebucket.storage.cnrm.cloud.google.com data-bucket": {
122
+ apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
123
+ kind: "StorageBucket",
124
+ metadata: {
125
+ name: "data-bucket",
126
+ namespace: "config-control",
127
+ uid: "uid-bucket",
128
+ labels: { app: "web-renamed" },
129
+ managedFields: [
130
+ {
131
+ manager: "kubectl-edit",
132
+ operation: "Update",
133
+ fieldsV1: { "f:metadata": { "f:labels": { "f:app": {} } } },
134
+ },
135
+ ],
136
+ },
137
+ spec: {},
138
+ status: {},
139
+ },
140
+ }),
141
+ );
142
+
143
+ const result = normalizeDeepObservation(
144
+ await observeResourcesDeepGcp({
145
+ environment: "prod",
146
+ entityNames: ["dataBucket", "unknownKind"],
147
+ entities: makeEntities([
148
+ {
149
+ name: "dataBucket",
150
+ entityType: "GCP::Storage::Bucket",
151
+ props: { metadata: { name: "data-bucket", namespace: "config-control", labels: { app: "web" } } },
152
+ },
153
+ { name: "unknownKind", entityType: "AWS::S3::Bucket", props: { metadata: { name: "x" } } },
154
+ ]),
155
+ }),
156
+ );
157
+
158
+ expect(result.resources.dataBucket.properties).toMatchObject({ metadata: { labels: { app: "web-renamed" } } });
159
+ expect(result.resources.dataBucket.properties).not.toHaveProperty("status");
160
+ expect(result.unobserved.unknownKind.reason).toBe("unsupported-kind");
161
+ });
162
+
163
+ test("a read failure is a hole with a reason, never silence", async () => {
164
+ execMock.mockImplementation(() => {
165
+ throw Object.assign(new Error("kubectl failed"), { stderr: "Error from server (Forbidden): storagebuckets.storage.cnrm.cloud.google.com is forbidden" });
166
+ });
167
+ const result = normalizeDeepObservation(
168
+ await observeResourcesDeepGcp({
169
+ environment: "prod",
170
+ entityNames: ["broken"],
171
+ entities: makeEntities([
172
+ { name: "broken", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "broken" } } },
173
+ ]),
174
+ }),
175
+ );
176
+ expect(result.resources).toEqual({});
177
+ expect(result.unobserved.broken.reason).toBe("no-credentials");
178
+ });
179
+
180
+ test("--owned withholds an unmarked object as filtered, not absent", async () => {
181
+ execMock.mockImplementation(
182
+ respondTo({
183
+ "storagebucket.storage.cnrm.cloud.google.com theirs": {
184
+ apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
185
+ kind: "StorageBucket",
186
+ metadata: { name: "theirs", uid: "uid-theirs" },
187
+ spec: {},
188
+ },
189
+ }),
190
+ );
191
+ const result = normalizeDeepObservation(
192
+ await observeResourcesDeepGcp({
193
+ environment: "prod",
194
+ entityNames: ["theirs"],
195
+ owned: true,
196
+ entities: makeEntities([{ name: "theirs", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "theirs" } } }]),
197
+ }),
198
+ );
199
+ expect(result.resources).toEqual({});
200
+ expect(result.unobserved.theirs.reason).toBe("filtered");
201
+ });
202
+ });
203
+
204
+ /**
205
+ * The acceptance test for #1087, in the reference shape
206
+ * (`lexicons/aws/src/deep-observe.test.ts` / `lexicons/k8s/src/deep-observe.test.ts`):
207
+ * declared source, a live tree carrying realistic Config Connector
208
+ * managedFields noise, driven through the real reader and core's real
209
+ * `diffDeepObservation`, with `gcpPlugin`'s real static hooks.
210
+ */
211
+ describe("end to end: Config Connector managed-fields-derived drift (#1087)", () => {
212
+ const declared = makeEntities([
213
+ // "dataBucket": chant's own kubectl-apply owns most fields (attributed to
214
+ // "kubectl-client-side-apply", not "chant" — see the module doc's GCP
215
+ // twist); CNRM owns an undeclared field and an observed-state annotation
216
+ // (both pruned); a hand kubectl-edit changed a declared label (drift);
217
+ // a declared annotation moved to a value the baseline accepts.
218
+ {
219
+ name: "dataBucket",
220
+ entityType: "GCP::Storage::Bucket",
221
+ props: {
222
+ metadata: {
223
+ name: "data-bucket",
224
+ namespace: "config-control",
225
+ labels: { app: "web", "cnrm-test": "true" },
226
+ annotations: { "build-id": "42" },
227
+ },
228
+ spec: { location: "US", storageClass: "STANDARD" },
229
+ },
230
+ },
231
+ // "sqlInstance": chant declares spec.tier; Config Connector's OWN
232
+ // controller currently holds it at a different value (a manual gcloud
233
+ // change the controller's "merge" reconciliation folded back in). Foreign
234
+ // ownership does not silence it, because chant declared it too —
235
+ // contested, same as k8s's "worker" case, but contested by CNRM itself
236
+ // rather than by a human.
237
+ {
238
+ name: "sqlInstance",
239
+ entityType: "GCP::SQL::Instance",
240
+ props: {
241
+ metadata: { name: "primary-db", namespace: "config-control" },
242
+ spec: { tier: "db-f1-micro" },
243
+ },
244
+ },
245
+ // No Config Connector GVK derivable for this type at all.
246
+ { name: "cache", entityType: "AWS::ElastiCache::CacheCluster", props: { metadata: { name: "cache" } } },
247
+ // The read itself fails.
248
+ { name: "broken", entityType: "GCP::Storage::Bucket", props: { metadata: { name: "broken" } } },
249
+ ]);
250
+
251
+ const bucketLive = {
252
+ apiVersion: "storage.cnrm.cloud.google.com/v1beta1",
253
+ kind: "StorageBucket",
254
+ metadata: {
255
+ name: "data-bucket",
256
+ namespace: "config-control",
257
+ uid: "uid-bucket",
258
+ resourceVersion: "42",
259
+ generation: 7,
260
+ creationTimestamp: "2026-01-01T00:00:00Z",
261
+ // GENUINE DRIFT: chant declares this label; a person ran `kubectl edit`
262
+ // and it now belongs to "kubectl-edit", not chant's own apply.
263
+ labels: { app: "web-renamed", "cnrm-test": "true" },
264
+ // ACCEPTED: chant declares this annotation; the platform's baseline
265
+ // accepts "43".
266
+ annotations: {
267
+ "build-id": "43",
268
+ // NOISE: CNRM's own observed-state bookkeeping, undeclared.
269
+ "cnrm.cloud.google.com/observed-secret-versions": '{"db-password":"1"}',
270
+ // NOISE: classic `kubectl apply`'s own bookkeeping, undeclared —
271
+ // pruned by the reused ownership rule with no special case needed,
272
+ // because its manager ("kubectl-client-side-apply") is foreign and
273
+ // chant's declared tree never carries this key.
274
+ "kubectl.kubernetes.io/last-applied-configuration": "{...}",
275
+ },
276
+ managedFields: [
277
+ {
278
+ manager: "kubectl-client-side-apply",
279
+ operation: "Update",
280
+ fieldsV1: {
281
+ "f:metadata": {
282
+ "f:labels": { "f:cnrm-test": {} },
283
+ "f:annotations": {
284
+ "f:build-id": {},
285
+ "f:kubectl.kubernetes.io/last-applied-configuration": {},
286
+ },
287
+ },
288
+ "f:spec": { "f:location": {}, "f:storageClass": {} },
289
+ },
290
+ },
291
+ {
292
+ // The hand edit — transferred ownership of just this one label.
293
+ manager: "kubectl-edit",
294
+ operation: "Update",
295
+ fieldsV1: { "f:metadata": { "f:labels": { "f:app": {} } } },
296
+ },
297
+ {
298
+ // NOISE: Config Connector's own controller sets an undeclared
299
+ // field and its own bookkeeping annotation.
300
+ manager: "cnrm-controller-manager",
301
+ operation: "Update",
302
+ fieldsV1: {
303
+ "f:spec": { "f:uniformBucketLevelAccess": {} },
304
+ "f:metadata": { "f:annotations": { "f:cnrm.cloud.google.com/observed-secret-versions": {} } },
305
+ },
306
+ },
307
+ {
308
+ // NOISE: status is a subresource write, excluded by default.
309
+ manager: "cnrm-controller-manager",
310
+ operation: "Update",
311
+ subresource: "status",
312
+ fieldsV1: { "f:status": { "f:conditions": {} } },
313
+ },
314
+ ],
315
+ },
316
+ spec: { location: "US", storageClass: "STANDARD", uniformBucketLevelAccess: true },
317
+ status: { conditions: [{ type: "Ready", status: "True" }] },
318
+ };
319
+
320
+ const sqlInstanceLive = {
321
+ apiVersion: "sql.cnrm.cloud.google.com/v1beta1",
322
+ kind: "SQLInstance",
323
+ metadata: {
324
+ name: "primary-db",
325
+ namespace: "config-control",
326
+ uid: "uid-sql",
327
+ managedFields: [
328
+ {
329
+ // Config Connector's own reconciliation currently holds `spec.tier`
330
+ // — contested because chant declares it too, regardless of who's
331
+ // holding it live.
332
+ manager: "cnrm-controller-manager",
333
+ operation: "Update",
334
+ fieldsV1: { "f:spec": { "f:tier": {} } },
335
+ },
336
+ ],
337
+ },
338
+ spec: { tier: "db-n1-standard-1" },
339
+ };
340
+
341
+ const cluster = () =>
342
+ respondTo({
343
+ "storagebucket.storage.cnrm.cloud.google.com data-bucket": bucketLive,
344
+ "sqlinstance.sql.cnrm.cloud.google.com primary-db": sqlInstanceLive,
345
+ });
346
+
347
+ const baseline = {
348
+ dataBucket: {
349
+ type: "GCP::Storage::Bucket",
350
+ accepted: [{ path: "metadata.annotations.build-id", value: "43" }],
351
+ },
352
+ };
353
+
354
+ test("exactly the genuine + contested drift surfaces; controller/kubectl-apply noise and the accepted annotation do not", async () => {
355
+ execMock.mockImplementation((cmd: string) => {
356
+ if (cmd.includes("broken")) throw Object.assign(new Error("boom"), { stderr: "Error from server (InternalError): backend unavailable" });
357
+ return cluster()(cmd);
358
+ });
359
+
360
+ const live = normalizeDeepObservation(
361
+ await observeResourcesDeepGcp({ environment: "prod", entityNames: [...declared.keys()], entities: declared }),
362
+ );
363
+ const result = diffDeepObservation(declared, live, gcpDeepNormalizationHooks, baseline);
364
+
365
+ expect(result.drifted).toEqual([
366
+ {
367
+ name: "dataBucket",
368
+ type: "GCP::Storage::Bucket",
369
+ changes: [{ path: "metadata.labels.app", kind: "changed", declared: "web", live: "web-renamed" }],
370
+ },
371
+ {
372
+ name: "sqlInstance",
373
+ type: "GCP::SQL::Instance",
374
+ changes: [{ path: "spec.tier", kind: "changed", declared: "db-f1-micro", live: "db-n1-standard-1" }],
375
+ },
376
+ ]);
377
+
378
+ expect(result.accepted).toEqual([
379
+ {
380
+ name: "dataBucket",
381
+ type: "GCP::Storage::Bucket",
382
+ changes: [{ path: "metadata.annotations.build-id", kind: "changed", declared: "42", live: "43", baseline: "43" }],
383
+ },
384
+ ]);
385
+
386
+ // CNRM's undeclared field, its observed-state annotation, and classic
387
+ // kubectl's own bookkeeping annotation never appear at all — not as
388
+ // drift, not as "undeclared" noise.
389
+ const bucketDriftPaths = result.drifted.find((d) => d.name === "dataBucket")?.changes.map((c) => c.path) ?? [];
390
+ expect(bucketDriftPaths).not.toContain("spec.uniformBucketLevelAccess");
391
+ expect(JSON.stringify(result)).not.toContain("observed-secret-versions");
392
+ expect(JSON.stringify(result)).not.toContain("last-applied-configuration");
393
+
394
+ expect(result.unobserved).toEqual([
395
+ { name: "broken", type: "GCP::Storage::Bucket", reason: "read-failed", detail: expect.stringContaining("InternalError") },
396
+ {
397
+ name: "cache",
398
+ type: "AWS::ElastiCache::CacheCluster",
399
+ reason: "unsupported-kind",
400
+ detail: expect.stringContaining("cannot derive a Config Connector GVK"),
401
+ },
402
+ ]);
403
+ });
404
+
405
+ test("without the baseline the annotation is drift too; accepting it is what silences it", async () => {
406
+ execMock.mockImplementation((cmd: string) => {
407
+ if (cmd.includes("broken")) throw Object.assign(new Error("boom"), { stderr: "Error from server (InternalError): backend unavailable" });
408
+ return cluster()(cmd);
409
+ });
410
+ const live = normalizeDeepObservation(
411
+ await observeResourcesDeepGcp({ environment: "prod", entityNames: [...declared.keys()], entities: declared }),
412
+ );
413
+ const result = diffDeepObservation(declared, live, gcpDeepNormalizationHooks);
414
+ const bucket = result.drifted.find((d) => d.name === "dataBucket");
415
+ expect(bucket?.changes.map((c) => c.path).sort()).toEqual(["metadata.annotations.build-id", "metadata.labels.app"]);
416
+ expect(result.accepted).toEqual([]);
417
+ });
418
+
419
+ test("a whole-lexicon failure (bound-context mismatch) is a hole for every declared entity, never a clean report", async () => {
420
+ loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-cnrm" } } } } });
421
+ execMock.mockImplementation((cmd: string) => {
422
+ if (cmd.includes("current-context")) return { stdout: "staging-cnrm\n", stderr: "" };
423
+ throw new Error(`unexpected cmd (should have refused before any kubectl get): ${cmd}`);
424
+ });
425
+
426
+ const live = await observeDeep(gcpPlugin, { environment: "prod", buildOutput: "", entities: declared });
427
+ expect(live.resources).toEqual({});
428
+ expect(new Set(Object.values(live.unobserved).map((u) => u.reason))).toEqual(new Set(["read-failed"]));
429
+ expect(Object.keys(live.unobserved).sort()).toEqual(["broken", "cache", "dataBucket", "sqlInstance"]);
430
+ });
431
+ });
432
+
433
+ /**
434
+ * The managers-specific case: on GCP's real kubectl-shelled apply path,
435
+ * chant's own writes are attributed to kubectl's own default manager
436
+ * (`kubectl-client-side-apply`), never to `chant`/`chant:<stack>` — unlike
437
+ * the k8s lexicon's typed-client SSA path. This proves the contested-field
438
+ * rule (question 3: "is it declared?") is what keeps GCP's drift semantics
439
+ * correct despite that, exactly as the module doc claims: the same live
440
+ * mutation is drift when declared and silence when undeclared, regardless of
441
+ * which non-chant manager holds the field.
442
+ */
443
+ describe("the GCP manager twist: no chant-branded field manager, and the contested rule covers it anyway (#1087)", () => {
444
+ const liveWith = (manager: string, imageTag: string) => ({
445
+ apiVersion: "run.cnrm.cloud.google.com/v1beta1",
446
+ kind: "RunService",
447
+ metadata: {
448
+ name: "app",
449
+ namespace: "config-control",
450
+ uid: "uid-app",
451
+ managedFields: [
452
+ { manager, operation: "Update", fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": { 'k:{"name":"app"}': { "f:image": {} } } } } } } },
453
+ ],
454
+ },
455
+ spec: { template: { spec: { containers: [{ name: "app", image: imageTag }] } } },
456
+ });
457
+
458
+ const declaredWith = (declareImage: boolean) =>
459
+ makeEntities([
460
+ {
461
+ name: "app",
462
+ entityType: "GCP::Run::Service",
463
+ props: {
464
+ metadata: { name: "app", namespace: "config-control" },
465
+ spec: { template: { spec: { containers: declareImage ? [{ name: "app", image: "app:1.0" }] : [{ name: "app" }] } } },
466
+ },
467
+ },
468
+ ]);
469
+
470
+ test("declared: the same mutated value is drift, whether the owning manager is kubectl's default or CNRM's controller", async () => {
471
+ for (const manager of ["kubectl-client-side-apply", "cnrm-controller-manager", "some-other-operator"]) {
472
+ execMock.mockImplementation(respondTo({ "runservice.run.cnrm.cloud.google.com app": liveWith(manager, "app:2.0") }));
473
+ const entities = declaredWith(true);
474
+ const live = normalizeDeepObservation(
475
+ await observeResourcesDeepGcp({ environment: "prod", entityNames: ["app"], entities }),
476
+ );
477
+ const result = diffDeepObservation(entities, live, gcpDeepNormalizationHooks);
478
+ expect(result.drifted, `manager ${manager}`).toEqual([
479
+ {
480
+ name: "app",
481
+ type: "GCP::Run::Service",
482
+ changes: [{ path: "spec.template.spec.containers[#app].image", kind: "changed", declared: "app:1.0", live: "app:2.0" }],
483
+ },
484
+ ]);
485
+ }
486
+ });
487
+
488
+ test("undeclared: the same mutated value is silence, not drift and not undeclared noise, whoever the manager is", async () => {
489
+ for (const manager of ["kubectl-client-side-apply", "cnrm-controller-manager", "some-other-operator"]) {
490
+ execMock.mockImplementation(respondTo({ "runservice.run.cnrm.cloud.google.com app": liveWith(manager, "app:2.0") }));
491
+ const entities = declaredWith(false);
492
+ const live = normalizeDeepObservation(
493
+ await observeResourcesDeepGcp({ environment: "prod", entityNames: ["app"], entities }),
494
+ );
495
+ const result = diffDeepObservation(entities, live, gcpDeepNormalizationHooks);
496
+ expect(result.drifted, `manager ${manager}`).toEqual([]);
497
+ expect(result.unchanged, `manager ${manager}`).toEqual(["app"]);
498
+ expect(JSON.stringify(result)).not.toContain("app:2.0");
499
+ }
500
+ });
501
+
502
+ test("chant's own field-manager naming scheme, if it were ever used on this path, would also be recognized", async () => {
503
+ // Future-proofing check: an explicit `chant`/`chant:<stack>` manager (were
504
+ // gcp's apply path ever to route through server-side apply, matching the
505
+ // k8s lexicon) is still classified chant-owned, not merely contested.
506
+ execMock.mockImplementation(respondTo({ "runservice.run.cnrm.cloud.google.com app": liveWith("chant:crdb-gke", "app:2.0") }));
507
+ const entities = declaredWith(false); // undeclared — the only way to tell "chant-owned" apart from "contested" behaviorally.
508
+ const live = normalizeDeepObservation(
509
+ await observeResourcesDeepGcp({ environment: "prod", entityNames: ["app"], entities }),
510
+ );
511
+ const result = diffDeepObservation(entities, live, gcpDeepNormalizationHooks);
512
+ // Chant-owned paths are never pruned by the ownership rule, even when
513
+ // undeclared — so this reports as an undeclared live property, not silence.
514
+ expect(result.drifted).toEqual([
515
+ {
516
+ name: "app",
517
+ type: "GCP::Run::Service",
518
+ changes: [{ path: "spec.template.spec.containers[#app].image", kind: "undeclared", live: "app:2.0" }],
519
+ },
520
+ ]);
521
+ });
522
+ });
@@ -0,0 +1,276 @@
1
+ /**
2
+ * GCP deep observation (#1087) — the GCP row of the deep-observe contract
3
+ * (#1014), reusing the k8s row (#1076) rather than writing a second one, per
4
+ * the issue and #1173's own per-row checklist: "A Config Connector resource
5
+ * *is* a Kubernetes object, so it carries managed-fields for the same
6
+ * reason. Share the hooks rather than writing a second implementation."
7
+ *
8
+ * ## A Config Connector CR is a Kubernetes object
9
+ *
10
+ * `kubectl get <kind>.<group> -o json` against a Config Connector-enabled
11
+ * cluster (`./describe-resources.ts`'s own transport, reused here) returns a
12
+ * real Kubernetes API object: `metadata.managedFields`, `status`,
13
+ * `metadata.{uid,resourceVersion,generation,creationTimestamp}` are all
14
+ * present for the identical reason they're present on a Deployment — the API
15
+ * server that served the read is the same kind of API server. The epic's own
16
+ * table lists `metadata.managedFields` under GCP's "field noise" to prune; it
17
+ * is the opposite of noise, and it is available here for exactly the reason
18
+ * chant #1076 reads it on the k8s row.
19
+ *
20
+ * So this reader reuses, unchanged, from `@intentius/chant/managed-fields`:
21
+ *
22
+ * - `K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS` — the generic Kubernetes object
23
+ * envelope (`status`, `metadata.{uid,resourceVersion,generation,
24
+ * creationTimestamp,managedFields,selfLink}`).
25
+ * - `k8sListMapOrderKey` — Kubernetes' own list-map-key conventions, needed
26
+ * because some CNRM kinds embed genuinely k8s-shaped substructures (Cloud
27
+ * Run's `RunService` wraps a Knative pod spec with
28
+ * `containers`/`env`/`ports`, keyed the same way a Deployment's are).
29
+ * - `buildOwnershipSets`/`pruneByOwnership` — the managed-fields ownership
30
+ * walk itself: chant #1076's actual contribution, and the piece this row
31
+ * exists to reuse rather than reimplement.
32
+ *
33
+ * These live in core, not in the k8s lexicon, specifically so this file can
34
+ * reuse them without adding a dependency on the k8s lexicon package or (never,
35
+ * per the issue) on `@intentius/chant-k8s-client` — this reader shells
36
+ * `kubectl` exactly like `./describe-resources.ts` already does. Chant
37
+ * #1177/#1180 kept GCP off the typed client deliberately ("a separate
38
+ * lexicon and a separate decision"); this row does not revisit that.
39
+ *
40
+ * ## The GCP twist: who is "chant" on this path?
41
+ *
42
+ * Chant #1076's rule treats a field as always-diffable when *any* chant field
43
+ * manager (`chant`, `chant:<stack>` — chant #1075's SSA identity) owns it.
44
+ * That identity is only ever set by the k8s lexicon's typed client, applying
45
+ * with an explicit `--field-manager`. GCP has no equivalent apply activity:
46
+ * every gcp example's own deploy script (e.g.
47
+ * `examples/cockroachdb-multi-region-gke/scripts/deploy.sh`) runs plain
48
+ * `kubectl apply -f dist/*.yaml` — classic client-side apply, no
49
+ * `--server-side`, no `--field-manager`. kubectl's own hardcoded default
50
+ * field manager for that command is `kubectl-client-side-apply`, identical
51
+ * to what a human's `kubectl apply -f their-own-file.yaml` would record.
52
+ * **There is no field-manager name on GCP's real deploy path that
53
+ * distinguishes "chant applied this" from "a person applied this".**
54
+ * (Config Connector's own controller is distinguishable — its reconciler
55
+ * records itself as `cnrm-controller-manager`, per the k8s-config-connector
56
+ * source's own `ControllerManagedFieldManager` constant — just chant isn't,
57
+ * on this path.)
58
+ *
59
+ * That sounds like it breaks question 1 of the three-question rule
60
+ * (`isChantManager`) below. It doesn't, because question 3 already covers
61
+ * it: chant's own kubectl apply always applies the *exact* built manifest, so
62
+ * any field chant's own most recent apply set is, by construction, also in
63
+ * `declaredRoot` (`props`) on the very next read — chant never authors a
64
+ * field it does not also declare. Working through what that means per field:
65
+ *
66
+ * - A field only chant's own applies have ever touched: not recognized as
67
+ * `chantOwned` (its manager is `kubectl-client-side-apply`, not `chant`),
68
+ * but foreign-owned *and* declared → contested → kept diffable anyway.
69
+ * Same outcome as being chant-owned would have been.
70
+ * - A field CNRM's controller sets and chant never declares: foreign-owned,
71
+ * undeclared → pruned. Correct regardless of whether "chant" is recognized.
72
+ * - A field a human sets by hand (`kubectl edit`, or their own `kubectl apply
73
+ * -f`) that chant *does* declare: foreign-owned (manager is `kubectl-edit`
74
+ * or `kubectl-client-side-apply`) and declared → contested → surfaces as
75
+ * drift. This is #1087's acceptance criterion 1.
76
+ *
77
+ * `isGcpChantFieldManager` below still checks the `chant`/`chant:<stack>`
78
+ * family, so a future GCP apply path that *does* route through server-side
79
+ * apply with an explicit chant field manager (matching the k8s lexicon's own
80
+ * path) is recognized with no change needed here. On today's plain-apply
81
+ * path it is inert — proven inert by this module's own test suite (the
82
+ * "managers-specific case") — and the contested-field rule (question 3) is
83
+ * what actually keeps GCP's drift semantics correct without it.
84
+ *
85
+ * ## What's genuinely GCP-specific
86
+ *
87
+ * - No typed operation surface (chant #1177's `operationFor` is a k8s
88
+ * lexicon/typed-client concept, generated from cluster discovery this
89
+ * reader never touches) — `./describe-resources.ts`'s `deriveGVK` stands in.
90
+ * - CNRM's own observed-state annotations (`./deep-observe-hooks.ts`'s
91
+ * `gcpDeepNormalizationHooks`) — bookkeeping the controller writes into
92
+ * `metadata.annotations` for GCP-side properties the CRD schema has no
93
+ * field for. See that module's doc for why the list is narrow.
94
+ *
95
+ * ## Deliberately out of scope
96
+ *
97
+ * `status.conditions` staleness as its own signal — the issue's own
98
+ * "Proposed" section floats this as worth *deciding*, not worth doing here.
99
+ * `status` is pruned outright (matching k8s, and the acceptance criteria),
100
+ * and a CNRM projection lagging real GCP state is a genuine gap, but
101
+ * detecting it means reading the actual GCP API, not the CR — a different
102
+ * read than this contract's "normalize what Kubernetes reports" scope.
103
+ * Flagged, not papered over.
104
+ *
105
+ * ## The build-path boundary
106
+ *
107
+ * This module imports `./describe-resources.ts` for the live kubectl
108
+ * transport, which imports `node:child_process`. `gcpPlugin.ts` reaches this
109
+ * file only via `await import("./deep-observe")` inside
110
+ * `observeResourcesDeep` — never statically — the same way it already
111
+ * dynamic-imports `describeResources`/`exportResources`, so `chant build`
112
+ * never resolves a process-spawning module just to synthesize a template.
113
+ * `deepNormalizationHooks` is plain data with no such dependency
114
+ * (`./deep-observe-hooks.ts`) and is imported statically from `plugin.ts`,
115
+ * because core normalizes the *declared* tree with it whether or not a
116
+ * cluster read ever happens.
117
+ */
118
+
119
+ import type {
120
+ DeepNormalizationHooks,
121
+ DeepObservationResult,
122
+ DeepResourceObservation,
123
+ UnobservedEntity,
124
+ } from "@intentius/chant/lexicon";
125
+ import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
126
+ import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
127
+ import { classifyKubectlFailure } from "@intentius/chant/kubectl-context";
128
+ import { buildOwnershipSets, pruneByOwnership, type OwnershipSets, type ManagedFieldsEntryLike } from "@intentius/chant/managed-fields";
129
+ import { deriveGVK, execConfigConnectorGet, resolveGcpKubectlContext } from "./describe-resources";
130
+ import { gcpDeepNormalizationHooks } from "./deep-observe-hooks";
131
+
132
+ // Re-exported so a dynamic importer of this module (plugin.ts's
133
+ // `observeResourcesDeep`, a test) can get the reader and its hooks from one
134
+ // place. `plugin.ts`'s own `deepNormalizationHooks` field imports the hooks
135
+ // separately, directly from `./deep-observe-hooks` — see the module doc.
136
+ export { gcpDeepNormalizationHooks };
137
+
138
+ export interface GcpDeepObserveOptions {
139
+ environment: string;
140
+ buildOutput?: string;
141
+ entityNames: string[];
142
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
143
+ stack?: string;
144
+ owned?: boolean;
145
+ }
146
+
147
+ /**
148
+ * Matches chant's field-manager naming scheme (chant #1075: bare `chant`, or
149
+ * `chant:<stack>`) — the same convention `@intentius/chant-k8s-client`'s
150
+ * `isChantFieldManager` checks. Restated here rather than imported: gcp must
151
+ * never depend on that package (chant #1074/#1177's structural boundary — the
152
+ * k8s lexicon reads live cluster state through a typed client, gcp shells
153
+ * kubectl, and the two stay independently deployable). This is a two-branch
154
+ * string comparison with nothing to drift out of sync; the piece that
155
+ * genuinely could — the managed-fields ownership walk — is shared through
156
+ * `@intentius/chant/managed-fields`, not reimplemented here. See the module
157
+ * doc for why this rarely matches anything on GCP's real deploy path today,
158
+ * and why that's fine.
159
+ */
160
+ function isGcpChantFieldManager(manager: string | undefined): boolean {
161
+ if (!manager) return false;
162
+ return manager === "chant" || manager.startsWith("chant:");
163
+ }
164
+
165
+ /**
166
+ * The managed-fields prune, composed with the static rules, for one
167
+ * resource's normalization call — the same layering k8s's `perResourceHooks`
168
+ * uses, sharing the actual rule (`pruneByOwnership`) rather than restating it.
169
+ */
170
+ function perResourceHooks(sets: OwnershipSets): DeepNormalizationHooks {
171
+ return {
172
+ prune(node) {
173
+ if (gcpDeepNormalizationHooks.prune?.(node)) return true;
174
+ return pruneByOwnership(node, sets);
175
+ },
176
+ orderKey: gcpDeepNormalizationHooks.orderKey,
177
+ };
178
+ }
179
+
180
+ /** `metadata.managedFields` off a raw `kubectl get -o json` object — a plain decode, no client-side type coercion. */
181
+ function managedFieldsOfRaw(obj: Record<string, unknown>): ManagedFieldsEntryLike[] {
182
+ const metadata = obj.metadata;
183
+ if (!metadata || typeof metadata !== "object") return [];
184
+ const entries = (metadata as Record<string, unknown>).managedFields;
185
+ if (!Array.isArray(entries)) return [];
186
+ return entries.filter((e): e is ManagedFieldsEntryLike => !!e && typeof e === "object");
187
+ }
188
+
189
+ /** The live object minus the envelope fields that live outside `properties` on {@link DeepResourceObservation} (mirrors k8s's `propertiesTreeOf`). */
190
+ function propertiesTreeOf(obj: Record<string, unknown>): Record<string, unknown> {
191
+ const { apiVersion: _apiVersion, kind: _kind, ...rest } = obj;
192
+ return rest;
193
+ }
194
+
195
+ /**
196
+ * Read the live property tree for each declared entity via `kubectl get
197
+ * <kind>.<group> -o json`, pruning by `metadata.managedFields` (see the
198
+ * module doc). Reuses `./describe-resources.ts`'s exact cluster-binding
199
+ * resolution and kubectl mechanics — the binding check (chant #1100) still
200
+ * refuses before any resource is touched, and a connect failure still
201
+ * becomes NOT-OBSERVED for every declared entity rather than an empty result.
202
+ */
203
+ export async function observeResourcesDeepGcp(options: GcpDeepObserveOptions): Promise<DeepObservationResult> {
204
+ const resources: Record<string, DeepResourceObservation> = {};
205
+ const unobserved: Record<string, UnobservedEntity> = {};
206
+
207
+ // Resolve the cluster identity once, before touching any resource — a
208
+ // declared-but-mismatched binding throws here (chant #1100), aborting the
209
+ // whole read rather than letting the per-entity try/catch below absorb it
210
+ // as an ordinary "not found". Core turns the throw into NOT-OBSERVED for
211
+ // every declared entity.
212
+ const ctxArg = await resolveGcpKubectlContext(options.environment);
213
+
214
+ for (const [entityName, { entityType, props }] of options.entities) {
215
+ const gvk = deriveGVK(entityType);
216
+ if (!gvk) {
217
+ unobserved[entityName] = {
218
+ type: entityType,
219
+ reason: "unsupported-kind",
220
+ detail: `cannot derive a Config Connector GVK from ${entityType}`,
221
+ };
222
+ continue;
223
+ }
224
+
225
+ const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
226
+ const name = metadata?.name;
227
+ if (!name) {
228
+ unobserved[entityName] = {
229
+ type: entityType,
230
+ reason: "read-failed",
231
+ detail: "declared entity has no metadata.name to query by",
232
+ };
233
+ continue;
234
+ }
235
+
236
+ try {
237
+ const obj = await execConfigConnectorGet(gvk, name, metadata.namespace, ctxArg);
238
+ const objMetadata = obj.metadata as { labels?: Record<string, string>; uid?: string } | undefined;
239
+
240
+ // owned filter: withhold resources not carrying chant's marker label.
241
+ // Withheld is not absent (#1089) — the CR exists, it just isn't chant's.
242
+ if (options.owned && !hasOwnershipMarker(objMetadata?.labels, LABEL_OWNERSHIP_KEYS)) {
243
+ unobserved[entityName] = {
244
+ type: entityType,
245
+ reason: "filtered",
246
+ detail: "live resource carries no chant ownership marker and --owned was requested",
247
+ };
248
+ continue;
249
+ }
250
+
251
+ const liveRoot = propertiesTreeOf(obj);
252
+ const sets = buildOwnershipSets(managedFieldsOfRaw(obj), liveRoot, props, isGcpChantFieldManager);
253
+
254
+ resources[entityName] = {
255
+ type: entityType,
256
+ physicalId: objMetadata?.uid,
257
+ properties: normalizeDeepProperties(liveRoot, {
258
+ entityType,
259
+ side: "live",
260
+ hooks: perResourceHooks(sets),
261
+ }),
262
+ };
263
+ } catch (err) {
264
+ // A NotFound is a real absence, same as the thin read — records
265
+ // nothing here, since restating it would turn one finding into two.
266
+ // Anything else (auth, connectivity, a mismatched context) proves
267
+ // nothing and is a hole rather than an absence (#1089).
268
+ const outcome = classifyKubectlFailure(err);
269
+ if (outcome.kind === "unobserved") {
270
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
271
+ }
272
+ }
273
+ }
274
+
275
+ return deepObservation(resources, unobserved);
276
+ }
@@ -20,6 +20,11 @@
20
20
  * `chant.config.ts` (see `lexicons/k8s/src/config.ts`), not a separate
21
21
  * `gcp.profiles` key, because it is fundamentally the same kubectl context a
22
22
  * project's K8s entities would use against the same cluster.
23
+ *
24
+ * `resolveGcpKubectlContext` and `execConfigConnectorGet` below are exported
25
+ * so `./deep-observe.ts` (chant #1087) shares this exact cluster-binding
26
+ * resolution and `kubectl get -o json` mechanics rather than restating them —
27
+ * the two readers differ only in how much of the response each one keeps.
23
28
  */
24
29
 
25
30
  import { exec } from "node:child_process";
@@ -32,6 +37,38 @@ import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/k
32
37
 
33
38
  const execAsync = promisify(exec);
34
39
 
40
+ /**
41
+ * Resolve this environment's cluster binding once and return the `--context`
42
+ * argv fragment every subsequent kubectl call should append (empty when
43
+ * unbound — ambient context, unchanged behavior). Throws on a bound-but-
44
+ * mismatched context (chant #1100), the same loud refusal both readers rely
45
+ * on core to turn into NOT-OBSERVED for every declared entity.
46
+ */
47
+ export async function resolveGcpKubectlContext(environment: string): Promise<string[]> {
48
+ const { config } = await loadChantConfig(process.cwd());
49
+ const target = await resolveClusterTarget(config as Record<string, unknown>, environment, "gcp");
50
+ return target.context ? ["--context", target.context] : [];
51
+ }
52
+
53
+ /**
54
+ * `kubectl get <kind>.<group> <name> [-n <namespace>] [--context <ctx>] -o
55
+ * json`, parsed. Throws on any kubectl failure — callers classify it with
56
+ * `classifyKubectlFailure` exactly like today, so a NotFound and an
57
+ * auth/connectivity failure are told apart at the call site, not here.
58
+ */
59
+ export async function execConfigConnectorGet(
60
+ gvk: { group: string; kind: string },
61
+ name: string,
62
+ namespace: string | undefined,
63
+ ctxArg: readonly string[],
64
+ ): Promise<Record<string, unknown>> {
65
+ const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
66
+ const nsArg = namespace ? ["-n", namespace] : [];
67
+ const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
68
+ const { stdout } = await execAsync(cmd);
69
+ return JSON.parse(stdout) as Record<string, unknown>;
70
+ }
71
+
35
72
  interface KubectlResponse {
36
73
  metadata?: {
37
74
  name?: string;
@@ -103,9 +140,7 @@ export async function describeResources(options: {
103
140
  // resource — a declared-but-mismatched binding throws here, aborting the
104
141
  // whole describe rather than letting the per-entity try/catch below
105
142
  // absorb it as an ordinary "not found".
106
- const { config } = await loadChantConfig(process.cwd());
107
- const target = await resolveClusterTarget(config as Record<string, unknown>, options.environment, "gcp");
108
- const ctxArg = target.context ? ["--context", target.context] : [];
143
+ const ctxArg = await resolveGcpKubectlContext(options.environment);
109
144
 
110
145
  for (const [entityName, { entityType, props }] of options.entities) {
111
146
  const gvk = deriveGVK(entityType);
@@ -131,13 +166,8 @@ export async function describeResources(options: {
131
166
  continue;
132
167
  }
133
168
 
134
- const kubectlResource = `${gvk.kind.toLowerCase()}.${gvk.group}`;
135
- const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
136
- const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
137
-
138
169
  try {
139
- const { stdout } = await execAsync(cmd);
140
- const obj: KubectlResponse = JSON.parse(stdout);
170
+ const obj = (await execConfigConnectorGet(gvk, name, metadata.namespace, ctxArg)) as KubectlResponse;
141
171
  // owned filter: withhold resources not carrying chant's marker label.
142
172
  // Withheld is not absent (#1089) — the CR exists, it just isn't chant's.
143
173
  if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
package/src/plugin.ts CHANGED
@@ -24,6 +24,7 @@ import { GcpParser } from "./import/parser";
24
24
  import { GcpGenerator } from "./import/generator";
25
25
  import { gcpCompletions } from "./lsp/completions";
26
26
  import { gcpHover } from "./lsp/hover";
27
+ import { gcpDeepNormalizationHooks } from "./deep-observe-hooks";
27
28
 
28
29
  export const gcpPlugin: LexiconPlugin = {
29
30
  name: "gcp",
@@ -384,4 +385,20 @@ export const bucket = new StorageBucket({
384
385
  const { exportResources } = await import("./export-resources");
385
386
  return exportResources(options);
386
387
  },
388
+
389
+ // Property-level live drift via SSA managed-fields (#1087, epic #1073),
390
+ // reusing the k8s row (#1076) rather than writing a second implementation —
391
+ // a Config Connector custom resource is a Kubernetes object too. The
392
+ // reader lives in ./deep-observe.ts, loaded only through this dynamic
393
+ // import — same reason describeResources/exportResources are — so nothing
394
+ // on the build path resolves the live kubectl transport it shares with
395
+ // ./describe-resources.ts. deepNormalizationHooks is plain data with no
396
+ // transport dependency and is imported statically above, because core
397
+ // applies it to the *declared* tree whether or not a live read ever happens.
398
+ async observeResourcesDeep(options) {
399
+ const { observeResourcesDeepGcp } = await import("./deep-observe");
400
+ return observeResourcesDeepGcp(options);
401
+ },
402
+
403
+ deepNormalizationHooks: gcpDeepNormalizationHooks,
387
404
  };