@intentius/chant-lexicon-k8s 0.44.13 → 0.45.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/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/generate-lexicon.d.ts +15 -1
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/crd/parser.d.ts +28 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +60 -4
- package/dist/deep-observe-hooks.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +36 -61
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/integrity.json +6 -3
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/crd-schema-helpers.d.ts +49 -0
- package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/wk8501.d.ts +14 -0
- package/dist/lint/post-synth/wk8501.d.ts.map +1 -0
- package/dist/lint/post-synth/wk8502.d.ts +13 -0
- package/dist/lint/post-synth/wk8502.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +126 -63
- package/dist/okf/index.md +2 -0
- package/dist/okf/rules/WK8501.md +11 -0
- package/dist/okf/rules/WK8502.md +11 -0
- package/dist/rules/crd-schema-helpers.ts +228 -0
- package/dist/rules/wk8501.ts +40 -0
- package/dist/rules/wk8502.ts +39 -0
- package/dist/spec/parse.d.ts +32 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/codegen/docs.ts +0 -1044
- package/src/codegen/generate-lexicon.ts +38 -2
- package/src/codegen/snapshot.test.ts +20 -0
- package/src/components/capability-plugin.test.ts +20 -0
- package/src/components/capability-plugin.ts +5 -2
- package/src/crd/cnpg.test.ts +1 -1
- package/src/crd/infisical.test.ts +1 -1
- package/src/crd/parser.test.ts +117 -1
- package/src/crd/parser.ts +71 -5
- package/src/crd/traefik.test.ts +1 -1
- package/src/deep-observe-hooks.ts +78 -16
- package/src/deep-observe.test.ts +145 -29
- package/src/deep-observe.ts +43 -85
- package/src/generated/lexicon-k8s.json +126 -63
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/crd-schema-helpers.ts +228 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/post-synth.test.ts +156 -0
- package/src/lint/post-synth/wk8501.ts +40 -0
- package/src/lint/post-synth/wk8502.ts +39 -0
- package/src/list-map-key-table.test.ts +83 -0
- package/src/op/activities/kubectl.ts +2 -2
- package/src/serializer.test.ts +4 -4
- package/src/spec/parse.ts +33 -0
- package/src/validate.ts +7 -4
package/src/deep-observe.test.ts
CHANGED
|
@@ -128,6 +128,38 @@ describe("k8sDeepNormalizationHooks — the static rules", () => {
|
|
|
128
128
|
expect(foreign).toEqual({ metadata: { name: "web", labels: { "app.kubernetes.io/managed-by": "helm" } } });
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
test("controller-stamped annotations and labels subtract; an out-of-band user label does not (#1191)", () => {
|
|
132
|
+
const live = {
|
|
133
|
+
metadata: {
|
|
134
|
+
name: "web",
|
|
135
|
+
labels: { "pod-template-hash": "5d4f8b7c9", team: "platform" },
|
|
136
|
+
annotations: {
|
|
137
|
+
"deployment.kubernetes.io/revision": "3",
|
|
138
|
+
"kubectl.kubernetes.io/last-applied-configuration": "{}",
|
|
139
|
+
"kubernetes.io/change-cause": "kubectl set image",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
const out = normalizeDeepProperties(live, {
|
|
144
|
+
entityType: "K8s::Apps::Deployment",
|
|
145
|
+
side: "live",
|
|
146
|
+
hooks: k8sDeepNormalizationHooks,
|
|
147
|
+
counterpartPaths: new Set(["metadata", "metadata.name"]),
|
|
148
|
+
});
|
|
149
|
+
expect(out).toEqual({
|
|
150
|
+
metadata: { name: "web", labels: { team: "platform" }, annotations: { "kubernetes.io/change-cause": "kubectl set image" } },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Declared, so compared — the allowlist is counterpart-gated like the defaults table.
|
|
154
|
+
const declaredRevision = normalizeDeepProperties(live, {
|
|
155
|
+
entityType: "K8s::Apps::Deployment",
|
|
156
|
+
side: "live",
|
|
157
|
+
hooks: k8sDeepNormalizationHooks,
|
|
158
|
+
counterpartPaths: new Set(["metadata", "metadata.name", "metadata.annotations", "metadata.annotations.deployment.kubernetes.io/revision"]),
|
|
159
|
+
});
|
|
160
|
+
expect((declaredRevision as { metadata: { annotations: Record<string, string> } }).metadata.annotations["deployment.kubernetes.io/revision"]).toBe("3");
|
|
161
|
+
});
|
|
162
|
+
|
|
131
163
|
test("a server-assigned ClusterIP nobody declared subtracts; a declared one is compared", () => {
|
|
132
164
|
const undeclared = normalizeDeepProperties(
|
|
133
165
|
{ spec: { clusterIP: "10.43.201.161", clusterIPs: ["10.43.201.161"], ipFamilies: ["IPv4"], selector: { app: "web" } } },
|
|
@@ -453,8 +485,10 @@ describe("observeResourcesDeepK8s — reading through the typed client (#1076)",
|
|
|
453
485
|
*/
|
|
454
486
|
describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
455
487
|
const declared = makeEntities([
|
|
456
|
-
// "web":
|
|
457
|
-
//
|
|
488
|
+
// "web": someone changed a label chant owns, someone added a label chant
|
|
489
|
+
// never declared (#1191), HPA owns replicas and Istio injected a sidecar
|
|
490
|
+
// (both undeclared — drift until accepted), and the Deployment controller
|
|
491
|
+
// stamped its revision annotation (system noise, never drift).
|
|
458
492
|
{
|
|
459
493
|
name: "web",
|
|
460
494
|
entityType: "K8s::Apps::Deployment",
|
|
@@ -505,10 +539,12 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
505
539
|
resourceVersion: "42",
|
|
506
540
|
generation: 7,
|
|
507
541
|
creationTimestamp: "2026-01-01T00:00:00Z",
|
|
508
|
-
// GENUINE DRIFT: chant owns
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
542
|
+
// GENUINE DRIFT: chant owns `tier`, and the live value moved.
|
|
543
|
+
// UNDECLARED DRIFT: `team` was added out of band by `kubectl label` (#1191).
|
|
544
|
+
labels: { app: "web", tier: "frontend", team: "platform" },
|
|
545
|
+
// ACCEPTED: chant owns build-id; the platform's baseline accepts "43".
|
|
546
|
+
// NOISE: the Deployment controller's revision counter is on the static allowlist.
|
|
547
|
+
annotations: { "build-id": "43", "deployment.kubernetes.io/revision": "3" },
|
|
512
548
|
managedFields: [
|
|
513
549
|
{
|
|
514
550
|
manager: "chant:web",
|
|
@@ -529,15 +565,27 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
529
565
|
},
|
|
530
566
|
},
|
|
531
567
|
{
|
|
532
|
-
//
|
|
568
|
+
// UNDECLARED: a human ran `kubectl label deploy web team=platform`.
|
|
569
|
+
manager: "kubectl-edit",
|
|
570
|
+
operation: "Update",
|
|
571
|
+
apiVersion: "apps/v1",
|
|
572
|
+
time: "2026-01-03T00:00:00Z",
|
|
573
|
+
fieldsV1: { "f:metadata": { "f:labels": { "f:team": {} } } },
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
// UNDECLARED until accepted: HPA owns replicas, and chant never declared it.
|
|
577
|
+
// SYSTEM NOISE: the revision annotation, stamped by the Deployment controller.
|
|
533
578
|
manager: "kube-controller-manager",
|
|
534
579
|
operation: "Update",
|
|
535
580
|
apiVersion: "apps/v1",
|
|
536
581
|
time: "2026-01-02T00:00:00Z",
|
|
537
|
-
fieldsV1: {
|
|
582
|
+
fieldsV1: {
|
|
583
|
+
"f:metadata": { "f:annotations": { "f:deployment.kubernetes.io/revision": {} } },
|
|
584
|
+
"f:spec": { "f:replicas": {} },
|
|
585
|
+
},
|
|
538
586
|
},
|
|
539
587
|
{
|
|
540
|
-
//
|
|
588
|
+
// UNDECLARED until accepted: a mutating webhook injected a whole sidecar container.
|
|
541
589
|
manager: "istio-sidecar-injector",
|
|
542
590
|
operation: "Update",
|
|
543
591
|
apiVersion: "apps/v1",
|
|
@@ -625,14 +673,21 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
625
673
|
respond: (req) => (req.path.endsWith("/deployments/broken") ? { status: 500, body: statusBody(500, "InternalError", "backend unavailable") } : undefined),
|
|
626
674
|
});
|
|
627
675
|
|
|
676
|
+
// The platform accepted the HPA and the sidecar — the valve #1191 leans on —
|
|
677
|
+
// but nobody accepted the `team` label.
|
|
628
678
|
const baseline = {
|
|
629
679
|
web: {
|
|
630
680
|
type: "K8s::Apps::Deployment",
|
|
631
|
-
accepted: [
|
|
681
|
+
accepted: [
|
|
682
|
+
{ path: "metadata.annotations.build-id", value: "43" },
|
|
683
|
+
{ path: "spec.replicas", value: 7 },
|
|
684
|
+
{ path: "spec.template.spec.containers[#istio-proxy].name", value: "istio-proxy" },
|
|
685
|
+
{ path: "spec.template.spec.containers[#istio-proxy].image", value: "istio/proxyv2:1.20" },
|
|
686
|
+
],
|
|
632
687
|
},
|
|
633
688
|
};
|
|
634
689
|
|
|
635
|
-
test("
|
|
690
|
+
test("genuine, undeclared and contested drift surface; accepted deviations and controller-stamped metadata do not", async () => {
|
|
636
691
|
const live = normalizeDeepObservation(
|
|
637
692
|
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
|
|
638
693
|
);
|
|
@@ -642,28 +697,36 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
642
697
|
{
|
|
643
698
|
name: "web",
|
|
644
699
|
type: "K8s::Apps::Deployment",
|
|
645
|
-
changes: [
|
|
700
|
+
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
|
+
{ path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend", owner: "chant:web" },
|
|
704
|
+
],
|
|
646
705
|
},
|
|
647
706
|
{
|
|
648
707
|
name: "worker",
|
|
649
708
|
type: "K8s::Apps::Deployment",
|
|
650
|
-
changes: [{ path: "spec.replicas", kind: "changed", declared: 5, live: 9 }],
|
|
709
|
+
changes: [{ path: "spec.replicas", kind: "changed", declared: 5, live: 9, owner: "kubectl-client-side-apply" }],
|
|
651
710
|
},
|
|
652
711
|
]);
|
|
653
712
|
|
|
713
|
+
// The accepted HPA replicas and sidecar are held back, not hidden.
|
|
654
714
|
expect(result.accepted).toEqual([
|
|
655
715
|
{
|
|
656
716
|
name: "web",
|
|
657
717
|
type: "K8s::Apps::Deployment",
|
|
658
|
-
changes: [
|
|
718
|
+
changes: [
|
|
719
|
+
{ 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
|
+
],
|
|
659
724
|
},
|
|
660
725
|
]);
|
|
661
726
|
|
|
662
|
-
// The
|
|
663
|
-
// as drift, not as
|
|
664
|
-
|
|
665
|
-
expect(webDriftPaths).not.toContain("spec.replicas");
|
|
666
|
-
expect(JSON.stringify(result)).not.toContain("istio-proxy");
|
|
727
|
+
// The Deployment controller's revision counter never appears at all — not
|
|
728
|
+
// as drift, not as accepted. Subtracted by the static allowlist.
|
|
729
|
+
expect(JSON.stringify(result)).not.toContain("deployment.kubernetes.io/revision");
|
|
667
730
|
|
|
668
731
|
expect(result.unobserved).toEqual([
|
|
669
732
|
{ name: "broken", type: "K8s::Apps::Deployment", reason: "read-failed", detail: expect.stringContaining("500") },
|
|
@@ -676,16 +739,57 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
676
739
|
]);
|
|
677
740
|
});
|
|
678
741
|
|
|
679
|
-
test("without the baseline
|
|
742
|
+
test("without the baseline every foreign-owned undeclared field is drift too; accepting is what silences it", async () => {
|
|
680
743
|
const live = normalizeDeepObservation(
|
|
681
744
|
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
|
|
682
745
|
);
|
|
683
746
|
const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
|
|
684
747
|
const web = result.drifted.find((d) => d.name === "web");
|
|
685
|
-
expect(web?.changes.map((c) => c.path).sort()).toEqual([
|
|
748
|
+
expect(web?.changes.map((c) => c.path).sort()).toEqual([
|
|
749
|
+
"metadata.annotations.build-id",
|
|
750
|
+
"metadata.labels.team",
|
|
751
|
+
"metadata.labels.tier",
|
|
752
|
+
"spec.replicas",
|
|
753
|
+
"spec.template.spec.containers[#istio-proxy].image",
|
|
754
|
+
"spec.template.spec.containers[#istio-proxy].name",
|
|
755
|
+
]);
|
|
686
756
|
expect(result.accepted).toEqual([]);
|
|
687
757
|
});
|
|
688
758
|
|
|
759
|
+
test("the same out-of-band label in the accepted baseline is not drift, at that value (#1191)", async () => {
|
|
760
|
+
const live = normalizeDeepObservation(
|
|
761
|
+
await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
|
|
762
|
+
);
|
|
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({
|
|
772
|
+
path: "metadata.labels.team",
|
|
773
|
+
kind: "undeclared",
|
|
774
|
+
live: "platform",
|
|
775
|
+
baseline: "platform",
|
|
776
|
+
owner: "kubectl-edit",
|
|
777
|
+
});
|
|
778
|
+
|
|
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",
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
|
|
689
793
|
test("a whole-lexicon failure (no client) is a hole for every declared entity, never a clean report", async () => {
|
|
690
794
|
// The reader's contract (matching describe-resources.ts) is to *throw* for
|
|
691
795
|
// an unrecognized connect failure, not to catch it — core's own
|
|
@@ -711,11 +815,12 @@ describe("end to end: managed-fields-derived drift (#1076)", () => {
|
|
|
711
815
|
});
|
|
712
816
|
|
|
713
817
|
/**
|
|
714
|
-
* The managed-fields
|
|
715
|
-
* live mutation on the same field is
|
|
716
|
-
* when a controller owns it and
|
|
818
|
+
* The managed-fields case named in #1076's acceptance, re-decided by #1191:
|
|
819
|
+
* the same live mutation on the same field is `changed` when chant owns it
|
|
820
|
+
* and source declares it, and `undeclared` when a controller owns it and
|
|
821
|
+
* source is silent. Ownership names the writer; it no longer silences.
|
|
717
822
|
*/
|
|
718
|
-
describe("the managed-fields twist: ownership
|
|
823
|
+
describe("the managed-fields twist: ownership names the writer, the declared tree decides the kind (#1076, #1191)", () => {
|
|
719
824
|
const liveWith = (owner: "chant" | "controller") => ({
|
|
720
825
|
apiVersion: "apps/v1",
|
|
721
826
|
kind: "Deployment",
|
|
@@ -768,15 +873,26 @@ describe("the managed-fields twist: ownership decides drift vs silence for the s
|
|
|
768
873
|
]);
|
|
769
874
|
});
|
|
770
875
|
|
|
771
|
-
test("a controller owns the field and source is silent: the same mutated value is
|
|
876
|
+
test("a controller owns the field and source is silent: the same mutated value is undeclared drift until accepted (#1191)", async () => {
|
|
772
877
|
const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "app", "prod")]: liveWith("controller") } });
|
|
773
878
|
const entities = declaredWith(false);
|
|
774
879
|
const live = normalizeDeepObservation(
|
|
775
880
|
await observeResourcesDeepK8s({ environment: "prod", entityNames: ["app"], entities }, cluster.connector),
|
|
776
881
|
);
|
|
777
882
|
const result = diffDeepObservation(entities, live, k8sDeepNormalizationHooks);
|
|
778
|
-
expect(result.drifted).toEqual([
|
|
779
|
-
|
|
780
|
-
|
|
883
|
+
expect(result.drifted).toEqual([
|
|
884
|
+
{
|
|
885
|
+
name: "app",
|
|
886
|
+
type: "K8s::Apps::Deployment",
|
|
887
|
+
changes: [{ path: "spec.template.spec.containers[#app].image", kind: "undeclared", live: "app:2.0" }],
|
|
888
|
+
},
|
|
889
|
+
]);
|
|
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"]);
|
|
781
897
|
});
|
|
782
898
|
});
|
package/src/deep-observe.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
* rather than the hand-maintained `ignoreDifferences` list every Argo user
|
|
8
8
|
* keeps.
|
|
9
9
|
*
|
|
10
|
-
* ##
|
|
11
|
-
* Control"
|
|
10
|
+
* ## What managedFields decides, and what it does not
|
|
12
11
|
*
|
|
13
12
|
* AWS, Azure and Temporal's rows all prune by a **static, entityType-keyed**
|
|
14
13
|
* table: an ARN always looks like an ARN, `provisioningState` is always
|
|
@@ -17,65 +16,43 @@
|
|
|
17
16
|
* what `./deep-observe-hooks.ts`'s `k8sDeepNormalizationHooks` is, and it
|
|
18
17
|
* covers Kubernetes' *equivalent* static noise (`status`,
|
|
19
18
|
* `metadata.{uid,resourceVersion,generation,creationTimestamp}`, a handful of
|
|
20
|
-
* server-defaulted pod-spec fields
|
|
19
|
+
* server-defaulted pod-spec fields, and the annotations and labels
|
|
20
|
+
* Kubernetes' own controllers stamp on every object of a kind —
|
|
21
|
+
* `K8S_SYSTEM_METADATA_PRUNE_PATTERNS` in `@intentius/chant/managed-fields`).
|
|
21
22
|
*
|
|
22
|
-
* managedFields is
|
|
23
|
-
* *this* Deployment
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `DeepNormalizationHooks.prune` cannot see the object it is walking, only a
|
|
28
|
-
* path and a value, so this cannot be expressed as a fixed hook the way the
|
|
29
|
-
* other three rows' entire contribution is. It has to be computed once per
|
|
30
|
-
* resource, here, and layered on top of the static rules before the tree is
|
|
31
|
-
* normalized.
|
|
23
|
+
* managedFields is a different kind of fact. Which manager owns
|
|
24
|
+
* `spec.replicas` on *this* Deployment differs between two Deployments of
|
|
25
|
+
* the same type, and the declared tree never carries it at all (chant's
|
|
26
|
+
* source has no `managedFields` key to normalize). It has to be resolved once
|
|
27
|
+
* per resource, here, against the object in hand.
|
|
32
28
|
*
|
|
33
|
-
*
|
|
29
|
+
* Until chant #1191 that resolution *pruned*: a foreign-owned path nobody
|
|
30
|
+
* declared was dropped before the tree reached core, on the theory that it
|
|
31
|
+
* was controller noise. It silenced exactly the case the deep read exists
|
|
32
|
+
* for — `kubectl label deploy web team=platform` is foreign-owned
|
|
33
|
+
* (`kubectl-edit`) and undeclared, and never appeared in fieldDrift. The
|
|
34
|
+
* policy now (recorded on #1202) is:
|
|
34
35
|
*
|
|
35
|
-
*
|
|
36
|
+
* 1. **Chant-owned** (`chant`, `chant:<stack>` — chant #1075, matched on the
|
|
37
|
+
* family so a stack rename does not stop recognizing its own history) is
|
|
38
|
+
* always diffable.
|
|
39
|
+
* 2. **Declared, whoever owns it live** (a contested field) is always
|
|
40
|
+
* diffable: chant's source is a statement of intent independent of which
|
|
41
|
+
* write currently holds the field, and a foreign write that overrides a
|
|
42
|
+
* value chant's manifest asks for is the thing `lifecycle diff --live`
|
|
43
|
+
* exists to surface. It reports as `changed`.
|
|
44
|
+
* 3. **Foreign-owned and undeclared** is reported as `undeclared` drift,
|
|
45
|
+
* unless the accepted baseline already carries it at that value. The
|
|
46
|
+
* baseline (`--update-baseline`) is the noise valve: an HPA that owns
|
|
47
|
+
* `spec.replicas` on a Deployment whose source is silent about replicas
|
|
48
|
+
* reports once, gets accepted, and stays quiet until the value moves.
|
|
36
49
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* webhook defaulting a field, `kubectl-client-side-apply` from a human
|
|
44
|
-
* operator — *provided* nobody declared it (next question). Prune it.
|
|
45
|
-
* 3. **Does chant's own manifest *also* set this path?** — independent of
|
|
46
|
-
* who currently owns it live. This is the case the issue calls out by
|
|
47
|
-
* name: **a contested field chant declared is drift-relevant.** The
|
|
48
|
-
* reasoning: chant's source is a statement of intent regardless of
|
|
49
|
-
* whether a previous apply is what currently holds the field, and a
|
|
50
|
-
* foreign write that overrides a value chant's manifest asks for is
|
|
51
|
-
* exactly the thing `lifecycle diff --live` exists to surface — silencing
|
|
52
|
-
* it because *something else* currently owns the field would hide the one
|
|
53
|
-
* case where chant and the cluster disagree about a property chant is
|
|
54
|
-
* actively declaring. So: foreign-owned *and* declared is never pruned by
|
|
55
|
-
* this rule, whatever #2 would otherwise do.
|
|
56
|
-
*
|
|
57
|
-
* `counterpart: "unknown"` and why it does not help here: core's own
|
|
58
|
-
* counterpart tri-state (`side === "live" && counterpart === "absent"`, what
|
|
59
|
-
* {@link K8S_SERVICE_DEFAULTS} is gated on) answers "did the *declared tree*
|
|
60
|
-
* carry this path at all" — which is exactly question 3, but it is computed
|
|
61
|
-
* by *core*, from the raw declared and raw live trees, only on the **second**
|
|
62
|
-
* normalization pass (`packages/core/src/lifecycle/deep-observe.ts`'s
|
|
63
|
-
* `diffDeepObservation`). This reader's own call to
|
|
64
|
-
* {@link normalizeDeepProperties} runs first, with no `counterpartPaths`
|
|
65
|
-
* supplied — same as the other three rows — so `counterpart` is `"unknown"`
|
|
66
|
-
* for every node here, and a rule gated on `"absent"` would never fire during
|
|
67
|
-
* this read at all. That is fine for a table keyed only by entityType (AWS's
|
|
68
|
-
* service defaults are subtracted on core's later pass, not this one), but it
|
|
69
|
-
* is *not* fine for the managed-fields rule: if this reader left
|
|
70
|
-
* `spec.replicas` in the tree waiting for core's second pass to prune it,
|
|
71
|
-
* core would be normalizing with the *static* `k8sDeepNormalizationHooks`,
|
|
72
|
-
* which has no managedFields for this object in hand either (this reader
|
|
73
|
-
* already computed its ownership sets from data it read once and does not
|
|
74
|
-
* return). So question 3 is answered **here**, directly against the declared
|
|
75
|
-
* `props` this reader was handed — not via `counterpart` — and the result is
|
|
76
|
-
* baked into what this reader returns: a foreign-owned, undeclared field is
|
|
77
|
-
* gone from the tree before it ever reaches core, and a contested one is
|
|
78
|
-
* still there for the ordinary declared-vs-live comparison to catch.
|
|
50
|
+
* So ownership decides nothing about *whether* a path is compared. What it
|
|
51
|
+
* contributes is `fieldOwners` (#1189): every reported drift names the
|
|
52
|
+
* manager that holds the field live, which is how an operator tells
|
|
53
|
+
* `hpa-controller` doing its job from somebody running `kubectl edit`. The
|
|
54
|
+
* only fields subtracted because a foreign manager wrote them are the
|
|
55
|
+
* well-known system ones on the static allowlist.
|
|
79
56
|
*
|
|
80
57
|
* ## Resolving a managedFields entry against a live array
|
|
81
58
|
*
|
|
@@ -99,10 +76,8 @@
|
|
|
99
76
|
* container that also sets no scalar sub-fields (unusual — a real apply
|
|
100
77
|
* setting a container almost always sets `image`/`name` too) would not confer
|
|
101
78
|
* ownership onto a sibling field nobody's `fieldsV1` entry lists explicitly.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* rather than silently pruned. Reported drift that turns out to be more
|
|
105
|
-
* noise than expected is a tuning problem; drift silently dropped is not.
|
|
79
|
+
* A path absent from every manager's fields simply has no `owner` on its
|
|
80
|
+
* drift line; it is compared like any other.
|
|
106
81
|
*
|
|
107
82
|
* ## `buildOwnershipSets` lives in core now
|
|
108
83
|
*
|
|
@@ -119,7 +94,6 @@
|
|
|
119
94
|
|
|
120
95
|
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
121
96
|
import type {
|
|
122
|
-
DeepNormalizationHooks,
|
|
123
97
|
DeepObservationResult,
|
|
124
98
|
DeepResourceObservation,
|
|
125
99
|
UnobservedEntity,
|
|
@@ -127,7 +101,7 @@ import type {
|
|
|
127
101
|
import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
|
|
128
102
|
import { unobservedAll } from "@intentius/chant/observation";
|
|
129
103
|
import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
130
|
-
import { buildOwnershipSets,
|
|
104
|
+
import { buildOwnershipSets, type OwnershipSets } from "@intentius/chant/managed-fields";
|
|
131
105
|
import { defaultK8sConnector, type K8sConnector } from "./api/connect";
|
|
132
106
|
import {
|
|
133
107
|
classifyApiFailure,
|
|
@@ -158,21 +132,6 @@ export interface K8sDeepObserveOptions {
|
|
|
158
132
|
cwd?: string;
|
|
159
133
|
}
|
|
160
134
|
|
|
161
|
-
/**
|
|
162
|
-
* The managed-fields prune, composed with the static rules, for one
|
|
163
|
-
* resource's normalization call. See the module doc for the three-question
|
|
164
|
-
* rule this encodes (`@intentius/chant/managed-fields`'s `pruneByOwnership`).
|
|
165
|
-
*/
|
|
166
|
-
function perResourceHooks(sets: OwnershipSets): DeepNormalizationHooks {
|
|
167
|
-
return {
|
|
168
|
-
prune(node) {
|
|
169
|
-
if (k8sDeepNormalizationHooks.prune?.(node)) return true;
|
|
170
|
-
return pruneByOwnership(node, sets);
|
|
171
|
-
},
|
|
172
|
-
orderKey: k8sDeepNormalizationHooks.orderKey,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
135
|
/** The live object minus the envelope fields that live outside `properties` on {@link DeepResourceObservation} (mirrors `type`/`physicalId`). */
|
|
177
136
|
function propertiesTreeOf(obj: K8sObject): Record<string, unknown> {
|
|
178
137
|
const { apiVersion: _apiVersion, kind: _kind, ...rest } = obj;
|
|
@@ -282,14 +241,13 @@ export async function observeResourcesDeepK8s(
|
|
|
282
241
|
properties: normalizeDeepProperties(liveRoot, {
|
|
283
242
|
entityType,
|
|
284
243
|
side: "live",
|
|
285
|
-
hooks:
|
|
244
|
+
hooks: k8sDeepNormalizationHooks,
|
|
286
245
|
}),
|
|
287
|
-
// Who owns each
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
// `kubectl edit`.
|
|
246
|
+
// Who owns each path (#1189). Nothing is pruned by ownership any more
|
|
247
|
+
// (#1191), so a foreign-owned undeclared field reaches the diff as
|
|
248
|
+
// `undeclared` — and naming the manager is the whole question there:
|
|
249
|
+
// an operator needs to tell `hpa-controller` doing its job from
|
|
250
|
+
// somebody running `kubectl edit`.
|
|
293
251
|
...(sets.owners.size > 0 ? { fieldOwners: Object.fromEntries(sets.owners) } : {}),
|
|
294
252
|
};
|
|
295
253
|
} catch (err) {
|