@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,242 @@
1
+ package runtime
2
+
3
+ import (
4
+ "fmt"
5
+ cr "github.com/opensoha/soha-contracts/resource"
6
+ "strings"
7
+ )
8
+
9
+ func (g *topologyGraph) traefikRoute(o topologyObject) {
10
+ protocol := map[string]string{"IngressRoute": "HTTP", "IngressRouteTCP": "TCP", "IngressRouteUDP": "UDP"}[o.ref.Kind]
11
+ entry := g.objectNode(o, "entry", "traefik", protocol)
12
+ g.properties(entry, cr.NetworkTopologyProperty{Name: "Entry points", Value: strings.Join(texts(at(o.body, "spec", "entryPoints")), ", ")})
13
+ for index, raw := range items(at(o.body, "spec", "routes")) {
14
+ rule := g.rule(o, "traefik", protocol, index, "", nil)
15
+ node := g.nodes[rule]
16
+ if node.Rule != nil {
17
+ node.Rule.MatchSummary = text(raw["match"])
18
+ priority := integer(raw["priority"], 0)
19
+ node.Rule.Priority = &priority
20
+ node.Rule.TLSMode = ""
21
+ if at(o.body, "spec", "tls") != nil {
22
+ node.Rule.TLSMode = "terminate"
23
+ if at(o.body, "spec", "tls", "passthrough") == true {
24
+ node.Rule.TLSMode = "passthrough"
25
+ }
26
+ }
27
+ }
28
+ g.nodes[rule] = node
29
+ if text(raw["match"]) == "[redacted match expression]" {
30
+ g.state(rule, "unsupported_fragment")
31
+ }
32
+ g.link(entry, rule, "matches", protocol, "")
33
+ tail := rule
34
+ kind := "Middleware"
35
+ if protocol != "HTTP" {
36
+ kind = "MiddlewareTCP"
37
+ }
38
+ for _, middleware := range items(raw["middlewares"]) {
39
+ tail = g.traefikMiddleware(o, tail, middleware, kind, protocol, map[string]bool{}, 0)
40
+ }
41
+ total := int64(0)
42
+ for _, backend := range items(raw["services"]) {
43
+ total += integer(backend["weight"], 1)
44
+ }
45
+ for _, backend := range items(raw["services"]) {
46
+ g.traefikBackend(o, tail, backend, protocol, "weights", total, map[string]bool{}, 0)
47
+ }
48
+ }
49
+ }
50
+
51
+ func (g *topologyGraph) traefikMiddleware(o topologyObject, parent string, ref map[string]any, kind, protocol string, seen map[string]bool, depth int) string {
52
+ if g.budgetReached() {
53
+ return ""
54
+ }
55
+ namespace := firstTopology(text(ref["namespace"]), o.ref.Namespace)
56
+ name := text(ref["name"])
57
+ middleware, ok := g.objects[topologyKey(kind, namespace, name)]
58
+ if !ok {
59
+ target := g.unknown(parent, kind, namespace, name, "")
60
+ g.link(parent, target, "processes", protocol, "")
61
+ return target
62
+ }
63
+ // Each occurrence retains its position in this configured middleware chain.
64
+ target := g.addNode(cr.NetworkTopologyGraphNode{ID: parent + "/middleware/" + middleware.id(), Type: "processor", Label: name, Provider: "traefik", Protocol: protocol, ResolutionState: "resolved", Resource: &middleware.ref})
65
+ edge := g.link(parent, target, "processes", protocol, "")
66
+ if namespace != o.ref.Namespace {
67
+ edge.ResolutionState = "unknown"
68
+ edge.Note = "Controller allowCrossNamespace setting unverified"
69
+ }
70
+ g.properties(target, cr.NetworkTopologyProperty{Name: "Types", Value: strings.Join(texts(middleware.body["processorTypes"]), ", ")})
71
+ if depth >= 8 || seen[middleware.id()] {
72
+ g.state(target, "unsupported_fragment")
73
+ return target
74
+ }
75
+ next := cloneTopologySeen(seen)
76
+ next[middleware.id()] = true
77
+ for _, child := range items(at(middleware.body, "spec", "chain", "middlewares")) {
78
+ target = g.traefikMiddleware(middleware, target, child, kind, protocol, next, depth+1)
79
+ }
80
+ return target
81
+ }
82
+
83
+ func (g *topologyGraph) traefikBackend(o topologyObject, parent string, ref map[string]any, protocol, relation string, total int64, seen map[string]bool, depth int) {
84
+ if g.budgetReached() {
85
+ return
86
+ }
87
+
88
+ namespace := firstTopology(text(ref["namespace"]), o.ref.Namespace)
89
+ name := text(ref["name"])
90
+ kind := firstTopology(text(ref["kind"]), "Service")
91
+ port := text(ref["port"])
92
+ target := ""
93
+ switch kind {
94
+ case "TraefikService":
95
+ target = g.traefikService(o, parent, namespace, name, protocol, seen, depth)
96
+ case "Service":
97
+ target = g.backend(parent, namespace, name, port, protocol)
98
+ default:
99
+ target = g.unknown(parent, kind, namespace, name, "unsupported_fragment")
100
+ }
101
+ edge := g.link(parent, target, relation, protocol, port)
102
+ weight := integer(ref["weight"], 1)
103
+ edge.Weight = &weight
104
+ if relation == "weights" {
105
+ edge.TotalWeight = &total
106
+ }
107
+ if value, ok := ref["percent"].(float64); ok {
108
+ edge.MirrorPercent = &value
109
+ }
110
+ if namespace != o.ref.Namespace {
111
+ edge.ResolutionState = "unknown"
112
+ edge.Note = "Controller allowCrossNamespace setting unverified"
113
+ }
114
+ }
115
+ func (g *topologyGraph) traefikService(o topologyObject, parent, namespace, name, protocol string, seen map[string]bool, depth int) string {
116
+ if g.budgetReached() {
117
+ return ""
118
+ }
119
+
120
+ item, ok := g.objects[topologyKey("TraefikService", namespace, name)]
121
+ if !ok {
122
+ return g.unknown(parent, "TraefikService", namespace, name, "")
123
+ }
124
+ target := g.objectNode(item, "backend-group", "traefik", protocol)
125
+ if depth >= 8 || seen[item.id()] {
126
+ g.state(target, "unsupported_fragment")
127
+ return target
128
+ }
129
+ next := cloneTopologySeen(seen)
130
+ next[item.id()] = true
131
+ weighted := items(at(item.body, "spec", "weighted", "services"))
132
+ total := int64(0)
133
+ for _, backend := range weighted {
134
+ total += integer(backend["weight"], 1)
135
+ }
136
+ for _, backend := range weighted {
137
+ g.traefikBackend(item, target, backend, protocol, "weights", total, next, depth+1)
138
+ }
139
+ if mirror := object(at(item.body, "spec", "mirroring")); len(mirror) > 0 {
140
+ g.traefikBackend(item, target, mirror, protocol, "forwards", 0, next, depth+1)
141
+ for _, backend := range items(mirror["mirrors"]) {
142
+ g.traefikBackend(item, target, backend, protocol, "mirrors", 0, next, depth+1)
143
+ }
144
+ }
145
+ return target
146
+ }
147
+ func cloneTopologySeen(seen map[string]bool) map[string]bool {
148
+ next := map[string]bool{}
149
+ for key, value := range seen {
150
+ next[key] = value
151
+ }
152
+ return next
153
+ }
154
+
155
+ func (g *topologyGraph) apisixRoute(o topologyObject) {
156
+ entry := g.objectNode(o, "entry", "apisix", "")
157
+ for index, raw := range items(at(o.body, "spec", "http")) {
158
+ rule := g.rule(o, "apisix", "HTTP", index, text(raw["name"]), apisixMatches(object(raw["match"])))
159
+ node := g.nodes[rule]
160
+ if node.Rule != nil {
161
+ priority := integer(raw["priority"], 0)
162
+ node.Rule.Priority = &priority
163
+ }
164
+ g.nodes[rule] = node
165
+ g.link(entry, rule, "matches", "HTTP", "")
166
+ for _, plugin := range items(raw["plugins"]) {
167
+ g.properties(rule, cr.NetworkTopologyProperty{Name: "Configured plugin", Value: fmt.Sprintf("%s (enabled=%v; runtime priority not inferred)", text(plugin["name"]), plugin["enable"] == true)})
168
+ }
169
+ backends := items(raw["backends"])
170
+ if backend := object(raw["backend"]); len(backend) > 0 {
171
+ backends = append(backends, backend)
172
+ }
173
+ total := int64(0)
174
+ for _, backend := range backends {
175
+ total += integer(backend["weight"], 100)
176
+ }
177
+ for _, backend := range backends {
178
+ g.apisixBackend(o, rule, backend, "HTTP", total)
179
+ }
180
+ if len(items(at(raw, "match", "exprs"))) > 0 {
181
+ g.state(rule, "unsupported_fragment")
182
+ g.properties(rule, cr.NetworkTopologyProperty{Name: "Expressions", Value: "Configured expression values redacted; evaluation not simulated"})
183
+ }
184
+ }
185
+ for index, raw := range items(at(o.body, "spec", "stream")) {
186
+ protocol := strings.ToUpper(firstTopology(text(raw["protocol"]), "TCP"))
187
+ rule := g.rule(o, "apisix", protocol, index, "stream/"+firstTopology(text(raw["name"]), fmt.Sprint(index)), [][]cr.NetworkTopologyMatch{{{Type: "ingress-port", Value: text(at(raw, "match", "ingressPort"))}}})
188
+ g.link(entry, rule, "matches", protocol, text(at(raw, "match", "ingressPort")))
189
+ g.apisixBackend(o, rule, object(raw["backend"]), protocol, 100)
190
+ }
191
+ }
192
+ func apisixMatches(match map[string]any) [][]cr.NetworkTopologyMatch {
193
+ // APISIX arrays are OR within one dimension and AND across dimensions; do not fabricate their Cartesian product.
194
+ group := []cr.NetworkTopologyMatch{}
195
+ for _, kind := range []string{"hosts", "paths", "methods", "remoteAddrs"} {
196
+ values := texts(match[kind])
197
+ if len(values) > 0 {
198
+ group = append(group, cr.NetworkTopologyMatch{Type: kind, Operator: "oneOf", Value: strings.Join(values, " | ")})
199
+ }
200
+ }
201
+ return [][]cr.NetworkTopologyMatch{group}
202
+ }
203
+ func (g *topologyGraph) apisixBackend(o topologyObject, parent string, backend map[string]any, protocol string, total int64) {
204
+ name := text(backend["serviceName"])
205
+ port := text(backend["servicePort"])
206
+ target := ""
207
+ if upstream, ok := g.objects[topologyKey("ApisixUpstream", o.ref.Namespace, name)]; ok {
208
+ if len(items(at(upstream.body, "spec", "externalNodes"))) == 0 {
209
+ target = g.backend(parent, o.ref.Namespace, name, port, protocol)
210
+ }
211
+ target = g.apisixUpstream(upstream, target, protocol)
212
+ } else {
213
+ target = g.backend(parent, o.ref.Namespace, name, port, protocol)
214
+ }
215
+ edge := g.link(parent, target, "weights", protocol, port)
216
+ weight := integer(backend["weight"], 100)
217
+ edge.Weight = &weight
218
+ edge.TotalWeight = &total
219
+ }
220
+ func (g *topologyGraph) apisixUpstream(upstream topologyObject, service, protocol string) string {
221
+ id := g.objectNode(upstream, "backend-group", "apisix", protocol)
222
+ if g.expanded[id] {
223
+ return id
224
+ }
225
+ g.expanded[id] = true
226
+ g.properties(id, cr.NetworkTopologyProperty{Name: "Scheme", Value: text(at(upstream.body, "spec", "scheme"))}, cr.NetworkTopologyProperty{Name: "Load balancer", Value: text(at(upstream.body, "spec", "loadbalancer", "type"))})
227
+ nodes := items(at(upstream.body, "spec", "externalNodes"))
228
+ if len(nodes) == 0 {
229
+ g.link(id, service, "resolves", protocol, "")
230
+ }
231
+ for index, raw := range nodes {
232
+ target := g.addNode(cr.NetworkTopologyGraphNode{ID: fmt.Sprintf("%s/external/%d", id, index), Type: "external", Label: text(raw["name"]), Provider: "apisix", Protocol: protocol, ResolutionState: "external"})
233
+ edge := g.link(id, target, "forwards", protocol, text(raw["port"]))
234
+ weight := integer(raw["weight"], 1)
235
+ edge.Weight = &weight
236
+ }
237
+ for _, subset := range items(at(upstream.body, "spec", "subsets")) {
238
+ g.properties(id, cr.NetworkTopologyProperty{Name: "Configured subset", Value: text(subset["name"]) + " (endpoint subset resolution unverified)"})
239
+ g.state(id, "unsupported_fragment")
240
+ }
241
+ return id
242
+ }
@@ -0,0 +1,186 @@
1
+ package runtime
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+
8
+ cr "github.com/opensoha/soha-contracts/resource"
9
+ )
10
+
11
+ func topologyFixture(source, namespace, name, body string) cr.NetworkTopologySourceResult {
12
+ definition, _ := cr.LookupNetworkTopologySource(source)
13
+ var raw map[string]any
14
+ _ = json.Unmarshal([]byte(body), &raw)
15
+ metadata, _ := raw["metadata"].(map[string]any)
16
+ if metadata == nil {
17
+ metadata = map[string]any{}
18
+ }
19
+ metadata["name"] = name
20
+ metadata["namespace"] = namespace
21
+ metadata["uid"] = name + "-uid"
22
+ metadata["resourceVersion"] = "1"
23
+ raw["metadata"] = metadata
24
+ projected, err := cr.ProjectNetworkTopologyObject(source, raw)
25
+ if err != nil {
26
+ panic(err)
27
+ }
28
+ content, _ := json.Marshal(projected)
29
+ return cr.NetworkTopologySourceResult{Coverage: cr.NetworkTopologyCoverage{SourceID: source, Provider: definition.Provider, Namespace: namespace, State: "available"}, Objects: []cr.NetworkTopologySourceObject{{Resource: cr.ResourceRef{ClusterID: "c", APIVersion: strings.TrimPrefix(definition.Group+"/"+definition.Version, "/"), Kind: definition.Kind, Namespace: namespace, Name: name, UID: name + "-uid"}, Content: string(content)}}}
30
+ }
31
+ func fixtureView(sources ...cr.NetworkTopologySourceResult) cr.NetworkTopologyView {
32
+ return BuildNetworkTopology("c", "team", cr.NetworkTopologySnapshot{Sources: sources})
33
+ }
34
+
35
+ func TestNetworkTopologyCoverageIncludesDeniedEntriesBeyondQueryLimit(t *testing.T) {
36
+ sources := make([]cr.NetworkTopologySourceResult, topologyCoverageLimit+1)
37
+ for i := range sources {
38
+ sources[i].Coverage = cr.NetworkTopologyCoverage{SourceID: "kubernetes.services", State: "denied"}
39
+ }
40
+ view := fixtureView(sources[:cr.NetworkTopologyMaxQueries+2]...)
41
+ if view.Truncated || len(view.Coverage) != cr.NetworkTopologyMaxQueries+2 {
42
+ t.Fatalf("coverage was capped at the issued-query limit: %d, truncated=%v", len(view.Coverage), view.Truncated)
43
+ }
44
+ view = fixtureView(sources...)
45
+ if !view.Truncated || len(view.Coverage) != topologyCoverageLimit {
46
+ t.Fatalf("coverage exceeded the public limit: %d, truncated=%v", len(view.Coverage), view.Truncated)
47
+ }
48
+ }
49
+ func TestTopologyKeepsIngressRuleBackendPortsAndEndpointEvidence(t *testing.T) {
50
+ view := fixtureView(
51
+ topologyFixture("kubernetes.ingresses", "team", "entry", `{"spec":{"rules":[{"host":"a.test","http":{"paths":[{"path":"/a","pathType":"Prefix","backend":{"service":{"name":"web","port":{"number":80}}}}]}},{"host":"b.test","http":{"paths":[{"path":"/b","backend":{"service":{"name":"web","port":{"number":81}}}}]}}]}}`),
52
+ topologyFixture("kubernetes.services", "team", "web", `{"spec":{"ports":[{"name":"http","port":80,"targetPort":8080},{"name":"other","port":81,"targetPort":8081}],"selector":{"app":"web"}}}`),
53
+ topologyFixture("kubernetes.endpointslices", "team", "web-1", `{"ports":[{"name":"http","port":8080}],"endpoints":[{"addresses":["10.1.1.1"],"targetRef":{"kind":"Pod","name":"web-1","uid":"web-1-uid"},"conditions":{"serving":true}}]}`),
54
+ topologyFixture("kubernetes.pods", "team", "web-1", `{"metadata":{"labels":{"app":"web"}},"status":{"phase":"Running"}}`),
55
+ )
56
+ rules := map[string]bool{}
57
+ ports := map[string]bool{}
58
+ for _, node := range view.Nodes {
59
+ if node.Rule != nil {
60
+ group := node.Rule.MatchGroups[0]
61
+ rules[group[0].Value+group[1].Value] = true
62
+ }
63
+ }
64
+ for _, edge := range view.Edges {
65
+ if edge.Relation == "forwards" {
66
+ ports[edge.Port] = true
67
+ }
68
+ }
69
+ if len(rules) != 2 || !rules["a.test/a"] || !rules["b.test/b"] || !ports["80"] || !ports["81"] {
70
+ t.Fatalf("flattened routes: %+v %+v", rules, ports)
71
+ }
72
+ // Selector matching remains a separate declared relation, never endpoint proof.
73
+ for _, edge := range view.Edges {
74
+ if edge.Relation == "selects" && (edge.ResolutionState != "resolved" || !strings.Contains(edge.Note, "Selector match only")) {
75
+ t.Fatal("selector must stay separate from endpoint readiness")
76
+ }
77
+ }
78
+ }
79
+ func TestTopologyPreservesZeroWeightMirrorAndCycles(t *testing.T) {
80
+ view := fixtureView(
81
+ topologyFixture("traefik.ingressroutes", "team", "route", `{"spec":{"entryPoints":["web"],"routes":[{"match":"Host(\"a.test\")","services":[{"name":"weighted","kind":"TraefikService","weight":0}]}]}}`),
82
+ topologyFixture("traefik.traefikservices", "team", "weighted", `{"spec":{"weighted":{"services":[{"name":"web","port":80,"weight":0},{"name":"weighted","kind":"TraefikService","weight":2}]},"mirroring":{"name":"web","port":80,"mirrors":[{"name":"shadow","port":80,"percent":15}]}}}`),
83
+ topologyFixture("kubernetes.services", "team", "web", `{"spec":{"type":"ExternalName","externalName":"upstream.example","ports":[{"port":80}]}}`),
84
+ )
85
+ zero := false
86
+ mirror := false
87
+ cycle := false
88
+ for _, edge := range view.Edges {
89
+ zero = zero || (edge.Weight != nil && *edge.Weight == 0)
90
+ mirror = mirror || (edge.Relation == "mirrors" && edge.MirrorPercent != nil && *edge.MirrorPercent == 15)
91
+ }
92
+ for _, node := range view.Nodes {
93
+ cycle = cycle || node.ResolutionState == "unsupported_fragment"
94
+ }
95
+ if !zero || !mirror || !cycle {
96
+ t.Fatalf("lost semantics zero=%v mirror=%v cycle=%v", zero, mirror, cycle)
97
+ }
98
+ if len(view.Nodes) > 200 || len(view.Edges) > 400 {
99
+ t.Fatal("graph budget exceeded")
100
+ }
101
+ }
102
+ func TestTopologyNeverLeaksInvisibleCrossNamespaceReferences(t *testing.T) {
103
+ view := fixtureView(cr.NetworkTopologySourceResult{Coverage: cr.NetworkTopologyCoverage{SourceID: "gateway.referencegrants", Namespace: "private", State: "available"}}, topologyFixture("gateway.httproutes", "team", "route", `{"spec":{"parentRefs":[{"name":"hidden-gateway","namespace":"private"}],"rules":[{"backendRefs":[{"name":"hidden-service","namespace":"private","port":80}]}]}}`))
104
+ data, _ := json.Marshal(struct {
105
+ Nodes []cr.NetworkTopologyGraphNode
106
+ Edges []cr.NetworkTopologyGraphEdge
107
+ Traces []cr.NetworkTopologyTraceView
108
+ }{view.Nodes, view.Edges, view.Traces})
109
+ if strings.Contains(string(data), "hidden-service") || strings.Contains(string(data), "hidden-gateway") || strings.Contains(string(data), "private") {
110
+ t.Fatalf("hidden reference escaped: %s", data)
111
+ }
112
+ }
113
+ func TestTopologyAPISIXStreamAndExternalUpstream(t *testing.T) {
114
+ view := fixtureView(topologyFixture("apisix.apisixroutes", "team", "route", `{"spec":{"stream":[{"name":"dns","protocol":"UDP","match":{"ingressPort":53},"backend":{"serviceName":"dns","servicePort":53}}]}}`), topologyFixture("apisix.apisixupstreams", "team", "dns", `{"spec":{"scheme":"udp","externalNodes":[{"type":"Domain","name":"resolver.example","port":53,"weight":1}]}}`))
115
+ udp := false
116
+ external := false
117
+ for _, node := range view.Nodes {
118
+ udp = udp || node.Protocol == "UDP"
119
+ external = external || (node.Type == "external" && node.Label == "resolver.example")
120
+ }
121
+ if !udp || !external {
122
+ t.Fatalf("missing UDP/external: %+v", view)
123
+ }
124
+ }
125
+
126
+ func TestTopologyEndpointSliceUsesReadyPortAndPodUID(t *testing.T) {
127
+ service := topologyFixture("kubernetes.services", "team", "web", `{"spec":{"ports":[{"name":"http","port":80,"targetPort":8080,"protocol":"TCP"}]}}`)
128
+ endpoints := topologyFixture("kubernetes.endpointslices", "team", "web-endpoints", `{"metadata":{"labels":{"kubernetes.io/service-name":"web"}},"ports":[{"name":"http","port":8080,"protocol":"TCP"}],"endpoints":[{"targetRef":{"kind":"Pod","name":"web-1","uid":"web-1-uid"},"conditions":{"serving":true}}]}`)
129
+ pod := topologyFixture("kubernetes.pods", "team", "web-1", `{}`)
130
+ view := fixtureView(service, endpoints, pod)
131
+ resolved := false
132
+ for _, edge := range view.Edges {
133
+ if edge.Relation == "resolves" && strings.Contains(edge.Target, "web-1-uid") && edge.ResolutionState == "resolved" {
134
+ resolved = true
135
+ }
136
+ }
137
+ if !resolved {
138
+ t.Fatal("ready=nil EndpointSlice with matching service label, port and Pod UID did not resolve")
139
+ }
140
+ endpoints.Objects[0].Content = strings.Replace(endpoints.Objects[0].Content, `"uid":"web-1-uid"`, `"uid":"old-pod-uid"`, 1)
141
+ view = fixtureView(service, endpoints, pod)
142
+ for _, node := range view.Nodes {
143
+ if node.Type == "pod" {
144
+ t.Fatal("stale Pod UID resolved to its replacement")
145
+ }
146
+ }
147
+ }
148
+ func TestTopologyGatewayGrantListenerAndZeroWeight(t *testing.T) {
149
+ route := topologyFixture("gateway.httproutes", "team", "route", `{"spec":{"parentRefs":[{"name":"gateway","namespace":"infra","sectionName":"https"}],"rules":[{"backendRefs":[{"name":"web","namespace":"backend","port":80,"weight":0},{"name":"web","namespace":"backend","port":80,"weight":2}]}]}}`)
150
+ gateway := topologyFixture("gateway.gateways", "infra", "gateway", `{"spec":{"listeners":[{"name":"https","port":443,"protocol":"HTTPS","allowedRoutes":{"namespaces":{"from":"All"}}}]}}`)
151
+ service := topologyFixture("kubernetes.services", "backend", "web", `{"spec":{"type":"ExternalName","externalName":"upstream.example","ports":[{"port":80}]}}`)
152
+ grant := topologyFixture("gateway.referencegrants", "backend", "allow", `{"spec":{"from":[{"group":"gateway.networking.k8s.io","kind":"HTTPRoute","namespace":"team"}],"to":[{"group":"","kind":"Service","name":"web"}]}}`)
153
+ view := fixtureView(route, gateway, service, grant)
154
+ zero, parent := false, false
155
+ for _, edge := range view.Edges {
156
+ zero = zero || (edge.Relation == "weights" && edge.Weight != nil && *edge.Weight == 0 && edge.TotalWeight != nil && *edge.TotalWeight == 2 && edge.ResolutionState == "resolved")
157
+ parent = parent || (edge.Relation == "matches" && strings.Contains(edge.Source, "gateway-uid") && edge.ResolutionState == "resolved")
158
+ }
159
+ if !zero || !parent {
160
+ t.Fatalf("lost granted backend/listener/zero semantics: %+v", view.Edges)
161
+ }
162
+ view = fixtureView(route, gateway, service, cr.NetworkTopologySourceResult{Coverage: cr.NetworkTopologyCoverage{SourceID: "gateway.referencegrants", Namespace: "backend", State: "available"}})
163
+ for _, edge := range view.Edges {
164
+ if edge.Relation == "weights" && edge.ResolutionState == "resolved" {
165
+ t.Fatal("cross-namespace backend resolved without ReferenceGrant")
166
+ }
167
+ }
168
+ }
169
+
170
+ func TestTopologyRejectsRouteProtocolServicePortMismatch(t *testing.T) {
171
+ route := topologyFixture("kubernetes.ingresses", "team", "http", `{"spec":{"rules":[{"http":{"paths":[{"path":"/","backend":{"service":{"name":"dns","port":{"number":53}}}}]}}]}}`)
172
+ service := topologyFixture("kubernetes.services", "team", "dns", `{"spec":{"type":"ExternalName","externalName":"dns.example","ports":[{"port":53,"protocol":"UDP"}]}}`)
173
+ view := fixtureView(route, service)
174
+ found := false
175
+ for _, edge := range view.Edges {
176
+ if edge.Relation == "forwards" {
177
+ found = true
178
+ if edge.ResolutionState != "unresolved" {
179
+ t.Fatal("HTTP ingress accepted a UDP Service port")
180
+ }
181
+ }
182
+ }
183
+ if !found {
184
+ t.Fatal("missing configured relationship")
185
+ }
186
+ }