@intentius/chant-lexicon-k8s 0.57.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.
Files changed (47) hide show
  1. package/dist/api/sweep-types.d.ts +1 -2
  2. package/dist/api/sweep-types.d.ts.map +1 -1
  3. package/dist/composites/operator-stack.d.ts +19 -18
  4. package/dist/composites/operator-stack.d.ts.map +1 -1
  5. package/dist/config.d.ts +4 -4
  6. package/dist/deep-observe-hooks.d.ts +4 -4
  7. package/dist/deep-observe.d.ts +23 -16
  8. package/dist/deep-observe.d.ts.map +1 -1
  9. package/dist/export-resources.d.ts +1 -1
  10. package/dist/integrity.json +3 -3
  11. package/dist/lint/audit-catalog.d.ts.map +1 -1
  12. package/dist/lint/audit-lineage.d.ts +10 -0
  13. package/dist/lint/audit-lineage.d.ts.map +1 -0
  14. package/dist/manifest.json +1 -1
  15. package/dist/op/activities/argo.d.ts +4 -4
  16. package/dist/op/activities/argo.d.ts.map +1 -1
  17. package/dist/op/activities/index.d.ts +9 -7
  18. package/dist/op/activities/index.d.ts.map +1 -1
  19. package/dist/op/activities/kubectl.d.ts +7 -7
  20. package/dist/op/activities/kubectl.d.ts.map +1 -1
  21. package/dist/op/activities/wait-for-ready.d.ts +6 -5
  22. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  23. package/dist/op/builders.d.ts +13 -13
  24. package/dist/skills/chant-k8s-argo.md +27 -13
  25. package/package.json +3 -3
  26. package/src/api/sweep-types.ts +1 -2
  27. package/src/composites/composites.test.ts +13 -2
  28. package/src/composites/operator-stack.ts +22 -20
  29. package/src/config.ts +4 -4
  30. package/src/crd/crd-sources.ts +4 -4
  31. package/src/deep-observe-hooks.ts +4 -4
  32. package/src/deep-observe.test.ts +219 -46
  33. package/src/deep-observe.ts +28 -21
  34. package/src/export-resources.ts +1 -1
  35. package/src/lint/audit-catalog.ts +5 -1
  36. package/src/lint/audit-lineage.ts +127 -0
  37. package/src/op/activities/argo.test.ts +7 -8
  38. package/src/op/activities/argo.ts +4 -4
  39. package/src/op/activities/index.ts +9 -7
  40. package/src/op/activities/kubectl.test.ts +5 -4
  41. package/src/op/activities/kubectl.ts +52 -67
  42. package/src/op/activities/wait-for-ready.test.ts +2 -2
  43. package/src/op/activities/wait-for-ready.ts +7 -7
  44. package/src/op/builders.ts +13 -13
  45. package/src/plugin.ts +1 -1
  46. package/src/serializer.test.ts +26 -0
  47. 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.57.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.57.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.57.0",
87
+ "@intentius/chant": "^0.58.0",
88
88
  "typescript": "^5.9.3"
89
89
  }
90
90
  }
@@ -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 a Temporal worker loading the apply
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 TMP014/convergeTick", () => {
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 — TMP014 only lets apply free-run a mutating dispatch", () => {
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 (`lexicons/temporal/src/composites/converge-op.ts`'s own doc:
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 Temporal schedule `overlap: "Skip"` policy — never
27
- * queue a second tick behind one still running.
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, independently of the temporal lexicon (this module has no
34
- * dependency on it see the layering note below): for each hosted
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
- * `TMP014`'s build-time refusal, restated as an RBAC ceiling:
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 (TMP014 refuses a
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 — TMP014 already refuses a
50
- * destructive `run()` target under any dial in v1 (the local dispatch
51
- * executor can't honor its required gate), so a `destructive` target
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
- * `lexicons/k8s` has no workspace dependency on `lexicons/temporal` (nor
65
- * the reverse see both packages' `package.json`), so this module doesn't
66
- * import `ConvergeOpConfig`/`ConvergeRule` types. `OperatorStackConvergeHost`
67
- * restates the handful of `ConvergeOp` fields this composite actually needs
68
- * (`name`, `schedule`, `env`, `dial`) structurally; `dispatchTargets` takes
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
- * (`lexicons/temporal/src/composites/converge-op.ts`) structurally — see
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 `TMP014` derives its build-time
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
- * (`lexicons/temporal/src/op/activities/converge.ts`) so the RBAC ceiling
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 `TMP014`/`convergeTick`, and needs no elevated grant).
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
- `(TMP014; the local dispatch executor can't honor its required gate). Remove it from dispatchTargets, or remediate manually via a gated op.`,
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
- * Temporal looks up `temporal.profiles.<env>`. Before this, k8s bound
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 `temporal.profiles`
44
- * (see `lexicons/temporal/src/config.ts`). The type side is the declaration
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
@@ -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 crd/parser.ts):
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
- * crd/parser.ts):
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 crd/parser.ts):
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 crd/parser.ts):
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/Azure/Temporal's tables.
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, not as `spec.strategy.type`, for the same
74
- * reason Temporal's `TEMPORAL_SCHEDULE_DEFAULTS` lists `state` whole: pruning
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
@@ -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("genuine, undeclared and contested drift surface; accepted deviations and controller-stamped metadata do not", async () => {
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
- // The accepted HPA replicas and sidecar are held back, not hidden.
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 every foreign-owned undeclared field is drift too; accepting is what silences it", async () => {
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 same out-of-band label in the accepted baseline is not drift, at that value (#1191)", async () => {
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 withTeam = {
764
- web: { ...baseline.web, accepted: [...baseline.web.accepted, { path: "metadata.labels.team", value: "platform" }] },
765
- };
766
- const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks, withTeam);
767
- const web = result.drifted.find((d) => d.name === "web");
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
- baseline: "platform",
776
- owner: "kubectl-edit",
806
+ heldBy: "kubectl-edit",
807
+ source: "field-manager",
777
808
  });
778
809
 
779
- // Value-bound: accepting `team=platform` does not bless `team=security`.
780
- const moved = {
781
- web: { ...baseline.web, accepted: [...baseline.web.accepted, { path: "metadata.labels.team", value: "security" }] },
782
- };
783
- const movedResult = diffDeepObservation(declared, live, k8sDeepNormalizationHooks, moved);
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 undeclared drift until accepted (#1191)", async () => {
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
- changes: [{ path: "spec.template.spec.containers[#app].image", kind: "undeclared", live: "app:2.0" }],
1059
+ fields: [
1060
+ { path: "spec.template.spec.containers[#app].image", live: "app:2.0", source: "claimed-fields" },
1061
+ ],
888
1062
  },
889
1063
  ]);
890
-
891
- const accepted = {
892
- app: { accepted: [{ path: "spec.template.spec.containers[#app].image", value: "app:2.0" }] },
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
- "data.INJECTED_KEY:undeclared",
959
- "data.PENDING_KEY:absent",
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