@intentius/chant-lexicon-k8s 0.42.1 → 0.44.1
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/classify.d.ts.map +1 -1
- package/dist/api/connect.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/components/argo-app.d.ts +104 -0
- package/dist/components/argo-app.d.ts.map +1 -0
- package/dist/components/builders.d.ts +8 -0
- package/dist/components/builders.d.ts.map +1 -1
- package/dist/components/capability-plugin.d.ts +1 -0
- package/dist/components/capability-plugin.d.ts.map +1 -1
- package/dist/components/flux-reconcile.d.ts +84 -0
- package/dist/components/flux-reconcile.d.ts.map +1 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/kustomize-apply.d.ts +29 -7
- package/dist/components/kustomize-apply.d.ts.map +1 -1
- package/dist/composites/flux-app.d.ts +132 -0
- package/dist/composites/flux-app.d.ts.map +1 -0
- package/dist/composites/index.d.ts +2 -0
- package/dist/composites/index.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 +32 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/crd-sources.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +10 -8
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/generated/index.d.ts +15 -4
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +10 -6
- package/dist/kube/connect.d.ts +5 -4
- package/dist/kube/connect.d.ts.map +1 -1
- package/dist/kube/get.d.ts +4 -4
- package/dist/kustomize/render.d.ts +15 -0
- package/dist/kustomize/render.d.ts.map +1 -0
- package/dist/kustomize/rendered-entity.d.ts +39 -0
- package/dist/kustomize/rendered-entity.d.ts.map +1 -0
- package/dist/kustomize/root.d.ts +19 -0
- package/dist/kustomize/root.d.ts.map +1 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/argo-helpers.d.ts +2 -0
- package/dist/lint/post-synth/argo-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/flux002.d.ts +14 -0
- package/dist/lint/post-synth/flux002.d.ts.map +1 -0
- package/dist/lint/post-synth/flux003.d.ts +17 -0
- package/dist/lint/post-synth/flux003.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/rules/flux-source-ref-pin.d.ts +16 -0
- package/dist/lint/rules/flux-source-ref-pin.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +92 -16
- package/dist/okf/index.md +7 -1
- package/dist/okf/rules/FLUX001.md +17 -0
- package/dist/okf/rules/FLUX002.md +19 -0
- package/dist/okf/rules/FLUX003.md +15 -0
- package/dist/okf/types/Addon.md +14 -0
- package/dist/okf/types/Bucket.md +4 -0
- package/dist/okf/types/{HelmChart.md → FluxHelmChart.md} +2 -2
- package/dist/okf/types/GitRepository.md +5 -0
- package/dist/okf/types/HelmChartConfig.md +14 -0
- package/dist/okf/types/K3sHelmChart.md +14 -0
- package/dist/okf/types/Kustomization.md +6 -0
- package/dist/okf/types/Namespace.md +1 -0
- package/dist/okf/types/Node.md +1 -0
- package/dist/okf/types/OCIRepository.md +4 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/argo-helpers.ts +2 -0
- package/dist/rules/flux-source-ref-pin.ts +52 -0
- package/dist/rules/flux002.ts +77 -0
- package/dist/rules/flux003.ts +75 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-k8s-flux.md +162 -0
- package/dist/types/index.d.ts +41 -3
- package/dist/validate.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/api/classify.test.ts +20 -0
- package/src/api/classify.ts +8 -1
- package/src/api/connect.ts +10 -0
- package/src/codegen/docs.ts +11 -0
- package/src/components/argo-app.test.ts +137 -0
- package/src/components/argo-app.ts +163 -0
- package/src/components/builders.ts +8 -0
- package/src/components/capability-plugin.ts +11 -1
- package/src/components/flux-reconcile.test.ts +145 -0
- package/src/components/flux-reconcile.ts +159 -0
- package/src/components/index.ts +13 -0
- package/src/components/kustomize-apply.ts +12 -41
- package/src/composites/composites.test.ts +122 -0
- package/src/composites/flux-app.ts +274 -0
- package/src/composites/index.ts +9 -0
- package/src/config-augmentation.test.ts +72 -0
- package/src/config-schema.ts +5 -0
- package/src/config.ts +33 -5
- package/src/crd/crd-sources.ts +37 -0
- package/src/crd/parser.ts +6 -0
- package/src/describe-resources.test.ts +215 -6
- package/src/describe-resources.ts +197 -118
- package/src/generated/index.d.ts +41 -3
- package/src/generated/index.ts +15 -4
- package/src/generated/lexicon-k8s.json +92 -16
- package/src/generated/operations.json +48 -0
- package/src/index.ts +4 -0
- package/src/kube/connect.ts +5 -4
- package/src/kube/get.ts +4 -4
- package/src/kustomize/render.ts +84 -0
- package/src/kustomize/rendered-entity.ts +72 -0
- package/src/kustomize/root.test.ts +268 -0
- package/src/kustomize/root.ts +109 -0
- package/src/lint/audit-catalog.ts +2 -0
- package/src/lint/post-synth/argo-helpers.ts +2 -0
- package/src/lint/post-synth/flux002.ts +77 -0
- package/src/lint/post-synth/flux003.ts +75 -0
- package/src/lint/post-synth/index.ts +4 -0
- package/src/lint/post-synth/post-synth.test.ts +132 -0
- package/src/lint/rules/flux-source-ref-pin.ts +52 -0
- package/src/lint/rules/rules.test.ts +51 -0
- package/src/plugin.test.ts +2 -0
- package/src/plugin.ts +47 -0
- package/src/serializer.ts +34 -4
- package/src/skills/chant-k8s-flux.md +162 -0
- package/src/testdata/kustomize-root/base/deployment.yaml +17 -0
- package/src/testdata/kustomize-root/base/kustomization.yaml +3 -0
- package/src/testdata/kustomize-root/base/service.yaml +9 -0
- package/src/testdata/kustomize-root/overlays/prod/kustomization.yaml +6 -0
- package/src/validate.ts +4 -0
|
@@ -465,6 +465,44 @@ describe("k8s describeResources", () => {
|
|
|
465
465
|
warnSpy.mockRestore();
|
|
466
466
|
});
|
|
467
467
|
|
|
468
|
+
test("a failed read names the bound context it read (#1488)", async () => {
|
|
469
|
+
loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-eks" } } } } });
|
|
470
|
+
const cluster = fakeCluster({
|
|
471
|
+
kubeconfig: twoContexts,
|
|
472
|
+
respond: (req) =>
|
|
473
|
+
req.path.endsWith("/deployments/web")
|
|
474
|
+
? { status: 500, body: statusBody(500, "InternalError", "etcdserver: leader changed") }
|
|
475
|
+
: undefined,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const result = await describeResources(
|
|
479
|
+
{ environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() },
|
|
480
|
+
(o) => defaultK8sConnector({ ...o, client: { kubeconfig: twoContexts, requestLayer: cluster.layer } }),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
expect(result.unobserved?.web?.reason).toBe("read-failed");
|
|
484
|
+
expect(result.unobserved?.web?.detail).toContain('context "prod-eks" (bound by k8s.profiles.prod.context)');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("an ambient read failure names the context that was read and the missing binding (#1488)", async () => {
|
|
488
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
489
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
490
|
+
const cluster = fakeCluster({
|
|
491
|
+
kubeconfig: twoContexts,
|
|
492
|
+
respond: (req) =>
|
|
493
|
+
req.path.endsWith("/deployments/web") ? { status: 500, body: statusBody(500, "InternalError", "nope") } : undefined,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const result = await describeResources(
|
|
497
|
+
{ environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() },
|
|
498
|
+
(o) => defaultK8sConnector({ ...o, client: { kubeconfig: twoContexts, requestLayer: cluster.layer } }),
|
|
499
|
+
);
|
|
500
|
+
warnSpy.mockRestore();
|
|
501
|
+
|
|
502
|
+
expect(result.unobserved?.web?.reason).toBe("read-failed");
|
|
503
|
+
expect(result.unobserved?.web?.detail).toContain('context "prod-eks" (ambient; no k8s.profiles.prod binding)');
|
|
504
|
+
});
|
|
505
|
+
|
|
468
506
|
test("a bound context the kubeconfig does not have refuses with the context name", async () => {
|
|
469
507
|
loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "gone-eks" } } } } });
|
|
470
508
|
const oneContext = fakeKubeconfig({ contexts: [{ name: "prod-eks" }], currentContext: "prod-eks" });
|
|
@@ -573,6 +611,161 @@ describe("k8s describeResources", () => {
|
|
|
573
611
|
expect(result.resources["prod/hand-made"]).toBeUndefined();
|
|
574
612
|
});
|
|
575
613
|
|
|
614
|
+
// The issue's own estate: a declared MicroVMReplicaSet whose operator
|
|
615
|
+
// made MicroVMs — a CRD kind no table anticipated. Discovery names it.
|
|
616
|
+
test("a controller-made child of a CRD kind surfaces as a runtime child of the declared CR", async () => {
|
|
617
|
+
const replicaSet = {
|
|
618
|
+
apiVersion: "lambda.aws.amazon.com/v1alpha1",
|
|
619
|
+
kind: "MicroVMReplicaSet",
|
|
620
|
+
metadata: { name: "kmv-dev-a-vm", namespace: "microvm-demo", uid: "mvrs-uid" },
|
|
621
|
+
};
|
|
622
|
+
const vm = {
|
|
623
|
+
apiVersion: "lambda.aws.amazon.com/v1alpha1",
|
|
624
|
+
kind: "MicroVM",
|
|
625
|
+
metadata: {
|
|
626
|
+
name: "kmv-dev-a-vm-42sgf",
|
|
627
|
+
namespace: "microvm-demo",
|
|
628
|
+
uid: "vm-uid",
|
|
629
|
+
ownerReferences: [
|
|
630
|
+
{ apiVersion: "lambda.aws.amazon.com/v1alpha1", kind: "MicroVMReplicaSet", name: "kmv-dev-a-vm", uid: "mvrs-uid", controller: true },
|
|
631
|
+
],
|
|
632
|
+
},
|
|
633
|
+
status: { phase: "Running" },
|
|
634
|
+
};
|
|
635
|
+
const cluster = fakeCluster({
|
|
636
|
+
objects: {
|
|
637
|
+
[objectKey("lambda.aws.amazon.com/v1alpha1", "MicroVMReplicaSet", "kmv-dev-a-vm", "microvm-demo")]: replicaSet,
|
|
638
|
+
[objectKey("lambda.aws.amazon.com/v1alpha1", "MicroVM", "kmv-dev-a-vm-42sgf", "microvm-demo")]: vm,
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const result = await describeResources(
|
|
643
|
+
{
|
|
644
|
+
environment: "dev",
|
|
645
|
+
buildOutput: "",
|
|
646
|
+
entityNames: ["workloadReplicaSet"],
|
|
647
|
+
entities: makeEntities([
|
|
648
|
+
{
|
|
649
|
+
name: "workloadReplicaSet",
|
|
650
|
+
entityType: "K8s::KubeMicroVM::MicroVMReplicaSet",
|
|
651
|
+
props: { metadata: { name: "kmv-dev-a-vm", namespace: "microvm-demo" } },
|
|
652
|
+
},
|
|
653
|
+
]),
|
|
654
|
+
},
|
|
655
|
+
cluster.connector,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
expect(result.resources["microvm-demo/kmv-dev-a-vm-42sgf"]).toMatchObject({
|
|
659
|
+
type: "K8s::Lambda::MicroVM",
|
|
660
|
+
physicalId: "vm-uid",
|
|
661
|
+
ownerChain: { root: "declared", entity: "workloadReplicaSet" },
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("a child in a group the estate never declares surfaces too — discovery names the kinds, not the declaration", async () => {
|
|
666
|
+
// A declared CR whose operator makes ordinary Deployments: ray.io is
|
|
667
|
+
// declared, apps/v1 is not, and the estate-groups sweep never listed it.
|
|
668
|
+
const rayCluster = { apiVersion: "ray.io/v1", kind: "RayCluster", metadata: { name: "ml", namespace: "ray", uid: "ray-uid" } };
|
|
669
|
+
const head = {
|
|
670
|
+
apiVersion: "apps/v1",
|
|
671
|
+
kind: "Deployment",
|
|
672
|
+
metadata: {
|
|
673
|
+
name: "ml-head",
|
|
674
|
+
namespace: "ray",
|
|
675
|
+
uid: "head-uid",
|
|
676
|
+
ownerReferences: [{ apiVersion: "ray.io/v1", kind: "RayCluster", name: "ml", uid: "ray-uid", controller: true }],
|
|
677
|
+
},
|
|
678
|
+
status: { readyReplicas: 1, replicas: 1 },
|
|
679
|
+
};
|
|
680
|
+
const cluster = fakeCluster({
|
|
681
|
+
objects: {
|
|
682
|
+
[objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: rayCluster,
|
|
683
|
+
[objectKey("apps/v1", "Deployment", "ml-head", "ray")]: head,
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const result = await describeResources(
|
|
688
|
+
{
|
|
689
|
+
environment: "prod",
|
|
690
|
+
buildOutput: "",
|
|
691
|
+
entityNames: ["ml"],
|
|
692
|
+
entities: makeEntities([
|
|
693
|
+
{ name: "ml", entityType: "K8s::Ray::RayCluster", props: { metadata: { name: "ml", namespace: "ray" } } },
|
|
694
|
+
]),
|
|
695
|
+
},
|
|
696
|
+
cluster.connector,
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
expect(result.resources["ray/ml-head"]).toMatchObject({
|
|
700
|
+
type: "K8s::Apps::Deployment",
|
|
701
|
+
physicalId: "head-uid",
|
|
702
|
+
ownerChain: { root: "declared", entity: "ml" },
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("a discovered kind the sweep cannot list is an unobserved hole, never silently absent", async () => {
|
|
707
|
+
const cluster = fakeCluster({
|
|
708
|
+
objects: { [objectKey("batch/v1", "CronJob", "backup", "prod")]: cronJob },
|
|
709
|
+
respond: (req) =>
|
|
710
|
+
req.path === "/apis/batch/v1/namespaces/prod/jobs"
|
|
711
|
+
? { status: 403, body: statusBody(403, "Forbidden", "jobs is forbidden") }
|
|
712
|
+
: undefined,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
const result = await describeResources(
|
|
716
|
+
{ environment: "prod", buildOutput: "", entityNames: ["backup"], entities: declaredCronJob() },
|
|
717
|
+
cluster.connector,
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
// Declared resolution is never failed by the sweep.
|
|
721
|
+
expect(result.resources.backup).toMatchObject({ type: "K8s::Batch::CronJob", physicalId: "cj-uid" });
|
|
722
|
+
// The failed list is a hole the observation admits, with the failure's
|
|
723
|
+
// own classification — a 403 is a credentials problem, and silence here
|
|
724
|
+
// would have read as "no runtime children in prod".
|
|
725
|
+
expect(result.unobserved?.["runtime-sweep:batch/v1/Job"]).toMatchObject({
|
|
726
|
+
type: "K8s::Batch::Job",
|
|
727
|
+
reason: "no-credentials",
|
|
728
|
+
});
|
|
729
|
+
expect(result.unobserved?.["runtime-sweep:batch/v1/Job"]?.detail).toContain("namespace prod");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("a cluster-scoped child chains to a declared cluster-scoped entity — the one case cluster-scoped kinds are swept", async () => {
|
|
733
|
+
const ns = { apiVersion: "v1", kind: "Namespace", metadata: { name: "team-a", uid: "ns-uid" }, status: { phase: "Active" } };
|
|
734
|
+
const role = {
|
|
735
|
+
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
736
|
+
kind: "ClusterRole",
|
|
737
|
+
metadata: {
|
|
738
|
+
name: "team-a-admin",
|
|
739
|
+
uid: "role-uid",
|
|
740
|
+
ownerReferences: [{ apiVersion: "v1", kind: "Namespace", name: "team-a", uid: "ns-uid", controller: true }],
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
const cluster = fakeCluster({
|
|
744
|
+
objects: {
|
|
745
|
+
[objectKey("v1", "Namespace", "team-a")]: ns,
|
|
746
|
+
[objectKey("rbac.authorization.k8s.io/v1", "ClusterRole", "team-a-admin")]: role,
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const result = await describeResources(
|
|
751
|
+
{
|
|
752
|
+
environment: "prod",
|
|
753
|
+
buildOutput: "",
|
|
754
|
+
entityNames: ["team"],
|
|
755
|
+
entities: makeEntities([
|
|
756
|
+
{ name: "team", entityType: "K8s::Core::Namespace", props: { metadata: { name: "team-a" } } },
|
|
757
|
+
]),
|
|
758
|
+
},
|
|
759
|
+
cluster.connector,
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
expect(result.resources["cluster:team-a-admin"]).toMatchObject({
|
|
763
|
+
type: "K8s::Rbac::ClusterRole",
|
|
764
|
+
physicalId: "role-uid",
|
|
765
|
+
ownerChain: { root: "declared", entity: "team" },
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
576
769
|
test("a Flux-managed Deployment is attributed to its declared Kustomization by the controller's labels (#1549)", async () => {
|
|
577
770
|
// Flux stamps managed objects with name+namespace labels instead of
|
|
578
771
|
// ownerReferences, so the chain walk reaches nothing — the label
|
|
@@ -702,7 +895,7 @@ describe("k8s describeResources", () => {
|
|
|
702
895
|
expect(result.resources["flux-system/cert-manager"]).toBeUndefined();
|
|
703
896
|
});
|
|
704
897
|
|
|
705
|
-
test("
|
|
898
|
+
test("the sweep's bound is the estate's namespaces, not a kind list — and no cluster-scoped list without a cluster-scoped entity", async () => {
|
|
706
899
|
const svc = { apiVersion: "v1", kind: "Service", metadata: { name: "web", namespace: "prod", uid: "svc-uid" } };
|
|
707
900
|
const cluster = fakeCluster({
|
|
708
901
|
objects: { [objectKey("v1", "Service", "web", "prod")]: svc },
|
|
@@ -720,10 +913,20 @@ describe("k8s describeResources", () => {
|
|
|
720
913
|
cluster.connector,
|
|
721
914
|
);
|
|
722
915
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
916
|
+
const paths = cluster.layer.paths();
|
|
917
|
+
const isDiscovery = (p: string) =>
|
|
918
|
+
p === "/api" || p === "/apis" || /^\/api\/[^/]+$/.test(p) || /^\/apis\/[^/]+\/[^/]+$/.test(p);
|
|
919
|
+
const reads = paths.filter((p) => !isDiscovery(p));
|
|
920
|
+
// Discovery names the kinds; every actual read or list stays inside the
|
|
921
|
+
// one namespace the estate touches. Nothing is listed cluster-wide.
|
|
922
|
+
expect(reads.length).toBeGreaterThan(0);
|
|
923
|
+
expect(reads.every((p) => p.includes("/namespaces/prod/"))).toBe(true);
|
|
924
|
+
// Nothing declared is cluster-scoped, so no owner chain could reach a
|
|
925
|
+
// cluster-scoped object — their kinds are not listed at all.
|
|
926
|
+
expect(paths.some((p) => p.endsWith("/clusterroles"))).toBe(false);
|
|
927
|
+
// Events churn by the thousands and can never chain (`involvedObject`,
|
|
928
|
+
// not `ownerReferences`) — discovery serves them, the sweep skips them.
|
|
929
|
+
expect(paths.some((p) => p.endsWith("/events"))).toBe(false);
|
|
727
930
|
});
|
|
728
931
|
});
|
|
729
932
|
|
|
@@ -914,7 +1117,7 @@ describe("k8s describeResources", () => {
|
|
|
914
1117
|
expect(Object.keys(result.resources)).toHaveLength(2); // web + standalonePod, no synthetic duplicate
|
|
915
1118
|
});
|
|
916
1119
|
|
|
917
|
-
test("a Pod-list failure
|
|
1120
|
+
test("a Pod-list failure never fails declared resolution — and surfaces as an unobserved hole (#1517)", async () => {
|
|
918
1121
|
const cluster = fakeCluster({
|
|
919
1122
|
objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web },
|
|
920
1123
|
respond: (req) => (req.path.endsWith("/pods") ? { status: 403, body: statusBody(403, "Forbidden", "no pods list") } : undefined),
|
|
@@ -927,6 +1130,12 @@ describe("k8s describeResources", () => {
|
|
|
927
1130
|
|
|
928
1131
|
expect(result.resources.web).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1" });
|
|
929
1132
|
expect(Object.keys(result.resources)).toEqual(["web"]);
|
|
1133
|
+
// Silence would read as "no runtime Pods in prod", which the failed
|
|
1134
|
+
// list never proved — the sweep admits the hole instead.
|
|
1135
|
+
expect(result.unobserved?.["runtime-sweep:v1/Pod"]).toMatchObject({
|
|
1136
|
+
type: "K8s::Core::Pod",
|
|
1137
|
+
reason: "no-credentials",
|
|
1138
|
+
});
|
|
930
1139
|
});
|
|
931
1140
|
|
|
932
1141
|
test("a lexicon-supported entity type with no namespaced entities resolved does no Pod scan at all", async () => {
|
|
@@ -42,17 +42,19 @@
|
|
|
42
42
|
* exactly the shape `lifecycle diff --live` has always classified `orphan`.
|
|
43
43
|
* On Kubernetes that is wrong: it is expected runtime, not drift, and will be
|
|
44
44
|
* recreated the moment it is deleted. After the declared-entity reads above
|
|
45
|
-
* resolve, this
|
|
46
|
-
* each
|
|
47
|
-
* entities this very call resolved. A chain reaching one of them
|
|
48
|
-
* with `ownerChain: { root: "declared", ... }`; core's
|
|
49
|
-
* reads that as `runtime` instead of `orphan`.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
45
|
+
* resolve, this sweeps candidate kinds in the namespaces they actually live in
|
|
46
|
+
* and walks each object's `ownerReferences` chain (`./api/owner-chain.ts`)
|
|
47
|
+
* against the entities this very call resolved. A chain reaching one of them
|
|
48
|
+
* is reported with `ownerChain: { root: "declared", ... }`; core's
|
|
49
|
+
* diff/change-set engine reads that as `runtime` instead of `orphan`.
|
|
50
|
+
*
|
|
51
|
+
* Which kinds are candidates is the cluster's own API discovery's answer
|
|
52
|
+
* (#1517): every listable kind it serves, one version per group. The sweep's
|
|
53
|
+
* bound is the namespaces the declared estate touches, never a kind table —
|
|
54
|
+
* see `addRuntimeChildren`.
|
|
53
55
|
*/
|
|
54
56
|
|
|
55
|
-
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
57
|
+
import type { ObservationResult, ResourceMetadata, UnobservedEntity, UnobservedReason } from "@intentius/chant/lexicon";
|
|
56
58
|
import { observation, unobservedAll } from "@intentius/chant/observation";
|
|
57
59
|
import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
58
60
|
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
@@ -366,44 +368,47 @@ export async function describeResources(
|
|
|
366
368
|
}
|
|
367
369
|
});
|
|
368
370
|
|
|
369
|
-
|
|
370
|
-
// controller's children usually live in its controller's group (a
|
|
371
|
-
// MicroVMReplicaSet makes MicroVMs), so the runtime scan sweeps those
|
|
372
|
-
// groupVersions' kinds besides Pods. Core (`v1`) is excluded from the group
|
|
373
|
-
// sweep — Pods represent it; sweeping every core kind would walk endpoints
|
|
374
|
-
// and events for nothing.
|
|
375
|
-
const runtimeGroupVersions = new Set<string>();
|
|
376
|
-
for (const { entityType } of declared) {
|
|
377
|
-
const op = operationFor(entityType);
|
|
378
|
-
if (op && op.apiVersion.includes("/")) runtimeGroupVersions.add(op.apiVersion);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
await addRuntimeChildren(client, resources, options.owned, runtimeGroupVersions, declared);
|
|
371
|
+
await addRuntimeChildren(client, resources, unobserved, options.owned, declared);
|
|
382
372
|
|
|
383
373
|
return observation(resources, unobserved);
|
|
384
374
|
}
|
|
385
375
|
|
|
386
376
|
/**
|
|
387
|
-
*
|
|
377
|
+
* Sweep runtime-child candidates in the namespaces of entities this call just
|
|
388
378
|
* resolved, and classify each one's owner-reference chain (#1077). Mutates
|
|
389
379
|
* `resources` in place, adding an entry per object whose chain was worth
|
|
390
|
-
* reporting.
|
|
380
|
+
* reporting, and `unobserved` for the parts of the sweep that could not look.
|
|
381
|
+
*
|
|
382
|
+
* The candidate kinds are the cluster's own API discovery's answer (#1517):
|
|
383
|
+
* every listable kind it serves, one version per group (the group's
|
|
384
|
+
* preferredVersion, so nothing is listed twice at a second served version).
|
|
385
|
+
* The first cut scoped this to Pods, the second to the estate's own declared
|
|
386
|
+
* API groups — and both missed exactly where the runtime tier says the most:
|
|
387
|
+
* an operator's children are whatever kinds its controllers make (a
|
|
388
|
+
* MicroVMReplicaSet's MicroVMs, a CR's Deployments in a group the estate
|
|
389
|
+
* never declares), which no kind table can anticipate. The same open-world
|
|
390
|
+
* inversion behold#74 made for declared CRDs, applied to the runtime axis.
|
|
391
391
|
*
|
|
392
|
-
*
|
|
393
|
-
* - **
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
392
|
+
* What bounds the sweep is not a kind list but reach:
|
|
393
|
+
* - **Namespaced kinds** are listed only in the namespaces the declared
|
|
394
|
+
* estate touches (where its entities resolved, plus the namespaces its
|
|
395
|
+
* GitOps CRs target) — owner references cannot cross a namespace, so a
|
|
396
|
+
* child elsewhere could never chain to this estate anyway.
|
|
397
|
+
* - **Cluster-scoped kinds** are listed only when the estate resolved a
|
|
398
|
+
* cluster-scoped entity — a cluster-scoped object's owners are themselves
|
|
399
|
+
* cluster-scoped (Kubernetes' own GC rule), so without one declared there
|
|
400
|
+
* is nothing such a chain could reach.
|
|
401
|
+
* - **It is not an inventory**: outside Pods (whose chain verdicts, foreign
|
|
402
|
+
* included, are #1077's original contract), a swept object is only
|
|
403
|
+
* reported when its chain resolves to a declared entity.
|
|
401
404
|
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
405
|
+
* Tri-state honesty (#1089, extended to the sweep): a kind discovery names
|
|
406
|
+
* but the sweep cannot list — RBAC denial, a flaking apiserver — becomes a
|
|
407
|
+
* `runtime-sweep:*` entry in `unobserved`, never a silent gap. Silence would
|
|
408
|
+
* read as "no runtime children there", which the failed list never proved.
|
|
409
|
+
* A list 404 (the kind vanished between discovery and the list) stays an
|
|
410
|
+
* absence, per `classifyApiFailure`'s standing rule. Declared-entity
|
|
411
|
+
* resolution is never failed by the sweep.
|
|
407
412
|
*/
|
|
408
413
|
/** The declared GitOps CRs, indexed the way their controllers' labels can be
|
|
409
414
|
* resolved (#1549). `byRef` keys are `type\0namespace\0name` (Flux — exact);
|
|
@@ -466,11 +471,29 @@ function gitopsOwner(labels: Record<string, string> | undefined, gitops: GitopsI
|
|
|
466
471
|
return undefined;
|
|
467
472
|
}
|
|
468
473
|
|
|
474
|
+
/** One candidate kind of the discovery-driven sweep. */
|
|
475
|
+
interface SweptKind {
|
|
476
|
+
apiVersion: string;
|
|
477
|
+
kind: string;
|
|
478
|
+
typeName: string;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Kinds the sweep skips even though discovery serves them. Not a kind table
|
|
483
|
+
* creeping back in: each entry is a kind that CANNOT attribute to a declared
|
|
484
|
+
* owner — an Event names its subject via `involvedObject`, never
|
|
485
|
+
* `ownerReferences` — and churns by the thousands in any active namespace,
|
|
486
|
+
* so listing it can only cost and never attach.
|
|
487
|
+
*/
|
|
488
|
+
function isSweepNoise(info: { kind: string; group: string }): boolean {
|
|
489
|
+
return info.kind === "Event" && (info.group === "" || info.group === "events.k8s.io");
|
|
490
|
+
}
|
|
491
|
+
|
|
469
492
|
async function addRuntimeChildren(
|
|
470
493
|
client: K8sClient,
|
|
471
494
|
resources: Record<string, ResourceMetadata>,
|
|
495
|
+
unobserved: Record<string, UnobservedEntity>,
|
|
472
496
|
owned: boolean | undefined,
|
|
473
|
-
groupVersions: ReadonlySet<string> = new Set(),
|
|
474
497
|
declared: readonly Declared[] = [],
|
|
475
498
|
): Promise<void> {
|
|
476
499
|
const declaredByUid = new Map<string, string>();
|
|
@@ -488,9 +511,11 @@ async function addRuntimeChildren(
|
|
|
488
511
|
const gitops = gitopsIndex(declared);
|
|
489
512
|
|
|
490
513
|
const namespaces = new Set<string>();
|
|
514
|
+
let clusterScopedDeclared = false;
|
|
491
515
|
for (const meta of Object.values(resources)) {
|
|
492
516
|
const namespace = (meta.attributes as { namespace?: string } | undefined)?.namespace;
|
|
493
517
|
if (namespace) namespaces.add(namespace);
|
|
518
|
+
else clusterScopedDeclared = true;
|
|
494
519
|
}
|
|
495
520
|
// The namespaces a declared GitOps CR TARGETS are declared literals too
|
|
496
521
|
// (Argo `spec.destination.namespace`, Flux `spec.targetNamespace`) — the
|
|
@@ -498,93 +523,147 @@ async function addRuntimeChildren(
|
|
|
498
523
|
// reading it off the declaration keeps this bounded by the estate, never a
|
|
499
524
|
// cluster-wide sweep.
|
|
500
525
|
for (const ns of gitops.targetNamespaces) namespaces.add(ns);
|
|
501
|
-
if (namespaces.size === 0) return;
|
|
502
|
-
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
);
|
|
526
|
+
if (namespaces.size === 0 && !clusterScopedDeclared) return;
|
|
527
|
+
|
|
528
|
+
// Every kind the cluster serves, from its own discovery (#1517): one
|
|
529
|
+
// groupVersion per group, every listable kind, split by scope. Failing to
|
|
530
|
+
// enumerate is a hole the observation must admit — silently sweeping
|
|
531
|
+
// nothing would read as "no runtime children", which was never established.
|
|
532
|
+
let groupVersions: string[];
|
|
533
|
+
try {
|
|
534
|
+
groupVersions = await client.preferredGroupVersions();
|
|
535
|
+
} catch (err) {
|
|
536
|
+
const outcome = classifyApiFailure(err);
|
|
537
|
+
unobserved["runtime-sweep"] = {
|
|
538
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
539
|
+
detail:
|
|
540
|
+
"cluster API discovery failed, so runtime children were not swept" +
|
|
541
|
+
(outcome.kind === "unobserved" ? `: ${outcome.detail}` : ""),
|
|
542
|
+
};
|
|
543
|
+
return;
|
|
520
544
|
}
|
|
521
|
-
|
|
522
|
-
|
|
545
|
+
|
|
546
|
+
const perGroup = await client.concurrently(groupVersions, async (gv) => {
|
|
523
547
|
try {
|
|
524
|
-
|
|
525
|
-
} catch {
|
|
526
|
-
|
|
548
|
+
return await client.resources(gv);
|
|
549
|
+
} catch (err) {
|
|
550
|
+
const outcome = classifyApiFailure(err);
|
|
551
|
+
if (outcome.kind === "unobserved") {
|
|
552
|
+
unobserved[`runtime-sweep:${gv}`] = {
|
|
553
|
+
reason: outcome.reason,
|
|
554
|
+
detail: `discovery for ${gv} failed, so its kinds were not swept: ${outcome.detail}`,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return [];
|
|
527
558
|
}
|
|
528
|
-
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const namespacedKinds: SweptKind[] = [];
|
|
562
|
+
const clusterKinds: SweptKind[] = [];
|
|
563
|
+
for (const infos of perGroup) {
|
|
529
564
|
for (const info of infos) {
|
|
530
|
-
if (!info.
|
|
531
|
-
|
|
565
|
+
if (!info.verbs.includes("list") || isSweepNoise(info)) continue;
|
|
566
|
+
const swept: SweptKind = {
|
|
567
|
+
apiVersion: info.apiVersion,
|
|
568
|
+
kind: info.kind,
|
|
569
|
+
typeName: gvkToTypeName({ group: info.group, version: info.version, kind: info.kind }),
|
|
570
|
+
};
|
|
571
|
+
(info.namespaced ? namespacedKinds : clusterKinds).push(swept);
|
|
532
572
|
}
|
|
533
573
|
}
|
|
534
574
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
575
|
+
// A failed list is recorded once per kind, naming every scope it failed in,
|
|
576
|
+
// rather than once per (kind, namespace) — one RBAC rule denying a kind
|
|
577
|
+
// across N namespaces is one hole, not N.
|
|
578
|
+
const listFailures = new Map<
|
|
579
|
+
string,
|
|
580
|
+
{ typeName: string; reason: UnobservedReason; scopes: string[]; detail: string }
|
|
581
|
+
>();
|
|
543
582
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if (owned && ownerChain.root !== "declared" && !marker) return;
|
|
564
|
-
|
|
565
|
-
// An undeclared object with NO owner chain at all is not a runtime
|
|
566
|
-
// child — reporting every loose object a group serves would turn the
|
|
567
|
-
// scan into an inventory. Pods keep their pre-#1517 reporting (their
|
|
568
|
-
// chain verdicts, including foreign, were already part of #1077's
|
|
569
|
-
// contract); swept group kinds only report when the chain reaches a
|
|
570
|
-
// declared entity.
|
|
571
|
-
if (kind !== "Pod" && ownerChain.root !== "declared") return;
|
|
572
|
-
|
|
573
|
-
resources[`${namespace}/${name}`] = {
|
|
574
|
-
type: typeName,
|
|
575
|
-
physicalId: uid,
|
|
576
|
-
status: statusFromObject(obj),
|
|
577
|
-
lastUpdated: obj.metadata?.creationTimestamp,
|
|
578
|
-
ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
579
|
-
ownerChain,
|
|
580
|
-
attributes: pruneUndefined({
|
|
581
|
-
namespace,
|
|
582
|
-
labels: obj.metadata?.labels,
|
|
583
|
-
resourceVersion: obj.metadata?.resourceVersion,
|
|
584
|
-
conditions: unhappyConditions(obj),
|
|
585
|
-
}),
|
|
586
|
-
};
|
|
587
|
-
});
|
|
583
|
+
const sweep = async (swept: SweptKind, namespace: string | undefined): Promise<void> => {
|
|
584
|
+
let objects: K8sObject[];
|
|
585
|
+
try {
|
|
586
|
+
objects = await client.list(
|
|
587
|
+
{ apiVersion: swept.apiVersion, kind: swept.kind },
|
|
588
|
+
namespace ? { namespace } : {},
|
|
589
|
+
);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
const outcome = classifyApiFailure(err);
|
|
592
|
+
// `absent` (a list 404: the kind vanished between discovery and now)
|
|
593
|
+
// proves no instance exists — nothing to record.
|
|
594
|
+
if (outcome.kind === "unobserved") {
|
|
595
|
+
const key = `runtime-sweep:${swept.apiVersion}/${swept.kind}`;
|
|
596
|
+
const scope = namespace ? `namespace ${namespace}` : "cluster scope";
|
|
597
|
+
const failure = listFailures.get(key);
|
|
598
|
+
if (failure) failure.scopes.push(scope);
|
|
599
|
+
else listFailures.set(key, { typeName: swept.typeName, reason: outcome.reason, scopes: [scope], detail: outcome.detail });
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
588
602
|
}
|
|
603
|
+
|
|
604
|
+
await client.concurrently(objects, async (obj) => {
|
|
605
|
+
const uid = obj.metadata?.uid;
|
|
606
|
+
const name = obj.metadata?.name;
|
|
607
|
+
if (!uid || !name || declaredByUid.has(uid)) return; // declared directly, or unaddressable
|
|
608
|
+
const key = namespace ? `${namespace}/${name}` : `cluster:${name}`;
|
|
609
|
+
if (resources[key]) return; // already reported by an earlier kind
|
|
610
|
+
|
|
611
|
+
let ownerChain = await resolveK8sOwnerChain(obj, { declaredByUid, reader: client, namespace });
|
|
612
|
+
// The label channel (#1549) — only when the chain itself said nothing:
|
|
613
|
+
// a verdict the walk DID reach is never overridden.
|
|
614
|
+
if (ownerChain.root !== "declared") {
|
|
615
|
+
const entity = gitopsOwner(obj.metadata?.labels, gitops);
|
|
616
|
+
if (entity) ownerChain = { root: "declared", entity };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// `--owned`: withhold an object that is neither a runtime child of a
|
|
620
|
+
// declared entity nor carrying chant's own marker — the same rule the
|
|
621
|
+
// declared-entity read above applies, extended to the undeclared axis
|
|
622
|
+
// this scan introduces.
|
|
623
|
+
const marker = hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS);
|
|
624
|
+
if (owned && ownerChain.root !== "declared" && !marker) return;
|
|
625
|
+
|
|
626
|
+
// An undeclared object with NO owner chain at all is not a runtime
|
|
627
|
+
// child — reporting every loose object the cluster serves would turn
|
|
628
|
+
// the sweep into an inventory. Pods keep their pre-#1517 reporting
|
|
629
|
+
// (their chain verdicts, including foreign, were already part of
|
|
630
|
+
// #1077's contract); every other swept kind only reports when the
|
|
631
|
+
// chain reaches a declared entity.
|
|
632
|
+
if (swept.kind !== "Pod" && ownerChain.root !== "declared") return;
|
|
633
|
+
|
|
634
|
+
resources[key] = {
|
|
635
|
+
type: swept.typeName,
|
|
636
|
+
physicalId: uid,
|
|
637
|
+
status: statusFromObject(obj),
|
|
638
|
+
lastUpdated: obj.metadata?.creationTimestamp,
|
|
639
|
+
ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
640
|
+
ownerChain,
|
|
641
|
+
attributes: pruneUndefined({
|
|
642
|
+
namespace,
|
|
643
|
+
labels: obj.metadata?.labels,
|
|
644
|
+
resourceVersion: obj.metadata?.resourceVersion,
|
|
645
|
+
conditions: unhappyConditions(obj),
|
|
646
|
+
}),
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
await client.concurrently([...namespaces], async (namespace) => {
|
|
652
|
+
for (const swept of namespacedKinds) await sweep(swept, namespace);
|
|
589
653
|
});
|
|
654
|
+
|
|
655
|
+
// Cluster-scoped kinds are swept once (their lists are cluster-wide by
|
|
656
|
+
// nature), and only when a declared cluster-scoped entity exists for a
|
|
657
|
+
// chain to reach — a cluster-scoped object's owners are cluster-scoped.
|
|
658
|
+
if (clusterScopedDeclared) {
|
|
659
|
+
await client.concurrently(clusterKinds, async (swept) => sweep(swept, undefined));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
for (const [key, failure] of listFailures) {
|
|
663
|
+
unobserved[key] = {
|
|
664
|
+
type: failure.typeName,
|
|
665
|
+
reason: failure.reason,
|
|
666
|
+
detail: `the runtime-children sweep could not list this kind in ${failure.scopes.join(", ")}: ${failure.detail}`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
590
669
|
}
|