@intentius/chant-lexicon-k8s 0.57.0 → 0.59.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/fake-cluster.d.ts +7 -6
- package/dist/api/fake-cluster.d.ts.map +1 -1
- package/dist/api/sweep-types.d.ts +1 -2
- package/dist/api/sweep-types.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/cron-schedule.d.ts +26 -0
- package/dist/composites/cron-schedule.d.ts.map +1 -0
- package/dist/composites/cron-workload.d.ts +1 -1
- package/dist/composites/cron-workload.d.ts.map +1 -1
- package/dist/composites/operator-stack.d.ts +20 -19
- package/dist/composites/operator-stack.d.ts.map +1 -1
- package/dist/config-schema.d.ts +3 -0
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config.d.ts +26 -4
- package/dist/config.d.ts.map +1 -1
- 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/describe-resources.d.ts.map +1 -1
- package/dist/effect-receipt-row.d.ts +164 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/export-resources.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- 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/lint/post-synth/wk8505.d.ts +3 -1
- package/dist/lint/post-synth/wk8505.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -1
- package/dist/okf/rules/WK8505.md +2 -2
- package/dist/okf/types/Kustomization.md +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 +15 -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/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +113 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/wk8505.ts +4 -2
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-k8s-argo.md +27 -13
- package/dist/subscribe-changes.d.ts +84 -0
- package/dist/subscribe-changes.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/api/fake-cluster.ts +17 -4
- package/src/api/sweep-types.ts +1 -2
- package/src/codegen/docs.ts +7 -0
- package/src/composites/composites.test.ts +72 -3
- package/src/composites/cron-schedule.ts +47 -0
- package/src/composites/cron-workload.ts +4 -1
- package/src/composites/operator-stack.ts +25 -24
- package/src/config-schema.ts +5 -0
- package/src/config.ts +27 -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 +54 -28
- package/src/describe-resources.ts +31 -7
- package/src/effect-receipt-row.test.ts +285 -0
- package/src/effect-receipt-row.ts +268 -0
- package/src/export-resources.ts +1 -1
- package/src/index.ts +22 -0
- package/src/lint/audit-catalog.ts +6 -2
- package/src/lint/audit-lineage.ts +127 -0
- package/src/lint/post-synth/wk8505.ts +4 -2
- package/src/op/activities/argo.test.ts +7 -8
- package/src/op/activities/argo.ts +4 -4
- package/src/op/activities/index.ts +23 -7
- package/src/op/activities/kubectl.test.ts +43 -4
- package/src/op/activities/kubectl.ts +67 -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 +16 -1
- package/src/receipt-store.test.ts +380 -0
- package/src/receipt-store.ts +290 -0
- package/src/serializer.test.ts +26 -0
- package/src/serializer.ts +92 -1
- package/src/skills/chant-k8s-argo.md +27 -13
- package/src/subscribe-changes.test.ts +368 -0
- package/src/subscribe-changes.ts +210 -0
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
|
package/src/deep-observe.ts
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
*
|
|
10
10
|
* ## What managedFields decides, and what it does not
|
|
11
11
|
*
|
|
12
|
-
* AWS
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* The AWS and Azure rows both prune by a **static, entityType-keyed** table
|
|
13
|
+
* (`AWS_SERVICE_DEFAULTS`, `AZURE_SERVICE_DEFAULTS`): an ARN always looks like
|
|
14
|
+
* an ARN, `provisioningState` is always server-populated. Neither needs the
|
|
15
|
+
* specific live object in hand — it is exactly
|
|
16
16
|
* what `./deep-observe-hooks.ts`'s `k8sDeepNormalizationHooks` is, and it
|
|
17
17
|
* covers Kubernetes' *equivalent* static noise (`status`,
|
|
18
18
|
* `metadata.{uid,resourceVersion,generation,creationTimestamp}`, a handful of
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* was controller noise. It silenced exactly the case the deep read exists
|
|
32
32
|
* for — `kubectl label deploy web team=platform` is foreign-owned
|
|
33
33
|
* (`kubectl-edit`) and undeclared, and never appeared in fieldDrift. The
|
|
34
|
-
* policy now (recorded on #1202) is:
|
|
34
|
+
* policy now (recorded on #1202, refined by #2160) is:
|
|
35
35
|
*
|
|
36
36
|
* 1. **Chant-owned** (`chant`, `chant:<stack>` — chant #1075, matched on the
|
|
37
37
|
* family so a stack rename does not stop recognizing its own history) is
|
|
@@ -41,18 +41,25 @@
|
|
|
41
41
|
* write currently holds the field, and a foreign write that overrides a
|
|
42
42
|
* value chant's manifest asks for is the thing `lifecycle diff --live`
|
|
43
43
|
* exists to surface. It reports as `changed`.
|
|
44
|
-
* 3. **
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
44
|
+
* 3. **Undeclared** — a path outside the declaration's claimed-field set
|
|
45
|
+
* (`@intentius/chant/claimed-fields`) — is reported as unclaimed and
|
|
46
|
+
* is not drift at all. Before #2160 it was `undeclared` drift that the
|
|
47
|
+
* accepted baseline had to absorb one path at a time; the declaration
|
|
48
|
+
* answers on the first read instead, so an HPA that owns `spec.replicas`
|
|
49
|
+
* on a Deployment whose source is silent about replicas is quiet with
|
|
50
|
+
* nothing recorded anywhere.
|
|
49
51
|
*
|
|
50
|
-
* So ownership decides nothing about *whether* a path is compared
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
52
|
+
* So ownership decides nothing about *whether* a path is compared, and since
|
|
53
|
+
* #2160 it no longer decides the verdict either — the declaration does. What
|
|
54
|
+
* it contributes is `fieldOwners` (#1189), and that is worth more here than
|
|
55
|
+
* anywhere else: on every other substrate a held field can only be reported
|
|
56
|
+
* as "not chant's", while here it is reported as `hpa-controller`'s or
|
|
57
|
+
* `kubectl-edit`'s by name, which is the difference between a controller
|
|
58
|
+
* doing its job and somebody bypassing the pipeline. Core takes the manager
|
|
59
|
+
* where this reader supplies one and falls back to the claim where it does
|
|
60
|
+
* not (a path inside a key-addressed list, which no `fieldsV1` entry names in
|
|
61
|
+
* that form). The only fields subtracted because a foreign manager wrote them
|
|
62
|
+
* are the well-known system ones on the static allowlist.
|
|
56
63
|
*
|
|
57
64
|
* ## Resolving a managedFields entry against a live array
|
|
58
65
|
*
|
|
@@ -111,10 +118,11 @@ import {
|
|
|
111
118
|
} from "./api/classify";
|
|
112
119
|
import { operationFor } from "./api/operation-surface";
|
|
113
120
|
import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
|
|
121
|
+
import { observeReceiptRowsDeep, receiptRowsFor } from "./receipt-store";
|
|
114
122
|
|
|
115
123
|
// Re-exported so a dynamic importer of this module (plugin.ts's
|
|
116
124
|
// `observeResourcesDeep`, a test) can get the reader and its hooks from one
|
|
117
|
-
// place, the same shape AWS
|
|
125
|
+
// place, the same shape the AWS and Azure deep-observe.ts modules offer.
|
|
118
126
|
// `plugin.ts`'s own `deepNormalizationHooks` field imports the hooks
|
|
119
127
|
// separately, directly from `./deep-observe-hooks` — that file has no
|
|
120
128
|
// dependency on `@intentius/chant-k8s-client`, so it is safe to import
|
|
@@ -152,11 +160,23 @@ export async function observeResourcesDeepK8s(
|
|
|
152
160
|
): Promise<DeepObservationResult> {
|
|
153
161
|
const { managedFieldsOf, isChantFieldManager } = await import("@intentius/chant-k8s-client");
|
|
154
162
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
// Effect receipts (#2074) are read by their own leg at the end: they carry
|
|
164
|
+
// no declared props, so every live path would be an unclaimed field (#2160)
|
|
165
|
+
// and their staleness is an `effect` row from the plan (#1832), never
|
|
166
|
+
// property drift. Reading them here keeps the deep read from calling a
|
|
167
|
+
// declared entity a hole; contributing an empty tree keeps it from calling
|
|
168
|
+
// one drift.
|
|
169
|
+
const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);
|
|
170
|
+
|
|
171
|
+
const declared = [...options.entities]
|
|
172
|
+
.filter(([entityName]) => !receiptRows.has(entityName))
|
|
173
|
+
.map(([entityName, entity]) => ({
|
|
174
|
+
entityName,
|
|
175
|
+
entityType: entity.entityType,
|
|
176
|
+
props: entity.props,
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];
|
|
160
180
|
|
|
161
181
|
let client;
|
|
162
182
|
try {
|
|
@@ -166,7 +186,7 @@ export async function observeResourcesDeepK8s(
|
|
|
166
186
|
return deepObservation(
|
|
167
187
|
{},
|
|
168
188
|
unobservedAll(
|
|
169
|
-
|
|
189
|
+
everyName,
|
|
170
190
|
"read-failed",
|
|
171
191
|
MISSING_CLIENT_DETAIL,
|
|
172
192
|
options.entities,
|
|
@@ -178,7 +198,7 @@ export async function observeResourcesDeepK8s(
|
|
|
178
198
|
return deepObservation(
|
|
179
199
|
{},
|
|
180
200
|
unobservedAll(
|
|
181
|
-
|
|
201
|
+
everyName,
|
|
182
202
|
outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
183
203
|
outcome.kind === "unobserved" ? outcome.detail : undefined,
|
|
184
204
|
options.entities,
|
|
@@ -244,10 +264,10 @@ export async function observeResourcesDeepK8s(
|
|
|
244
264
|
hooks: k8sDeepNormalizationHooks,
|
|
245
265
|
}),
|
|
246
266
|
// Who owns each path (#1189). Nothing is pruned by ownership any more
|
|
247
|
-
// (#1191), so a foreign-owned undeclared field reaches the diff
|
|
248
|
-
//
|
|
249
|
-
// an operator needs to tell `hpa-controller` doing its
|
|
250
|
-
// somebody running `kubectl edit`.
|
|
267
|
+
// (#1191), so a foreign-owned undeclared field still reaches the diff
|
|
268
|
+
// — as a held field since #2160 — and naming the manager is the whole
|
|
269
|
+
// question there: an operator needs to tell `hpa-controller` doing its
|
|
270
|
+
// job from somebody running `kubectl edit`.
|
|
251
271
|
...(sets.owners.size > 0 ? { fieldOwners: Object.fromEntries(sets.owners) } : {}),
|
|
252
272
|
};
|
|
253
273
|
} catch (err) {
|
|
@@ -260,5 +280,11 @@ export async function observeResourcesDeepK8s(
|
|
|
260
280
|
}
|
|
261
281
|
});
|
|
262
282
|
|
|
283
|
+
if (receiptRows.size > 0) {
|
|
284
|
+
const receiptObs = await observeReceiptRowsDeep(client, receiptRows);
|
|
285
|
+
Object.assign(resources, receiptObs.resources);
|
|
286
|
+
Object.assign(unobserved, receiptObs.unobserved);
|
|
287
|
+
}
|
|
288
|
+
|
|
263
289
|
return deepObservation(resources, unobserved);
|
|
264
290
|
}
|
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
import { operationFor } from "./api/operation-surface";
|
|
69
69
|
import { resolveK8sOwnerChain } from "./api/owner-chain";
|
|
70
70
|
import { gvkToTypeName } from "./spec/parse";
|
|
71
|
+
import { observeReceiptRows, receiptRowsFor } from "./receipt-store";
|
|
71
72
|
|
|
72
73
|
function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
|
73
74
|
const out: Record<string, unknown> = {};
|
|
@@ -357,11 +358,26 @@ export async function describeResources(
|
|
|
357
358
|
// looked for where it lives".
|
|
358
359
|
const queried: Record<string, string> = {};
|
|
359
360
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
361
|
+
// Effect receipt rows (#2074) are read by their own leg below: the applier
|
|
362
|
+
// never wrote them (#1832), and their declaration carries no props, so the
|
|
363
|
+
// generic sweep has no `metadata.name` to query by and would report a hole
|
|
364
|
+
// where the receipt leg has a real answer. Their addresses come from the
|
|
365
|
+
// build output's receipt comment, which is the serializer's one rendering of
|
|
366
|
+
// the derivation (./effect-receipt-row.ts).
|
|
367
|
+
const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);
|
|
368
|
+
|
|
369
|
+
const declared: Declared[] = [...options.entities]
|
|
370
|
+
.filter(([entityName]) => !receiptRows.has(entityName))
|
|
371
|
+
.map(([entityName, entity]) => ({
|
|
372
|
+
entityName,
|
|
373
|
+
entityType: entity.entityType,
|
|
374
|
+
props: entity.props,
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
// A whole-lexicon failure below is a hole for the receipts too: nobody
|
|
378
|
+
// looked at those either, and a connect that never happened proves nothing
|
|
379
|
+
// about a receipt's presence.
|
|
380
|
+
const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];
|
|
365
381
|
|
|
366
382
|
// Connect first. The binding check lives here, so a bound-but-mismatched
|
|
367
383
|
// context throws before any resource is read — core turns that into
|
|
@@ -374,7 +390,7 @@ export async function describeResources(
|
|
|
374
390
|
return observation(
|
|
375
391
|
{},
|
|
376
392
|
unobservedAll(
|
|
377
|
-
|
|
393
|
+
everyName,
|
|
378
394
|
"read-failed",
|
|
379
395
|
MISSING_CLIENT_DETAIL,
|
|
380
396
|
options.entities,
|
|
@@ -386,7 +402,7 @@ export async function describeResources(
|
|
|
386
402
|
return observation(
|
|
387
403
|
{},
|
|
388
404
|
unobservedAll(
|
|
389
|
-
|
|
405
|
+
everyName,
|
|
390
406
|
outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
391
407
|
outcome.kind === "unobserved" ? outcome.detail : undefined,
|
|
392
408
|
options.entities,
|
|
@@ -507,6 +523,14 @@ export async function describeResources(
|
|
|
507
523
|
|
|
508
524
|
await addRuntimeChildren(client, resources, unobserved, options.owned, declared);
|
|
509
525
|
|
|
526
|
+
// The receipt leg last, so its answers are the ones that stand for the
|
|
527
|
+
// receipt entities, because nothing above ever looked at one.
|
|
528
|
+
if (receiptRows.size > 0) {
|
|
529
|
+
const receiptObs = await observeReceiptRows(client, receiptRows);
|
|
530
|
+
Object.assign(resources, receiptObs.resources);
|
|
531
|
+
Object.assign(unobserved, receiptObs.unobserved);
|
|
532
|
+
}
|
|
533
|
+
|
|
510
534
|
return observation(resources, unobserved, queried);
|
|
511
535
|
}
|
|
512
536
|
|