@intentius/chant-lexicon-k8s 0.42.1 → 0.44.2

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 +37 -0
  79. package/src/api/classify.ts +11 -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 +290 -6
  99. package/src/describe-resources.ts +235 -127
  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
@@ -105,6 +105,81 @@ describe("k8s describeResources", () => {
105
105
  expect(result.unobserved ?? {}).toEqual({});
106
106
  });
107
107
 
108
+ // The behold#192 shape (#1620): a Flux-deployed object declares no
109
+ // metadata.namespace (the controller stamps targetNamespace at apply), so the
110
+ // live read scopes to the client's default namespace, finds nothing, and
111
+ // correctly reports absence. Every layer is spec-correct and the picture
112
+ // still says the opposite of the truth — unless the report carries the
113
+ // address it actually asked, which is what `queried` is.
114
+ test("an absent verdict carries the resolved query address, namespace defaulted and visible (#1620)", async () => {
115
+ const cluster = fakeCluster({
116
+ // The object lives where Flux put it — not where the read will look.
117
+ objects: { [objectKey("apps/v1", "Deployment", "web", "flux-target")]: web },
118
+ });
119
+ const result = await describeResources(
120
+ {
121
+ environment: "prod",
122
+ buildOutput: "",
123
+ entityNames: ["web"],
124
+ entities: makeEntities([
125
+ // No metadata.namespace declared — Flux stamps it at apply time.
126
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } },
127
+ ]),
128
+ },
129
+ cluster.connector,
130
+ );
131
+
132
+ // Still a clean absence: in neither map, the tri-state unshifted.
133
+ expect(result.resources).toEqual({});
134
+ expect(result.unobserved ?? {}).toEqual({});
135
+ // But the report can now explain itself: the read went to `default`.
136
+ expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/default/deployments/web");
137
+ });
138
+
139
+ test("a present entity carries its query address too — the map is total over issued reads (#1620)", async () => {
140
+ const cluster = fakeCluster({
141
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web },
142
+ });
143
+ const result = await describeResources(
144
+ {
145
+ environment: "prod",
146
+ buildOutput: "",
147
+ entityNames: ["web"],
148
+ entities: makeEntities([
149
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
150
+ ]),
151
+ },
152
+ cluster.connector,
153
+ );
154
+ expect(result.resources.web).toBeDefined();
155
+ expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
156
+ });
157
+
158
+ test("a read-failed unobserved entry carries the address the failing read was issued against (#1620)", async () => {
159
+ const cluster = fakeCluster({
160
+ respond: (req) =>
161
+ req.path.endsWith("/deployments/web")
162
+ ? { status: 500, body: statusBody(500, "InternalError", "etcdserver: request timed out") }
163
+ : undefined,
164
+ });
165
+ const result = await describeResources(
166
+ {
167
+ environment: "prod",
168
+ buildOutput: "",
169
+ entityNames: ["web"],
170
+ entities: makeEntities([
171
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
172
+ ]),
173
+ },
174
+ cluster.connector,
175
+ );
176
+ expect(result.unobserved?.web).toMatchObject({
177
+ reason: "read-failed",
178
+ queried: "/apis/apps/v1/namespaces/prod/deployments/web",
179
+ });
180
+ expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
181
+ });
182
+
108
183
  // The headline of chant #1074: the twenty-entry map is gone, so a CRD is an
109
184
  // ordinary read rather than a permanent hole.
110
185
  test("a CRD is observed like anything else — no KUBECTL_RESOURCE entry required", async () => {
@@ -465,6 +540,44 @@ describe("k8s describeResources", () => {
465
540
  warnSpy.mockRestore();
466
541
  });
467
542
 
543
+ test("a failed read names the bound context it read (#1488)", async () => {
544
+ loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-eks" } } } } });
545
+ const cluster = fakeCluster({
546
+ kubeconfig: twoContexts,
547
+ respond: (req) =>
548
+ req.path.endsWith("/deployments/web")
549
+ ? { status: 500, body: statusBody(500, "InternalError", "etcdserver: leader changed") }
550
+ : undefined,
551
+ });
552
+
553
+ const result = await describeResources(
554
+ { environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() },
555
+ (o) => defaultK8sConnector({ ...o, client: { kubeconfig: twoContexts, requestLayer: cluster.layer } }),
556
+ );
557
+
558
+ expect(result.unobserved?.web?.reason).toBe("read-failed");
559
+ expect(result.unobserved?.web?.detail).toContain('context "prod-eks" (bound by k8s.profiles.prod.context)');
560
+ });
561
+
562
+ test("an ambient read failure names the context that was read and the missing binding (#1488)", async () => {
563
+ loadChantConfigMock.mockResolvedValue({ config: {} });
564
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
565
+ const cluster = fakeCluster({
566
+ kubeconfig: twoContexts,
567
+ respond: (req) =>
568
+ req.path.endsWith("/deployments/web") ? { status: 500, body: statusBody(500, "InternalError", "nope") } : undefined,
569
+ });
570
+
571
+ const result = await describeResources(
572
+ { environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() },
573
+ (o) => defaultK8sConnector({ ...o, client: { kubeconfig: twoContexts, requestLayer: cluster.layer } }),
574
+ );
575
+ warnSpy.mockRestore();
576
+
577
+ expect(result.unobserved?.web?.reason).toBe("read-failed");
578
+ expect(result.unobserved?.web?.detail).toContain('context "prod-eks" (ambient; no k8s.profiles.prod binding)');
579
+ });
580
+
468
581
  test("a bound context the kubeconfig does not have refuses with the context name", async () => {
469
582
  loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "gone-eks" } } } } });
470
583
  const oneContext = fakeKubeconfig({ contexts: [{ name: "prod-eks" }], currentContext: "prod-eks" });
@@ -573,6 +686,161 @@ describe("k8s describeResources", () => {
573
686
  expect(result.resources["prod/hand-made"]).toBeUndefined();
574
687
  });
575
688
 
689
+ // The issue's own estate: a declared MicroVMReplicaSet whose operator
690
+ // made MicroVMs — a CRD kind no table anticipated. Discovery names it.
691
+ test("a controller-made child of a CRD kind surfaces as a runtime child of the declared CR", async () => {
692
+ const replicaSet = {
693
+ apiVersion: "lambda.aws.amazon.com/v1alpha1",
694
+ kind: "MicroVMReplicaSet",
695
+ metadata: { name: "kmv-dev-a-vm", namespace: "microvm-demo", uid: "mvrs-uid" },
696
+ };
697
+ const vm = {
698
+ apiVersion: "lambda.aws.amazon.com/v1alpha1",
699
+ kind: "MicroVM",
700
+ metadata: {
701
+ name: "kmv-dev-a-vm-42sgf",
702
+ namespace: "microvm-demo",
703
+ uid: "vm-uid",
704
+ ownerReferences: [
705
+ { apiVersion: "lambda.aws.amazon.com/v1alpha1", kind: "MicroVMReplicaSet", name: "kmv-dev-a-vm", uid: "mvrs-uid", controller: true },
706
+ ],
707
+ },
708
+ status: { phase: "Running" },
709
+ };
710
+ const cluster = fakeCluster({
711
+ objects: {
712
+ [objectKey("lambda.aws.amazon.com/v1alpha1", "MicroVMReplicaSet", "kmv-dev-a-vm", "microvm-demo")]: replicaSet,
713
+ [objectKey("lambda.aws.amazon.com/v1alpha1", "MicroVM", "kmv-dev-a-vm-42sgf", "microvm-demo")]: vm,
714
+ },
715
+ });
716
+
717
+ const result = await describeResources(
718
+ {
719
+ environment: "dev",
720
+ buildOutput: "",
721
+ entityNames: ["workloadReplicaSet"],
722
+ entities: makeEntities([
723
+ {
724
+ name: "workloadReplicaSet",
725
+ entityType: "K8s::KubeMicroVM::MicroVMReplicaSet",
726
+ props: { metadata: { name: "kmv-dev-a-vm", namespace: "microvm-demo" } },
727
+ },
728
+ ]),
729
+ },
730
+ cluster.connector,
731
+ );
732
+
733
+ expect(result.resources["microvm-demo/kmv-dev-a-vm-42sgf"]).toMatchObject({
734
+ type: "K8s::Lambda::MicroVM",
735
+ physicalId: "vm-uid",
736
+ ownerChain: { root: "declared", entity: "workloadReplicaSet" },
737
+ });
738
+ });
739
+
740
+ test("a child in a group the estate never declares surfaces too — discovery names the kinds, not the declaration", async () => {
741
+ // A declared CR whose operator makes ordinary Deployments: ray.io is
742
+ // declared, apps/v1 is not, and the estate-groups sweep never listed it.
743
+ const rayCluster = { apiVersion: "ray.io/v1", kind: "RayCluster", metadata: { name: "ml", namespace: "ray", uid: "ray-uid" } };
744
+ const head = {
745
+ apiVersion: "apps/v1",
746
+ kind: "Deployment",
747
+ metadata: {
748
+ name: "ml-head",
749
+ namespace: "ray",
750
+ uid: "head-uid",
751
+ ownerReferences: [{ apiVersion: "ray.io/v1", kind: "RayCluster", name: "ml", uid: "ray-uid", controller: true }],
752
+ },
753
+ status: { readyReplicas: 1, replicas: 1 },
754
+ };
755
+ const cluster = fakeCluster({
756
+ objects: {
757
+ [objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: rayCluster,
758
+ [objectKey("apps/v1", "Deployment", "ml-head", "ray")]: head,
759
+ },
760
+ });
761
+
762
+ const result = await describeResources(
763
+ {
764
+ environment: "prod",
765
+ buildOutput: "",
766
+ entityNames: ["ml"],
767
+ entities: makeEntities([
768
+ { name: "ml", entityType: "K8s::Ray::RayCluster", props: { metadata: { name: "ml", namespace: "ray" } } },
769
+ ]),
770
+ },
771
+ cluster.connector,
772
+ );
773
+
774
+ expect(result.resources["ray/ml-head"]).toMatchObject({
775
+ type: "K8s::Apps::Deployment",
776
+ physicalId: "head-uid",
777
+ ownerChain: { root: "declared", entity: "ml" },
778
+ });
779
+ });
780
+
781
+ test("a discovered kind the sweep cannot list is an unobserved hole, never silently absent", async () => {
782
+ const cluster = fakeCluster({
783
+ objects: { [objectKey("batch/v1", "CronJob", "backup", "prod")]: cronJob },
784
+ respond: (req) =>
785
+ req.path === "/apis/batch/v1/namespaces/prod/jobs"
786
+ ? { status: 403, body: statusBody(403, "Forbidden", "jobs is forbidden") }
787
+ : undefined,
788
+ });
789
+
790
+ const result = await describeResources(
791
+ { environment: "prod", buildOutput: "", entityNames: ["backup"], entities: declaredCronJob() },
792
+ cluster.connector,
793
+ );
794
+
795
+ // Declared resolution is never failed by the sweep.
796
+ expect(result.resources.backup).toMatchObject({ type: "K8s::Batch::CronJob", physicalId: "cj-uid" });
797
+ // The failed list is a hole the observation admits, with the failure's
798
+ // own classification — a 403 is a credentials problem, and silence here
799
+ // would have read as "no runtime children in prod".
800
+ expect(result.unobserved?.["runtime-sweep:batch/v1/Job"]).toMatchObject({
801
+ type: "K8s::Batch::Job",
802
+ reason: "no-credentials",
803
+ });
804
+ expect(result.unobserved?.["runtime-sweep:batch/v1/Job"]?.detail).toContain("namespace prod");
805
+ });
806
+
807
+ test("a cluster-scoped child chains to a declared cluster-scoped entity — the one case cluster-scoped kinds are swept", async () => {
808
+ const ns = { apiVersion: "v1", kind: "Namespace", metadata: { name: "team-a", uid: "ns-uid" }, status: { phase: "Active" } };
809
+ const role = {
810
+ apiVersion: "rbac.authorization.k8s.io/v1",
811
+ kind: "ClusterRole",
812
+ metadata: {
813
+ name: "team-a-admin",
814
+ uid: "role-uid",
815
+ ownerReferences: [{ apiVersion: "v1", kind: "Namespace", name: "team-a", uid: "ns-uid", controller: true }],
816
+ },
817
+ };
818
+ const cluster = fakeCluster({
819
+ objects: {
820
+ [objectKey("v1", "Namespace", "team-a")]: ns,
821
+ [objectKey("rbac.authorization.k8s.io/v1", "ClusterRole", "team-a-admin")]: role,
822
+ },
823
+ });
824
+
825
+ const result = await describeResources(
826
+ {
827
+ environment: "prod",
828
+ buildOutput: "",
829
+ entityNames: ["team"],
830
+ entities: makeEntities([
831
+ { name: "team", entityType: "K8s::Core::Namespace", props: { metadata: { name: "team-a" } } },
832
+ ]),
833
+ },
834
+ cluster.connector,
835
+ );
836
+
837
+ expect(result.resources["cluster:team-a-admin"]).toMatchObject({
838
+ type: "K8s::Rbac::ClusterRole",
839
+ physicalId: "role-uid",
840
+ ownerChain: { root: "declared", entity: "team" },
841
+ });
842
+ });
843
+
576
844
  test("a Flux-managed Deployment is attributed to its declared Kustomization by the controller's labels (#1549)", async () => {
577
845
  // Flux stamps managed objects with name+namespace labels instead of
578
846
  // ownerReferences, so the chain walk reaches nothing — the label
@@ -702,7 +970,7 @@ describe("k8s describeResources", () => {
702
970
  expect(result.resources["flux-system/cert-manager"]).toBeUndefined();
703
971
  });
704
972
 
705
- test("a core-only estate sweeps no extra groupsPods stay the core representative", async () => {
973
+ 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
974
  const svc = { apiVersion: "v1", kind: "Service", metadata: { name: "web", namespace: "prod", uid: "svc-uid" } };
707
975
  const cluster = fakeCluster({
708
976
  objects: { [objectKey("v1", "Service", "web", "prod")]: svc },
@@ -720,10 +988,20 @@ describe("k8s describeResources", () => {
720
988
  cluster.connector,
721
989
  );
722
990
 
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([]);
991
+ const paths = cluster.layer.paths();
992
+ const isDiscovery = (p: string) =>
993
+ p === "/api" || p === "/apis" || /^\/api\/[^/]+$/.test(p) || /^\/apis\/[^/]+\/[^/]+$/.test(p);
994
+ const reads = paths.filter((p) => !isDiscovery(p));
995
+ // Discovery names the kinds; every actual read or list stays inside the
996
+ // one namespace the estate touches. Nothing is listed cluster-wide.
997
+ expect(reads.length).toBeGreaterThan(0);
998
+ expect(reads.every((p) => p.includes("/namespaces/prod/"))).toBe(true);
999
+ // Nothing declared is cluster-scoped, so no owner chain could reach a
1000
+ // cluster-scoped object — their kinds are not listed at all.
1001
+ expect(paths.some((p) => p.endsWith("/clusterroles"))).toBe(false);
1002
+ // Events churn by the thousands and can never chain (`involvedObject`,
1003
+ // not `ownerReferences`) — discovery serves them, the sweep skips them.
1004
+ expect(paths.some((p) => p.endsWith("/events"))).toBe(false);
727
1005
  });
728
1006
  });
729
1007
 
@@ -914,7 +1192,7 @@ describe("k8s describeResources", () => {
914
1192
  expect(Object.keys(result.resources)).toHaveLength(2); // web + standalonePod, no synthetic duplicate
915
1193
  });
916
1194
 
917
- test("a Pod-list failure for a namespace is best-effort declared entities still resolve", async () => {
1195
+ test("a Pod-list failure never fails declared resolutionand surfaces as an unobserved hole (#1517)", async () => {
918
1196
  const cluster = fakeCluster({
919
1197
  objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web },
920
1198
  respond: (req) => (req.path.endsWith("/pods") ? { status: 403, body: statusBody(403, "Forbidden", "no pods list") } : undefined),
@@ -927,6 +1205,12 @@ describe("k8s describeResources", () => {
927
1205
 
928
1206
  expect(result.resources.web).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1" });
929
1207
  expect(Object.keys(result.resources)).toEqual(["web"]);
1208
+ // Silence would read as "no runtime Pods in prod", which the failed
1209
+ // list never proved — the sweep admits the hole instead.
1210
+ expect(result.unobserved?.["runtime-sweep:v1/Pod"]).toMatchObject({
1211
+ type: "K8s::Core::Pod",
1212
+ reason: "no-credentials",
1213
+ });
930
1214
  });
931
1215
 
932
1216
  test("a lexicon-supported entity type with no namespaced entities resolved does no Pod scan at all", async () => {