@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.
Files changed (127) hide show
  1. package/dist/api/classify.d.ts.map +1 -1
  2. package/dist/api/connect.d.ts.map +1 -1
  3. package/dist/codegen/docs.d.ts.map +1 -1
  4. package/dist/components/argo-app.d.ts +104 -0
  5. package/dist/components/argo-app.d.ts.map +1 -0
  6. package/dist/components/builders.d.ts +8 -0
  7. package/dist/components/builders.d.ts.map +1 -1
  8. package/dist/components/capability-plugin.d.ts +1 -0
  9. package/dist/components/capability-plugin.d.ts.map +1 -1
  10. package/dist/components/flux-reconcile.d.ts +84 -0
  11. package/dist/components/flux-reconcile.d.ts.map +1 -0
  12. package/dist/components/index.d.ts +2 -0
  13. package/dist/components/index.d.ts.map +1 -1
  14. package/dist/components/kustomize-apply.d.ts +29 -7
  15. package/dist/components/kustomize-apply.d.ts.map +1 -1
  16. package/dist/composites/flux-app.d.ts +132 -0
  17. package/dist/composites/flux-app.d.ts.map +1 -0
  18. package/dist/composites/index.d.ts +2 -0
  19. package/dist/composites/index.d.ts.map +1 -1
  20. package/dist/config-schema.d.ts +3 -0
  21. package/dist/config-schema.d.ts.map +1 -1
  22. package/dist/config.d.ts +32 -5
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/crd/crd-sources.d.ts.map +1 -1
  25. package/dist/crd/parser.d.ts.map +1 -1
  26. package/dist/describe-resources.d.ts +10 -8
  27. package/dist/describe-resources.d.ts.map +1 -1
  28. package/dist/generated/index.d.ts +15 -4
  29. package/dist/generated/index.d.ts.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/integrity.json +10 -6
  33. package/dist/kube/connect.d.ts +5 -4
  34. package/dist/kube/connect.d.ts.map +1 -1
  35. package/dist/kube/get.d.ts +4 -4
  36. package/dist/kustomize/render.d.ts +15 -0
  37. package/dist/kustomize/render.d.ts.map +1 -0
  38. package/dist/kustomize/rendered-entity.d.ts +39 -0
  39. package/dist/kustomize/rendered-entity.d.ts.map +1 -0
  40. package/dist/kustomize/root.d.ts +19 -0
  41. package/dist/kustomize/root.d.ts.map +1 -0
  42. package/dist/lint/audit-catalog.d.ts.map +1 -1
  43. package/dist/lint/post-synth/argo-helpers.d.ts +2 -0
  44. package/dist/lint/post-synth/argo-helpers.d.ts.map +1 -1
  45. package/dist/lint/post-synth/flux002.d.ts +14 -0
  46. package/dist/lint/post-synth/flux002.d.ts.map +1 -0
  47. package/dist/lint/post-synth/flux003.d.ts +17 -0
  48. package/dist/lint/post-synth/flux003.d.ts.map +1 -0
  49. package/dist/lint/post-synth/index.d.ts.map +1 -1
  50. package/dist/lint/rules/flux-source-ref-pin.d.ts +16 -0
  51. package/dist/lint/rules/flux-source-ref-pin.d.ts.map +1 -0
  52. package/dist/manifest.json +1 -1
  53. package/dist/meta.json +92 -16
  54. package/dist/okf/index.md +7 -1
  55. package/dist/okf/rules/FLUX001.md +17 -0
  56. package/dist/okf/rules/FLUX002.md +19 -0
  57. package/dist/okf/rules/FLUX003.md +15 -0
  58. package/dist/okf/types/Addon.md +14 -0
  59. package/dist/okf/types/Bucket.md +4 -0
  60. package/dist/okf/types/{HelmChart.md → FluxHelmChart.md} +2 -2
  61. package/dist/okf/types/GitRepository.md +5 -0
  62. package/dist/okf/types/HelmChartConfig.md +14 -0
  63. package/dist/okf/types/K3sHelmChart.md +14 -0
  64. package/dist/okf/types/Kustomization.md +6 -0
  65. package/dist/okf/types/Namespace.md +1 -0
  66. package/dist/okf/types/Node.md +1 -0
  67. package/dist/okf/types/OCIRepository.md +4 -0
  68. package/dist/plugin.d.ts.map +1 -1
  69. package/dist/rules/argo-helpers.ts +2 -0
  70. package/dist/rules/flux-source-ref-pin.ts +52 -0
  71. package/dist/rules/flux002.ts +77 -0
  72. package/dist/rules/flux003.ts +75 -0
  73. package/dist/serializer.d.ts.map +1 -1
  74. package/dist/skills/chant-k8s-flux.md +162 -0
  75. package/dist/types/index.d.ts +41 -3
  76. package/dist/validate.d.ts.map +1 -1
  77. package/package.json +3 -3
  78. package/src/api/classify.test.ts +20 -0
  79. package/src/api/classify.ts +8 -1
  80. package/src/api/connect.ts +10 -0
  81. package/src/codegen/docs.ts +11 -0
  82. package/src/components/argo-app.test.ts +137 -0
  83. package/src/components/argo-app.ts +163 -0
  84. package/src/components/builders.ts +8 -0
  85. package/src/components/capability-plugin.ts +11 -1
  86. package/src/components/flux-reconcile.test.ts +145 -0
  87. package/src/components/flux-reconcile.ts +159 -0
  88. package/src/components/index.ts +13 -0
  89. package/src/components/kustomize-apply.ts +12 -41
  90. package/src/composites/composites.test.ts +122 -0
  91. package/src/composites/flux-app.ts +274 -0
  92. package/src/composites/index.ts +9 -0
  93. package/src/config-augmentation.test.ts +72 -0
  94. package/src/config-schema.ts +5 -0
  95. package/src/config.ts +33 -5
  96. package/src/crd/crd-sources.ts +37 -0
  97. package/src/crd/parser.ts +6 -0
  98. package/src/describe-resources.test.ts +215 -6
  99. package/src/describe-resources.ts +197 -118
  100. package/src/generated/index.d.ts +41 -3
  101. package/src/generated/index.ts +15 -4
  102. package/src/generated/lexicon-k8s.json +92 -16
  103. package/src/generated/operations.json +48 -0
  104. package/src/index.ts +4 -0
  105. package/src/kube/connect.ts +5 -4
  106. package/src/kube/get.ts +4 -4
  107. package/src/kustomize/render.ts +84 -0
  108. package/src/kustomize/rendered-entity.ts +72 -0
  109. package/src/kustomize/root.test.ts +268 -0
  110. package/src/kustomize/root.ts +109 -0
  111. package/src/lint/audit-catalog.ts +2 -0
  112. package/src/lint/post-synth/argo-helpers.ts +2 -0
  113. package/src/lint/post-synth/flux002.ts +77 -0
  114. package/src/lint/post-synth/flux003.ts +75 -0
  115. package/src/lint/post-synth/index.ts +4 -0
  116. package/src/lint/post-synth/post-synth.test.ts +132 -0
  117. package/src/lint/rules/flux-source-ref-pin.ts +52 -0
  118. package/src/lint/rules/rules.test.ts +51 -0
  119. package/src/plugin.test.ts +2 -0
  120. package/src/plugin.ts +47 -0
  121. package/src/serializer.ts +34 -4
  122. package/src/skills/chant-k8s-flux.md +162 -0
  123. package/src/testdata/kustomize-root/base/deployment.yaml +17 -0
  124. package/src/testdata/kustomize-root/base/kustomization.yaml +3 -0
  125. package/src/testdata/kustomize-root/base/service.yaml +9 -0
  126. package/src/testdata/kustomize-root/overlays/prod/kustomization.yaml +6 -0
  127. 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("a core-only estate sweeps no extra groupsPods stay the core representative", async () => {
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
- // Discovery was consulted for no non-core groupVersion: the only list
724
- // sweeps are the namespace's pods.
725
- const groupDiscovery = cluster.layer.paths().filter((p) => /^\/apis\/[^/]+\/[^/]+$/.test(p) && !p.includes("namespaces"));
726
- expect(groupDiscovery.filter((p) => !p.startsWith("/apis/apps"))).toEqual([]);
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 for a namespace is best-effort declared entities still resolve", async () => {
1120
+ test("a Pod-list failure never fails declared resolutionand 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 scans Pods in the namespaces they actually live in and walks
46
- * each one's `ownerReferences` chain (`./api/owner-chain.ts`) against the
47
- * entities this very call resolved. A chain reaching one of them is reported
48
- * with `ownerChain: { root: "declared", ... }`; core's diff/change-set engine
49
- * reads that as `runtime` instead of `orphan`. Deliberately scoped to Pods —
50
- * the concrete case the issue and its acceptance criteria name — and to the
51
- * namespaces this observation already touched, not a cluster-wide sweep;
52
- * widening to other controller-spawned kinds is the same walk repeated.
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
- // chant #1517 the API groups this estate's own declarations use. A
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
- * Scan runtime-child candidates in the namespaces of entities this call just
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
- * Two candidate pools:
393
- * - **Pods** the built-in workload leaf, always scanned.
394
- * - **The estate's own API groups** (#1517) every namespaced, listable
395
- * kind in each `groupVersions` entry, from the cluster's own discovery.
396
- * An operator estate's interesting children are custom resources its
397
- * controllers made (a MicroVMReplicaSet's MicroVMs), which no kind table
398
- * could anticipatethe same open-world inversion behold#74 made for
399
- * declared CRDs, applied to the runtime axis. Bounded by what the estate
400
- * declares, never a cluster-wide sweep.
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 entitya 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
- * Best-effort and additive: a namespace or kind this scan cannot list (RBAC
403
- * denial, a transient error) is simply skipped rather than failing the
404
- * observation these are not declared entities, so there is no NOT-OBSERVED
405
- * axis for them to report against, the same way an out-of-band AWS child
406
- * resource has none either.
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
- // The kinds to scan: Pods, plus each declared groupVersion's namespaced,
504
- // listable kinds from discovery (#1517). A groupVersion the cluster does
505
- // not serve contributes nothing an answer, not a failure.
506
- const kinds: Array<{ apiVersion: string; kind: string; typeName: string }> = [
507
- { apiVersion: "v1", kind: "Pod", typeName: "K8s::Core::Pod" },
508
- ];
509
- // A GitOps estate's managed WORKLOADS live in groups the estate itself never
510
- // declares (a pure Argo/Flux estate declares only the CRs), so without this
511
- // the label channel could only ever fire on Pods (#1549).
512
- if (gitops.present) {
513
- kinds.push(
514
- { apiVersion: "apps/v1", kind: "Deployment", typeName: "K8s::Apps::Deployment" },
515
- { apiVersion: "apps/v1", kind: "StatefulSet", typeName: "K8s::Apps::StatefulSet" },
516
- { apiVersion: "apps/v1", kind: "DaemonSet", typeName: "K8s::Apps::DaemonSet" },
517
- { apiVersion: "v1", kind: "Service", typeName: "K8s::Core::Service" },
518
- { apiVersion: "v1", kind: "ConfigMap", typeName: "K8s::Core::ConfigMap" },
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
- for (const gv of [...groupVersions].sort()) {
522
- let infos;
545
+
546
+ const perGroup = await client.concurrently(groupVersions, async (gv) => {
523
547
  try {
524
- infos = await client.resources(gv);
525
- } catch {
526
- continue; // best-effort — see the function doc
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
- const [group, version] = [gv.slice(0, gv.indexOf("/")), gv.slice(gv.indexOf("/") + 1)];
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.namespaced || !info.verbs.includes("list")) continue;
531
- kinds.push({ apiVersion: gv, kind: info.kind, typeName: gvkToTypeName({ group, version, kind: info.kind }) });
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
- await client.concurrently([...namespaces], async (namespace) => {
536
- for (const { apiVersion, kind, typeName } of kinds) {
537
- let objects: K8sObject[];
538
- try {
539
- objects = await client.list({ apiVersion, kind }, { namespace });
540
- } catch {
541
- continue; // best-effort — see the function doc
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
- await client.concurrently(objects, async (obj) => {
545
- const uid = obj.metadata?.uid;
546
- const name = obj.metadata?.name;
547
- if (!uid || !name || declaredByUid.has(uid)) return; // declared directly, or unaddressable
548
- if (resources[`${namespace}/${name}`]) return; // already reported by an earlier kind
549
-
550
- let ownerChain = await resolveK8sOwnerChain(obj, { declaredByUid, reader: client, namespace });
551
- // The label channel (#1549) — only when the chain itself said nothing:
552
- // a verdict the walk DID reach is never overridden.
553
- if (ownerChain.root !== "declared") {
554
- const entity = gitopsOwner(obj.metadata?.labels, gitops);
555
- if (entity) ownerChain = { root: "declared", entity };
556
- }
557
-
558
- // `--owned`: withhold an object that is neither a runtime child of a
559
- // declared entity nor carrying chant's own marker — the same rule the
560
- // declared-entity read above applies, extended to the undeclared axis
561
- // this scan introduces.
562
- const marker = hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS);
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
  }