@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.
- package/dist/deep-observe-hooks.d.ts +39 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +141 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +23 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/deep-observe-hooks.ts +79 -0
- package/src/deep-observe.test.ts +522 -0
- package/src/deep-observe.ts +276 -0
- package/src/describe-resources.ts +39 -9
- package/src/plugin.ts +17 -0
|
@@ -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
|
|
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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
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": "
|
|
40
|
+
"composite": "ee170703110b77abd20502d2047a75cf91c12c2e70da5203b0e057ff6d4788c7"
|
|
41
41
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
};
|