@intentius/chant-lexicon-k8s 0.56.0 → 0.58.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/api/sweep-types.d.ts +1 -2
- package/dist/api/sweep-types.d.ts.map +1 -1
- package/dist/composites/operator-stack.d.ts +19 -18
- package/dist/composites/operator-stack.d.ts.map +1 -1
- package/dist/config.d.ts +4 -4
- package/dist/deep-observe-hooks.d.ts +4 -4
- package/dist/deep-observe.d.ts +23 -16
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/export-resources.d.ts +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +10 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/argo.d.ts +4 -4
- package/dist/op/activities/argo.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +9 -7
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +7 -7
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +6 -5
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/op/builders.d.ts +13 -13
- package/dist/skills/chant-k8s-argo.md +27 -13
- package/package.json +3 -3
- package/src/api/sweep-types.ts +1 -2
- package/src/composites/composites.test.ts +13 -2
- package/src/composites/operator-stack.ts +22 -20
- package/src/config.ts +4 -4
- package/src/crd/crd-sources.ts +4 -4
- package/src/deep-observe-hooks.ts +4 -4
- package/src/deep-observe.test.ts +219 -46
- package/src/deep-observe.ts +28 -21
- package/src/export-resources.ts +1 -1
- package/src/lint/audit-catalog.ts +5 -1
- package/src/lint/audit-lineage.ts +127 -0
- package/src/op/activities/argo.test.ts +7 -8
- package/src/op/activities/argo.ts +4 -4
- package/src/op/activities/index.ts +9 -7
- package/src/op/activities/kubectl.test.ts +5 -4
- package/src/op/activities/kubectl.ts +52 -67
- package/src/op/activities/wait-for-ready.test.ts +2 -2
- package/src/op/activities/wait-for-ready.ts +7 -7
- package/src/op/builders.ts +13 -13
- package/src/plugin.ts +1 -1
- package/src/serializer.test.ts +26 -0
- package/src/skills/chant-k8s-argo.md +27 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "Kubernetes 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
|
"@types/js-yaml": "^4.0.9"
|
|
77
77
|
},
|
|
78
78
|
"optionalDependencies": {
|
|
79
|
-
"@intentius/chant-k8s-client": "^0.
|
|
79
|
+
"@intentius/chant-k8s-client": "^0.58.0"
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
82
|
"@intentius/chant": "*",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
86
86
|
"zod": "^4.3.6",
|
|
87
|
-
"@intentius/chant": "^0.
|
|
87
|
+
"@intentius/chant": "^0.58.0",
|
|
88
88
|
"typescript": "^5.9.3"
|
|
89
89
|
}
|
|
90
90
|
}
|
package/src/api/sweep-types.ts
CHANGED
|
@@ -10,8 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* It lives in its own module, with no imports of its own, because both
|
|
12
12
|
* consumers reach it from different directions — `../export-resources.ts`
|
|
13
|
-
* pulls in the whole import parser, and
|
|
14
|
-
* activity should not.
|
|
13
|
+
* pulls in the whole import parser, and the apply activity should not.
|
|
15
14
|
*/
|
|
16
15
|
export const DEFAULT_IMPORT_TYPES: readonly string[] = [
|
|
17
16
|
"K8s::Apps::Deployment",
|
|
@@ -3868,7 +3868,7 @@ describe("OperatorStack", () => {
|
|
|
3868
3868
|
}
|
|
3869
3869
|
});
|
|
3870
3870
|
|
|
3871
|
-
test("observe-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — observe never dispatches, per
|
|
3871
|
+
test("observe-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — observe never dispatches, per OPS014/convergeTick", () => {
|
|
3872
3872
|
const result: any = OperatorStack({
|
|
3873
3873
|
name: "chant-operator",
|
|
3874
3874
|
image: "chant:latest",
|
|
@@ -3880,7 +3880,7 @@ describe("OperatorStack", () => {
|
|
|
3880
3880
|
}
|
|
3881
3881
|
});
|
|
3882
3882
|
|
|
3883
|
-
test("reconcile-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC —
|
|
3883
|
+
test("reconcile-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — OPS014 only lets apply free-run a mutating dispatch", () => {
|
|
3884
3884
|
const result: any = OperatorStack({
|
|
3885
3885
|
name: "chant-operator",
|
|
3886
3886
|
image: "chant:latest",
|
|
@@ -3979,6 +3979,17 @@ describe("OperatorStack", () => {
|
|
|
3979
3979
|
expect(() => deriveHostVerbClass("x", "observe", [DESTRUCTIVE_OP])).toThrow(/destructive/);
|
|
3980
3980
|
});
|
|
3981
3981
|
|
|
3982
|
+
// #2192 — the refusal cites the rule that actually owns it (OPS014), and
|
|
3983
|
+
// gives OPS014's own reason. The old message argued from the local
|
|
3984
|
+
// executor's inability to honour a gate, which #2119 retired: a gated op
|
|
3985
|
+
// now ends the run `gated` rather than being refused, so the refusal stands
|
|
3986
|
+
// on the unattended-approval ground instead.
|
|
3987
|
+
test("deriveHostVerbClass: the destructive refusal names OPS014 and the unattended-approval reason", () => {
|
|
3988
|
+
expect(() => deriveHostVerbClass("x", "apply", [DESTRUCTIVE_OP])).toThrow(/OPS014/);
|
|
3989
|
+
expect(() => deriveHostVerbClass("x", "apply", [DESTRUCTIVE_OP])).toThrow(/unattended/);
|
|
3990
|
+
expect(() => deriveHostVerbClass("x", "apply", [DESTRUCTIVE_OP])).not.toThrow(/TMP014/);
|
|
3991
|
+
});
|
|
3992
|
+
|
|
3982
3993
|
// ── Build-time refusals ──────────────────────────────────────────────
|
|
3983
3994
|
|
|
3984
3995
|
test("refuses an empty converge array", () => {
|
|
@@ -16,39 +16,40 @@
|
|
|
16
16
|
* (lease fencing, `chant operator status`, durable gate-as-fact semantics).
|
|
17
17
|
* `OperatorStack`'s container command is deliberately `chant run <name>`,
|
|
18
18
|
* the one-shot local tick `ConvergeOp` (#1484) already ships and tests
|
|
19
|
-
* against (`
|
|
19
|
+
* against (`packages/core/src/op/composites/converge-op.ts`'s own doc:
|
|
20
20
|
* "one-shot runnable locally for a single tick"). When #1485 lands, a
|
|
21
21
|
* caller can override `command` to shell out to `chant operator tick`
|
|
22
22
|
* instead — this composite doesn't need to change for that; only the
|
|
23
23
|
* command a caller passes does.
|
|
24
24
|
*
|
|
25
25
|
* `concurrencyPolicy: "Forbid"` is the k8s-native analogue of
|
|
26
|
-
* `ConvergeOp`'s own
|
|
27
|
-
*
|
|
26
|
+
* `ConvergeOp`'s own `schedule.overlap: "skip"` policy — never queue a
|
|
27
|
+
* second tick behind one still running.
|
|
28
28
|
*
|
|
29
29
|
* ## RBAC derivation
|
|
30
30
|
*
|
|
31
31
|
* `ConvergeOp` adds no authority an environment did not already grant
|
|
32
32
|
* (#1484's Autonomy table). `OperatorStack` re-derives the same bound at the
|
|
33
|
-
* k8s RBAC layer,
|
|
34
|
-
*
|
|
33
|
+
* k8s RBAC layer, from the Op configs alone (see the Layering note below for
|
|
34
|
+
* what this module imports and what it restates): for each hosted
|
|
35
35
|
* ConvergeOp, walk its `dispatchTargets` (the OpConfigs its rule table's
|
|
36
36
|
* `run()` actions may name) through `classifyOpVerbClass`
|
|
37
37
|
* (`packages/core/src/op/op-verb-class.ts`, #1954), then keep only the
|
|
38
38
|
* highest verb class this host's `dial` could ever actually free-run —
|
|
39
39
|
* exactly `convergeTick`'s own `verbClassAllowedToDispatch` gate and
|
|
40
|
-
* `
|
|
40
|
+
* `OPS014`'s build-time refusal, restated as an RBAC ceiling:
|
|
41
41
|
*
|
|
42
42
|
* - `dial: "observe"` never dispatches (report-only) → read-only RBAC,
|
|
43
43
|
* regardless of what the rule table's targets could otherwise do.
|
|
44
|
-
* - `dial: "reconcile"` only free-runs a read-only target (
|
|
44
|
+
* - `dial: "reconcile"` only free-runs a read-only target (OPS014 refuses a
|
|
45
45
|
* mutating dispatch under reconcile in v1) → read-only RBAC.
|
|
46
46
|
* - `dial: "apply"` free-runs read-only and mutating targets → RBAC gains
|
|
47
47
|
* create/update/patch, never delete.
|
|
48
48
|
* - A `dispatchTargets` entry that itself classifies `destructive` is
|
|
49
|
-
* refused outright, at construction —
|
|
50
|
-
* destructive `run()` target under any dial in v1 (
|
|
51
|
-
*
|
|
49
|
+
* refused outright, at construction — OPS014 already refuses a
|
|
50
|
+
* destructive `run()` target under any dial in v1 (a converge tick runs
|
|
51
|
+
* unattended, and a destructive dispatch needs a person's approval before
|
|
52
|
+
* it is attempted, not a gate read after the fact), so a `destructive` target
|
|
52
53
|
* reaching this composite is either a config bypassing that build check
|
|
53
54
|
* or a target `OperatorStack` should never grant permission toward.
|
|
54
55
|
* `never delete`, unconditionally, in v1 — no verb class here ever grants
|
|
@@ -61,11 +62,11 @@
|
|
|
61
62
|
*
|
|
62
63
|
* ## Layering
|
|
63
64
|
*
|
|
64
|
-
* `
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
65
|
+
* `OperatorStackConvergeHost` restates the handful of `ConvergeOp` fields
|
|
66
|
+
* this composite actually needs (`name`, `schedule`, `env`, `dial`)
|
|
67
|
+
* structurally rather than importing `ConvergeOpConfig`: the CronJob is
|
|
68
|
+
* built from a host description, not from an Op config, and a caller that
|
|
69
|
+
* has neither should still be able to describe one. `dispatchTargets` takes
|
|
69
70
|
* plain `OpConfig`-shaped values from `@intentius/chant/op` (a dependency
|
|
70
71
|
* this lexicon already has via the `@intentius/chant` peer dependency),
|
|
71
72
|
* the same type `classifyOpVerbClass` itself takes.
|
|
@@ -80,7 +81,7 @@ import { Namespace, CronJob, ServiceAccount, Role, RoleBinding } from "../genera
|
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* Mirrors `ConvergeOp`'s own `ConvergeDial`
|
|
83
|
-
* (`
|
|
84
|
+
* (`packages/core/src/op/composites/converge-op.ts`) structurally — see
|
|
84
85
|
* this module's Layering doc for why it's restated rather than imported.
|
|
85
86
|
*/
|
|
86
87
|
export type OperatorDial = "observe" | "reconcile" | "apply";
|
|
@@ -104,7 +105,7 @@ export interface OperatorStackConvergeHost {
|
|
|
104
105
|
/**
|
|
105
106
|
* OpConfigs for every op this ConvergeOp's rule table may `run()` — the
|
|
106
107
|
* sibling `*.op.ts` declarations its `run()` actions name. Used to derive
|
|
107
|
-
* least-privilege RBAC the same way `
|
|
108
|
+
* least-privilege RBAC the same way `OPS014` derives its build-time
|
|
108
109
|
* refusals. Omit or leave empty when every rule only `report()`s; the
|
|
109
110
|
* host still gets read-only RBAC for its own observation.
|
|
110
111
|
*/
|
|
@@ -183,7 +184,7 @@ function rbacVerbsFor(verbClass: OpVerbClass): string[] {
|
|
|
183
184
|
/**
|
|
184
185
|
* Whether `dial` ever actually free-runs a dispatch classified `verbClass` —
|
|
185
186
|
* restates `convergeTick`'s own `verbClassAllowedToDispatch`
|
|
186
|
-
* (`
|
|
187
|
+
* (`packages/core/src/op/activities/converge.ts`) so the RBAC ceiling
|
|
187
188
|
* this composite grants matches the ceiling the tick itself enforces at
|
|
188
189
|
* runtime, without importing across the lexicon boundary (see this module's
|
|
189
190
|
* Layering doc).
|
|
@@ -198,7 +199,7 @@ function dialAllowsVerbClass(dial: OperatorDial, verbClass: OpVerbClass): boolea
|
|
|
198
199
|
* Derive the highest verb class a host's ServiceAccount actually needs:
|
|
199
200
|
* the max, across `dispatchTargets`, of each target's own class — but only
|
|
200
201
|
* counting a target `dial` could ever actually dispatch (one it can't just
|
|
201
|
-
* gets reported, per `
|
|
202
|
+
* gets reported, per `OPS014`/`convergeTick`, and needs no elevated grant).
|
|
202
203
|
* A `dispatchTargets` entry that classifies `destructive` is refused
|
|
203
204
|
* outright rather than silently ignored — see this module's RBAC
|
|
204
205
|
* derivation doc on why a destructive target reaching this composite is
|
|
@@ -211,7 +212,8 @@ export function deriveHostVerbClass(hostName: string, dial: OperatorDial, dispat
|
|
|
211
212
|
if (verbClass === "destructive") {
|
|
212
213
|
throw new Error(
|
|
213
214
|
`OperatorStack host "${hostName}": a dispatchTargets entry classifies as destructive — ConvergeOp v1 refuses a destructive run() target under any dial ` +
|
|
214
|
-
`(
|
|
215
|
+
`(OPS014; a converge tick runs unattended, and a destructive dispatch needs a person's approval before it is attempted, not a gate the tick reads after it has already committed to the run). ` +
|
|
216
|
+
`Remove it from dispatchTargets, and run the destructive op yourself through its gate.`,
|
|
215
217
|
);
|
|
216
218
|
}
|
|
217
219
|
if (!dialAllowsVerbClass(dial, verbClass)) continue; // dial refuses this dispatch — reported, not run; needs no elevated RBAC
|
package/src/config.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* K8s environment → cluster binding — chant #1100.
|
|
3
3
|
*
|
|
4
4
|
* Every cloud lexicon binds an environment to a scope: AWS resolves `<env>`
|
|
5
|
-
* to a CloudFormation stack, Azure treats `<env>` as the resource group
|
|
6
|
-
*
|
|
5
|
+
* to a CloudFormation stack, Azure treats `<env>` as the resource group.
|
|
6
|
+
* Before this, k8s bound
|
|
7
7
|
* nothing — `describeResources` shelled out to `kubectl get` with no
|
|
8
8
|
* `--context`, so `chant lifecycle diff prod --live` read whichever cluster
|
|
9
9
|
* `kubectl config current-context` happened to point at.
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
* and the missing binding.
|
|
41
41
|
*
|
|
42
42
|
* `ChantConfig` uses `.passthrough()` in its Zod schema so the `k8s` key is
|
|
43
|
-
* accepted at runtime without core changes, exactly like `
|
|
44
|
-
* (see `lexicons/
|
|
43
|
+
* accepted at runtime without core changes, exactly like the `terraform` key
|
|
44
|
+
* (see `lexicons/terraform/src/config.ts`). The type side is the declaration
|
|
45
45
|
* merge at the bottom of this file — without it the snippet above compiles
|
|
46
46
|
* only until someone adds `satisfies ChantConfig`, which every example
|
|
47
47
|
* project does and which is the only thing type-checking the rest of the
|
package/src/crd/crd-sources.ts
CHANGED
|
@@ -26,7 +26,7 @@ const KUBERAY_CRD_BASE = `https://raw.githubusercontent.com/ray-project/kuberay/
|
|
|
26
26
|
* Argo CD CRDs — argoproj.io/v1alpha1
|
|
27
27
|
*
|
|
28
28
|
* Produces (the `argoproj.io` group is mapped to the `Argo` namespace —
|
|
29
|
-
* see GROUP_NAMESPACE_OVERRIDES in
|
|
29
|
+
* see GROUP_NAMESPACE_OVERRIDES in group-namespace.ts):
|
|
30
30
|
* K8s::Argo::Application → apiVersion: argoproj.io/v1alpha1, kind: Application
|
|
31
31
|
* K8s::Argo::ApplicationSet → apiVersion: argoproj.io/v1alpha1, kind: ApplicationSet
|
|
32
32
|
* K8s::Argo::AppProject → apiVersion: argoproj.io/v1alpha1, kind: AppProject
|
|
@@ -221,7 +221,7 @@ const PROM_OPERATOR_CRD_BASE = `https://raw.githubusercontent.com/prometheus-ope
|
|
|
221
221
|
* also carries `ExternalArtifact` and `ArtifactGenerator` (experimental, and a
|
|
222
222
|
* separate `source.extensions.fluxcd.io` group), which are intentionally left
|
|
223
223
|
* out. All groups map to the `Flux` namespace (see GROUP_NAMESPACE_OVERRIDES in
|
|
224
|
-
*
|
|
224
|
+
* group-namespace.ts):
|
|
225
225
|
* K8s::Flux::GitRepository / OCIRepository / HelmRepository / HelmChart / Bucket
|
|
226
226
|
* → source.toolkit.fluxcd.io/v1
|
|
227
227
|
* K8s::Flux::Kustomization → kustomize.toolkit.fluxcd.io/v1
|
|
@@ -259,7 +259,7 @@ const FLUX_OPERATOR_INSTALL = `https://github.com/controlplaneio-fluxcd/flux-ope
|
|
|
259
259
|
* KubeMicroVM CRDs — lambda.aws.amazon.com/v1alpha1
|
|
260
260
|
*
|
|
261
261
|
* Produces (the group is mapped to the `KubeMicroVM` namespace — see
|
|
262
|
-
* GROUP_NAMESPACE_OVERRIDES in
|
|
262
|
+
* GROUP_NAMESPACE_OVERRIDES in group-namespace.ts):
|
|
263
263
|
* K8s::KubeMicroVM::MicroVM → kind: MicroVM
|
|
264
264
|
* K8s::KubeMicroVM::MicroVMImage → kind: MicroVMImage
|
|
265
265
|
* K8s::KubeMicroVM::MicroVMNetwork → kind: MicroVMNetwork
|
|
@@ -287,7 +287,7 @@ const KUBEMICROVM_VERSION = "1.0.11";
|
|
|
287
287
|
* k3s estate's GitOps surface is written in.
|
|
288
288
|
*
|
|
289
289
|
* Produces (both groups map to the `K3s` namespace — see
|
|
290
|
-
* GROUP_NAMESPACE_OVERRIDES in
|
|
290
|
+
* GROUP_NAMESPACE_OVERRIDES in group-namespace.ts):
|
|
291
291
|
* K8s::K3s::HelmChart → apiVersion: helm.cattle.io/v1, kind: HelmChart
|
|
292
292
|
* K8s::K3s::HelmChartConfig → apiVersion: helm.cattle.io/v1, kind: HelmChartConfig
|
|
293
293
|
* K8s::K3s::Addon → apiVersion: k3s.cattle.io/v1, kind: Addon
|
|
@@ -61,7 +61,8 @@ import { GENERATED_ONCE_LABEL_KEY } from "./secret-labels";
|
|
|
61
61
|
* Kubernetes-defaulted fields, per entity type, as index-erased property
|
|
62
62
|
* paths. Subtracted only where source never declared the property
|
|
63
63
|
* (`side === "live" && counterpart === "absent"`) — cdk-real-drift's default
|
|
64
|
-
* subtraction, same convention as AWS
|
|
64
|
+
* subtraction, same convention as the AWS and Azure tables
|
|
65
|
+
* (`AWS_SERVICE_DEFAULTS`, `AZURE_SERVICE_DEFAULTS`).
|
|
65
66
|
*
|
|
66
67
|
* Sparse and evidence-based rather than derived from the generated schema:
|
|
67
68
|
* the k8s OpenAPI spec this lexicon's codegen consumes
|
|
@@ -70,9 +71,8 @@ import { GENERATED_ONCE_LABEL_KEY } from "./secret-labels";
|
|
|
70
71
|
* actually expressible today. Widening this table is additive and needs no
|
|
71
72
|
* contract change.
|
|
72
73
|
*
|
|
73
|
-
* `spec.strategy` is listed whole
|
|
74
|
-
*
|
|
75
|
-
* only the leaf would still recurse into the object, and a nested default the
|
|
74
|
+
* `spec.strategy` is listed whole rather than as `spec.strategy.type`, because
|
|
75
|
+
* pruning only the leaf would still recurse into the object, and a nested default the
|
|
76
76
|
* table does not separately name (`rollingUpdate.maxSurge`/`maxUnavailable`,
|
|
77
77
|
* both `"25%"` when `spec.strategy` is omitted entirely) would leave behind an
|
|
78
78
|
* empty `strategy: {}` — a value distinct from no `strategy` key at all, and
|
package/src/deep-observe.test.ts
CHANGED
|
@@ -36,6 +36,7 @@ const { statusBody } = await import("@intentius/chant-k8s-client/testing");
|
|
|
36
36
|
const { isChantFieldManager } = await import("@intentius/chant-k8s-client");
|
|
37
37
|
const { diffDeepObservation, observeDeep } = await import("@intentius/chant/lifecycle/deep-observe");
|
|
38
38
|
const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
|
|
39
|
+
const { heldElsewhere } = await import("@intentius/chant/held-elsewhere");
|
|
39
40
|
|
|
40
41
|
type Entity = { name: string; entityType: string; props: Record<string, unknown> };
|
|
41
42
|
function makeEntities(records: Entity[]): Map<string, { entityType: string; props: Record<string, unknown> }> {
|
|
@@ -687,19 +688,20 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
687
688
|
},
|
|
688
689
|
};
|
|
689
690
|
|
|
690
|
-
test("
|
|
691
|
+
test("contested drift surfaces; fields nobody declared are unclaimed and accepted deviations are held back", async () => {
|
|
691
692
|
const live = normalizeDeepObservation(
|
|
692
693
|
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
|
|
693
694
|
);
|
|
694
695
|
const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks, baseline);
|
|
695
696
|
|
|
697
|
+
// Only a path chant's source declares can be drift (#2160). `tier` is
|
|
698
|
+
// declared and holds a different value live, which is the whole point of
|
|
699
|
+
// the contested case: a foreign write over a field chant asks for.
|
|
696
700
|
expect(result.drifted).toEqual([
|
|
697
701
|
{
|
|
698
702
|
name: "web",
|
|
699
703
|
type: "K8s::Apps::Deployment",
|
|
700
704
|
changes: [
|
|
701
|
-
// The #1191 case: foreign-owned, undeclared, not accepted — and the owner says who.
|
|
702
|
-
{ path: "metadata.labels.team", kind: "undeclared", live: "platform", owner: "kubectl-edit" },
|
|
703
705
|
{ path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend", owner: "chant:web" },
|
|
704
706
|
],
|
|
705
707
|
},
|
|
@@ -710,16 +712,39 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
710
712
|
},
|
|
711
713
|
]);
|
|
712
714
|
|
|
713
|
-
//
|
|
715
|
+
// Everything source never set, with the manager where managedFields has one
|
|
716
|
+
// and the claim where it does not. The `team` label needs no baseline entry
|
|
717
|
+
// to stay quiet now, and `kubectl-edit` still says who put it there.
|
|
718
|
+
expect(result.unclaimed).toEqual([
|
|
719
|
+
{
|
|
720
|
+
name: "web",
|
|
721
|
+
type: "K8s::Apps::Deployment",
|
|
722
|
+
fields: [
|
|
723
|
+
{ path: "metadata.labels.team", live: "platform", heldBy: "kubectl-edit", source: "field-manager" },
|
|
724
|
+
{ path: "spec.replicas", live: 7, heldBy: "kube-controller-manager", source: "field-manager", baseline: 7 },
|
|
725
|
+
{
|
|
726
|
+
path: "spec.template.spec.containers[#istio-proxy].image",
|
|
727
|
+
live: "istio/proxyv2:1.20",
|
|
728
|
+
source: "claimed-fields",
|
|
729
|
+
baseline: "istio/proxyv2:1.20",
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
path: "spec.template.spec.containers[#istio-proxy].name",
|
|
733
|
+
live: "istio-proxy",
|
|
734
|
+
source: "claimed-fields",
|
|
735
|
+
baseline: "istio-proxy",
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
},
|
|
739
|
+
]);
|
|
740
|
+
|
|
741
|
+
// The accepted build-id is held back, not hidden.
|
|
714
742
|
expect(result.accepted).toEqual([
|
|
715
743
|
{
|
|
716
744
|
name: "web",
|
|
717
745
|
type: "K8s::Apps::Deployment",
|
|
718
746
|
changes: [
|
|
719
747
|
{ path: "metadata.annotations.build-id", kind: "changed", declared: "42", live: "43", baseline: "43", owner: "chant:web" },
|
|
720
|
-
{ path: "spec.replicas", kind: "undeclared", live: 7, baseline: 7, owner: "kube-controller-manager" },
|
|
721
|
-
{ path: "spec.template.spec.containers[#istio-proxy].image", kind: "undeclared", live: "istio/proxyv2:1.20", baseline: "istio/proxyv2:1.20" },
|
|
722
|
-
{ path: "spec.template.spec.containers[#istio-proxy].name", kind: "undeclared", live: "istio-proxy", baseline: "istio-proxy" },
|
|
723
748
|
],
|
|
724
749
|
},
|
|
725
750
|
]);
|
|
@@ -739,7 +764,10 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
739
764
|
]);
|
|
740
765
|
});
|
|
741
766
|
|
|
742
|
-
test("without the baseline
|
|
767
|
+
test("without the baseline only the declared paths are drift; the rest are still unclaimed", async () => {
|
|
768
|
+
// The baseline was the noise valve before #2160. It is no longer load
|
|
769
|
+
// bearing for a field nobody declared: the claim answers first, so an HPA's
|
|
770
|
+
// replica count and an injected sidecar are quiet with no baseline at all.
|
|
743
771
|
const live = normalizeDeepObservation(
|
|
744
772
|
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
|
|
745
773
|
);
|
|
@@ -747,8 +775,10 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
747
775
|
const web = result.drifted.find((d) => d.name === "web");
|
|
748
776
|
expect(web?.changes.map((c) => c.path).sort()).toEqual([
|
|
749
777
|
"metadata.annotations.build-id",
|
|
750
|
-
"metadata.labels.team",
|
|
751
778
|
"metadata.labels.tier",
|
|
779
|
+
]);
|
|
780
|
+
expect(result.unclaimed.find((d) => d.name === "web")?.fields.map((f) => f.path)).toEqual([
|
|
781
|
+
"metadata.labels.team",
|
|
752
782
|
"spec.replicas",
|
|
753
783
|
"spec.template.spec.containers[#istio-proxy].image",
|
|
754
784
|
"spec.template.spec.containers[#istio-proxy].name",
|
|
@@ -756,38 +786,40 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
756
786
|
expect(result.accepted).toEqual([]);
|
|
757
787
|
});
|
|
758
788
|
|
|
759
|
-
test("the
|
|
789
|
+
test("managedFields names the manager where it has one, and the claim answers where it does not (#2160)", async () => {
|
|
790
|
+
// The k8s row is the only substrate where both sources are available, so
|
|
791
|
+
// it is the one place the fallback can be tested against real data. The
|
|
792
|
+
// `team` label has a managedFields entry addressed by a plain path, so the
|
|
793
|
+
// API server answers. The injected sidecar's fields are inside a list the
|
|
794
|
+
// diff addresses by key (`[#istio-proxy]`), which no managedFields entry
|
|
795
|
+
// names in that form, so the claimed-field set answers instead.
|
|
760
796
|
const live = normalizeDeepObservation(
|
|
761
797
|
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
|
|
762
798
|
);
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
expect(web?.changes).toEqual([
|
|
769
|
-
{ path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend", owner: "chant:web" },
|
|
770
|
-
]);
|
|
771
|
-
expect(result.accepted.find((d) => d.name === "web")?.changes).toContainEqual({
|
|
799
|
+
const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
|
|
800
|
+
const web = result.unclaimed.find((d) => d.name === "web");
|
|
801
|
+
|
|
802
|
+
const byManager = web?.fields.find((f) => f.path === "metadata.labels.team");
|
|
803
|
+
expect(byManager).toEqual({
|
|
772
804
|
path: "metadata.labels.team",
|
|
773
|
-
kind: "undeclared",
|
|
774
805
|
live: "platform",
|
|
775
|
-
|
|
776
|
-
|
|
806
|
+
heldBy: "kubectl-edit",
|
|
807
|
+
source: "field-manager",
|
|
777
808
|
});
|
|
778
809
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
expect(movedResult.drifted.find((d) => d.name === "web")?.changes).toContainEqual({
|
|
785
|
-
path: "metadata.labels.team",
|
|
786
|
-
kind: "undeclared",
|
|
787
|
-
live: "platform",
|
|
788
|
-
baseline: "security",
|
|
789
|
-
owner: "kubectl-edit",
|
|
810
|
+
const byClaim = web?.fields.find((f) => f.path === "spec.template.spec.containers[#istio-proxy].image");
|
|
811
|
+
expect(byClaim).toEqual({
|
|
812
|
+
path: "spec.template.spec.containers[#istio-proxy].image",
|
|
813
|
+
live: "istio/proxyv2:1.20",
|
|
814
|
+
source: "claimed-fields",
|
|
790
815
|
});
|
|
816
|
+
expect(byClaim).not.toHaveProperty("heldBy");
|
|
817
|
+
|
|
818
|
+
// Both sources agree on the verdict; they differ only in how much they can
|
|
819
|
+
// say about it. Neither is drift, and neither is proposed for update.
|
|
820
|
+
expect(result.drifted.find((d) => d.name === "web")?.changes.map((c) => c.path)).not.toContain(
|
|
821
|
+
"metadata.labels.team",
|
|
822
|
+
);
|
|
791
823
|
});
|
|
792
824
|
|
|
793
825
|
test("a whole-lexicon failure (no client) is a hole for every declared entity, never a clean report", async () => {
|
|
@@ -814,6 +846,142 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
814
846
|
});
|
|
815
847
|
});
|
|
816
848
|
|
|
849
|
+
/**
|
|
850
|
+
* #2162's honest example, driven through the real reader and core's real
|
|
851
|
+
* `diffDeepObservation` the same way the "end to end" suite above does — the
|
|
852
|
+
* declared alternative to the `accepted` baseline dance that suite's own
|
|
853
|
+
* `web` fixture needs for the very same field: an HPA owns `spec.replicas`
|
|
854
|
+
* on a Deployment source never sets a literal value for. Before this, the
|
|
855
|
+
* only way to stop hearing about it was `--update-baseline`, and a later
|
|
856
|
+
* scale event (7 -> 12) reported as drift again the moment the accepted
|
|
857
|
+
* value moved. `heldElsewhere()` needs no baseline entry at all, and does
|
|
858
|
+
* not re-alert when the HPA changes its mind.
|
|
859
|
+
*/
|
|
860
|
+
describe("heldElsewhere(): the HPA/replicas case without a baseline entry (#2162)", () => {
|
|
861
|
+
const declared = makeEntities([
|
|
862
|
+
{
|
|
863
|
+
name: "web",
|
|
864
|
+
entityType: "K8s::Apps::Deployment",
|
|
865
|
+
props: {
|
|
866
|
+
metadata: { name: "web", namespace: "prod", labels: { app: "web" } },
|
|
867
|
+
spec: {
|
|
868
|
+
replicas: heldElsewhere<number>({ by: "hpa", reason: "the autoscaler owns replicas after the first apply" }),
|
|
869
|
+
selector: { matchLabels: { app: "web" } },
|
|
870
|
+
template: { metadata: { labels: { app: "web" } }, spec: { containers: [{ name: "app", image: "web:1.0" }] } },
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
]);
|
|
875
|
+
|
|
876
|
+
const webLiveAt = (replicas: number) => ({
|
|
877
|
+
apiVersion: "apps/v1",
|
|
878
|
+
kind: "Deployment",
|
|
879
|
+
metadata: {
|
|
880
|
+
name: "web",
|
|
881
|
+
namespace: "prod",
|
|
882
|
+
uid: "uid-web",
|
|
883
|
+
labels: { app: "web" },
|
|
884
|
+
managedFields: [
|
|
885
|
+
{
|
|
886
|
+
manager: "chant:web",
|
|
887
|
+
operation: "Apply",
|
|
888
|
+
apiVersion: "apps/v1",
|
|
889
|
+
fieldsV1: {
|
|
890
|
+
"f:metadata": { "f:labels": { "f:app": {} } },
|
|
891
|
+
"f:spec": {
|
|
892
|
+
"f:selector": {},
|
|
893
|
+
"f:template": {
|
|
894
|
+
"f:spec": { "f:containers": { 'k:{"name":"app"}': { ".": {}, "f:name": {}, "f:image": {} } } },
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
// The autoscaler: chant's own manifest never mentions `replicas`,
|
|
901
|
+
// so nothing here contests it — the HPA is the only writer.
|
|
902
|
+
manager: "kube-controller-manager",
|
|
903
|
+
operation: "Update",
|
|
904
|
+
apiVersion: "apps/v1",
|
|
905
|
+
fieldsV1: { "f:spec": { "f:replicas": {} } },
|
|
906
|
+
},
|
|
907
|
+
],
|
|
908
|
+
},
|
|
909
|
+
spec: {
|
|
910
|
+
replicas,
|
|
911
|
+
selector: { matchLabels: { app: "web" } },
|
|
912
|
+
template: { metadata: { labels: { app: "web" } }, spec: { containers: [{ name: "app", image: "web:1.0" }] } },
|
|
913
|
+
},
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
const clusterAt = (replicas: number) =>
|
|
917
|
+
fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: webLiveAt(replicas) } });
|
|
918
|
+
|
|
919
|
+
test("reports held with the HPA as owner — no drift, no baseline entry needed", async () => {
|
|
920
|
+
const live = normalizeDeepObservation(
|
|
921
|
+
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, clusterAt(7).connector),
|
|
922
|
+
);
|
|
923
|
+
const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
|
|
924
|
+
|
|
925
|
+
expect(result.drifted).toEqual([]);
|
|
926
|
+
expect(result.accepted).toEqual([]);
|
|
927
|
+
expect(result.held).toEqual([
|
|
928
|
+
{
|
|
929
|
+
name: "web",
|
|
930
|
+
type: "K8s::Apps::Deployment",
|
|
931
|
+
held: [
|
|
932
|
+
{
|
|
933
|
+
path: "spec.replicas",
|
|
934
|
+
by: "hpa",
|
|
935
|
+
reason: "the autoscaler owns replicas after the first apply",
|
|
936
|
+
live: 7,
|
|
937
|
+
suspicious: false,
|
|
938
|
+
owner: "kube-controller-manager",
|
|
939
|
+
},
|
|
940
|
+
],
|
|
941
|
+
},
|
|
942
|
+
]);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test("a later scale event does not re-alert — unlike the accepted baseline, held is not value-bound", async () => {
|
|
946
|
+
const live = normalizeDeepObservation(
|
|
947
|
+
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, clusterAt(12).connector),
|
|
948
|
+
);
|
|
949
|
+
const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
|
|
950
|
+
|
|
951
|
+
expect(result.drifted).toEqual([]);
|
|
952
|
+
expect(result.held[0].held[0]).toMatchObject({ live: 12, suspicious: false });
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test("no live value at all is suspicious — the declared hand-over never showed up", async () => {
|
|
956
|
+
// No managedFields entry for replicas, and the live object carries none
|
|
957
|
+
// either — a Deployment where the HPA was never actually wired up.
|
|
958
|
+
const neverScaled = {
|
|
959
|
+
apiVersion: "apps/v1",
|
|
960
|
+
kind: "Deployment",
|
|
961
|
+
metadata: { name: "web", namespace: "prod", uid: "uid-web", labels: { app: "web" } },
|
|
962
|
+
spec: {
|
|
963
|
+
selector: { matchLabels: { app: "web" } },
|
|
964
|
+
template: { metadata: { labels: { app: "web" } }, spec: { containers: [{ name: "app", image: "web:1.0" }] } },
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
const live = normalizeDeepObservation(
|
|
968
|
+
await observeResourcesDeepK8s(
|
|
969
|
+
{ environment: "prod", entityNames: [...declared.keys()], entities: declared },
|
|
970
|
+
fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: neverScaled } }).connector,
|
|
971
|
+
),
|
|
972
|
+
);
|
|
973
|
+
const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
|
|
974
|
+
|
|
975
|
+
expect(result.held).toEqual([
|
|
976
|
+
{
|
|
977
|
+
name: "web",
|
|
978
|
+
type: "K8s::Apps::Deployment",
|
|
979
|
+
held: [{ path: "spec.replicas", by: "hpa", reason: "the autoscaler owns replicas after the first apply", suspicious: true }],
|
|
980
|
+
},
|
|
981
|
+
]);
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
817
985
|
/**
|
|
818
986
|
* The managed-fields case named in #1076's acceptance, re-decided by #1191:
|
|
819
987
|
* the same live mutation on the same field is `changed` when chant owns it
|
|
@@ -873,27 +1041,29 @@ describe("the managed-fields twist: ownership names the writer, the declared tre
|
|
|
873
1041
|
]);
|
|
874
1042
|
});
|
|
875
1043
|
|
|
876
|
-
test("a controller owns the field and source is silent: the same mutated value is
|
|
1044
|
+
test("a controller owns the field and source is silent: the same mutated value is unclaimed, never drift (#2160)", async () => {
|
|
1045
|
+
// The mirror of the test above, and the whole claim in one pair: the same
|
|
1046
|
+
// live value, the same manager, and the declaration is what decides
|
|
1047
|
+
// whether it is chant's problem.
|
|
877
1048
|
const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "app", "prod")]: liveWith("controller") } });
|
|
878
1049
|
const entities = declaredWith(false);
|
|
879
1050
|
const live = normalizeDeepObservation(
|
|
880
1051
|
await observeResourcesDeepK8s({ environment: "prod", entityNames: ["app"], entities }, cluster.connector),
|
|
881
1052
|
);
|
|
882
1053
|
const result = diffDeepObservation(entities, live, k8sDeepNormalizationHooks);
|
|
883
|
-
expect(result.drifted).toEqual([
|
|
1054
|
+
expect(result.drifted).toEqual([]);
|
|
1055
|
+
expect(result.unclaimed).toEqual([
|
|
884
1056
|
{
|
|
885
1057
|
name: "app",
|
|
886
1058
|
type: "K8s::Apps::Deployment",
|
|
887
|
-
|
|
1059
|
+
fields: [
|
|
1060
|
+
{ path: "spec.template.spec.containers[#app].image", live: "app:2.0", source: "claimed-fields" },
|
|
1061
|
+
],
|
|
888
1062
|
},
|
|
889
1063
|
]);
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
};
|
|
894
|
-
const quiet = diffDeepObservation(entities, live, k8sDeepNormalizationHooks, accepted);
|
|
895
|
-
expect(quiet.drifted).toEqual([]);
|
|
896
|
-
expect(quiet.accepted.map((d) => d.name)).toEqual(["app"]);
|
|
1064
|
+
// No baseline entry needed to silence it, which is what #1191's valve was
|
|
1065
|
+
// being used for.
|
|
1066
|
+
expect(result.unchanged).toEqual(["app"]);
|
|
897
1067
|
});
|
|
898
1068
|
});
|
|
899
1069
|
|
|
@@ -954,9 +1124,12 @@ describe("secret masking — diff --live never holds a Secret data value (#1830,
|
|
|
954
1124
|
// MASTER_KEY is present on both sides: both values collapse to the mask,
|
|
955
1125
|
// so a value-only difference is NOT drift — that is the contract, chant
|
|
956
1126
|
// cannot know and must not learn whether the bytes moved.
|
|
957
|
-
expect(changes.map((c) => `${c.path}:${c.kind}`).sort()).toEqual([
|
|
958
|
-
|
|
959
|
-
|
|
1127
|
+
expect(changes.map((c) => `${c.path}:${c.kind}`).sort()).toEqual(["data.PENDING_KEY:absent"]);
|
|
1128
|
+
|
|
1129
|
+
// The injected key is a key source never declared, so it is unclaimed
|
|
1130
|
+
// (#2160). Presence still classifies; the value is still the mask.
|
|
1131
|
+
expect(result.unclaimed.find((d) => d.name === "master-key")?.fields).toEqual([
|
|
1132
|
+
{ path: "data.INJECTED_KEY", live: "[REDACTED]", source: "claimed-fields" },
|
|
960
1133
|
]);
|
|
961
1134
|
|
|
962
1135
|
// The hard line, asserted over the whole serialized result: no data
|