@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.
- package/COMPATIBILITY.md +4 -3
- package/README.md +8 -9
- package/compat/consumer-revisions.json +1 -1
- package/compat/json-schema-ecosystem-0.1.0.json +1 -1
- package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-length.json +3 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-type.json +3 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-alias.json +5 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-reset.json +3 -0
- package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/invalid/revision-overflow.json +4 -0
- package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/legacy.json +3 -0
- package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/observed-revision.json +4 -0
- package/fixtures/openapi/KubernetesJobDetail/invalid/execution-observation.json +23 -0
- package/fixtures/openapi/KubernetesJobDetail/valid/execution-observation.json +23 -0
- package/fixtures/openapi/KubernetesNode/invalid/labels.json +10 -0
- package/fixtures/openapi/KubernetesNode/valid/labels.json +11 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/invalid/empty-uid.json +13 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/invalid/invalid-container-state.json +20 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/invalid/missing-container-ready.json +19 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/valid/container-statuses.json +67 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/valid/running-pod.json +1 -0
- package/fixtures/openapi/KubernetesPrometheusDetail/invalid/metric-cluster-value-type.json +4 -0
- package/fixtures/openapi/KubernetesPrometheusDetail/valid/metric-cluster-alias.json +5 -0
- package/fixtures/openapi/KubernetesResourceGraphEnvelope/invalid/network-edge-source-payload.json +73 -0
- package/fixtures/openapi/KubernetesResourceGraphEnvelope/valid/network-edge-facts.json +67 -0
- package/fixtures/openapi/KubernetesSecretCertificate/invalid/secret-data.json +1 -0
- package/fixtures/openapi/KubernetesSecretCertificate/invalid/status.json +1 -0
- package/fixtures/openapi/KubernetesSecretCertificate/valid/available.json +1 -0
- package/fixtures/openapi/KubernetesSecretCertificate/valid/missing.json +1 -0
- package/gen/go/sohaapi/client.go +1 -1
- package/gen/go/sohaapi/client_test.go +13 -0
- package/gen/go/sohaapi/kubernetes_workload_optional_test.go +40 -0
- package/gen/go/sohaapi/pod_status_compat_test.go +36 -0
- package/gen/go/sohaapi/types.go +421 -88
- package/gen/ts/sohaapi/index.ts +756 -4
- package/helmrelease/runtime/values_upgrade.go +78 -0
- package/helmrelease/runtime/values_upgrade_test.go +123 -0
- package/helmrelease/values_update.go +31 -0
- package/openapi/soha-api.json +1191 -14
- package/openapi/soha-api.yaml +588 -28
- package/openapi/spec_generated.go +3 -3
- package/package.json +1 -1
- package/resource/agent_profiles.go +9 -15
- package/resource/agent_profiles_test.go +9 -3
- package/resource/custom_graph_page.go +18 -0
- package/resource/models.go +102 -57
- package/resource/network_topology.go +22 -0
- package/resource/network_topology_projection.go +36 -22
- package/resource/network_topology_projection_test.go +4 -0
- package/resource/network_topology_test.go +20 -0
- package/resource/network_topology_validation.go +5 -0
- package/resource/observed_workload.go +18 -0
- package/resource/pod_status_test.go +22 -0
- package/resource/rbac_graph.go +4 -0
- package/resource/rollout_optional_test.go +33 -0
- package/resource/runtime/network_relationship_primitives.go +66 -0
- package/resource/runtime/network_relationship_primitives_test.go +147 -0
- package/resource/runtime/network_topology.go +17 -5
- package/resource/runtime/network_topology_builder.go +405 -0
- package/resource/runtime/network_topology_builder_backends.go +195 -0
- package/resource/runtime/network_topology_builder_gateway_l4_test.go +86 -0
- package/resource/runtime/network_topology_builder_ingress.go +302 -0
- package/resource/runtime/network_topology_builder_legacy.go +66 -0
- package/resource/runtime/network_topology_builder_providers.go +242 -0
- package/resource/runtime/network_topology_builder_test.go +186 -0
- package/resource/runtime/network_topology_declarations.go +329 -0
- package/resource/runtime/resource_edit.go +22 -0
- package/resource/runtime/resource_edit_test.go +32 -0
- package/resource/runtime/resource_graph_collect.go +325 -0
- package/resource/runtime/resource_graph_configuration.go +59 -0
- package/resource/runtime/resource_graph_configuration_collect.go +198 -0
- package/resource/runtime/resource_graph_configuration_test.go +470 -0
- package/resource/runtime/resource_graph_custom_page.go +159 -0
- package/resource/runtime/resource_graph_custom_root.go +132 -0
- package/resource/runtime/resource_graph_custom_root_test.go +197 -0
- package/resource/runtime/resource_graph_enrichment.go +417 -0
- package/resource/runtime/resource_graph_enrichment_hpa_test.go +117 -0
- package/resource/runtime/resource_graph_enrichment_test.go +201 -0
- package/resource/runtime/resource_graph_metadata.go +125 -0
- package/resource/runtime/resource_graph_metadata_test.go +204 -0
- package/resource/runtime/resource_graph_network.go +224 -0
- package/resource/runtime/resource_graph_network_collect.go +174 -0
- package/resource/runtime/resource_graph_network_collect_test.go +147 -0
- package/resource/runtime/resource_graph_network_test.go +342 -0
- package/resource/runtime/resource_graph_rbac.go +278 -0
- package/resource/runtime/resource_graph_rbac_test.go +372 -0
- package/resource/runtime/resource_graph_rules.go +834 -0
- package/resource/runtime/resource_graph_storage.go +144 -0
- package/resource/runtime/resource_graph_storage_collect.go +163 -0
- package/resource/runtime/resource_graph_storage_test.go +397 -0
- package/resource/runtime/resource_graph_test.go +627 -0
- package/resource/runtime/secret_certificate.go +44 -0
- package/resource/runtime/secret_certificate_test.go +87 -0
- 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
|
+
}
|