@opensoha/contracts 0.1.27 → 0.1.29

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 (94) hide show
  1. package/COMPATIBILITY.md +4 -3
  2. package/README.md +8 -9
  3. package/compat/consumer-revisions.json +1 -1
  4. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  5. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  6. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-length.json +3 -0
  7. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-type.json +3 -0
  8. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-alias.json +5 -0
  9. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-reset.json +3 -0
  10. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/invalid/revision-overflow.json +4 -0
  11. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/legacy.json +3 -0
  12. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/observed-revision.json +4 -0
  13. package/fixtures/openapi/KubernetesJobDetail/invalid/execution-observation.json +23 -0
  14. package/fixtures/openapi/KubernetesJobDetail/valid/execution-observation.json +23 -0
  15. package/fixtures/openapi/KubernetesNode/invalid/labels.json +10 -0
  16. package/fixtures/openapi/KubernetesNode/valid/labels.json +11 -0
  17. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/empty-uid.json +13 -0
  18. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/invalid-container-state.json +20 -0
  19. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/missing-container-ready.json +19 -0
  20. package/fixtures/openapi/KubernetesPodListEnvelope/valid/container-statuses.json +67 -0
  21. package/fixtures/openapi/KubernetesPodListEnvelope/valid/running-pod.json +1 -0
  22. package/fixtures/openapi/KubernetesPrometheusDetail/invalid/metric-cluster-value-type.json +4 -0
  23. package/fixtures/openapi/KubernetesPrometheusDetail/valid/metric-cluster-alias.json +5 -0
  24. package/fixtures/openapi/KubernetesResourceGraphEnvelope/invalid/network-edge-source-payload.json +73 -0
  25. package/fixtures/openapi/KubernetesResourceGraphEnvelope/valid/network-edge-facts.json +67 -0
  26. package/fixtures/openapi/KubernetesSecretCertificate/invalid/secret-data.json +1 -0
  27. package/fixtures/openapi/KubernetesSecretCertificate/invalid/status.json +1 -0
  28. package/fixtures/openapi/KubernetesSecretCertificate/valid/available.json +1 -0
  29. package/fixtures/openapi/KubernetesSecretCertificate/valid/missing.json +1 -0
  30. package/gen/go/sohaapi/client.go +1 -1
  31. package/gen/go/sohaapi/client_test.go +13 -0
  32. package/gen/go/sohaapi/kubernetes_workload_optional_test.go +40 -0
  33. package/gen/go/sohaapi/pod_status_compat_test.go +36 -0
  34. package/gen/go/sohaapi/types.go +421 -88
  35. package/gen/ts/sohaapi/index.ts +756 -4
  36. package/helmrelease/runtime/values_upgrade.go +78 -0
  37. package/helmrelease/runtime/values_upgrade_test.go +123 -0
  38. package/helmrelease/values_update.go +31 -0
  39. package/openapi/soha-api.json +1191 -14
  40. package/openapi/soha-api.yaml +588 -28
  41. package/openapi/spec_generated.go +3 -3
  42. package/package.json +1 -1
  43. package/resource/agent_profiles.go +9 -15
  44. package/resource/agent_profiles_test.go +9 -3
  45. package/resource/custom_graph_page.go +18 -0
  46. package/resource/models.go +102 -57
  47. package/resource/network_topology.go +22 -0
  48. package/resource/network_topology_projection.go +36 -22
  49. package/resource/network_topology_projection_test.go +4 -0
  50. package/resource/network_topology_test.go +20 -0
  51. package/resource/network_topology_validation.go +5 -0
  52. package/resource/observed_workload.go +18 -0
  53. package/resource/pod_status_test.go +22 -0
  54. package/resource/rbac_graph.go +4 -0
  55. package/resource/rollout_optional_test.go +33 -0
  56. package/resource/runtime/network_relationship_primitives.go +66 -0
  57. package/resource/runtime/network_relationship_primitives_test.go +147 -0
  58. package/resource/runtime/network_topology.go +17 -5
  59. package/resource/runtime/network_topology_builder.go +405 -0
  60. package/resource/runtime/network_topology_builder_backends.go +195 -0
  61. package/resource/runtime/network_topology_builder_gateway_l4_test.go +86 -0
  62. package/resource/runtime/network_topology_builder_ingress.go +302 -0
  63. package/resource/runtime/network_topology_builder_legacy.go +66 -0
  64. package/resource/runtime/network_topology_builder_providers.go +242 -0
  65. package/resource/runtime/network_topology_builder_test.go +186 -0
  66. package/resource/runtime/network_topology_declarations.go +329 -0
  67. package/resource/runtime/resource_edit.go +22 -0
  68. package/resource/runtime/resource_edit_test.go +32 -0
  69. package/resource/runtime/resource_graph_collect.go +325 -0
  70. package/resource/runtime/resource_graph_configuration.go +59 -0
  71. package/resource/runtime/resource_graph_configuration_collect.go +198 -0
  72. package/resource/runtime/resource_graph_configuration_test.go +470 -0
  73. package/resource/runtime/resource_graph_custom_page.go +159 -0
  74. package/resource/runtime/resource_graph_custom_root.go +132 -0
  75. package/resource/runtime/resource_graph_custom_root_test.go +197 -0
  76. package/resource/runtime/resource_graph_enrichment.go +417 -0
  77. package/resource/runtime/resource_graph_enrichment_hpa_test.go +117 -0
  78. package/resource/runtime/resource_graph_enrichment_test.go +201 -0
  79. package/resource/runtime/resource_graph_metadata.go +125 -0
  80. package/resource/runtime/resource_graph_metadata_test.go +204 -0
  81. package/resource/runtime/resource_graph_network.go +224 -0
  82. package/resource/runtime/resource_graph_network_collect.go +174 -0
  83. package/resource/runtime/resource_graph_network_collect_test.go +147 -0
  84. package/resource/runtime/resource_graph_network_test.go +342 -0
  85. package/resource/runtime/resource_graph_rbac.go +278 -0
  86. package/resource/runtime/resource_graph_rbac_test.go +372 -0
  87. package/resource/runtime/resource_graph_rules.go +834 -0
  88. package/resource/runtime/resource_graph_storage.go +144 -0
  89. package/resource/runtime/resource_graph_storage_collect.go +163 -0
  90. package/resource/runtime/resource_graph_storage_test.go +397 -0
  91. package/resource/runtime/resource_graph_test.go +627 -0
  92. package/resource/runtime/secret_certificate.go +44 -0
  93. package/resource/runtime/secret_certificate_test.go +87 -0
  94. package/resource/storage_graph.go +4 -0
@@ -0,0 +1,397 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "reflect"
8
+ "slices"
9
+ "strings"
10
+ "testing"
11
+
12
+ cr "github.com/opensoha/soha-contracts/resource"
13
+ corev1 "k8s.io/api/core/v1"
14
+ storagev1 "k8s.io/api/storage/v1"
15
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
16
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17
+ kruntime "k8s.io/apimachinery/pkg/runtime"
18
+ "k8s.io/apimachinery/pkg/runtime/schema"
19
+ "k8s.io/client-go/kubernetes/fake"
20
+ ktesting "k8s.io/client-go/testing"
21
+ )
22
+
23
+ func storageGraphFixture() (*corev1.Pod, *corev1.PersistentVolumeClaim, *corev1.PersistentVolume, *storagev1.StorageClass) {
24
+ className := "fast"
25
+ claim := &corev1.PersistentVolumeClaim{
26
+ ObjectMeta: metav1.ObjectMeta{Name: "data", Namespace: "team", UID: "claim-uid"},
27
+ Spec: corev1.PersistentVolumeClaimSpec{VolumeName: "disk", StorageClassName: &className},
28
+ Status: corev1.PersistentVolumeClaimStatus{Phase: corev1.ClaimBound},
29
+ }
30
+ volume := &corev1.PersistentVolume{
31
+ ObjectMeta: metav1.ObjectMeta{Name: "disk", UID: "volume-uid"},
32
+ Spec: corev1.PersistentVolumeSpec{
33
+ StorageClassName: className,
34
+ ClaimRef: &corev1.ObjectReference{APIVersion: "v1", Kind: "PersistentVolumeClaim", Namespace: "team", Name: "data", UID: claim.UID},
35
+ },
36
+ Status: corev1.PersistentVolumeStatus{Phase: corev1.VolumeBound},
37
+ }
38
+ pod := &corev1.Pod{
39
+ ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "team", UID: "pod-uid"},
40
+ Spec: corev1.PodSpec{Volumes: []corev1.Volume{
41
+ {Name: "first", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: claim.Name}}},
42
+ {Name: "duplicate", VolumeSource: corev1.VolumeSource{PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ClaimName: claim.Name, ReadOnly: true}}},
43
+ }},
44
+ Status: corev1.PodStatus{Phase: corev1.PodPending},
45
+ }
46
+ class := &storagev1.StorageClass{ObjectMeta: metav1.ObjectMeta{Name: className, UID: "class-uid"}}
47
+ return pod, claim, volume, class
48
+ }
49
+
50
+ func collectStorageGraph(t *testing.T, client *fake.Clientset, namespace, kind, name string) cr.ResourceGraph {
51
+ t.Helper()
52
+ graph, err := BuildKubernetesResourceGraph(context.Background(), client, cr.ResourceRef{ClusterID: "cluster", Namespace: namespace, Kind: kind, Name: name})
53
+ if err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ return graph
57
+ }
58
+
59
+ func assertStorageGraphActions(t *testing.T, client *fake.Clientset, want []string) {
60
+ t.Helper()
61
+ got := []string{}
62
+ for _, action := range client.Actions() {
63
+ record := action.GetVerb() + " " + action.GetResource().Resource + "/" + action.GetNamespace()
64
+ switch action.GetVerb() {
65
+ case "get":
66
+ get, ok := action.(ktesting.GetAction)
67
+ if !ok {
68
+ t.Fatalf("get action has no resource name: %+v", action)
69
+ }
70
+ record += "/" + get.GetName()
71
+ case "list":
72
+ list, ok := action.(interface{ GetListOptions() metav1.ListOptions })
73
+ if !ok {
74
+ t.Fatalf("list action has no list options: %+v", action)
75
+ }
76
+ options := list.GetListOptions()
77
+ if options.Limit != ResourceGraphSourceLimit || options.Continue != "" {
78
+ t.Fatalf("unbounded source options for %s: %+v", record, options)
79
+ }
80
+ }
81
+ got = append(got, record)
82
+ }
83
+ if !slices.Equal(got, want) {
84
+ t.Fatalf("actions = %v, want %v", got, want)
85
+ }
86
+ }
87
+
88
+ func storageGraphNodes(graph cr.ResourceGraph) map[string]cr.ResourceGraphNode {
89
+ nodes := map[string]cr.ResourceGraphNode{}
90
+ for _, node := range graph.Nodes {
91
+ nodes[node.ID] = node
92
+ }
93
+ return nodes
94
+ }
95
+
96
+ func TestResourceGraphStorageCollectorRootsAndScopes(t *testing.T) {
97
+ for _, tc := range []struct {
98
+ kind, name, namespace string
99
+ calls []string
100
+ }{
101
+ {"persistentvolumeclaim", "data", "team", []string{"get persistentvolumeclaims/team/data", "get persistentvolumes//disk", "get storageclasses//fast", "list pods/team"}},
102
+ {"PersistentVolume", "disk", "", []string{"get persistentvolumes//disk", "get persistentvolumeclaims/team/data", "get storageclasses//fast", "list pods/team"}},
103
+ {"storageclass", "fast", "", []string{"get storageclasses//fast", "list persistentvolumes/", "list persistentvolumeclaims/", "list pods/"}},
104
+ } {
105
+ t.Run(tc.kind, func(t *testing.T) {
106
+ pod, claim, volume, class := storageGraphFixture()
107
+ pod.Spec.NodeName = "unrelated-node"
108
+ pod.OwnerReferences = []metav1.OwnerReference{{APIVersion: "custom.example/v1", Kind: "UnrelatedOwner", Name: "unrelated-owner", UID: "owner-uid"}}
109
+ claim.OwnerReferences = pod.OwnerReferences
110
+ foreignPod := pod.DeepCopy()
111
+ foreignPod.Name, foreignPod.Namespace, foreignPod.UID = "foreign-pod", "other", "foreign-pod-uid"
112
+ foreignClaim := claim.DeepCopy()
113
+ foreignClaim.Namespace, foreignClaim.UID, foreignClaim.Spec.StorageClassName = "other", "foreign-claim-uid", nil
114
+ client := fake.NewClientset(pod, claim, volume, class, foreignPod, foreignClaim)
115
+ graph := collectStorageGraph(t, client, tc.namespace, tc.kind, tc.name)
116
+ assertStorageGraphActions(t, client, tc.calls)
117
+ if len(graph.Nodes) != 4 || len(graph.Warnings) != 0 || graphTestHas(graph, "foreign-pod") {
118
+ t.Fatalf("unexpected graph: %+v", graph)
119
+ }
120
+ nodes := storageGraphNodes(graph)
121
+ if nodes[graph.RootID].Resource.Name != tc.name || nodes[graph.RootID].Resource.Namespace != tc.namespace {
122
+ t.Fatalf("wrong root: %+v", nodes[graph.RootID])
123
+ }
124
+ matches := map[string]int{}
125
+ for _, edge := range graph.Edges {
126
+ if edge.ResolutionState != "resolved" {
127
+ t.Fatalf("unexpected resolution: %+v", edge)
128
+ }
129
+ matches[edge.Relation+"/"+nodes[edge.SourceID].Resource.Name+"/"+nodes[edge.TargetID].Resource.Name]++
130
+ if edge.Relation == ResourceGraphRelationMounts && (edge.Note != "PVC reference declared in Pod spec" || nodes[edge.SourceID].Status != "Pending") {
131
+ t.Fatalf("spec reference became mount success: %+v", edge)
132
+ }
133
+ }
134
+ for _, expected := range []string{"mounts/app/data", "binds-to/data/disk", "uses-storage-class/disk/fast"} {
135
+ if matches[expected] != 1 {
136
+ t.Errorf("%s count = %d, want 1: %v", expected, matches[expected], matches)
137
+ }
138
+ }
139
+ if !strings.EqualFold(tc.kind, "PersistentVolume") && matches["uses-storage-class/data/fast"] != 1 {
140
+ t.Errorf("missing declared claim class: %v", matches)
141
+ }
142
+ })
143
+ }
144
+ }
145
+
146
+ func TestResourceGraphStorageClassFollowsConfirmedVolumeToClaimWithoutClass(t *testing.T) {
147
+ pod, claim, volume, class := storageGraphFixture()
148
+ claim.Spec.StorageClassName = nil
149
+ client := fake.NewClientset(pod, claim, volume, class)
150
+ graph := collectStorageGraph(t, client, "", "StorageClass", "fast")
151
+ assertStorageGraphActions(t, client, []string{"get storageclasses//fast", "list persistentvolumes/", "list persistentvolumeclaims/", "list pods/"})
152
+ if !graphTestEdge(graph, "mounts", "app", "data") || !graphTestEdge(graph, "binds-to", "data", "disk") || graphTestEdge(graph, "uses-storage-class", "data", "fast") {
153
+ t.Fatalf("class root lost the binding chain or invented a claim class: %+v", graph)
154
+ }
155
+ }
156
+
157
+ func TestResourceGraphStorageCollectorStaleAndOneSidedBindings(t *testing.T) {
158
+ for _, tc := range []struct {
159
+ name string
160
+ edit func(*corev1.PersistentVolumeClaim, *corev1.PersistentVolume)
161
+ }{
162
+ {"recreated claim", func(claim *corev1.PersistentVolumeClaim, _ *corev1.PersistentVolume) { claim.UID = "new-uid" }},
163
+ {"another namespace", func(_ *corev1.PersistentVolumeClaim, volume *corev1.PersistentVolume) {
164
+ volume.Spec.ClaimRef.Namespace = "other"
165
+ }},
166
+ {"another name", func(_ *corev1.PersistentVolumeClaim, volume *corev1.PersistentVolume) {
167
+ volume.Spec.ClaimRef.Name = "other"
168
+ }},
169
+ {"no UID", func(_ *corev1.PersistentVolumeClaim, volume *corev1.PersistentVolume) { volume.Spec.ClaimRef.UID = "" }},
170
+ {"no reciprocal volume", func(claim *corev1.PersistentVolumeClaim, _ *corev1.PersistentVolume) { claim.Spec.VolumeName = "" }},
171
+ {"another reciprocal volume", func(claim *corev1.PersistentVolumeClaim, _ *corev1.PersistentVolume) { claim.Spec.VolumeName = "other" }},
172
+ {"terminating claim", func(claim *corev1.PersistentVolumeClaim, _ *corev1.PersistentVolume) {
173
+ now := metav1.Now()
174
+ claim.DeletionTimestamp = &now
175
+ }},
176
+ } {
177
+ t.Run(tc.name, func(t *testing.T) {
178
+ pod, claim, volume, class := storageGraphFixture()
179
+ tc.edit(claim, volume)
180
+ client := fake.NewClientset(pod, claim, volume, class)
181
+ graph := collectStorageGraph(t, client, "", "PersistentVolume", "disk")
182
+ for _, action := range client.Actions() {
183
+ if action.GetResource().Resource == "pods" {
184
+ t.Fatalf("unconfirmed claim triggered a Pod read: %+v", action)
185
+ }
186
+ }
187
+ nodes := storageGraphNodes(graph)
188
+ count := 0
189
+ for _, edge := range graph.Edges {
190
+ if edge.Relation != ResourceGraphRelationBindsTo {
191
+ continue
192
+ }
193
+ count++
194
+ source := nodes[edge.SourceID]
195
+ ref := volume.Spec.ClaimRef
196
+ if edge.TargetID != graph.RootID || edge.ResolutionState != "unresolved" || source.Status != "unresolved" || source.Resource.UID != string(ref.UID) || source.Resource.Namespace != ref.Namespace || source.Resource.Name != ref.Name {
197
+ t.Fatalf("binding attached an observed claim or wrong declaration: %+v, %+v", edge, source)
198
+ }
199
+ if edge.FieldPath != "spec.claimRef" || edge.SourceRef == nil || *edge.SourceRef != nodes[graph.RootID].Resource {
200
+ t.Fatalf("reversed edge does not identify the PV declaration: %+v", edge)
201
+ }
202
+ }
203
+ if count != 1 || graphTestHas(graph, pod.Name) {
204
+ t.Fatalf("unexpected graph: %+v", graph)
205
+ }
206
+ })
207
+ }
208
+ t.Run("claim side excludes an observed mismatched volume", func(t *testing.T) {
209
+ pod, claim, volume, class := storageGraphFixture()
210
+ volume.Spec.ClaimRef.UID = "old-claim-uid"
211
+ client := fake.NewClientset(pod, claim, volume, class)
212
+ graph := collectStorageGraph(t, client, "team", "PersistentVolumeClaim", "data")
213
+ nodes := storageGraphNodes(graph)
214
+ for _, edge := range graph.Edges {
215
+ if edge.Relation != ResourceGraphRelationBindsTo {
216
+ continue
217
+ }
218
+ target := nodes[edge.TargetID]
219
+ if edge.SourceID != graph.RootID || edge.ResolutionState != "unresolved" || target.Status != "unresolved" || target.Resource.UID != "" || target.Resource.Name != volume.Name {
220
+ t.Fatalf("binding attached an unrelated observed volume: %+v, %+v", edge, target)
221
+ }
222
+ return
223
+ }
224
+ t.Fatal("PVC volume declaration missing")
225
+ })
226
+ }
227
+
228
+ func TestResourceGraphStorageBindingRequiresBothBoundPhases(t *testing.T) {
229
+ for _, tc := range []struct {
230
+ name string
231
+ claimPhase corev1.PersistentVolumeClaimPhase
232
+ volumePhase corev1.PersistentVolumePhase
233
+ }{
234
+ {"pending claim", corev1.ClaimPending, corev1.VolumeBound},
235
+ {"lost claim", corev1.ClaimLost, corev1.VolumeBound},
236
+ {"missing claim phase", "", corev1.VolumeBound},
237
+ {"released volume", corev1.ClaimBound, corev1.VolumeReleased},
238
+ {"failed volume", corev1.ClaimBound, corev1.VolumeFailed},
239
+ {"missing volume phase", corev1.ClaimBound, ""},
240
+ } {
241
+ t.Run(tc.name, func(t *testing.T) {
242
+ pod, claim, volume, class := storageGraphFixture()
243
+ claim.Status.Phase = tc.claimPhase
244
+ volume.Status.Phase = tc.volumePhase
245
+ for _, root := range []struct {
246
+ namespace, kind, name, phase string
247
+ }{
248
+ {claim.Namespace, "PersistentVolumeClaim", claim.Name, string(tc.claimPhase)},
249
+ {"", "PersistentVolume", volume.Name, string(tc.volumePhase)},
250
+ } {
251
+ graph := collectStorageGraph(t, fake.NewClientset(pod, claim, volume, class), root.namespace, root.kind, root.name)
252
+ if node := storageGraphNodes(graph)[graph.RootID]; node.Status != root.phase {
253
+ t.Errorf("%s phase = %q, want observed phase %q", root.kind, node.Status, root.phase)
254
+ }
255
+ if edges := networkEdges(graph, ResourceGraphRelationBindsTo); len(edges) != 1 || edges[0].ResolutionState != "unresolved" {
256
+ t.Errorf("%s needs one unresolved declaration, got %+v", root.kind, edges)
257
+ }
258
+ }
259
+ })
260
+ }
261
+ }
262
+
263
+ func TestResourceGraphStorageCollectorUnboundAndUnavailableSources(t *testing.T) {
264
+ t.Run("explicit empty or nil class stays absent", func(t *testing.T) {
265
+ empty := ""
266
+ for _, className := range []*string{nil, &empty} {
267
+ pod, claim, _, class := storageGraphFixture()
268
+ claim.Spec.VolumeName, claim.Spec.StorageClassName, claim.Status.Phase = "", className, corev1.ClaimPending
269
+ class.Annotations = map[string]string{"storageclass.kubernetes.io/is-default-class": "true"}
270
+ client := fake.NewClientset(pod, claim, class)
271
+ graph := collectStorageGraph(t, client, "team", "PersistentVolumeClaim", "data")
272
+ assertStorageGraphActions(t, client, []string{"get persistentvolumeclaims/team/data", "list pods/team"})
273
+ if storageGraphNodes(graph)[graph.RootID].Status != "Pending" || len(graph.Warnings) != 0 || len(graph.Edges) != 1 || graph.Edges[0].Relation != ResourceGraphRelationMounts {
274
+ t.Fatalf("unbound claim state lost: %+v", graph)
275
+ }
276
+ }
277
+ })
278
+ t.Run("missing volume and forbidden class", func(t *testing.T) {
279
+ pod, claim, _, _ := storageGraphFixture()
280
+ client := fake.NewClientset(pod, claim)
281
+ client.PrependReactor("get", "storageclasses", func(ktesting.Action) (bool, kruntime.Object, error) {
282
+ return true, nil, apierrors.NewForbidden(schema.GroupResource{Group: "storage.k8s.io", Resource: "storageclasses"}, "private-name", errors.New("private error"))
283
+ })
284
+ graph := collectStorageGraph(t, client, "team", "PersistentVolumeClaim", "data")
285
+ want := []string{"PersistentVolume reference source NotFound", "StorageClass reference source Forbidden"}
286
+ if !slices.Equal(graph.Warnings, want) {
287
+ t.Fatalf("warnings = %v, want %v", graph.Warnings, want)
288
+ }
289
+ nodes := storageGraphNodes(graph)
290
+ for _, edge := range graph.Edges {
291
+ if edge.Relation != ResourceGraphRelationMounts && (edge.ResolutionState != "unresolved" || nodes[edge.TargetID].Status != "unresolved" || nodes[edge.TargetID].Resource.UID != "") {
292
+ t.Fatalf("unavailable target resolved: %+v", edge)
293
+ }
294
+ }
295
+ })
296
+ t.Run("truncated class claims do not trigger an unrelated Pod scan", func(t *testing.T) {
297
+ pod, _, volume, class := storageGraphFixture()
298
+ client := fake.NewClientset(pod, volume, class)
299
+ client.PrependReactor("list", "persistentvolumeclaims", func(ktesting.Action) (bool, kruntime.Object, error) {
300
+ return true, &corev1.PersistentVolumeClaimList{ListMeta: metav1.ListMeta{Continue: "second-page"}}, nil
301
+ })
302
+ graph := collectStorageGraph(t, client, "", "StorageClass", "fast")
303
+ assertStorageGraphActions(t, client, []string{"get storageclasses//fast", "list persistentvolumes/", "list persistentvolumeclaims/"})
304
+ if !slices.Equal(graph.Warnings, []string{"PersistentVolumeClaim source truncated at 500 objects"}) || !graphTestEdge(graph, "binds-to", "data", "disk") {
305
+ t.Fatalf("truncated source or declared claim missing: %+v", graph)
306
+ }
307
+ for _, node := range graph.Nodes {
308
+ if node.Resource.Kind == "PersistentVolumeClaim" && (node.Resource.UID != "claim-uid" || node.Status != "unresolved") {
309
+ t.Fatalf("unobserved claim resolved: %+v", node)
310
+ }
311
+ }
312
+ })
313
+ t.Run("canceled sources fail the request", func(t *testing.T) {
314
+ _, claim, volume, class := storageGraphFixture()
315
+ client := fake.NewClientset(claim, volume, class)
316
+ client.PrependReactor("list", "pods", func(ktesting.Action) (bool, kruntime.Object, error) { return true, nil, context.DeadlineExceeded })
317
+ _, err := BuildKubernetesResourceGraph(context.Background(), client, cr.ResourceRef{ClusterID: "cluster", Namespace: "team", Kind: "PersistentVolumeClaim", Name: "data"})
318
+ if !errors.Is(err, context.DeadlineExceeded) {
319
+ t.Fatalf("cancellation did not stop graph: %v", err)
320
+ }
321
+ })
322
+ }
323
+
324
+ func TestResourceGraphStorageCollectorRejectsInvalidRoots(t *testing.T) {
325
+ for _, root := range []cr.ResourceRef{
326
+ {Kind: "PersistentVolumeClaim", Name: "data"},
327
+ {Kind: "PersistentVolumeClaim", Name: "data", Namespace: "team", ScopeMode: cr.ResourceScopeModeCluster},
328
+ {Kind: "PersistentVolume", Name: "disk", Namespace: "team"},
329
+ {Kind: "StorageClass", Name: "fast", Namespace: "team"},
330
+ {Kind: "StorageClass", Name: "fast", ScopeMode: cr.ResourceScopeModeNamespace},
331
+ {Kind: "StorageClass", Name: "fast", APIVersion: "v1"},
332
+ } {
333
+ root.ClusterID = "cluster"
334
+ client := fake.NewClientset()
335
+ _, err := BuildKubernetesResourceGraph(context.Background(), client, root)
336
+ if !errors.Is(err, ErrInvalidResourceGraphRoot) || len(client.Actions()) != 0 {
337
+ t.Errorf("invalid root %+v triggered provider actions: %v, %v", root, err, client.Actions())
338
+ }
339
+ }
340
+ _, claim, volume, class := storageGraphFixture()
341
+ for _, mutate := range []func(*corev1.PersistentVolume){
342
+ func(item *corev1.PersistentVolume) { item.Name = "different" },
343
+ func(item *corev1.PersistentVolume) { item.Namespace = "team" },
344
+ func(item *corev1.PersistentVolume) {
345
+ item.APIVersion, item.Kind = "custom.example/v1", "PersistentVolume"
346
+ },
347
+ } {
348
+ mismatched := volume.DeepCopy()
349
+ mutate(mismatched)
350
+ client := fake.NewClientset(claim, volume, class)
351
+ client.PrependReactor("get", "persistentvolumes", func(ktesting.Action) (bool, kruntime.Object, error) { return true, mismatched, nil })
352
+ _, err := BuildKubernetesResourceGraph(context.Background(), client, cr.ResourceRef{ClusterID: "cluster", Kind: "PersistentVolume", Name: "disk"})
353
+ if !errors.Is(err, ErrInvalidResourceGraphRoot) {
354
+ t.Errorf("changed named root was accepted: %v", err)
355
+ }
356
+ assertStorageGraphActions(t, client, []string{"get persistentvolumes//disk"})
357
+ }
358
+ }
359
+
360
+ func TestResourceGraphStorageProjectionAndExistingPodRoot(t *testing.T) {
361
+ pod, claim, volume, class := storageGraphFixture()
362
+ volume.Labels = map[string]string{"private": "private-label"}
363
+ volume.Spec.CSI = &corev1.CSIPersistentVolumeSource{
364
+ Driver: "private-driver", VolumeHandle: "private-handle", VolumeAttributes: map[string]string{"token": "private-token"},
365
+ NodePublishSecretRef: &corev1.SecretReference{Name: "private-secret", Namespace: "private-namespace"},
366
+ }
367
+ volume.Spec.ClaimRef.FieldPath = "private-field"
368
+ class.Provisioner = "private-provisioner"
369
+ class.Parameters = map[string]string{"key": "private-value"}
370
+ for _, tc := range []struct {
371
+ obj kruntime.Object
372
+ kind string
373
+ }{
374
+ {volume, "PersistentVolume"}, {class, "StorageClass"},
375
+ } {
376
+ projection, err := graphObjectProjection(tc.obj, graphBuiltinAPIs[tc.kind], tc.kind)
377
+ if err != nil {
378
+ t.Fatal(err)
379
+ }
380
+ content, err := json.Marshal(projection)
381
+ if err != nil || strings.Contains(string(content), "private-") {
382
+ t.Fatalf("storage projection retained provider payload: %s, %v", content, err)
383
+ }
384
+ }
385
+ client := fake.NewClientset(pod, claim, volume, class)
386
+ graph := collectStorageGraph(t, client, "team", "Pod", "app")
387
+ for _, action := range client.Actions() {
388
+ if slices.Contains([]string{"persistentvolumes", "storageclasses", "secrets"}, action.GetResource().Resource) {
389
+ t.Fatalf("existing Pod root issued new storage or Secret read: %+v", action)
390
+ }
391
+ }
392
+ if !reflect.DeepEqual(graph.Edges, []cr.ResourceGraphEdge{{
393
+ ID: "mounts:v1:Pod:team/app:pod-uid:v1:PersistentVolumeClaim:team/data:claim-uid", SourceID: graph.RootID, TargetID: "v1:PersistentVolumeClaim:team/data:claim-uid", Relation: "mounts",
394
+ }}) {
395
+ t.Fatalf("existing Pod PVC edge changed: %+v", graph.Edges)
396
+ }
397
+ }