@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,470 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+ "testing"
10
+
11
+ cr "github.com/opensoha/soha-contracts/resource"
12
+ admissionv1 "k8s.io/api/admissionregistration/v1"
13
+ appsv1 "k8s.io/api/apps/v1"
14
+ autoscalingv2 "k8s.io/api/autoscaling/v2"
15
+ coordinationv1 "k8s.io/api/coordination/v1"
16
+ corev1 "k8s.io/api/core/v1"
17
+ nodev1 "k8s.io/api/node/v1"
18
+ policyv1 "k8s.io/api/policy/v1"
19
+ schedulingv1 "k8s.io/api/scheduling/v1"
20
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
21
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
23
+ kruntime "k8s.io/apimachinery/pkg/runtime"
24
+ "k8s.io/apimachinery/pkg/runtime/schema"
25
+ "k8s.io/apimachinery/pkg/types"
26
+ "k8s.io/client-go/kubernetes/fake"
27
+ metadatafake "k8s.io/client-go/metadata/fake"
28
+ ktesting "k8s.io/client-go/testing"
29
+ )
30
+
31
+ func graphConfigurationRoot(kind, name string) cr.ResourceRef {
32
+ root := graphTestRoot(kind, name)
33
+ root.APIVersion = graphBuiltinAPIs[kind]
34
+ if graphClusterConfigurationKind(kind) {
35
+ root.Namespace = ""
36
+ root.ScopeMode = cr.ResourceScopeModeCluster
37
+ }
38
+ return root
39
+ }
40
+
41
+ func TestResourceGraphConfigurationRootScopes(t *testing.T) {
42
+ for _, kind := range []string{"ConfigMap", "Secret", "ResourceQuota", "LimitRange", "HorizontalPodAutoscaler", "PodDisruptionBudget", "PriorityClass", "RuntimeClass", "Lease", "MutatingWebhookConfiguration", "ValidatingWebhookConfiguration"} {
43
+ t.Run(kind, func(t *testing.T) {
44
+ root := graphConfigurationRoot(kind, "root")
45
+ if _, err := NormalizeResourceGraphRoot(root); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ if root.Namespace == "" {
49
+ root.Namespace = "forbidden"
50
+ } else {
51
+ root.Namespace = ""
52
+ }
53
+ if _, err := NormalizeResourceGraphRoot(root); !errors.Is(err, ErrInvalidResourceGraphRoot) {
54
+ t.Fatalf("invalid scope accepted: %v", err)
55
+ }
56
+ })
57
+ }
58
+ }
59
+
60
+ func TestResourceGraphConfigurationUsersBothDirections(t *testing.T) {
61
+ for _, kind := range []string{"ConfigMap", "Secret"} {
62
+ for _, source := range []string{"env", "envFrom", "volume", "projected", "init", "ephemeral", "csi", "imagePull"} {
63
+ if kind == "ConfigMap" && (source == "csi" || source == "imagePull") {
64
+ continue
65
+ }
66
+ t.Run(kind+"/"+source, func(t *testing.T) {
67
+ config := graphTestObject("v1", kind, "config", "config-uid")
68
+ pod := graphTestObject("v1", "Pod", "user", "pod-uid")
69
+ spec := map[string]interface{}{}
70
+ container := map[string]interface{}{}
71
+ refKind, relation := "configMap", "uses-config"
72
+ if kind == "Secret" {
73
+ refKind, relation = "secret", "uses-secret"
74
+ }
75
+ switch source {
76
+ case "env", "init", "ephemeral":
77
+ container["env"] = []interface{}{map[string]interface{}{"valueFrom": map[string]interface{}{refKind + "KeyRef": map[string]interface{}{"name": "config"}}}}
78
+ case "envFrom":
79
+ container["envFrom"] = []interface{}{map[string]interface{}{refKind + "Ref": map[string]interface{}{"name": "config"}}}
80
+ case "volume":
81
+ nameField := "name"
82
+ if kind == "Secret" {
83
+ nameField = "secretName"
84
+ }
85
+ spec["volumes"] = []interface{}{map[string]interface{}{refKind: map[string]interface{}{nameField: "config"}}}
86
+ case "projected":
87
+ spec["volumes"] = []interface{}{map[string]interface{}{"projected": map[string]interface{}{"sources": []interface{}{map[string]interface{}{refKind: map[string]interface{}{"name": "config"}}}}}}
88
+ case "csi":
89
+ spec["volumes"] = []interface{}{map[string]interface{}{"csi": map[string]interface{}{"nodePublishSecretRef": map[string]interface{}{"name": "config"}}}}
90
+ case "imagePull":
91
+ spec["imagePullSecrets"] = []interface{}{map[string]interface{}{"name": "config"}}
92
+ }
93
+ field := "containers"
94
+ switch source {
95
+ case "init":
96
+ field = "initContainers"
97
+ case "ephemeral":
98
+ field = "ephemeralContainers"
99
+ }
100
+ spec[field] = []interface{}{container}
101
+ pod.Object["spec"] = spec
102
+ otherNamespace := pod.DeepCopy()
103
+ otherNamespace.SetName("other-namespace")
104
+ otherNamespace.SetNamespace("other")
105
+ for _, root := range []cr.ResourceRef{graphConfigurationRoot(kind, "config"), graphTestRoot("Pod", "user")} {
106
+ graph, err := BuildResourceGraphFromObjects(root, []*unstructured.Unstructured{config, pod, otherNamespace})
107
+ if err != nil {
108
+ t.Fatal(err)
109
+ }
110
+ if !graphTestEdge(graph, relation, "user", "config") || graphTestHas(graph, "other-namespace") {
111
+ t.Fatalf("reference lost or namespace crossed: %+v", graph)
112
+ }
113
+ }
114
+ })
115
+ }
116
+ }
117
+ }
118
+
119
+ func TestResourceGraphConfigurationClassesAndScope(t *testing.T) {
120
+ for _, test := range []struct{ kind, field, relation string }{{"PriorityClass", "priorityClassName", ResourceGraphRelationUsesPriorityClass}, {"RuntimeClass", "runtimeClassName", ResourceGraphRelationUsesRuntimeClass}} {
121
+ t.Run(test.kind, func(t *testing.T) {
122
+ class := graphTestObject(graphBuiltinAPIs[test.kind], test.kind, "class", "class-uid")
123
+ class.SetNamespace("")
124
+ objects := []*unstructured.Unstructured{class}
125
+ for _, namespace := range []string{"ns", "other"} {
126
+ pod := graphTestObject("v1", "Pod", namespace+"-pod", namespace+"-uid")
127
+ pod.SetNamespace(namespace)
128
+ pod.Object["spec"] = map[string]interface{}{test.field: "class"}
129
+ objects = append(objects, pod)
130
+ }
131
+ custom := graphTestObject("custom.example/v1", "Pod", "custom", "custom-uid")
132
+ custom.Object["spec"] = map[string]interface{}{test.field: "class"}
133
+ objects = append(objects, custom)
134
+ graph, err := BuildResourceGraphFromObjects(graphConfigurationRoot(test.kind, "class"), objects)
135
+ if err != nil {
136
+ t.Fatal(err)
137
+ }
138
+ for _, name := range []string{"ns-pod", "other-pod"} {
139
+ if !graphTestEdge(graph, test.relation, name, "class") {
140
+ t.Fatalf("class user %s missing: %+v", name, graph)
141
+ }
142
+ }
143
+ if graphTestHas(graph, "custom") {
144
+ t.Fatal("custom Pod kind was interpreted as built-in")
145
+ }
146
+ })
147
+ }
148
+ for _, kind := range []string{"ResourceQuota", "LimitRange"} {
149
+ t.Run(kind, func(t *testing.T) {
150
+ config := graphTestObject("v1", kind, "scope", "scope-uid")
151
+ namespace := graphTestObject("v1", "Namespace", "ns", "namespace-uid")
152
+ namespace.SetNamespace("")
153
+ pod := graphTestObject("v1", "Pod", "unowned", "pod-uid")
154
+ graph, err := BuildResourceGraphFromObjects(graphConfigurationRoot(kind, "scope"), []*unstructured.Unstructured{config, namespace, pod})
155
+ if err != nil {
156
+ t.Fatal(err)
157
+ }
158
+ if len(graph.Edges) != 1 || !graphTestEdge(graph, ResourceGraphRelationAppliesIn, "scope", "ns") || graphTestHas(graph, "unowned") {
159
+ t.Fatalf("scope became ownership: %+v", graph)
160
+ }
161
+ })
162
+ }
163
+ }
164
+
165
+ func TestResourceGraphPDBSelectorSemantics(t *testing.T) {
166
+ pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
167
+ pod.SetLabels(map[string]string{"app": "a"})
168
+ for _, test := range []struct {
169
+ name string
170
+ selector interface{}
171
+ match bool
172
+ }{{"nil", nil, false}, {"empty", map[string]interface{}{}, true}, {"expression", map[string]interface{}{"matchExpressions": []interface{}{map[string]interface{}{"key": "app", "operator": "In", "values": []interface{}{"a"}}}}, true}, {"different", map[string]interface{}{"matchLabels": map[string]interface{}{"app": "b"}}, false}, {"invalid", map[string]interface{}{"matchExpressions": []interface{}{map[string]interface{}{"key": "app", "operator": "unsupported"}}}, false}} {
173
+ t.Run(test.name, func(t *testing.T) {
174
+ pdb := graphTestObject("policy/v1", "PodDisruptionBudget", "pdb", "pdb-uid")
175
+ pdb.Object["spec"] = map[string]interface{}{"selector": test.selector}
176
+ graph, err := BuildResourceGraphFromObjects(graphConfigurationRoot("PodDisruptionBudget", "pdb"), []*unstructured.Unstructured{pdb, pod})
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ if got := graphTestEdge(graph, "protects", "pdb", "pod"); got != test.match {
181
+ t.Fatalf("match=%v want %v", got, test.match)
182
+ }
183
+ })
184
+ }
185
+ service := graphTestObject("v1", "Service", "svc", "svc-uid")
186
+ service.Object["spec"] = map[string]interface{}{"selector": map[string]interface{}{}}
187
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("Service", "svc"), []*unstructured.Unstructured{service, pod})
188
+ if err != nil || len(graph.Edges) != 0 {
189
+ t.Fatalf("empty Service selector matched pods: %v %+v", err, graph)
190
+ }
191
+ }
192
+
193
+ func TestResourceGraphConfigurationLeaseAndWebhook(t *testing.T) {
194
+ lease := graphTestObject("coordination.k8s.io/v1", "Lease", "lease", "lease-uid")
195
+ lease.Object["spec"] = map[string]interface{}{"holderIdentity": "pod"}
196
+ pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
197
+ graph, err := BuildResourceGraphFromObjects(graphConfigurationRoot("Lease", "lease"), []*unstructured.Unstructured{lease, pod})
198
+ if err != nil || len(graph.Edges) != 0 || graphTestHas(graph, "pod") {
199
+ t.Fatalf("holderIdentity was guessed: %v %+v", err, graph)
200
+ }
201
+ graphTestOwner(lease, "v1", "Pod", "pod", "pod-uid", false)
202
+ graph, err = BuildResourceGraphFromObjects(graphConfigurationRoot("Lease", "lease"), []*unstructured.Unstructured{lease, pod})
203
+ if err != nil || !graphTestEdge(graph, "owns", "pod", "lease") {
204
+ t.Fatalf("Lease actual owner lost: %v %+v", err, graph)
205
+ }
206
+ for _, kind := range []string{"MutatingWebhookConfiguration", "ValidatingWebhookConfiguration"} {
207
+ t.Run(kind, func(t *testing.T) {
208
+ webhook := graphTestObject(graphBuiltinAPIs[kind], kind, "webhook", "webhook-uid")
209
+ webhook.SetNamespace("")
210
+ webhook.Object["webhooks"] = []interface{}{
211
+ map[string]interface{}{"clientConfig": map[string]interface{}{"service": map[string]interface{}{"namespace": "other", "name": "admission"}}, "objectSelector": map[string]interface{}{}},
212
+ map[string]interface{}{"clientConfig": map[string]interface{}{"url": "https://admission.other.svc/"}},
213
+ map[string]interface{}{"clientConfig": map[string]interface{}{"service": map[string]interface{}{"name": "no-namespace"}}},
214
+ map[string]interface{}{"clientConfig": map[string]interface{}{"url": "https://example.test", "service": map[string]interface{}{"namespace": "other", "name": "ambiguous"}}},
215
+ }
216
+ service := graphTestObject("v1", "Service", "admission", "service-uid")
217
+ service.SetNamespace("other")
218
+ graph, err := BuildResourceGraphFromObjects(graphConfigurationRoot(kind, "webhook"), []*unstructured.Unstructured{webhook, service, pod})
219
+ if err != nil || len(graph.Edges) != 1 || !graphTestEdge(graph, ResourceGraphRelationWebhookService, "webhook", "admission") || graphTestHas(graph, "pod") {
220
+ t.Fatalf("webhook relation guessed or absent: %v %+v", err, graph)
221
+ }
222
+ })
223
+ }
224
+ }
225
+
226
+ func TestResourceGraphConfigurationCollectorAllRoots(t *testing.T) {
227
+ nsmeta := metav1.ObjectMeta{Name: "root", Namespace: "ns", UID: "root-uid"}
228
+ clmeta := nsmeta
229
+ clmeta.Namespace = ""
230
+ objects := []kruntime.Object{
231
+ &corev1.ConfigMap{ObjectMeta: nsmeta}, &corev1.ResourceQuota{ObjectMeta: nsmeta}, &corev1.LimitRange{ObjectMeta: nsmeta},
232
+ &autoscalingv2.HorizontalPodAutoscaler{ObjectMeta: nsmeta}, &policyv1.PodDisruptionBudget{ObjectMeta: nsmeta},
233
+ &schedulingv1.PriorityClass{ObjectMeta: clmeta}, &nodev1.RuntimeClass{ObjectMeta: clmeta}, &coordinationv1.Lease{ObjectMeta: nsmeta},
234
+ &admissionv1.MutatingWebhookConfiguration{ObjectMeta: clmeta}, &admissionv1.ValidatingWebhookConfiguration{ObjectMeta: clmeta},
235
+ &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns", UID: "namespace-uid"}},
236
+ }
237
+ for _, kind := range []string{"ConfigMap", "ResourceQuota", "LimitRange", "HorizontalPodAutoscaler", "PodDisruptionBudget", "PriorityClass", "RuntimeClass", "Lease", "MutatingWebhookConfiguration", "ValidatingWebhookConfiguration"} {
238
+ t.Run(kind, func(t *testing.T) {
239
+ client := fake.NewSimpleClientset(objects...)
240
+ graph, err := BuildKubernetesResourceGraph(context.Background(), client, graphConfigurationRoot(kind, "root"))
241
+ if err != nil || !graphTestHas(graph, "root") {
242
+ t.Fatalf("root unsupported: %v %+v", err, graph)
243
+ }
244
+ for _, action := range client.Actions() {
245
+ if action.GetResource().Resource == "secrets" {
246
+ t.Fatal("typed Secret API was read")
247
+ }
248
+ if action.GetVerb() == "list" {
249
+ list, ok := action.(interface{ GetListOptions() metav1.ListOptions })
250
+ if !ok {
251
+ t.Fatalf("list action has no list options: %+v", action)
252
+ }
253
+ if opts := list.GetListOptions(); opts.Limit != 500 || opts.Continue != "" {
254
+ t.Fatalf("unbounded list: %+v", opts)
255
+ }
256
+ if graphClusterConfigurationKind(kind) && action.GetNamespace() != "" {
257
+ t.Fatal("cluster root fanned out over namespaces")
258
+ }
259
+ }
260
+ }
261
+ if (kind == "ResourceQuota" || kind == "LimitRange") && !graphTestEdge(graph, ResourceGraphRelationAppliesIn, "root", "ns") {
262
+ t.Fatal("namespace scope missing")
263
+ }
264
+ })
265
+ }
266
+ }
267
+
268
+ func graphSecretMetadataClient() *metadatafake.FakeMetadataClient {
269
+ client := metadatafake.NewSimpleMetadataClient(kruntime.NewScheme())
270
+ client.PrependReactor("*", "*", func(action ktesting.Action) (bool, kruntime.Object, error) {
271
+ get, ok := action.(ktesting.GetAction)
272
+ if !ok || action.GetVerb() != "get" || action.GetResource() != graphSecretResource {
273
+ return true, nil, errors.New("metadata source only permits named Secret Get")
274
+ }
275
+ return true, &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{
276
+ Name: get.GetName(), Namespace: action.GetNamespace(), UID: "secret-uid",
277
+ Annotations: map[string]string{"sensitive": "must-not-retain"}, ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "must-not-retain"}},
278
+ }}, nil
279
+ })
280
+ return client
281
+ }
282
+
283
+ func TestResourceGraphSecretMetadataRootAndReferences(t *testing.T) {
284
+ pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "user", Namespace: "ns", UID: "pod-uid"}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "main", EnvFrom: []corev1.EnvFromSource{{SecretRef: &corev1.SecretEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: "config"}}}}}}}}
285
+ for _, root := range []cr.ResourceRef{graphConfigurationRoot("Secret", "config"), graphTestRoot("Pod", "user")} {
286
+ client := fake.NewSimpleClientset(pod)
287
+ client.PrependReactor("*", "secrets", func(ktesting.Action) (bool, kruntime.Object, error) {
288
+ t.Error("typed Secret Get/List is prohibited")
289
+ return true, nil, errors.New("prohibited")
290
+ })
291
+ metadataClient := graphSecretMetadataClient()
292
+ graph, err := BuildKubernetesResourceGraphWithMetadata(context.Background(), client, metadataClient, root)
293
+ if err != nil || !graphTestEdge(graph, "uses-secret", "user", "config") {
294
+ t.Fatalf("metadata graph failed: %v %+v", err, graph)
295
+ }
296
+ for _, node := range graph.Nodes {
297
+ if node.Resource.Kind == "Secret" && (node.Status == "unresolved" || node.Resource.UID != "secret-uid") {
298
+ t.Fatalf("metadata not confirmed: %+v", node)
299
+ }
300
+ }
301
+ if len(metadataClient.Actions()) != 1 {
302
+ t.Fatalf("Secret was not read once by exact identity: %+v", metadataClient.Actions())
303
+ }
304
+ encoded, _ := json.Marshal(graph)
305
+ if strings.Contains(string(encoded), "must-not-retain") {
306
+ t.Fatal("sensitive metadata leaked into graph")
307
+ }
308
+ }
309
+ projection, err := graphObjectProjection(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "ns", Annotations: map[string]string{"sensitive": "x"}, ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "x"}}}}, "v1", "Secret")
310
+ if err != nil || len(projection.Object) != 3 || projection.GetAnnotations() != nil || projection.GetManagedFields() != nil {
311
+ t.Fatalf("metadata projection retained payload: %v %+v", err, projection)
312
+ }
313
+ }
314
+
315
+ func TestResourceGraphSecretMetadataErrorsAndBudget(t *testing.T) {
316
+ for _, sourceError := range []error{apierrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, "config", errors.New("denied")), apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, "config"), context.Canceled, context.DeadlineExceeded} {
317
+ metadataClient := graphSecretMetadataClient()
318
+ metadataClient.PrependReactor("get", "secrets", func(ktesting.Action) (bool, kruntime.Object, error) { return true, nil, sourceError })
319
+ _, err := BuildKubernetesResourceGraphWithMetadata(context.Background(), fake.NewSimpleClientset(), metadataClient, graphConfigurationRoot("Secret", "config"))
320
+ if !errors.Is(err, sourceError) {
321
+ t.Fatalf("root error swallowed: %v", err)
322
+ }
323
+ }
324
+ root := graphConfigurationRoot("Secret", "config")
325
+ root.UID = "old-uid"
326
+ _, err := BuildKubernetesResourceGraphWithMetadata(context.Background(), fake.NewSimpleClientset(), graphSecretMetadataClient(), root)
327
+ if !errors.Is(err, ErrInvalidResourceGraphRoot) {
328
+ t.Fatalf("stale root UID accepted: %v", err)
329
+ }
330
+ pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "user", Namespace: "ns", UID: "pod-uid"}}
331
+ for i := 0; i < 20; i++ {
332
+ pod.Spec.ImagePullSecrets = append(pod.Spec.ImagePullSecrets, corev1.LocalObjectReference{Name: fmt.Sprintf("secret-%02d", i)})
333
+ }
334
+ metadataClient := graphSecretMetadataClient()
335
+ graph, err := BuildKubernetesResourceGraphWithMetadata(context.Background(), fake.NewSimpleClientset(pod), metadataClient, graphTestRoot("Pod", "user"))
336
+ if err != nil || len(metadataClient.Actions()) != ResourceGraphMetadataTargetLimit || !strings.Contains(strings.Join(graph.Warnings, " "), "Secret reference metadata truncated at 16") {
337
+ t.Fatalf("metadata budget missing: %v %d %+v", err, len(metadataClient.Actions()), graph.Warnings)
338
+ }
339
+ metadataClient = graphSecretMetadataClient()
340
+ metadataClient.PrependReactor("get", "secrets", func(ktesting.Action) (bool, kruntime.Object, error) {
341
+ return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "secrets"}, "", errors.New("sensitive=must-not-leak"))
342
+ })
343
+ graph, err = BuildKubernetesResourceGraphWithMetadata(context.Background(), fake.NewSimpleClientset(pod), metadataClient, graphTestRoot("Pod", "user"))
344
+ warnings := strings.Join(graph.Warnings, " ")
345
+ if err != nil || !strings.Contains(warnings, "Secret reference metadata Forbidden") || strings.Contains(warnings, "sensitive=") {
346
+ t.Fatalf("partial failure hidden or unsafe: %v %s", err, warnings)
347
+ }
348
+ }
349
+
350
+ func TestResourceGraphConfigurationCollectorBoundedTargets(t *testing.T) {
351
+ webhook := &admissionv1.MutatingWebhookConfiguration{ObjectMeta: metav1.ObjectMeta{Name: "root", UID: "root-uid"}}
352
+ for i := 0; i < 20; i++ {
353
+ webhook.Webhooks = append(webhook.Webhooks, admissionv1.MutatingWebhook{ClientConfig: admissionv1.WebhookClientConfig{Service: &admissionv1.ServiceReference{Namespace: fmt.Sprintf("namespace-%02d", i), Name: "admission"}, CABundle: []byte("must-not-retain")}})
354
+ }
355
+ client := fake.NewSimpleClientset(webhook)
356
+ metadataClient := metadatafake.NewSimpleMetadataClient(kruntime.NewScheme())
357
+ metadataClient.PrependReactor("get", "services", func(action ktesting.Action) (bool, kruntime.Object, error) {
358
+ return true, &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "admission", Namespace: action.GetNamespace(), UID: types.UID(action.GetNamespace())}}, nil
359
+ })
360
+ graph, err := BuildKubernetesResourceGraphWithMetadata(context.Background(), client, metadataClient, graphConfigurationRoot("MutatingWebhookConfiguration", "root"))
361
+ if err != nil || !strings.Contains(strings.Join(graph.Warnings, " "), "configuration reference metadata truncated at 16") {
362
+ t.Fatalf("bounded targets failed: %v %+v", err, graph.Warnings)
363
+ }
364
+ gets := 0
365
+ for _, action := range client.Actions() {
366
+ if action.GetVerb() == "list" {
367
+ t.Fatal("webhook root performed namespace/source scans")
368
+ }
369
+ }
370
+ gets = len(metadataClient.Actions())
371
+ if gets != ResourceGraphMetadataTargetLimit || len(graph.Edges) != 20 {
372
+ t.Fatalf("target budget or unresolved declarations lost: gets=%d edges=%d", gets, len(graph.Edges))
373
+ }
374
+ projected, err := graphObjectProjection(webhook, "admissionregistration.k8s.io/v1", "MutatingWebhookConfiguration")
375
+ encoded, _ := json.Marshal(projected)
376
+ if err != nil || strings.Contains(string(encoded), "must-not-retain") {
377
+ t.Fatalf("CA bundle retained: %v", err)
378
+ }
379
+ deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "ns", UID: "deployment-uid"}}
380
+ deployment.Spec.Template.Spec.PriorityClassName = "class"
381
+ class := &schedulingv1.PriorityClass{ObjectMeta: metav1.ObjectMeta{Name: "class", UID: "class-uid"}}
382
+ client = fake.NewSimpleClientset(class, deployment)
383
+ client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, kruntime.Object, error) {
384
+ return true, &corev1.PodList{ListMeta: metav1.ListMeta{Continue: "another-page"}}, nil
385
+ })
386
+ graph, err = BuildKubernetesResourceGraph(context.Background(), client, graphConfigurationRoot("PriorityClass", "class"))
387
+ if err != nil || !graphTestEdge(graph, ResourceGraphRelationUsesPriorityClass, "app", "class") || !strings.Contains(strings.Join(graph.Warnings, " "), "Pod source truncated") {
388
+ t.Fatalf("class collection relation or partial lost: %v %+v", err, graph)
389
+ }
390
+ }
391
+
392
+ func TestResourceGraphConfigurationNamespaceMetadata(t *testing.T) {
393
+ quota := &corev1.ResourceQuota{ObjectMeta: metav1.ObjectMeta{Name: "quota", Namespace: "ns", UID: "quota-uid"}}
394
+ for _, state := range []string{"available", "missing-client", "forbidden", "wrong-scope"} {
395
+ t.Run(state, func(t *testing.T) {
396
+ metadataClient := metadatafake.NewSimpleMetadataClient(kruntime.NewScheme())
397
+ metadataClient.PrependReactor("get", "namespaces", func(action ktesting.Action) (bool, kruntime.Object, error) {
398
+ get, ok := action.(ktesting.GetAction)
399
+ if !ok || action.GetNamespace() != "" || get.GetName() != "ns" {
400
+ t.Fatalf("namespace metadata identity was not exact: %+v", action)
401
+ }
402
+ if state == "forbidden" {
403
+ return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "namespaces"}, "ns", errors.New("denied"))
404
+ }
405
+ namespace := ""
406
+ if state == "wrong-scope" {
407
+ namespace = "invalid"
408
+ }
409
+ return true, &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "ns", Namespace: namespace, UID: "namespace-uid"}}, nil
410
+ })
411
+ client := fake.NewSimpleClientset(quota)
412
+ var graph cr.ResourceGraph
413
+ var err error
414
+ if state == "missing-client" {
415
+ graph, err = BuildKubernetesResourceGraph(context.Background(), client, graphConfigurationRoot("ResourceQuota", "quota"))
416
+ } else {
417
+ graph, err = BuildKubernetesResourceGraphWithMetadata(context.Background(), client, metadataClient, graphConfigurationRoot("ResourceQuota", "quota"))
418
+ }
419
+ if err != nil || !graphTestEdge(graph, ResourceGraphRelationAppliesIn, "quota", "ns") {
420
+ t.Fatalf("scope declaration lost: %v %+v", err, graph)
421
+ }
422
+ for _, node := range graph.Nodes {
423
+ if node.Resource.Kind != "Namespace" {
424
+ continue
425
+ }
426
+ if node.Resource.APIVersion != "v1" || node.Resource.Namespace != "" || node.Resource.ScopeMode != cr.ResourceScopeModeCluster || (node.Status != "unresolved") != (state == "available") {
427
+ t.Fatalf("namespace identity or observation was falsified: %+v", node)
428
+ }
429
+ }
430
+ if state != "available" && len(graph.Warnings) == 0 {
431
+ t.Fatal("missing metadata was silently treated as no relation")
432
+ }
433
+ for _, action := range client.Actions() {
434
+ if action.GetResource().Resource != "resourcequotas" || action.GetVerb() != "get" {
435
+ t.Fatalf("scope root read unrelated/full targets: %+v", action)
436
+ }
437
+ }
438
+ })
439
+ }
440
+ }
441
+
442
+ func TestResourceGraphConfigurationHPAExactTarget(t *testing.T) {
443
+ for _, api := range []string{"apps/v1", "custom.example/v1", ""} {
444
+ t.Run(api, func(t *testing.T) {
445
+ hpa := graphTestObject("autoscaling/v2", "HorizontalPodAutoscaler", "hpa", "hpa-uid")
446
+ hpa.Object["spec"] = map[string]interface{}{"scaleTargetRef": map[string]interface{}{"apiVersion": api, "kind": "Deployment", "name": "app"}}
447
+ deployment := graphTestObject("apps/v1", "Deployment", "app", "app-uid")
448
+ deployment.SetLabels(map[string]string{"same": "label"})
449
+ hpa.SetLabels(deployment.GetLabels())
450
+ graph, err := BuildResourceGraphFromObjects(graphConfigurationRoot("HorizontalPodAutoscaler", "hpa"), []*unstructured.Unstructured{hpa, deployment})
451
+ if err != nil {
452
+ t.Fatal(err)
453
+ }
454
+ if api == "" {
455
+ if len(graph.Edges) != 0 {
456
+ t.Fatal("invalid target guessed from kind/labels")
457
+ }
458
+ return
459
+ }
460
+ if !graphTestEdge(graph, "scales", "hpa", "app") {
461
+ t.Fatal("declared scale target missing")
462
+ }
463
+ for _, node := range graph.Nodes {
464
+ if node.Resource.Name == "app" && (node.Resource.APIVersion != api || (node.Status != "unresolved") != (api == "apps/v1")) {
465
+ t.Fatalf("wrong API-group target confirmed: %+v", node)
466
+ }
467
+ }
468
+ })
469
+ }
470
+ }
@@ -0,0 +1,159 @@
1
+ package runtime
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+
11
+ cr "github.com/opensoha/soha-contracts/resource"
12
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
13
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14
+ "k8s.io/apimachinery/pkg/runtime/schema"
15
+ "k8s.io/apimachinery/pkg/util/validation"
16
+ "k8s.io/client-go/metadata"
17
+ "k8s.io/client-go/rest"
18
+ )
19
+
20
+ // ReadCustomResourceGraphObject performs one metadata-only GET on an authorized GVR.
21
+ func ReadCustomResourceGraphObject(ctx context.Context, config *rest.Config, request cr.CustomResourceGraphObjectRequest) (json.RawMessage, error) {
22
+ definition, namespace, name := request.Definition, request.Namespace, request.Name
23
+ if !validCustomGraphPageScope(definition, namespace) || definition.Namespaced != (namespace != "") || len(validation.IsDNS1123Subdomain(name)) != 0 {
24
+ return nil, apierrors.NewBadRequest("invalid named custom relationship source")
25
+ }
26
+ client, err := NewResourceGraphMetadataClient(config)
27
+ if err != nil {
28
+ return nil, err
29
+ }
30
+ ctx, cancel := context.WithTimeout(ctx, ResourceGraphTimeout)
31
+ defer cancel()
32
+ item, err := customGraphMetadataResource(client, definition, namespace).Get(ctx, name, metav1.GetOptions{})
33
+ if err != nil {
34
+ return nil, err
35
+ }
36
+ if item.Name != name || item.Namespace != namespace || item.UID == "" {
37
+ return nil, fmt.Errorf("named custom graph metadata has invalid identity")
38
+ }
39
+ projected, err := projectCustomGraphMetadata(definition, *item)
40
+ if err != nil {
41
+ return nil, err
42
+ }
43
+ if len(projected) > ResourceGraphMetadataJSONLimit {
44
+ return nil, fmt.Errorf("named custom graph metadata exceeds its size limit")
45
+ }
46
+ return projected, ctx.Err()
47
+ }
48
+
49
+ // ReadCustomResourceGraphPage observes a single metadata page for an authorized
50
+ // GVR. It never follows continuation tokens or negotiates full objects.
51
+ func ReadCustomResourceGraphPage(ctx context.Context, config *rest.Config, definition cr.CRDResourceDefinition, namespace string) (cr.CustomResourceGraphPage, error) {
52
+ page := cr.CustomResourceGraphPage{Items: []json.RawMessage{}}
53
+ if !validCustomGraphPageScope(definition, namespace) {
54
+ return page, apierrors.NewBadRequest("invalid custom relationship source")
55
+ }
56
+ if config == nil {
57
+ return page, fmt.Errorf("custom graph metadata REST configuration unavailable")
58
+ }
59
+ strict := rest.CopyConfig(config)
60
+ strict.Wrap(func(base http.RoundTripper) http.RoundTripper {
61
+ return customGraphPageTransport{RoundTripper: base}
62
+ })
63
+ client, err := metadata.NewForConfig(strict)
64
+ if err != nil {
65
+ return page, err
66
+ }
67
+ ctx, cancel := context.WithTimeout(ctx, ResourceGraphTimeout)
68
+ defer cancel()
69
+ limit := int64(ResourceGraphMetadataEnrichmentLimit)
70
+ list, err := customGraphMetadataResource(client, definition, namespace).List(ctx, metav1.ListOptions{Limit: limit})
71
+ if err != nil {
72
+ return page, err
73
+ }
74
+ page.Truncated = list.Continue != "" || len(list.Items) > int(limit) || (list.RemainingItemCount != nil && *list.RemainingItemCount > 0)
75
+ for _, item := range list.Items[:min(len(list.Items), int(limit))] {
76
+ if item.UID == "" || item.Name == "" || definition.Namespaced != (item.Namespace != "") || (namespace != "" && item.Namespace != namespace) {
77
+ return cr.CustomResourceGraphPage{}, fmt.Errorf("custom graph metadata has invalid identity")
78
+ }
79
+ projected, err := projectCustomGraphMetadata(definition, item)
80
+ if err != nil {
81
+ return cr.CustomResourceGraphPage{}, err
82
+ }
83
+ page.Items = append(page.Items, projected)
84
+ }
85
+ return page, ctx.Err()
86
+ }
87
+
88
+ func customGraphMetadataResource(client metadata.Interface, definition cr.CRDResourceDefinition, namespace string) metadata.ResourceInterface {
89
+ resource := client.Resource(schema.GroupVersionResource{Group: definition.Group, Version: definition.Version, Resource: definition.Resource})
90
+ if namespace != "" {
91
+ return resource.Namespace(namespace)
92
+ }
93
+ return resource
94
+ }
95
+
96
+ func projectCustomGraphMetadata(definition cr.CRDResourceDefinition, item metav1.PartialObjectMetadata) ([]byte, error) {
97
+ return json.Marshal(struct {
98
+ APIVersion string `json:"apiVersion"`
99
+ Kind string `json:"kind"`
100
+ Metadata metav1.ObjectMeta `json:"metadata"`
101
+ }{
102
+ APIVersion: definition.Group + "/" + definition.Version, Kind: definition.Kind,
103
+ Metadata: metav1.ObjectMeta{Name: item.Name, Namespace: item.Namespace, UID: item.UID,
104
+ ResourceVersion: item.ResourceVersion, OwnerReferences: item.OwnerReferences},
105
+ })
106
+ }
107
+
108
+ func validCustomGraphPageScope(definition cr.CRDResourceDefinition, namespace string) bool {
109
+ return definition.Kind != "" && definition.Group != "" && definition.CRDName == definition.Resource+"."+definition.Group &&
110
+ len(validation.IsDNS1123Subdomain(definition.Group)) == 0 &&
111
+ len(validation.IsDNS1035Label(definition.Version)) == 0 &&
112
+ len(validation.IsDNS1035Label(definition.Resource)) == 0 &&
113
+ (namespace == "" || definition.Namespaced && len(validation.IsDNS1123Label(namespace)) == 0)
114
+ }
115
+
116
+ type customGraphPageTransport struct{ http.RoundTripper }
117
+
118
+ func (t customGraphPageTransport) RoundTrip(request *http.Request) (*http.Response, error) {
119
+ request = request.Clone(request.Context())
120
+ request.Header.Set("Accept", "application/json;as=PartialObjectMetadataList;g=meta.k8s.io;v=v1")
121
+ response, err := t.RoundTripper.RoundTrip(request)
122
+ if err != nil {
123
+ return response, err
124
+ }
125
+ body := response.Body
126
+ defer func() { _ = body.Close() }()
127
+ if response.StatusCode >= 300 && response.StatusCode < 400 {
128
+ return nil, fmt.Errorf("custom graph metadata endpoint redirected")
129
+ }
130
+ raw, err := io.ReadAll(io.LimitReader(body, resourceGraphMetadataResponseLimit+1))
131
+ if err != nil {
132
+ return nil, err
133
+ }
134
+ if len(raw) > resourceGraphMetadataResponseLimit ||
135
+ (response.StatusCode >= 200 && response.StatusCode < 300 && !validCustomGraphPageEnvelope(raw)) {
136
+ return nil, fmt.Errorf("invalid or oversized custom graph metadata page")
137
+ }
138
+ response.Body = io.NopCloser(bytes.NewReader(raw))
139
+ response.ContentLength = int64(len(raw))
140
+ return response, nil
141
+ }
142
+
143
+ func validCustomGraphPageEnvelope(raw []byte) bool {
144
+ var list metav1.PartialObjectMetadataList
145
+ decoder := json.NewDecoder(bytes.NewReader(raw))
146
+ decoder.DisallowUnknownFields()
147
+ var extra any
148
+ if decoder.Decode(&list) != nil || decoder.Decode(&extra) != io.EOF ||
149
+ list.APIVersion != "meta.k8s.io/v1" || list.Kind != "PartialObjectMetadataList" || list.Items == nil {
150
+ return false
151
+ }
152
+ for _, item := range list.Items {
153
+ if (item.Kind != "" && item.Kind != "PartialObjectMetadata") ||
154
+ (item.APIVersion != "" && item.APIVersion != "meta.k8s.io/v1") {
155
+ return false
156
+ }
157
+ }
158
+ return true
159
+ }