@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,627 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+
11
+ cr "github.com/opensoha/soha-contracts/resource"
12
+ appsv1 "k8s.io/api/apps/v1"
13
+ corev1 "k8s.io/api/core/v1"
14
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
15
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
17
+ kruntime "k8s.io/apimachinery/pkg/runtime"
18
+ "k8s.io/apimachinery/pkg/runtime/schema"
19
+ "k8s.io/apimachinery/pkg/types"
20
+ "k8s.io/client-go/kubernetes/fake"
21
+ ktesting "k8s.io/client-go/testing"
22
+ )
23
+
24
+ func graphTestObject(api, kind, name, uid string) *unstructured.Unstructured {
25
+ o := &unstructured.Unstructured{Object: map[string]interface{}{"apiVersion": api, "kind": kind, "metadata": map[string]interface{}{"name": name, "namespace": "ns", "uid": uid}}}
26
+ return o
27
+ }
28
+ func graphTestRoot(kind, name string) cr.ResourceRef {
29
+ return cr.ResourceRef{ClusterID: "cluster-a", Kind: kind, Name: name, Namespace: "ns", ScopeMode: cr.ResourceScopeModeNamespace}
30
+ }
31
+ func graphTestEdge(graph cr.ResourceGraph, relation, source, target string) bool {
32
+ nodes := map[string]string{}
33
+ for _, node := range graph.Nodes {
34
+ nodes[node.ID] = node.Resource.Name
35
+ }
36
+ for _, edge := range graph.Edges {
37
+ if edge.Relation == relation && nodes[edge.SourceID] == source && nodes[edge.TargetID] == target {
38
+ return true
39
+ }
40
+ }
41
+ return false
42
+ }
43
+ func graphTestHas(graph cr.ResourceGraph, name string) bool {
44
+ for _, node := range graph.Nodes {
45
+ if node.Resource.Name == name {
46
+ return true
47
+ }
48
+ }
49
+ return false
50
+ }
51
+ func graphTestOwner(o *unstructured.Unstructured, api, kind, name, uid string, controller bool) {
52
+ o.SetOwnerReferences([]metav1.OwnerReference{{APIVersion: api, Kind: kind, Name: name, UID: types.UID(uid), Controller: &controller}})
53
+ }
54
+
55
+ func TestResourceGraphOwnerIdentityAndUnknownMetadata(t *testing.T) {
56
+ for _, controller := range []bool{false, true} {
57
+ t.Run(fmt.Sprint(controller), func(t *testing.T) {
58
+ owner := graphTestObject("custom.example/v1", "CustomOwner", "owner", "owner-new")
59
+ pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
60
+ graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-old", controller)
61
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{owner, pod})
62
+ if err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ relation := "owns"
66
+ if controller {
67
+ relation = "controls"
68
+ }
69
+ if !graphTestEdge(graph, relation, "owner", "pod") {
70
+ t.Fatalf("missing owner edge: %+v", graph)
71
+ }
72
+ for _, node := range graph.Nodes {
73
+ if node.Resource.Name == "owner" && (node.Status != "unresolved" || node.Resource.UID != "owner-old") {
74
+ t.Fatalf("stale owner incorrectly resolved: %+v", node)
75
+ }
76
+ }
77
+ graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-new", controller)
78
+ graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{owner, pod})
79
+ if err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ for _, node := range graph.Nodes {
83
+ if node.Resource.Name == "owner" && node.Status == "unresolved" {
84
+ t.Fatal("matching owner not resolved")
85
+ }
86
+ }
87
+ })
88
+ }
89
+ }
90
+ func TestResourceGraphEightWorkloadsAndPodSources(t *testing.T) {
91
+ for _, kind := range []string{"Deployment", "Pod", "StatefulSet", "DaemonSet", "Job", "CronJob", "ReplicaSet", "ReplicationController"} {
92
+ t.Run(kind, func(t *testing.T) {
93
+ api := "apps/v1"
94
+ if kind == "Pod" || kind == "ReplicationController" {
95
+ api = "v1"
96
+ }
97
+ if kind == "Job" || kind == "CronJob" {
98
+ api = "batch/v1"
99
+ }
100
+ o := graphTestObject(api, kind, "root", "root-uid")
101
+ spec := map[string]interface{}{
102
+ "serviceAccountName": "account", "nodeName": "node",
103
+ "imagePullSecrets": []interface{}{map[string]interface{}{"name": "pull"}},
104
+ "containers": []interface{}{map[string]interface{}{"name": "main", "env": []interface{}{map[string]interface{}{"valueFrom": map[string]interface{}{"configMapKeyRef": map[string]interface{}{"name": "config"}}}}}},
105
+ "initContainers": []interface{}{map[string]interface{}{"name": "init", "envFrom": []interface{}{map[string]interface{}{"secretRef": map[string]interface{}{"name": "init-secret"}}}}},
106
+ "volumes": []interface{}{map[string]interface{}{"persistentVolumeClaim": map[string]interface{}{"claimName": "claim"}}, map[string]interface{}{"projected": map[string]interface{}{"sources": []interface{}{map[string]interface{}{"secret": map[string]interface{}{"name": "projected"}}, map[string]interface{}{"configMap": map[string]interface{}{"name": "projected-config"}}}}}},
107
+ }
108
+ path := []string{"spec", "template", "spec"}
109
+ if kind == "Pod" {
110
+ path = []string{"spec"}
111
+ }
112
+ if kind == "CronJob" {
113
+ path = []string{"spec", "jobTemplate", "spec", "template", "spec"}
114
+ }
115
+ if err := unstructured.SetNestedMap(o.Object, spec, path...); err != nil {
116
+ t.Fatal(err)
117
+ }
118
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot(kind, "root"), []*unstructured.Unstructured{o})
119
+ if err != nil {
120
+ t.Fatal(err)
121
+ }
122
+ for _, name := range []string{"config", "init-secret", "claim", "account", "pull", "node", "projected", "projected-config"} {
123
+ if !graphTestHas(graph, name) {
124
+ t.Errorf("missing %s", name)
125
+ }
126
+ }
127
+ if !graphTestEdge(graph, "mounts", "root", "claim") {
128
+ t.Fatal("PVC relation token changed")
129
+ }
130
+ })
131
+ }
132
+ }
133
+ func TestResourceGraphDirectionalBoundsAndSelectors(t *testing.T) {
134
+ root := graphTestObject("apps/v1", "Deployment", "root", "root-uid")
135
+ replica := graphTestObject("apps/v1", "ReplicaSet", "rs", "rs-uid")
136
+ graphTestOwner(replica, "apps/v1", "Deployment", "root", "root-uid", true)
137
+ pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
138
+ graphTestOwner(pod, "apps/v1", "ReplicaSet", "rs", "rs-uid", true)
139
+ pod.SetLabels(map[string]string{"app": "a"})
140
+ other := graphTestObject("v1", "Pod", "unrelated", "other-uid")
141
+ other.SetLabels(map[string]string{"app": "a"})
142
+ spec := map[string]interface{}{"volumes": []interface{}{map[string]interface{}{"configMap": map[string]interface{}{"name": "shared"}}}}
143
+ _ = unstructured.SetNestedMap(pod.Object, spec, "spec")
144
+ _ = unstructured.SetNestedMap(other.Object, spec, "spec")
145
+ service := graphTestObject("v1", "Service", "svc", "svc-uid")
146
+ _ = unstructured.SetNestedStringMap(service.Object, map[string]string{"app": "a"}, "spec", "selector")
147
+ empty := graphTestObject("v1", "Service", "empty", "empty-uid")
148
+ _ = unstructured.SetNestedMap(empty.Object, map[string]interface{}{}, "spec", "selector")
149
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("Deployment", "root"), []*unstructured.Unstructured{root, replica, pod, other, service, empty})
150
+ if err != nil {
151
+ t.Fatal(err)
152
+ }
153
+ if graphTestHas(graph, "unrelated") || graphTestHas(graph, "empty") {
154
+ t.Fatal("graph leaked through shared config or selector")
155
+ }
156
+ if !graphTestHas(graph, "shared") || !graphTestHas(graph, "svc") {
157
+ t.Fatalf("missing third-level relationships: %+v", graph)
158
+ }
159
+ // Starting at Pod may ascend owners, but must not turn around into siblings.
160
+ sibling := graphTestObject("v1", "Pod", "sibling", "sibling-uid")
161
+ graphTestOwner(sibling, "apps/v1", "ReplicaSet", "rs", "rs-uid", true)
162
+ graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{root, replica, pod, sibling})
163
+ if err != nil {
164
+ t.Fatal(err)
165
+ }
166
+ if graphTestHas(graph, "sibling") {
167
+ t.Fatal("ancestor traversal reversed into sibling")
168
+ }
169
+ }
170
+ func TestResourceGraphLimitsRootIdentityAndProjection(t *testing.T) {
171
+ root := graphTestObject("v1", "ConfigMap", "root", "root-uid")
172
+ objects := []*unstructured.Unstructured{root}
173
+ for i := 0; i < 600; i++ {
174
+ child := graphTestObject("v1", "Pod", fmt.Sprintf("p-%03d", i), fmt.Sprintf("uid-%d", i))
175
+ graphTestOwner(child, "v1", "ConfigMap", "root", "root-uid", true)
176
+ objects = append(objects, child)
177
+ }
178
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("ConfigMap", "root"), objects)
179
+ if err != nil {
180
+ t.Fatal(err)
181
+ }
182
+ if len(graph.Nodes) != 500 || len(graph.Edges) > 1000 || len(graph.Warnings) == 0 {
183
+ t.Fatalf("limits not applied: %d/%d %+v", len(graph.Nodes), len(graph.Edges), graph.Warnings)
184
+ }
185
+ ref := graphTestRoot("ConfigMap", "root")
186
+ ref.UID = "replaced"
187
+ if _, err = BuildResourceGraphFromObjects(ref, objects); err == nil {
188
+ t.Fatal("root UID mismatch accepted")
189
+ }
190
+ ref = graphTestRoot("Pod", "root")
191
+ ref.Namespace = ""
192
+ ref.ScopeMode = cr.ResourceScopeModeCluster
193
+ if _, err = BuildResourceGraphFromObjects(ref, objects); err == nil {
194
+ t.Fatal("namespace bypass accepted")
195
+ }
196
+ secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "ns", Annotations: map[string]string{"password": "hidden"}}, Data: map[string][]byte{"password": []byte("sensitive")}}
197
+ projected, err := graphObjectProjection(secret, "v1", "Secret")
198
+ if err != nil {
199
+ t.Fatal(err)
200
+ }
201
+ for _, key := range []string{"data", "stringData", "spec"} {
202
+ if _, ok := projected.Object[key]; ok {
203
+ t.Fatalf("secret payload retained: %s", key)
204
+ }
205
+ }
206
+ if len(projected.GetAnnotations()) > 0 {
207
+ t.Fatal("annotations retained")
208
+ }
209
+ }
210
+ func TestResourceGraphCollectorErrorsWarningsAndLimit(t *testing.T) {
211
+ deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "ns", UID: "root-uid"}}
212
+ client := fake.NewSimpleClientset(deployment)
213
+ client.PrependReactor("*", "secrets", func(ktesting.Action) (bool, kruntime.Object, error) {
214
+ t.Error("Secret API must not be called")
215
+ return true, nil, errors.New("Secret read prohibited")
216
+ })
217
+ client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, kruntime.Object, error) {
218
+ list, ok := action.(interface{ GetListOptions() metav1.ListOptions })
219
+ if !ok || list.GetListOptions().Limit != 500 {
220
+ t.Error("list was not bounded")
221
+ }
222
+ return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, "", errors.New("denied"))
223
+ })
224
+ _, secretErr := BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Secret", "secret"))
225
+ if secretErr == nil {
226
+ t.Fatal("Secret root must require metadata client")
227
+ }
228
+ // A single-page response may be truncated; the independent root Get must survive.
229
+ client.PrependReactor("list", "deployments", func(action ktesting.Action) (bool, kruntime.Object, error) {
230
+ return true, &appsv1.DeploymentList{ListMeta: metav1.ListMeta{Continue: "next-page"}}, nil
231
+ })
232
+ graph, err := BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Deployment", "root"))
233
+ if err != nil {
234
+ t.Fatal(err)
235
+ }
236
+ if !graphTestHas(graph, "root") {
237
+ t.Fatal("root lost to source truncation")
238
+ }
239
+ warnings := strings.Join(graph.Warnings, "\n")
240
+ if !strings.Contains(warnings, "Pod source Forbidden") || !strings.Contains(warnings, "Deployment source truncated") {
241
+ t.Fatalf("missing warnings: %s", warnings)
242
+ }
243
+ _, err = BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Deployment", "missing"))
244
+ if !apierrors.IsNotFound(err) {
245
+ t.Fatalf("root error not preserved: %v", err)
246
+ }
247
+ ctx, cancel := context.WithCancel(context.Background())
248
+ cancel()
249
+ _, err = BuildKubernetesResourceGraph(ctx, client, graphTestRoot("Deployment", "root"))
250
+ if !errors.Is(err, context.Canceled) {
251
+ t.Fatalf("cancellation swallowed: %v", err)
252
+ }
253
+ client.PrependReactor("list", "pods", func(ktesting.Action) (bool, kruntime.Object, error) { return true, nil, context.DeadlineExceeded })
254
+ _, err = BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Deployment", "root"))
255
+ if !errors.Is(err, context.DeadlineExceeded) {
256
+ t.Fatalf("source timeout swallowed: %v", err)
257
+ }
258
+ }
259
+
260
+ func TestResourceGraphSupportingRulesAndUnknownSpec(t *testing.T) {
261
+ pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
262
+ pod.SetLabels(map[string]string{"app": "a"})
263
+ service := graphTestObject("v1", "Service", "svc", "svc-uid")
264
+ _ = unstructured.SetNestedStringMap(service.Object, map[string]string{"app": "a"}, "spec", "selector")
265
+ ingress := graphTestObject("networking.k8s.io/v1", "Ingress", "ingress", "ingress-uid")
266
+ _ = unstructured.SetNestedMap(ingress.Object, map[string]interface{}{"service": map[string]interface{}{"name": "svc"}}, "spec", "defaultBackend")
267
+ pdb := graphTestObject("policy/v1", "PodDisruptionBudget", "pdb", "pdb-uid")
268
+ _ = unstructured.SetNestedMap(pdb.Object, map[string]interface{}{"matchLabels": map[string]interface{}{"app": "a"}}, "spec", "selector")
269
+ hpa := graphTestObject("autoscaling/v2", "HorizontalPodAutoscaler", "hpa", "hpa-uid")
270
+ _ = unstructured.SetNestedMap(hpa.Object, map[string]interface{}{"apiVersion": "v1", "kind": "Pod", "name": "pod"}, "spec", "scaleTargetRef")
271
+ event := graphTestObject("v1", "Event", "event", "event-uid")
272
+ event.Object["involvedObject"] = map[string]interface{}{"apiVersion": "v1", "kind": "Pod", "namespace": "ns", "name": "pod", "uid": "pod-uid"}
273
+ event.Object["type"] = "Warning"
274
+ event.Object["reason"] = "BackOff"
275
+ event.Object["message"] = "retrying"
276
+ event.Object["lastTimestamp"] = "2026-11-05T00:00:00Z"
277
+ stale := event.DeepCopy()
278
+ stale.SetName("stale")
279
+ _ = unstructured.SetNestedField(stale.Object, "old-uid", "involvedObject", "uid")
280
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{pod, service, ingress, pdb, hpa, event, stale})
281
+ if err != nil {
282
+ t.Fatal(err)
283
+ }
284
+ for _, edge := range []struct{ relation, source, target string }{{"selects", "svc", "pod"}, {"routes-to", "ingress", "svc"}, {"protects", "pdb", "pod"}, {"scales", "hpa", "pod"}} {
285
+ if !graphTestEdge(graph, edge.relation, edge.source, edge.target) {
286
+ t.Errorf("missing %s", edge.relation)
287
+ }
288
+ }
289
+ if len(graph.Evidence) != 1 || graph.Evidence[0].Severity != "warning" || graph.Evidence[0].Summary != "BackOff retrying" {
290
+ t.Fatalf("events lost identity or semantics: %+v", graph.Evidence)
291
+ }
292
+ custom := graphTestObject("custom.example/v1", "Pod", "custom", "custom-uid")
293
+ _ = unstructured.SetNestedMap(custom.Object, map[string]interface{}{"serviceAccountName": "must-not-infer"}, "spec")
294
+ graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "custom"), []*unstructured.Unstructured{custom})
295
+ if err != nil {
296
+ t.Fatal(err)
297
+ }
298
+ if len(graph.Nodes) != 1 || len(graph.Edges) != 0 {
299
+ t.Fatal("unknown GVK spec inferred")
300
+ }
301
+ }
302
+
303
+ func TestResourceGraphEventEvidenceUsesObservedTimeAndSortsNewestFirst(t *testing.T) {
304
+ base := time.Date(2026, time.November, 5, 10, 0, 0, 0, time.UTC)
305
+ pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "ns", UID: "pod-uid"}}
306
+ involved := corev1.ObjectReference{APIVersion: "v1", Kind: "Pod", Name: "root", Namespace: "ns", UID: "pod-uid"}
307
+ client := fake.NewSimpleClientset(pod,
308
+ &corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "a-creation", Namespace: "ns", CreationTimestamp: metav1.NewTime(base)}, InvolvedObject: involved, Reason: "Created"},
309
+ &corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "b-event", Namespace: "ns", CreationTimestamp: metav1.NewTime(base)}, InvolvedObject: involved, EventTime: metav1.NewMicroTime(base.Add(2 * time.Second)), Reason: "Scheduled"},
310
+ &corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "c-last", Namespace: "ns", CreationTimestamp: metav1.NewTime(base)}, InvolvedObject: involved, EventTime: metav1.NewMicroTime(base.Add(time.Second)), LastTimestamp: metav1.NewTime(base.Add(3 * time.Second)), Reason: "Started"},
311
+ &corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "d-unknown", Namespace: "ns"}, InvolvedObject: involved, Reason: "Unknown"},
312
+ )
313
+ graph, err := BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Pod", "root"))
314
+ if err != nil {
315
+ t.Fatal(err)
316
+ }
317
+ if len(graph.Evidence) != 3 {
318
+ t.Fatalf("unexpected evidence: %+v", graph.Evidence)
319
+ }
320
+ for i, want := range []struct{ id, observed string }{
321
+ {"event/ns/c-last", "2026-11-05T10:00:03Z"},
322
+ {"event/ns/b-event", "2026-11-05T10:00:02Z"},
323
+ {"event/ns/a-creation", "2026-11-05T10:00:00Z"},
324
+ } {
325
+ if got := graph.Evidence[i]; got.ID != want.id || got.ObservedAt != want.observed {
326
+ t.Errorf("evidence[%d]=%+v, want %+v", i, got, want)
327
+ }
328
+ }
329
+ }
330
+
331
+ func TestResourceGraphNormalizeAndSafeWarnings(t *testing.T) {
332
+ root, err := NormalizeResourceGraphRoot(cr.ResourceRef{ClusterID: " cluster ", Kind: " deployment ", Name: " root ", Namespace: " ns "})
333
+ if err != nil {
334
+ t.Fatal(err)
335
+ }
336
+ if root.Kind != "Deployment" || root.Namespace != "ns" || root.Name != "root" {
337
+ t.Fatalf("bad normalization: %+v", root)
338
+ }
339
+ root.Namespace = " "
340
+ if _, err = NormalizeResourceGraphRoot(root); err == nil {
341
+ t.Fatal("blank namespace accepted")
342
+ }
343
+ client := fake.NewSimpleClientset(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "ns", UID: "root-uid"}})
344
+ client.PrependReactor("list", "pods", func(ktesting.Action) (bool, kruntime.Object, error) {
345
+ return true, nil, fmt.Errorf("sensitive transport response token=must-not-appear")
346
+ })
347
+ remaining := int64(12)
348
+ client.PrependReactor("list", "deployments", func(ktesting.Action) (bool, kruntime.Object, error) {
349
+ return true, &appsv1.DeploymentList{ListMeta: metav1.ListMeta{RemainingItemCount: &remaining}}, nil
350
+ })
351
+ input := graphTestRoot("deployment", "root")
352
+ graph, err := BuildKubernetesResourceGraph(context.Background(), client, input)
353
+ if err != nil {
354
+ t.Fatal(err)
355
+ }
356
+ warnings := strings.Join(graph.Warnings, "\n")
357
+ if strings.Contains(warnings, "token=") || !strings.Contains(warnings, "Pod source unavailable") || !strings.Contains(warnings, "Deployment source truncated") {
358
+ t.Fatalf("unsafe or missing warnings: %s", warnings)
359
+ }
360
+ for _, node := range graph.Nodes {
361
+ if node.Resource.Name == "root" && node.Resource.Kind != "Deployment" {
362
+ t.Fatal("wire kind not canonical")
363
+ }
364
+ }
365
+ }
366
+ func TestResourceGraphDepthAndReplacementUID(t *testing.T) {
367
+ objects := []*unstructured.Unstructured{}
368
+ for i := 0; i < 5; i++ {
369
+ object := graphTestObject("custom.example/v1", "Owner", fmt.Sprintf("owner-%d", i), fmt.Sprintf("uid-%d", i))
370
+ if i > 0 {
371
+ graphTestOwner(object, "custom.example/v1", "Owner", fmt.Sprintf("owner-%d", i-1), fmt.Sprintf("uid-%d", i-1), true)
372
+ }
373
+ objects = append(objects, object)
374
+ }
375
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("Owner", "owner-0"), objects)
376
+ if err != nil {
377
+ t.Fatal(err)
378
+ }
379
+ if graphTestHas(graph, "owner-4") || !strings.Contains(strings.Join(graph.Warnings, " "), "truncated by depth") {
380
+ t.Fatalf("depth omission not explicit: %+v", graph)
381
+ }
382
+ replacement := graphTestObject("apps/v1", "Deployment", "same-name", "new-uid")
383
+ child := graphTestObject("v1", "Pod", "child", "child-uid")
384
+ graphTestOwner(child, "apps/v1", "Deployment", "same-name", "old-uid", true)
385
+ graph, err = BuildResourceGraphFromObjects(graphTestRoot("Deployment", "same-name"), []*unstructured.Unstructured{replacement, child})
386
+ if err != nil {
387
+ t.Fatal(err)
388
+ }
389
+ if graphTestHas(graph, "child") || len(graph.Edges) != 0 {
390
+ t.Fatal("stale owner UID connected to replacement root")
391
+ }
392
+ graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "child"), []*unstructured.Unstructured{replacement, child})
393
+ if err != nil {
394
+ t.Fatal(err)
395
+ }
396
+ for _, node := range graph.Nodes {
397
+ if node.Resource.Name == "same-name" && (node.Resource.UID != "old-uid" || node.Status != "unresolved") {
398
+ t.Fatal("stale owner resolved as replacement")
399
+ }
400
+ }
401
+ }
402
+
403
+ func TestResourceGraphErrorClassification(t *testing.T) {
404
+ _, err := BuildKubernetesResourceGraph(context.Background(), fake.NewSimpleClientset(), graphTestRoot("secret", "secret"))
405
+ if !errors.Is(err, ErrUnsupportedResourceGraphRoot) {
406
+ t.Fatalf("unsupported root unclassified: %v", err)
407
+ }
408
+ invalid := graphTestRoot("pod", "pod")
409
+ invalid.Namespace = ""
410
+ _, err = NormalizeResourceGraphRoot(invalid)
411
+ if !errors.Is(err, ErrInvalidResourceGraphRoot) {
412
+ t.Fatalf("invalid root unclassified: %v", err)
413
+ }
414
+ }
415
+
416
+ func TestResourceGraphGenericOwnerScope(t *testing.T) {
417
+ for _, test := range []struct {
418
+ name, ownerNS string
419
+ confirmed bool
420
+ }{{"cluster owner", "", true}, {"same namespace", "ns", true}, {"other namespace", "other", false}} {
421
+ t.Run(test.name, func(t *testing.T) {
422
+ owner := graphTestObject("custom.example/v1", "CustomOwner", "owner", "owner-uid")
423
+ owner.SetNamespace(test.ownerNS)
424
+ pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
425
+ graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-uid", true)
426
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{owner, pod})
427
+ if err != nil {
428
+ t.Fatal(err)
429
+ }
430
+ found := false
431
+ for _, node := range graph.Nodes {
432
+ if node.Resource.Kind != "CustomOwner" {
433
+ continue
434
+ }
435
+ found = true
436
+ if test.confirmed {
437
+ if node.Status == "unresolved" || node.Resource.Namespace != test.ownerNS {
438
+ t.Fatalf("actual owner scope lost: %+v", node)
439
+ }
440
+ } else if node.Status != "unresolved" || node.Resource.Namespace == "other" {
441
+ t.Fatalf("unobserved scope falsely confirmed: %+v", node)
442
+ }
443
+ }
444
+ if !found {
445
+ t.Fatal("owner missing")
446
+ }
447
+ })
448
+ }
449
+ // A cluster-scoped root can traverse its explicitly supplied namespaced children.
450
+ owner := graphTestObject("custom.example/v1", "CustomOwner", "owner", "owner-uid")
451
+ owner.SetNamespace("")
452
+ pod := graphTestObject("v1", "Pod", "child", "child-uid")
453
+ graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-uid", true)
454
+ root := graphTestRoot("CustomOwner", "owner")
455
+ root.Namespace = ""
456
+ root.ScopeMode = cr.ResourceScopeModeCluster
457
+ graph, err := BuildResourceGraphFromObjects(root, []*unstructured.Unstructured{owner, pod})
458
+ if err != nil {
459
+ t.Fatal(err)
460
+ }
461
+ if !graphTestEdge(graph, "controls", "owner", "child") {
462
+ t.Fatal("cluster owner could not reach namespaced dependent")
463
+ }
464
+ }
465
+
466
+ // Cancel after actual work has started; cancellation still closes Done through
467
+ // a normal cancel function, without sleep-based timing or goroutine races.
468
+ type graphCancelAfterChecks struct {
469
+ context.Context
470
+ cancel context.CancelFunc
471
+ checks, after int
472
+ }
473
+
474
+ func (c *graphCancelAfterChecks) Err() error {
475
+ c.checks++
476
+ if c.checks >= c.after {
477
+ c.cancel()
478
+ }
479
+ return c.Context.Err()
480
+ }
481
+ func graphFanoutObjects(count int) []*unstructured.Unstructured {
482
+ objects := make([]*unstructured.Unstructured, 0, count*3)
483
+ for i := 0; i < count; i++ {
484
+ pod := graphTestObject("v1", "Pod", fmt.Sprintf("pod-%03d", i), fmt.Sprintf("pod-uid-%d", i))
485
+ pod.SetLabels(map[string]string{"app": "shared"})
486
+ objects = append(objects, pod)
487
+ service := graphTestObject("v1", "Service", fmt.Sprintf("svc-%03d", i), fmt.Sprintf("svc-uid-%d", i))
488
+ _ = unstructured.SetNestedStringMap(service.Object, map[string]string{"app": "shared"}, "spec", "selector")
489
+ objects = append(objects, service)
490
+ pdb := graphTestObject("policy/v1", "PodDisruptionBudget", fmt.Sprintf("pdb-%03d", i), fmt.Sprintf("pdb-uid-%d", i))
491
+ _ = unstructured.SetNestedMap(pdb.Object, map[string]interface{}{"matchLabels": map[string]interface{}{"app": "shared"}}, "spec", "selector")
492
+ objects = append(objects, pdb)
493
+ }
494
+ return objects
495
+ }
496
+ func TestResourceGraphWorkingSetAndHighFanout(t *testing.T) {
497
+ objects := graphFanoutObjects(500)
498
+ owner := graphTestObject("custom.example/v1", "Owner", "owner", "owner-uid")
499
+ grand := graphTestObject("custom.example/v1", "Owner", "grand", "grand-uid")
500
+ graphTestOwner(owner, "custom.example/v1", "Owner", "grand", "grand-uid", true)
501
+ for _, o := range objects {
502
+ if o.GetKind() == "Service" && o.GetName() == "svc-000" {
503
+ graphTestOwner(o, "custom.example/v1", "Owner", "owner", "owner-uid", true)
504
+ }
505
+ }
506
+ objects = append(objects, owner, grand)
507
+ root := graphTestRoot("Service", "svc-000")
508
+ graph, err := BuildResourceGraphFromObjects(root, objects)
509
+ if err != nil {
510
+ t.Fatal(err)
511
+ }
512
+ if len(graph.Nodes) > 500 || len(graph.Edges) > 1000 || !graphTestHas(graph, "svc-000") || !graphTestHas(graph, "owner") || !graphTestHas(graph, "grand") {
513
+ t.Fatal("output bounds or root lost")
514
+ }
515
+ if !strings.Contains(strings.Join(graph.Warnings, " "), "working set truncated") {
516
+ t.Fatal("high fanout was silently treated as complete")
517
+ }
518
+ // Internal writes have a hard bound independent of the final output filter.
519
+ b := &graphRules{ctx: context.Background(), nodes: map[string]cr.ResourceGraphNode{}, edges: map[string]cr.ResourceGraphEdge{}}
520
+ for i := 0; i < ResourceGraphWorkingNodeLimit+1; i++ {
521
+ b.node(cr.ResourceRef{Kind: "Pod", Name: fmt.Sprint(i)}, "")
522
+ }
523
+ if len(b.nodes) != ResourceGraphWorkingNodeLimit || !b.partial {
524
+ t.Fatal("working node set is unbounded")
525
+ }
526
+ b = &graphRules{ctx: context.Background(), nodes: map[string]cr.ResourceGraphNode{}, edges: map[string]cr.ResourceGraphEdge{}}
527
+ for i := 0; i < ResourceGraphWorkingEdgeLimit+1; i++ {
528
+ b.edge("source", fmt.Sprint(i), "owns")
529
+ }
530
+ if len(b.edges) != ResourceGraphWorkingEdgeLimit || !b.partial {
531
+ t.Fatal("working edge set is unbounded")
532
+ }
533
+ _, err = BuildResourceGraphFromObjects(root, make([]*unstructured.Unstructured, ResourceGraphWorkingNodeLimit+1))
534
+ if !errors.Is(err, ErrResourceGraphBudget) {
535
+ t.Fatalf("input budget not classified: %v", err)
536
+ }
537
+ }
538
+ func TestResourceGraphCancellationDuringBuildAndTraversal(t *testing.T) {
539
+ objects := graphFanoutObjects(200)
540
+ base, cancel := context.WithCancel(context.Background())
541
+ defer cancel()
542
+ ctx := &graphCancelAfterChecks{Context: base, cancel: cancel, after: 10000}
543
+ _, err := buildResourceGraphWithContext(ctx, graphTestRoot("Service", "svc-000"), objects)
544
+ if !errors.Is(err, context.Canceled) || ctx.checks < 10000 {
545
+ t.Fatalf("processing cancellation ignored: %v (%d checks)", err, ctx.checks)
546
+ }
547
+ edges := make([]cr.ResourceGraphEdge, 3000)
548
+ for i := range edges {
549
+ edges[i] = cr.ResourceGraphEdge{ID: fmt.Sprint(i), SourceID: "root", TargetID: fmt.Sprint(i), Relation: "owns"}
550
+ }
551
+ base, cancel = context.WithCancel(context.Background())
552
+ defer cancel()
553
+ ctx = &graphCancelAfterChecks{Context: base, cancel: cancel, after: 3100}
554
+ _, err = graphTraverse(ctx, "root", edges)
555
+ if !errors.Is(err, context.Canceled) {
556
+ t.Fatalf("traversal cancellation ignored: %v", err)
557
+ }
558
+ }
559
+ func TestResourceGraphStatusAndUnknownEvent(t *testing.T) {
560
+ for _, test := range []struct{ kind, api, ready string }{{"Deployment", "apps/v1", "availableReplicas"}, {"ReplicaSet", "apps/v1", "readyReplicas"}} {
561
+ t.Run(test.kind, func(t *testing.T) {
562
+ object := graphTestObject(test.api, test.kind, "root", "root-uid")
563
+ for _, ready := range []int64{0, 2} {
564
+ _ = unstructured.SetNestedField(object.Object, int64(2), "status", "replicas")
565
+ _ = unstructured.SetNestedField(object.Object, ready, "status", test.ready)
566
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot(test.kind, "root"), []*unstructured.Unstructured{object})
567
+ if err != nil {
568
+ t.Fatal(err)
569
+ }
570
+ want := "progressing"
571
+ if ready == 2 {
572
+ want = "healthy"
573
+ }
574
+ if graph.Nodes[0].Status != want {
575
+ t.Errorf("got %s want %s", graph.Nodes[0].Status, want)
576
+ }
577
+ }
578
+ delete(object.Object, "status")
579
+ if graphStatus(object) == "healthy" {
580
+ t.Fatal("missing status claimed healthy")
581
+ }
582
+ })
583
+ }
584
+ ingress := graphTestObject("networking.k8s.io/v1", "Ingress", "ingress", "ingress-uid")
585
+ if graphStatus(ingress) != "pending" {
586
+ t.Fatal("pending Ingress lost")
587
+ }
588
+ _ = unstructured.SetNestedSlice(ingress.Object, []interface{}{map[string]interface{}{"ip": "192.0.2.1"}}, "status", "loadBalancer", "ingress")
589
+ if graphStatus(ingress) != "ready" {
590
+ t.Fatal("ready Ingress lost")
591
+ }
592
+ service := graphTestObject("v1", "Service", "svc", "svc-uid")
593
+ _ = unstructured.SetNestedField(service.Object, "ClusterIP", "spec", "type")
594
+ if graphStatus(service) != "ClusterIP" {
595
+ t.Fatal("service type lost")
596
+ }
597
+ custom := graphTestObject("custom.example/v1", "Event", "custom-event", "custom-event-uid")
598
+ graphTestOwner(custom, "v1", "Service", "svc", "svc-uid", false)
599
+ custom.Object["involvedObject"] = map[string]interface{}{"apiVersion": "v1", "kind": "Service", "namespace": "ns", "name": "svc", "uid": "svc-uid"}
600
+ custom.Object["message"] = "must-not-be-interpreted"
601
+ graph, err := BuildResourceGraphFromObjects(graphTestRoot("Event", "custom-event"), []*unstructured.Unstructured{custom, service})
602
+ if err != nil {
603
+ t.Fatal(err)
604
+ }
605
+ if !graphTestEdge(graph, "owns", "svc", "custom-event") || len(graph.Evidence) != 0 {
606
+ t.Fatal("custom Event metadata lost or spec interpreted")
607
+ }
608
+ }
609
+
610
+ func TestResourceGraphCustomKindClusterScope(t *testing.T) {
611
+ for _, kind := range []string{"Event", "Pod"} {
612
+ object := graphTestObject("custom.example/v1", kind, "custom", "custom-uid")
613
+ object.SetNamespace("")
614
+ root := graphTestRoot(kind, "custom")
615
+ root.APIVersion = "custom.example/v1"
616
+ root.Namespace = ""
617
+ root.ScopeMode = cr.ResourceScopeModeCluster
618
+ graph, err := BuildResourceGraphFromObjects(root, []*unstructured.Unstructured{object})
619
+ if err != nil || len(graph.Nodes) != 1 {
620
+ t.Fatalf("cluster custom %s rejected: %v", kind, err)
621
+ }
622
+ root.APIVersion = "v1"
623
+ if _, err := NormalizeResourceGraphRoot(root); !errors.Is(err, ErrInvalidResourceGraphRoot) {
624
+ t.Fatalf("actual v1/%s namespace bypass accepted", kind)
625
+ }
626
+ }
627
+ }
@@ -0,0 +1,44 @@
1
+ package runtime
2
+
3
+ import (
4
+ "bytes"
5
+ "crypto/x509"
6
+ "encoding/pem"
7
+ "time"
8
+
9
+ corev1 "k8s.io/api/core/v1"
10
+
11
+ cr "github.com/opensoha/soha-contracts/resource"
12
+ )
13
+
14
+ // SecretCertificateMetadata reads only tls.crt, never a private key. Both the
15
+ // direct and Agent adapters use this bounded projection of the first certificate.
16
+ func SecretCertificateMetadata(name, namespace, secretType string, certificatePEM []byte) cr.SecretCertificateView {
17
+ view := cr.SecretCertificateView{Name: name, Namespace: namespace, Status: "unsupported_type"}
18
+ if secretType != string(corev1.SecretTypeTLS) {
19
+ return view
20
+ }
21
+ view.Status = "missing"
22
+ if len(certificatePEM) == 0 {
23
+ return view
24
+ }
25
+ view.Status = "invalid"
26
+ if len(certificatePEM) > 1<<20 {
27
+ return view
28
+ }
29
+ block, _ := pem.Decode(certificatePEM)
30
+ if block == nil || block.Type != "CERTIFICATE" {
31
+ return view
32
+ }
33
+ certificate, err := x509.ParseCertificate(block.Bytes)
34
+ if err != nil {
35
+ return view
36
+ }
37
+ view.Status = "available"
38
+ view.NotBefore = certificate.NotBefore.UTC().Format(time.RFC3339)
39
+ view.NotAfter = certificate.NotAfter.UTC().Format(time.RFC3339)
40
+ selfSigned := bytes.Equal(certificate.RawIssuer, certificate.RawSubject) &&
41
+ certificate.CheckSignature(certificate.SignatureAlgorithm, certificate.RawTBSCertificate, certificate.Signature) == nil
42
+ view.SelfSigned = &selfSigned
43
+ return view
44
+ }