@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,342 @@
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
+ )
13
+
14
+ func networkFixtureGraph(t *testing.T, root cr.NetworkTopologySourceResult, sources ...cr.NetworkTopologySourceResult) cr.ResourceGraph {
15
+ t.Helper()
16
+ sources = append([]cr.NetworkTopologySourceResult{root}, sources...)
17
+ graph, err := BuildNetworkResourceGraph(context.Background(), root.Objects[0].Resource, cr.NetworkTopologySnapshot{Sources: sources})
18
+ if err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ return graph
22
+ }
23
+ func networkEdges(graph cr.ResourceGraph, relation string) []cr.ResourceGraphEdge {
24
+ result := []cr.ResourceGraphEdge{}
25
+ for _, e := range graph.Edges {
26
+ if e.Relation == relation {
27
+ result = append(result, e)
28
+ }
29
+ }
30
+ return result
31
+ }
32
+
33
+ func TestNetworkGraphEndpointFactsKeepSelectorReadinessAndUIDSeparate(t *testing.T) {
34
+ service := topologyFixture("kubernetes.services", "team", "web", `{"spec":{"selector":{"app":"web"},"ports":[{"name":"http","port":80}]}}`)
35
+ pod := topologyFixture("kubernetes.pods", "team", "pod", `{"metadata":{"labels":{"app":"web"}}}`)
36
+ for _, tc := range []struct{ name, uid, ready, state string }{{"ready", "pod-uid", "true", "resolved"}, {"notReady", "pod-uid", "false", "no_ready_endpoints"}, {"stale", "old-uid", "true", "unresolved"}, {"missingUID", "", "true", "unresolved"}} {
37
+ t.Run(tc.name, func(t *testing.T) {
38
+ slice := topologyFixture("kubernetes.endpointslices", "team", "eps", fmt.Sprintf(`{"metadata":{"labels":{"kubernetes.io/service-name":"web"}},"ports":[{"name":"http","port":8080}],"endpoints":[{"targetRef":{"apiVersion":"v1","kind":"Pod","name":"pod","uid":%q},"conditions":{"ready":%s}}]}`, tc.uid, tc.ready))
39
+ graph := networkFixtureGraph(t, service, slice, pod)
40
+ if len(networkEdges(graph, "selects")) != 1 {
41
+ t.Fatal("lost independent selector")
42
+ }
43
+ found := false
44
+ for _, e := range networkEdges(graph, "resolves") {
45
+ if strings.Contains(e.SourceID, "EndpointSlice") && e.ResolutionState == tc.state {
46
+ found = true
47
+ }
48
+ }
49
+ if !found {
50
+ t.Fatalf("endpoint state %s missing: %+v", tc.state, graph.Edges)
51
+ }
52
+ })
53
+ }
54
+ slice := topologyFixture("kubernetes.endpointslices", "team", "unlabelled", `{"endpoints":[{"targetRef":{"kind":"Pod","name":"pod","uid":"pod-uid"}}]}`)
55
+ graph := networkFixtureGraph(t, service, slice, pod)
56
+ if len(networkEdges(graph, "resolves")) != 0 {
57
+ t.Fatal("unlabelled slice associated by name")
58
+ }
59
+ }
60
+ func TestNetworkGraphGrantFromAndToAndTargetNamespace(t *testing.T) {
61
+ route := topologyFixture("gateway.httproutes", "team", "route", `{"spec":{"rules":[{"backendRefs":[{"name":"backend","namespace":"other","port":80}]}]}}`)
62
+ backend := topologyFixture("kubernetes.services", "other", "backend", `{"spec":{"ports":[{"port":80}]}}`)
63
+ for _, tc := range []struct{ name, ns, from, to, state, want string }{{"valid", "other", "team", "backend", "available", "resolved"}, {"wrongNamespace", "elsewhere", "team", "backend", "available", "unknown"}, {"wrongFrom", "other", "elsewhere", "backend", "available", "unresolved"}, {"wrongTo", "other", "team", "elsewhere", "available", "unresolved"}, {"partial", "other", "team", "backend", "partial", "unknown"}} {
64
+ t.Run(tc.name, func(t *testing.T) {
65
+ grant := topologyFixture("gateway.referencegrants", tc.ns, "grant", fmt.Sprintf(`{"spec":{"from":[{"group":"wrong","kind":"HTTPRoute","namespace":"team"},{"group":"gateway.networking.k8s.io","kind":"HTTPRoute","namespace":%q}],"to":[{"group":"","kind":"Service","name":%q}]}}`, tc.from, tc.to))
66
+ grant.Coverage.State = tc.state
67
+ graph := networkFixtureGraph(t, route, backend, grant)
68
+ edges := networkEdges(graph, "routes-to")
69
+ if len(edges) != 1 || edges[0].ResolutionState != tc.want {
70
+ t.Fatalf("grant mismatch: %+v", edges)
71
+ }
72
+ auth := networkEdges(graph, "authorizes-reference")
73
+ if (len(auth) > 0) != (tc.want == "resolved") {
74
+ t.Fatalf("incorrect authorization fact: %+v", auth)
75
+ }
76
+ })
77
+ }
78
+ }
79
+ func TestNetworkGraphGatewayListenerSelectorsTLSAndNamedCoverage(t *testing.T) {
80
+ route := topologyFixture("gateway.httproutes", "team", "route", `{"spec":{"parentRefs":[{"name":"gw","namespace":"infra","sectionName":"https","port":443}]}}`)
81
+ gateway := topologyFixture("gateway.gateways", "infra", "gw", `{"spec":{"gatewayClassName":"managed","listeners":[{"name":"https","port":443,"protocol":"HTTPS","allowedRoutes":{"namespaces":{"from":"Selector","selector":{"matchLabels":{"access":"yes"}}}},"tls":{"certificateRefs":[{"name":"tls"}]}}]}}`)
82
+ namespace := topologyFixture("kubernetes.namespaces", "", "team", `{"metadata":{"labels":{"access":"yes"}}}`)
83
+ graph := networkFixtureGraph(t, route, gateway, namespace)
84
+ edges := networkEdges(graph, "attaches-to")
85
+ if len(edges) != 1 || edges[0].ResolutionState != "resolved" || edges[0].Port != "443" || edges[0].SectionName != "https" || edges[0].SourceRef.Kind != "HTTPRoute" {
86
+ t.Fatalf("lost parent attachment: %+v", edges)
87
+ }
88
+ graph = networkFixtureGraph(t, gateway, route, namespace)
89
+ secrets := networkEdges(graph, "uses-secret")
90
+ if len(secrets) != 1 || secrets[0].ResolutionState != "unresolved" || secrets[0].SectionName != "https" {
91
+ t.Fatalf("lost metadata-only TLS declaration: %+v", secrets)
92
+ }
93
+ namespace.Coverage.Name = "team"
94
+ graph = networkFixtureGraph(t, route, gateway, namespace)
95
+ if edges = networkEdges(graph, "attaches-to"); len(edges) != 1 || edges[0].ResolutionState == "resolved" {
96
+ t.Fatalf("named namespace falsely proved all selector coverage: %+v", edges)
97
+ }
98
+ }
99
+ func TestNetworkGraphBackendTLSAndIngressDeclarations(t *testing.T) {
100
+ policy := topologyFixture("gateway.backendtlspolicies", "team", "tls", `{"spec":{"targetRefs":[{"group":"","kind":"Service","name":"web","sectionName":"https"}],"validation":{"caCertificateRefs":[{"group":"","kind":"ConfigMap","name":"ca"}]}}}`)
101
+ service := topologyFixture("kubernetes.services", "team", "web", `{"spec":{"ports":[{"name":"https","port":443}]}}`)
102
+ config := topologyFixture("kubernetes.configmaps", "team", "ca", `{"data":{"private":"MUST_NOT_ESCAPE"}}`)
103
+ graph := networkFixtureGraph(t, policy, service, config)
104
+ targets := networkEdges(graph, "targets")
105
+ if len(targets) != 1 || targets[0].ResolutionState != "resolved" || targets[0].SectionName != "https" || targets[0].Port != "443" || len(networkEdges(graph, "uses-config")) != 1 {
106
+ t.Fatalf("lost TLS policy refs: %+v", graph)
107
+ }
108
+ encoded, _ := json.Marshal(graph)
109
+ if strings.Contains(string(encoded), "MUST_NOT_ESCAPE") {
110
+ t.Fatal("ConfigMap contents escaped")
111
+ }
112
+ ingress := topologyFixture("kubernetes.ingresses", "team", "ing", `{"spec":{"ingressClassName":"nginx","tls":[{"secretName":"tls"}],"defaultBackend":{"service":{"name":"web","port":{"number":80}}}}}`)
113
+ graph = networkFixtureGraph(t, ingress, service)
114
+ if len(networkEdges(graph, "uses-class")) != 1 || len(networkEdges(graph, "uses-secret")) != 1 || len(networkEdges(graph, "routes-to")) != 1 {
115
+ t.Fatalf("lost ingress refs: %+v", graph.Edges)
116
+ }
117
+ }
118
+
119
+ func TestNetworkGraphBackendTLSPolicySectionRequiresExactServicePortName(t *testing.T) {
120
+ service := topologyFixture("kubernetes.services", "team", "web", `{"spec":{"ports":[{"name":"https","port":443,"targetPort":"backend"}]}}`)
121
+ for _, section := range []string{"443", "backend", "missing"} {
122
+ t.Run(section, func(t *testing.T) {
123
+ policy := topologyFixture("gateway.backendtlspolicies", "team", "tls", fmt.Sprintf(`{"spec":{"targetRefs":[{"group":"","kind":"Service","name":"web","sectionName":%q}]}}`, section))
124
+ edges := networkEdges(networkFixtureGraph(t, policy, service), "targets")
125
+ if len(edges) != 1 || edges[0].ResolutionState != "unresolved" || edges[0].SectionName != section || edges[0].Port != "" {
126
+ t.Fatalf("incorrect Service port match: %+v", edges)
127
+ }
128
+ })
129
+ }
130
+ }
131
+ func TestNetworkGraphNetworkPolicyPeerANDAndIPBlockEvidence(t *testing.T) {
132
+ policy := topologyFixture("kubernetes.networkpolicies", "team", "policy", `{"spec":{"podSelector":{},"ingress":[{"from":[{"namespaceSelector":{"matchLabels":{"role":"client"}},"podSelector":{"matchLabels":{"app":"client"}}},{"ipBlock":{"cidr":"10.0.0.0/8","except":["10.1.0.0/16"]}}]}]}}`)
133
+ namespace := topologyFixture("kubernetes.namespaces", "", "other", `{"metadata":{"labels":{"role":"client"}}}`)
134
+ pod := topologyFixture("kubernetes.pods", "team", "selected", `{}`)
135
+ peer := topologyFixture("kubernetes.pods", "other", "peer", `{"metadata":{"labels":{"app":"client"}}}`)
136
+ unrelated := topologyFixture("kubernetes.pods", "other", "unrelated", `{"metadata":{"labels":{"app":"wrong"}}}`)
137
+ graph := networkFixtureGraph(t, policy, namespace, pod, peer, unrelated)
138
+ if len(networkEdges(graph, "selects")) != 1 || len(networkEdges(graph, "policy-peer")) != 1 {
139
+ t.Fatalf("policy selector AND lost: %+v", graph.Edges)
140
+ }
141
+ found := false
142
+ for _, e := range graph.Evidence {
143
+ if strings.Contains(e.Summary, "10.1.0.0/16") {
144
+ found = true
145
+ }
146
+ }
147
+ if !found {
148
+ t.Fatal("IPBlock/except declaration missing")
149
+ }
150
+ }
151
+ func TestNetworkGraphExactIdentityCancellationAndNamedRootPriority(t *testing.T) {
152
+ root := topologyFixture("kubernetes.services", "team", "web", `{}`)
153
+ root.Objects[0].Resource.APIVersion = "evil/v1"
154
+ if _, err := BuildNetworkResourceGraph(context.Background(), root.Objects[0].Resource, cr.NetworkTopologySnapshot{Sources: []cr.NetworkTopologySourceResult{root}}); !errors.Is(err, ErrUnsupportedResourceGraphRoot) {
155
+ t.Fatalf("accepted lookalike GVK: %v", err)
156
+ }
157
+ root = topologyFixture("kubernetes.services", "team", "web", `{}`)
158
+ ctx, cancel := context.WithCancel(context.Background())
159
+ cancel()
160
+ if _, err := BuildNetworkResourceGraph(ctx, root.Objects[0].Resource, cr.NetworkTopologySnapshot{Sources: []cr.NetworkTopologySourceResult{root}}); !errors.Is(err, context.Canceled) {
161
+ t.Fatalf("cancellation ignored: %v", err)
162
+ }
163
+ stale := topologyFixture("kubernetes.services", "team", "web", `{}`)
164
+ stale.Objects[0].Resource.UID = "old"
165
+ stale.Objects[0].Content = strings.ReplaceAll(stale.Objects[0].Content, "web-uid", "old")
166
+ root.Coverage.Name = "web"
167
+ graph, err := BuildNetworkResourceGraph(context.Background(), root.Objects[0].Resource, cr.NetworkTopologySnapshot{Sources: []cr.NetworkTopologySourceResult{stale, root}})
168
+ if err != nil || !strings.Contains(graph.RootID, "web-uid") {
169
+ t.Fatalf("named root lost to stale list: %+v %v", graph, err)
170
+ }
171
+ }
172
+ func TestNetworkGraphTraversalNeverReversesAndBounds(t *testing.T) {
173
+ edges := []cr.ResourceGraphEdge{{ID: "a", SourceID: "root", TargetID: "shared"}, {ID: "b", SourceID: "unrelated", TargetID: "shared"}}
174
+ for i := 0; i < 8; i++ {
175
+ edges = append(edges, cr.ResourceGraphEdge{ID: fmt.Sprint(i), SourceID: fmt.Sprintf("n%d", i), TargetID: fmt.Sprintf("n%d", i+1)})
176
+ }
177
+ edges = append(edges, cr.ResourceGraphEdge{ID: "start", SourceID: "root", TargetID: "n0"})
178
+ selected, _, depth, _, err := networkGraphTraverse(context.Background(), "root", edges)
179
+ if err != nil || selected["unrelated"] || selected["n3"] || !depth {
180
+ t.Fatalf("unbounded/reversed traversal: %+v %v", selected, err)
181
+ }
182
+ edges = nil
183
+ for i := 0; i < 2000; i++ {
184
+ edges = append(edges, cr.ResourceGraphEdge{ID: fmt.Sprint(i), SourceID: "root", TargetID: fmt.Sprint(i)})
185
+ }
186
+ selected, chosen, _, limit, err := networkGraphTraverse(context.Background(), "root", edges)
187
+ if err != nil || !limit || len(selected) > 500 || len(chosen) > 1000 {
188
+ t.Fatalf("output budget failed: %d %d %v", len(selected), len(chosen), err)
189
+ }
190
+ }
191
+
192
+ func TestNetworkGraphSliceRootAndLegacyBackendTLS(t *testing.T) {
193
+ slice := topologyFixture("kubernetes.endpointslices", "team", "eps", `{"ports":[{"protocol":"UDP","port":53}],"endpoints":[{"targetRef":{"kind":"Pod","name":"dns","uid":"dns-uid"}}]}`)
194
+ pod := topologyFixture("kubernetes.pods", "team", "dns", `{}`)
195
+ graph := networkFixtureGraph(t, slice, pod)
196
+ edges := networkEdges(graph, "resolves")
197
+ if len(edges) != 1 || edges[0].Port != "53" || edges[0].Protocol != "UDP" {
198
+ t.Fatalf("slice root lost endpoint reference: %+v", graph)
199
+ }
200
+ policy := topologyFixture("gateway.backendtlspolicies", "team", "legacy", `{"spec":{"targetRef":{"group":"","kind":"Service","name":"dns"}}}`)
201
+ policy.Objects[0].Resource.APIVersion = "gateway.networking.k8s.io/v1alpha2"
202
+ service := topologyFixture("kubernetes.services", "team", "dns", `{}`)
203
+ graph = networkFixtureGraph(t, policy, service)
204
+ if edges := networkEdges(graph, "targets"); len(edges) != 1 || edges[0].FieldPath != "spec.targetRef" {
205
+ t.Fatalf("legacy targetRef lost: %+v", graph)
206
+ }
207
+ }
208
+
209
+ func TestNetworkGraphLateRootFactsPrecedeUnrelatedHighFanout(t *testing.T) {
210
+ // 80 earlier Services selecting 150 Pods exhaust the 10000-edge working
211
+ // set. The selected root sorts after those Services and must keep its facts.
212
+ services := topologyFixture("kubernetes.services", "team", "a-noise-000", `{"spec":{"selector":{"noise":"yes"}}}`)
213
+ for i := 1; i < 80; i++ {
214
+ f := topologyFixture("kubernetes.services", "team", fmt.Sprintf("a-noise-%03d", i), `{"spec":{"selector":{"noise":"yes"}}}`)
215
+ services.Objects = append(services.Objects, f.Objects...)
216
+ }
217
+ pods := topologyFixture("kubernetes.pods", "team", "chosen", `{"metadata":{"labels":{"app":"chosen"}}}`)
218
+ for i := 0; i < 150; i++ {
219
+ f := topologyFixture("kubernetes.pods", "team", fmt.Sprintf("noise-%03d", i), `{"metadata":{"labels":{"noise":"yes"}}}`)
220
+ pods.Objects = append(pods.Objects, f.Objects...)
221
+ }
222
+ for _, tc := range []struct{ name, source, body, relation, path string }{
223
+ {"service", "kubernetes.services", `{"spec":{"selector":{"app":"chosen"}}}`, "selects", "spec.selector"},
224
+ {"policy", "kubernetes.networkpolicies", `{"spec":{"podSelector":{"matchLabels":{"app":"chosen"}}}}`, "selects", "spec.podSelector"},
225
+ {"gateway", "gateway.gateways", `{"spec":{"gatewayClassName":"chosen"}}`, "uses-class", "spec.gatewayClassName"},
226
+ } {
227
+ t.Run(tc.name, func(t *testing.T) {
228
+ root := topologyFixture(tc.source, "team", "z-selected", tc.body)
229
+ graph := networkFixtureGraph(t, root, services, pods)
230
+ found := false
231
+ for _, e := range graph.Edges {
232
+ if e.SourceID == graph.RootID && e.Relation == tc.relation && e.FieldPath == tc.path {
233
+ found = true
234
+ }
235
+ }
236
+ if !found {
237
+ t.Fatalf("unrelated facts starved selected root: %+v", graph)
238
+ }
239
+ if len(graph.Warnings) == 0 {
240
+ t.Fatal("working-set truncation must stay visible")
241
+ }
242
+ if len(graph.Nodes) > 500 || len(graph.Edges) > 1000 {
243
+ t.Fatal("root priority bypassed output budgets")
244
+ }
245
+ })
246
+ }
247
+ }
248
+
249
+ func TestNetworkGraphIngressBackendFieldPathsMatchDeclarations(t *testing.T) {
250
+ ingress := topologyFixture("kubernetes.ingresses", "team", "ing", `{"spec":{"defaultBackend":{"service":{"name":"web","port":{"number":80}}},"rules":[{"http":{"paths":[{"backend":{"service":{"name":"web","port":{"number":80}}}},{"backend":{"service":{"name":"web","port":{"number":81}}}}]}},{"http":{"paths":[{"backend":{"service":{"name":"web","port":{"number":82}}}}]}}]}}`)
251
+ service := topologyFixture("kubernetes.services", "team", "web", `{"spec":{"ports":[{"port":80},{"port":81},{"port":82}]}}`)
252
+ graph := networkFixtureGraph(t, ingress, service)
253
+ paths := map[string]bool{}
254
+ for _, e := range networkEdges(graph, "routes-to") {
255
+ paths[e.FieldPath] = true
256
+ }
257
+ for _, path := range []string{"spec.defaultBackend", "spec.rules[0].http.paths[0].backend", "spec.rules[0].http.paths[1].backend", "spec.rules[1].http.paths[0].backend"} {
258
+ if !paths[path] {
259
+ t.Fatalf("missing actual field path %s: %+v", path, graph.Edges)
260
+ }
261
+ }
262
+ if len(paths) != 4 {
263
+ t.Fatalf("unexpected flattened field paths: %+v", paths)
264
+ }
265
+ }
266
+
267
+ func TestNetworkGraphPublicTopologyRedactsUnavailableDeclarationIdentities(t *testing.T) {
268
+ class := topologyFixture("kubernetes.ingressclasses", "", "class", `{"spec":{"controller":"example.io/controller","parameters":{"apiGroup":"","kind":"ConfigMap","namespace":"private","name":"hidden-ca","scope":"Namespace"}}}`)
269
+ denied := cr.NetworkTopologySourceResult{Coverage: cr.NetworkTopologyCoverage{SourceID: "kubernetes.configmaps", Namespace: "private", State: "denied"}}
270
+ ingress := topologyFixture("kubernetes.ingresses", "team", "ing", `{"spec":{"tls":[{"secretName":"private-tls"}]}}`)
271
+ for _, tc := range []struct {
272
+ name string
273
+ sources []cr.NetworkTopologySourceResult
274
+ hidden []string
275
+ }{
276
+ {"deniedConfig", []cr.NetworkTopologySourceResult{class, denied}, []string{"hidden-ca", "private"}},
277
+ {"absentConfig", []cr.NetworkTopologySourceResult{class}, []string{"hidden-ca", "private"}},
278
+ {"secret", []cr.NetworkTopologySourceResult{ingress}, []string{"private-tls"}},
279
+ } {
280
+ t.Run(tc.name, func(t *testing.T) {
281
+ view := fixtureView(tc.sources...)
282
+ raw, _ := json.Marshal(struct {
283
+ Nodes []cr.NetworkTopologyGraphNode
284
+ Edges []cr.NetworkTopologyGraphEdge
285
+ Traces []cr.NetworkTopologyTraceView
286
+ }{view.Nodes, view.Edges, view.Traces})
287
+ for _, hidden := range tc.hidden {
288
+ if strings.Contains(string(raw), hidden) {
289
+ t.Fatalf("private topology identity escaped (%s): %s", hidden, raw)
290
+ }
291
+ }
292
+ })
293
+ }
294
+ graph := networkFixtureGraph(t, ingress)
295
+ refs := networkEdges(graph, "uses-secret")
296
+ if len(refs) != 1 || refs[0].ResolutionState != "unresolved" {
297
+ t.Fatalf("Graph lost minimal Secret declaration: %+v", graph)
298
+ }
299
+ found := false
300
+ for _, node := range graph.Nodes {
301
+ if node.Resource.Kind == "Secret" && node.Resource.Name == "private-tls" && node.Resource.UID == "" && node.Status == "unresolved" {
302
+ found = true
303
+ }
304
+ }
305
+ if !found {
306
+ t.Fatal("Graph declaration unavailable for Core permission filtering")
307
+ }
308
+ }
309
+
310
+ func TestNetworkGraphUnknownBackendGroupCannotBecomeCoreService(t *testing.T) {
311
+ route := topologyFixture("gateway.httproutes", "team", "route", `{"spec":{"rules":[{"backendRefs":[{"group":"example.io","kind":"Service","name":"custom-backend","port":80}]}]}}`)
312
+ service := topologyFixture("kubernetes.services", "team", "custom-backend", `{"spec":{"ports":[{"port":80}]}}`)
313
+ snapshot := cr.NetworkTopologySnapshot{Sources: []cr.NetworkTopologySourceResult{route, service}}
314
+ view := BuildNetworkTopology("c", "team", snapshot)
315
+ for _, edge := range view.Edges {
316
+ if edge.ResolutionState == "unsupported_fragment" {
317
+ for _, node := range view.Nodes {
318
+ if node.ID == edge.Target && node.Resource != nil {
319
+ t.Fatalf("unknown group received fabricated resource identity: %+v", node)
320
+ }
321
+ }
322
+ }
323
+ }
324
+ graph := networkFixtureGraph(t, route, service)
325
+ if len(networkEdges(graph, "routes-to")) != 0 {
326
+ t.Fatalf("unknown group became a core Service route: %+v", graph.Edges)
327
+ }
328
+ for _, node := range graph.Nodes {
329
+ if node.Resource.Kind == "Service" {
330
+ t.Fatalf("unrelated core Service became navigable: %+v", node)
331
+ }
332
+ }
333
+ warned := false
334
+ for _, warning := range graph.Warnings {
335
+ if strings.Contains(warning, "Unsupported network reference") {
336
+ warned = true
337
+ }
338
+ }
339
+ if !warned {
340
+ t.Fatal("unsupported fragment silently disappeared")
341
+ }
342
+ }
@@ -0,0 +1,278 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "iter"
8
+
9
+ cr "github.com/opensoha/soha-contracts/resource"
10
+ corev1 "k8s.io/api/core/v1"
11
+ rbacv1 "k8s.io/api/rbac/v1"
12
+ "k8s.io/apimachinery/pkg/api/validate/content"
13
+ "k8s.io/apimachinery/pkg/api/validation"
14
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
16
+ "k8s.io/apimachinery/pkg/labels"
17
+ kruntime "k8s.io/apimachinery/pkg/runtime"
18
+ "k8s.io/client-go/kubernetes"
19
+ )
20
+
21
+ const (
22
+ graphRBACAPI = rbacv1.GroupName + "/v1"
23
+ ResourceGraphRelationBindsRole = "binds-role"
24
+ ResourceGraphRelationBindsSubject = "binds-subject"
25
+ ResourceGraphRelationAggregatesRole = "aggregates-role"
26
+ ResourceGraphRelationUsesServiceAccount = "uses-service-account"
27
+ graphCrossNamespaceAccountReadLimit = 16
28
+ )
29
+
30
+ func graphRBACRootKind(kind string) bool {
31
+ switch kind {
32
+ case "ServiceAccount", "Role", "RoleBinding", "ClusterRole", "ClusterRoleBinding":
33
+ return true
34
+ }
35
+ return false
36
+ }
37
+
38
+ func graphRBACClusterKind(kind string) bool {
39
+ return kind == "ClusterRole" || kind == "ClusterRoleBinding"
40
+ }
41
+
42
+ func graphCollectRBACKind(rootKind, kind string) bool {
43
+ switch rootKind {
44
+ case "ServiceAccount":
45
+ return kind == "Pod" || kind == "RoleBinding" || kind == "ClusterRoleBinding"
46
+ case "Role":
47
+ return kind == "RoleBinding"
48
+ case "RoleBinding":
49
+ return kind == "Role" || kind == "ClusterRole" || kind == "ServiceAccount"
50
+ case "ClusterRole":
51
+ return kind == "ClusterRole" || kind == "RoleBinding" || kind == "ClusterRoleBinding"
52
+ case "ClusterRoleBinding":
53
+ return kind == "ClusterRole" || kind == "ServiceAccount"
54
+ }
55
+ return false
56
+ }
57
+
58
+ func graphRBACProjection(obj kruntime.Object, target *unstructured.Unstructured) error {
59
+ var declaration interface{}
60
+ switch source := obj.(type) {
61
+ case *rbacv1.RoleBinding:
62
+ declaration = &rbacv1.RoleBinding{RoleRef: source.RoleRef, Subjects: source.Subjects}
63
+ case *rbacv1.ClusterRoleBinding:
64
+ declaration = &rbacv1.ClusterRoleBinding{RoleRef: source.RoleRef, Subjects: source.Subjects}
65
+ case *rbacv1.ClusterRole:
66
+ declaration = &rbacv1.ClusterRole{AggregationRule: source.AggregationRule}
67
+ case *rbacv1.Role, *corev1.ServiceAccount, *metav1.PartialObjectMetadata:
68
+ return nil
69
+ default:
70
+ return fmt.Errorf("unexpected RBAC graph projection type %T", obj)
71
+ }
72
+ fields, err := kruntime.DefaultUnstructuredConverter.ToUnstructured(declaration)
73
+ if err != nil {
74
+ return err
75
+ }
76
+ for _, key := range []string{"roleRef", "subjects", "aggregationRule"} {
77
+ if value, ok := fields[key]; ok {
78
+ target.Object[key] = value
79
+ }
80
+ }
81
+ return nil
82
+ }
83
+
84
+ func collectGraphCrossNamespaceAccounts(ctx context.Context, client kubernetes.Interface, root *unstructured.Unstructured, objects *[]*unstructured.Unstructured, warnings *[]string) error {
85
+ if root.GetAPIVersion() != graphRBACAPI || root.GetKind() != "RoleBinding" {
86
+ return nil
87
+ }
88
+ reads := 0
89
+ for _, target := range graphRBACServiceAccountSubjects(root, func() bool { return ctx.Err() == nil }) {
90
+ if target.namespace == root.GetNamespace() {
91
+ continue
92
+ }
93
+ if reads == graphCrossNamespaceAccountReadLimit {
94
+ *warnings = append(*warnings, "ServiceAccount cross-namespace references truncated at 16 targets")
95
+ break
96
+ }
97
+ reads++
98
+ object, err := client.CoreV1().ServiceAccounts(target.namespace).Get(ctx, target.name, metav1.GetOptions{})
99
+ if err != nil {
100
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
101
+ return err
102
+ }
103
+ *warnings = append(*warnings, "ServiceAccount cross-namespace reference "+graphSourceErrorClass(err))
104
+ continue
105
+ }
106
+ projected, err := graphObjectProjection(object, "v1", "ServiceAccount")
107
+ if err != nil || projected.GetName() != target.name || projected.GetNamespace() != target.namespace {
108
+ *warnings = append(*warnings, "ServiceAccount cross-namespace reference identity unverified")
109
+ continue
110
+ }
111
+ *objects = append(*objects, projected)
112
+ }
113
+ return ctx.Err()
114
+ }
115
+
116
+ // Foreign ServiceAccounts are targets of the observed root declaration only.
117
+ // The caller keeps them out of the regular graph source and owner traversal.
118
+ func indexGraphCrossNamespaceAccounts(ctx context.Context, root *unstructured.Unstructured, objects []*unstructured.Unstructured, index map[string]*unstructured.Unstructured) error {
119
+ if root.GetAPIVersion() != graphRBACAPI || root.GetKind() != "RoleBinding" {
120
+ return nil
121
+ }
122
+ targets := map[graphNamespacedName]bool{}
123
+ for _, target := range graphRBACServiceAccountSubjects(root, func() bool { return ctx.Err() == nil }) {
124
+ if target.namespace != root.GetNamespace() {
125
+ targets[target] = true
126
+ }
127
+ }
128
+ if len(targets) == 0 {
129
+ return ctx.Err()
130
+ }
131
+ for _, object := range objects {
132
+ if err := ctx.Err(); err != nil {
133
+ return err
134
+ }
135
+ if object == nil || object.GetAPIVersion() != "v1" || object.GetKind() != "ServiceAccount" {
136
+ continue
137
+ }
138
+ target := graphNamespacedName{namespace: object.GetNamespace(), name: object.GetName()}
139
+ if targets[target] {
140
+ index[graphIdentity("v1", "ServiceAccount", target.namespace, target.name)] = object
141
+ delete(targets, target)
142
+ }
143
+ }
144
+ return ctx.Err()
145
+ }
146
+
147
+ func (b *graphRules) rbacRelations(o *unstructured.Unstructured, id string) {
148
+ if !graphBuiltinSpec(o) {
149
+ return
150
+ }
151
+ switch o.GetKind() {
152
+ case "Pod":
153
+ b.podServiceAccountReference(o, id, graphPodSpec(o))
154
+ case "RoleBinding", "ClusterRoleBinding":
155
+ if (o.GetNamespace() == "") == graphRBACClusterKind(o.GetKind()) {
156
+ b.rbacBindingRelations(o, id)
157
+ }
158
+ case "ClusterRole":
159
+ if o.GetNamespace() == "" {
160
+ b.rbacAggregationRelations(o, id)
161
+ }
162
+ }
163
+ }
164
+
165
+ func (b *graphRules) podServiceAccountReference(o *unstructured.Unstructured, id string, spec map[string]interface{}) {
166
+ namespace, name := o.GetNamespace(), graphString(spec, "serviceAccountName")
167
+ if namespace != "" && len(validation.ValidateServiceAccountName(name, false)) == 0 {
168
+ b.rbacReferenceEdge(id, "v1", "ServiceAccount", namespace, name, ResourceGraphRelationUsesServiceAccount, "spec.serviceAccountName")
169
+ }
170
+ }
171
+
172
+ func (b *graphRules) rbacBindingRelations(o *unstructured.Unstructured, id string) {
173
+ rawRole, _, _ := graphNestedMap(o.Object, "roleRef")
174
+ var role rbacv1.RoleRef
175
+ if err := kruntime.DefaultUnstructuredConverter.FromUnstructured(rawRole, &role); err == nil &&
176
+ role.APIGroup == rbacv1.GroupName && role.Name != "" && len(content.IsPathSegmentName(role.Name)) == 0 {
177
+ switch role.Kind {
178
+ case "ClusterRole":
179
+ b.rbacReferenceEdge(id, graphRBACAPI, role.Kind, "", role.Name, ResourceGraphRelationBindsRole, "roleRef")
180
+ case "Role":
181
+ if o.GetKind() == "RoleBinding" {
182
+ b.rbacReferenceEdge(id, graphRBACAPI, role.Kind, o.GetNamespace(), role.Name, ResourceGraphRelationBindsRole, "roleRef")
183
+ }
184
+ }
185
+ }
186
+ for i, target := range graphRBACServiceAccountSubjects(o, b.active) {
187
+ b.rbacReferenceEdge(id, "v1", "ServiceAccount", target.namespace, target.name, ResourceGraphRelationBindsSubject, fmt.Sprintf("subjects[%d]", i))
188
+ }
189
+ }
190
+
191
+ func graphRBACServiceAccountSubjects(binding *unstructured.Unstructured, active func() bool) iter.Seq2[int, graphNamespacedName] {
192
+ return func(yield func(int, graphNamespacedName) bool) {
193
+ seen := map[graphNamespacedName]bool{}
194
+ for i, raw := range graphSlice(binding.Object, "subjects") {
195
+ if !active() {
196
+ return
197
+ }
198
+ data, ok := raw.(map[string]interface{})
199
+ var subject rbacv1.Subject
200
+ if !ok || kruntime.DefaultUnstructuredConverter.FromUnstructured(data, &subject) != nil ||
201
+ subject.Kind != rbacv1.ServiceAccountKind || subject.APIGroup != "" ||
202
+ len(validation.ValidateServiceAccountName(subject.Name, false)) != 0 {
203
+ continue
204
+ }
205
+ if subject.Namespace == "" {
206
+ subject.Namespace = binding.GetNamespace()
207
+ }
208
+ if len(validation.ValidateNamespaceName(subject.Namespace, false)) != 0 {
209
+ continue
210
+ }
211
+ target := graphNamespacedName{namespace: subject.Namespace, name: subject.Name}
212
+ if seen[target] {
213
+ continue
214
+ }
215
+ seen[target] = true
216
+ if !yield(i, target) {
217
+ return
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ func (b *graphRules) rbacReferenceEdge(source, api, kind, namespace, name, relation, fieldPath string) {
224
+ state := "unresolved"
225
+ if b.objects[graphIdentity(api, kind, namespace, name)] != nil {
226
+ state = "resolved"
227
+ }
228
+ b.addEdge(cr.ResourceGraphEdge{
229
+ SourceID: source, TargetID: b.target(api, kind, namespace, name, ""),
230
+ Relation: relation, ResolutionState: state, FieldPath: fieldPath,
231
+ })
232
+ }
233
+
234
+ func (b *graphRules) rbacAggregationRelations(o *unstructured.Unstructured, id string) {
235
+ raw, _, _ := graphNestedSlice(o.Object, "aggregationRule", "clusterRoleSelectors")
236
+ if len(raw) == 0 {
237
+ return
238
+ }
239
+ selectors := make([]labels.Selector, 0, len(raw))
240
+ for _, entry := range raw {
241
+ if !b.active() {
242
+ return
243
+ }
244
+ data, ok := entry.(map[string]interface{})
245
+ var selector metav1.LabelSelector
246
+ if !ok || data == nil || kruntime.DefaultUnstructuredConverter.FromUnstructuredWithValidation(data, &selector, true) != nil {
247
+ return
248
+ }
249
+ compiled, err := metav1.LabelSelectorAsSelector(&selector)
250
+ if err != nil {
251
+ return
252
+ }
253
+ selectors = append(selectors, compiled)
254
+ }
255
+ for _, candidate := range b.clusterRoles {
256
+ if !b.active() {
257
+ return
258
+ }
259
+ if candidate.GetName() == o.GetName() {
260
+ continue
261
+ }
262
+ actual := labels.Set(candidate.GetLabels())
263
+ for i, selector := range selectors {
264
+ if !b.active() {
265
+ return
266
+ }
267
+ if selector.Matches(actual) {
268
+ b.addEdge(cr.ResourceGraphEdge{
269
+ SourceID: id, TargetID: b.node(b.ref(candidate), graphStatus(candidate)),
270
+ Relation: ResourceGraphRelationAggregatesRole, ResolutionState: "resolved",
271
+ FieldPath: fmt.Sprintf("aggregationRule.clusterRoleSelectors[%d]", i),
272
+ Note: "ClusterRole labels match the declared selector",
273
+ })
274
+ break
275
+ }
276
+ }
277
+ }
278
+ }