@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,195 @@
1
+ package runtime
2
+
3
+ import (
4
+ "fmt"
5
+ cr "github.com/opensoha/soha-contracts/resource"
6
+ "k8s.io/apimachinery/pkg/labels"
7
+ "strings"
8
+ )
9
+
10
+ func (g *topologyGraph) backend(parent, namespace, name, port, protocol string) string {
11
+ service, ok := g.objects[topologyKey("Service", namespace, name)]
12
+ if !ok {
13
+ return g.unknown(parent, "Service", namespace, name, "")
14
+ }
15
+ id := g.service(service, port)
16
+ if port != "" && !servicePortExists(service, port, protocol) {
17
+ g.properties(id, cr.NetworkTopologyProperty{Name: "Unresolved service port", Value: port})
18
+ }
19
+ return id
20
+ }
21
+ func servicePortExists(service topologyObject, port, protocol string) bool {
22
+ for _, value := range items(at(service.body, "spec", "ports")) {
23
+ if text(value["name"]) == port || text(value["port"]) == port {
24
+ expected := "TCP"
25
+ if protocol == "UDP" {
26
+ expected = "UDP"
27
+ }
28
+ return protocol == "" || firstTopology(text(value["protocol"]), "TCP") == expected
29
+ }
30
+ }
31
+ return false
32
+ }
33
+
34
+ func (g *topologyGraph) service(service topologyObject, port string) string {
35
+ id := g.objectNode(service, "service", "kubernetes", "")
36
+ if g.expanded[id+":"+port] {
37
+ return id
38
+ }
39
+ g.expanded[id+":"+port] = true
40
+ if text(at(service.body, "spec", "type")) == "ExternalName" {
41
+ target := g.addNode(cr.NetworkTopologyGraphNode{ID: id + "/external", Type: "external", Label: text(at(service.body, "spec", "externalName")), Provider: "kubernetes", ResolutionState: "external"})
42
+ g.link(id, target, "resolves", "", port)
43
+ return id
44
+ }
45
+ if port != "" && !servicePortExists(service, port, "") {
46
+ return id
47
+ }
48
+ slices := []topologyObject{}
49
+ for _, object := range g.ordered {
50
+ if g.ctx.Err() != nil {
51
+ break
52
+ }
53
+ if object.ref.Kind == "EndpointSlice" && object.ref.Namespace == service.ref.Namespace && text(at(object.body, "metadata", "labels", "kubernetes.io/service-name")) == service.ref.Name {
54
+ slices = append(slices, object)
55
+ }
56
+ }
57
+ ready := 0
58
+ for _, slice := range slices {
59
+ ready += g.endpointSlice(service, slice, id, port)
60
+ }
61
+ if g.completeSource("kubernetes.endpointslices", service.ref.Namespace) && ready == 0 {
62
+ g.state(id, "no_ready_endpoints")
63
+ } else if ready > 0 {
64
+ g.state(id, "resolved")
65
+ }
66
+ g.serviceSelector(service, id)
67
+ if len(slices) == 0 && !g.completeSource("kubernetes.endpointslices", service.ref.Namespace) {
68
+ g.state(id, "unknown")
69
+ }
70
+ return id
71
+ }
72
+
73
+ func (g *topologyGraph) endpointSlice(service, slice topologyObject, serviceID, port string) int {
74
+ ready := 0
75
+ if !endpointPortMatches(service, slice, port) {
76
+ return 0
77
+ }
78
+ groupID := g.objectNode(slice, "endpoint-group", "kubernetes", "")
79
+ edge := g.link(serviceID, groupID, "resolves", "", port)
80
+ edge.SourceRef = &slice.ref
81
+ edge.FieldPath = "metadata.labels[kubernetes.io/service-name]"
82
+ edge.Note = "Explicit service-name protocol label"
83
+ g.expanded["slice:"+slice.id()] = true
84
+ for index, endpoint := range items(slice.body["endpoints"]) {
85
+ conditions := object(endpoint["conditions"])
86
+ eligible := conditions["ready"] != false
87
+ if eligible {
88
+ ready++
89
+ }
90
+ podRef := object(endpoint["targetRef"])
91
+ state := "resolved"
92
+ if !eligible {
93
+ state = "no_ready_endpoints"
94
+ }
95
+ if text(podRef["kind"]) == "Pod" && (text(podRef["apiVersion"]) == "" || text(podRef["apiVersion"]) == "v1") {
96
+ g.endpointPod(slice, groupID, podRef, state, index)
97
+ continue
98
+ }
99
+ target := g.addNode(cr.NetworkTopologyGraphNode{ID: fmt.Sprintf("%s/endpoint/%d", slice.id(), index), Type: "external", Label: strings.Join(texts(endpoint["addresses"]), ", "), Provider: "kubernetes", ResolutionState: "external"})
100
+ edge := g.link(groupID, target, "resolves", "", port)
101
+ edge.ResolutionState = state
102
+ edge.Note = fmt.Sprintf("ready=%v, serving=%v, terminating=%v", eligible, conditions["serving"] != false, conditions["terminating"] == true)
103
+ }
104
+ if ready == 0 {
105
+ g.state(groupID, "no_ready_endpoints")
106
+ }
107
+ return ready
108
+ }
109
+ func endpointPortMatches(service, slice topologyObject, port string) bool {
110
+ if port == "" {
111
+ return true
112
+ }
113
+ name := ""
114
+ protocol := "TCP"
115
+ for _, servicePort := range items(at(service.body, "spec", "ports")) {
116
+ if text(servicePort["name"]) == port || text(servicePort["port"]) == port {
117
+ name = text(servicePort["name"])
118
+ protocol = firstTopology(text(servicePort["protocol"]), "TCP")
119
+ }
120
+ }
121
+ for _, endpointPort := range items(slice.body["ports"]) {
122
+ if text(endpointPort["name"]) == name && firstTopology(text(endpointPort["protocol"]), "TCP") == protocol && endpointPort["port"] != nil {
123
+ return true
124
+ }
125
+ }
126
+ return false
127
+ }
128
+ func (g *topologyGraph) endpointPod(slice topologyObject, group string, ref map[string]any, state string, index int) {
129
+ namespace := firstTopology(text(ref["namespace"]), slice.ref.Namespace)
130
+ pod, ok := g.objects[topologyKey("Pod", namespace, text(ref["name"]))]
131
+ if !ok {
132
+ g.unresolvedEndpoint(slice, group, ref, namespace, index, g.referenceState("Pod", namespace))
133
+ return
134
+ }
135
+ if uid := text(ref["uid"]); uid == "" || uid != pod.ref.UID {
136
+ g.unresolvedEndpoint(slice, group, ref, namespace, index, "unresolved")
137
+ return
138
+ }
139
+ id := g.objectNode(pod, "pod", "kubernetes", "")
140
+ ports := items(slice.body["ports"])
141
+ if len(ports) == 0 {
142
+ ports = []map[string]any{{}}
143
+ }
144
+ for _, port := range ports {
145
+ edge := g.link(group, id, "resolves", firstTopology(text(port["protocol"]), "TCP"), text(port["port"]))
146
+ edge.ResolutionState = state
147
+ edge.SectionName = text(port["name"])
148
+ edge.FieldPath = fmt.Sprintf("endpoints[%d].targetRef", index)
149
+ edge.Note = "EndpointSlice targetRef with matching UID; readiness belongs to this endpoint"
150
+ }
151
+ }
152
+ func (g *topologyGraph) serviceSelector(service topologyObject, id string) {
153
+ selector := servicePodSelector(object(at(service.body, "spec", "selector")))
154
+ if selector == nil {
155
+ return
156
+ }
157
+ for _, pod := range g.ordered {
158
+ if g.ctx.Err() != nil || g.budgetReached() {
159
+ return
160
+ }
161
+ if pod.ref.Kind != "Pod" || pod.ref.Namespace != service.ref.Namespace {
162
+ continue
163
+ }
164
+ podLabels := labels.Set{}
165
+ for key, value := range object(at(pod.body, "metadata", "labels")) {
166
+ if label, ok := value.(string); ok {
167
+ podLabels[key] = label
168
+ }
169
+ }
170
+ if selector.Matches(podLabels) {
171
+ target := g.objectNode(pod, "pod", "kubernetes", "")
172
+ edge := g.link(id, target, "selects", "", "")
173
+ edge.ResolutionState = "resolved"
174
+ edge.Note = "Selector match only; does not establish endpoint readiness or traffic"
175
+ edge.FieldPath = "spec.selector"
176
+ }
177
+ }
178
+ }
179
+
180
+ func (g *topologyGraph) unresolvedEndpoint(slice topologyObject, group string, ref map[string]any, namespace string, index int, state string) {
181
+ target := g.unknown(fmt.Sprintf("%s/%d", group, index), "Pod", namespace, text(ref["name"]), state)
182
+ if hidden, ok := g.hiddenRefs[target]; ok {
183
+ hidden.UID = text(ref["uid"])
184
+ g.hiddenRefs[target] = hidden
185
+ }
186
+ if n, ok := g.nodes[target]; ok && n.Resource != nil {
187
+ n.Resource.UID = text(ref["uid"])
188
+ g.nodes[target] = n
189
+ }
190
+ edge := g.link(group, target, "resolves", "", "")
191
+ edge.ResolutionState = state
192
+ edge.SourceRef = &slice.ref
193
+ edge.FieldPath = fmt.Sprintf("endpoints[%d].targetRef", index)
194
+ edge.Note = "Declared targetRef not confirmed by matching Pod UID"
195
+ }
@@ -0,0 +1,86 @@
1
+ package runtime
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestGatewayL4TopologyKeepsListenerPortsWeightsAndTLSMatches(t *testing.T) {
10
+ for _, protocol := range []string{"TCP", "TLS", "UDP"} {
11
+ t.Run(protocol, func(t *testing.T) {
12
+ kind := protocol + "Route"
13
+ source := "gateway." + strings.ToLower(protocol) + "routes"
14
+ tls := ""
15
+ if protocol == "TLS" {
16
+ tls = `,"tls":{"mode":"Passthrough"},"hostname":"*.example.test"`
17
+ }
18
+ view := fixtureView(
19
+ topologyFixture("gateway.gateways", "team", "gw", fmt.Sprintf(`{"spec":{"listeners":[{"name":"l4","port":9000,"protocol":%q%s},{"name":"other","port":80,"protocol":"HTTP"}]}}`, protocol, tls)),
20
+ topologyFixture(source, "team", "route", `{"spec":{"hostnames":["app.example.test"],"parentRefs":[{"name":"gw"}],"rules":[{"backendRefs":[{"name":"web","port":9000,"weight":0},{"name":"web","port":9000,"weight":3}]}]}}`),
21
+ topologyFixture("kubernetes.services", "team", "web", fmt.Sprintf(`{"spec":{"ports":[{"port":9000,"protocol":%q}]}}`, map[bool]string{true: "UDP", false: "TCP"}[protocol == "UDP"])),
22
+ )
23
+ parent, zero, total, ruleFound := false, false, false, false
24
+ for _, node := range view.Nodes {
25
+ if node.Resource != nil && node.Resource.Kind == kind && node.Rule != nil {
26
+ ruleFound = node.Protocol == protocol
27
+ if protocol == "TLS" && (node.Rule.TLSMode != "Passthrough" || len(node.Rule.MatchGroups) != 1 || node.Rule.MatchGroups[0][0].Value != "app.example.test") {
28
+ t.Fatalf("TLS evidence lost: %+v", node.Rule)
29
+ }
30
+ }
31
+ }
32
+ for _, edge := range view.Edges {
33
+ if edge.Relation == "matches" && edge.Port == "9000" && edge.Protocol == protocol && edge.ResolutionState == "resolved" {
34
+ parent = true
35
+ }
36
+ if edge.Relation == "weights" && edge.Protocol == protocol && edge.Port == "9000" {
37
+ zero = zero || edge.Weight != nil && *edge.Weight == 0
38
+ total = total || edge.TotalWeight != nil && *edge.TotalWeight == 3
39
+ }
40
+ }
41
+ if !parent || !zero || !total || !ruleFound {
42
+ t.Fatalf("lost L4 evidence: %+v", view)
43
+ }
44
+ })
45
+ }
46
+ }
47
+
48
+ func TestGatewayL4TopologyRejectsWrongListenerSNIAndServiceProtocol(t *testing.T) {
49
+ for _, test := range []struct{ name, kind, listener, backendProtocol string }{
50
+ {"tcp-vs-udp", "TCP", `{"name":"entry","protocol":"UDP","port":9000}`, "TCP"},
51
+ {"tls-termination", "TLS", `{"name":"entry","protocol":"TLS","port":9000,"tls":{"mode":"Terminate"}}`, "TCP"},
52
+ {"wrong-sni", "TLS", `{"name":"entry","protocol":"TLS","port":9000,"hostname":"other.test","tls":{"mode":"Passthrough"}}`, "TCP"},
53
+ {"wrong-service-protocol", "UDP", `{"name":"entry","protocol":"UDP","port":9000}`, "TCP"},
54
+ } {
55
+ t.Run(test.name, func(t *testing.T) {
56
+ view := fixtureView(topologyFixture("gateway.gateways", "team", "gw", `{"spec":{"listeners":[`+test.listener+`]}}`), topologyFixture("gateway."+strings.ToLower(test.kind)+"routes", "team", "route", `{"spec":{"hostnames":["app.test"],"parentRefs":[{"name":"gw","sectionName":"entry"}],"rules":[{"backendRefs":[{"name":"web","port":9000}]}]}}`), topologyFixture("kubernetes.services", "team", "web", fmt.Sprintf(`{"spec":{"ports":[{"port":9000,"protocol":%q}]}}`, test.backendProtocol)))
57
+ found := false
58
+ for _, edge := range view.Edges {
59
+ if test.name == "wrong-service-protocol" {
60
+ found = found || edge.Relation == "weights" && edge.ResolutionState == "unresolved"
61
+ } else {
62
+ found = found || edge.Relation == "matches" && edge.Port == "" && edge.ResolutionState != "resolved"
63
+ }
64
+ }
65
+ if !found {
66
+ t.Fatalf("invalid L4 relationship accepted: %+v", view.Edges)
67
+ }
68
+ })
69
+ }
70
+ }
71
+
72
+ func TestGatewayL4CrossNamespaceBackendRequiresMatchingReferenceGrant(t *testing.T) {
73
+ route := topologyFixture("gateway.tcproutes", "team", "route", `{"spec":{"rules":[{"backendRefs":[{"name":"private-backend","namespace":"other","port":9000}]}]}}`)
74
+ service := topologyFixture("kubernetes.services", "other", "private-backend", `{"spec":{"ports":[{"port":9000}]}}`)
75
+ for _, kind := range []string{"HTTPRoute", "TCPRoute"} {
76
+ grant := topologyFixture("gateway.referencegrants", "other", "grant", fmt.Sprintf(`{"spec":{"from":[{"group":"gateway.networking.k8s.io","kind":%q,"namespace":"team"}],"to":[{"group":"","kind":"Service","name":"private-backend"}]}}`, kind))
77
+ view := fixtureView(route, service, grant)
78
+ linked := false
79
+ for _, edge := range view.Edges {
80
+ linked = linked || edge.Relation == "weights" && strings.Contains(edge.Target, "|Service|other|private-backend|")
81
+ }
82
+ if linked != (kind == "TCPRoute") {
83
+ t.Fatalf("ReferenceGrant kind boundary lost for %s: %+v", kind, view.Edges)
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,302 @@
1
+ package runtime
2
+
3
+ import (
4
+ "fmt"
5
+ cr "github.com/opensoha/soha-contracts/resource"
6
+ "strings"
7
+ )
8
+
9
+ func (g *topologyGraph) ingress(o topologyObject) {
10
+ provider := g.classProvider("IngressClass", text(at(o.body, "spec", "ingressClassName")), "kubernetes")
11
+ entry := g.objectNode(o, "entry", provider, "HTTP")
12
+ if len(items(at(o.body, "spec", "tls"))) > 0 {
13
+ g.properties(entry, cr.NetworkTopologyProperty{Name: "TLS", Value: "Configured; certificate and controller execution unverified"})
14
+ }
15
+ index := 0
16
+ active := func() bool { return g.ctx.Err() == nil && !g.budgetReached() }
17
+ forEachIngressBackendDeclaration(o.body, active, func(ref ingressBackendDeclaration) {
18
+ matches := [][]cr.NetworkTopologyMatch{{{Type: "host", Value: ref.host}, {Type: "path", Operator: text(ref.path["pathType"]), Value: text(ref.path["path"])}}}
19
+ rule := g.rule(o, provider, "HTTP", index, "", matches)
20
+ index++
21
+ g.link(entry, rule, "matches", "HTTP", "")
22
+ if !ref.serviceDeclared {
23
+ g.state(rule, "unsupported_fragment")
24
+ g.properties(rule, cr.NetworkTopologyProperty{Name: "Backend", Value: "Custom resource backend requires its provider resolver"})
25
+ return
26
+ }
27
+ target := g.backend(rule, o.ref.Namespace, ref.serviceName, ref.port, "HTTP")
28
+ edge := g.link(rule, target, "forwards", "HTTP", ref.port)
29
+ edge.FieldPath = ref.fieldPath
30
+ edge.SourceRef = &o.ref
31
+ })
32
+ for _, tls := range items(at(o.body, "spec", "tls")) {
33
+ g.properties(entry, cr.NetworkTopologyProperty{Name: "TLS hosts", Value: strings.Join(texts(tls["hosts"]), ", ")})
34
+ }
35
+ }
36
+
37
+ func (g *topologyGraph) classProvider(kind, name, fallback string) string {
38
+ class, ok := g.objects[topologyKey(kind, "", name)]
39
+ if !ok {
40
+ return fallback
41
+ }
42
+ controller := text(at(class.body, "spec", "controller"))
43
+ if kind == "GatewayClass" {
44
+ controller = text(at(class.body, "spec", "controllerName"))
45
+ }
46
+ switch controller {
47
+ case "traefik.io/ingress-controller", "traefik.io/gateway-controller":
48
+ return "traefik"
49
+ case "apisix.apache.org/ingress-controller", "apisix.apache.org/apisix-ingress-controller":
50
+ return "apisix"
51
+ }
52
+ return fallback
53
+ }
54
+
55
+ func (g *topologyGraph) gatewayRoute(o topologyObject) {
56
+ protocol := gatewayRouteProtocol(o.ref.Kind)
57
+ entry := g.objectNode(o, "entry", "gateway", protocol)
58
+ g.properties(entry, cr.NetworkTopologyProperty{Name: "Hostnames", Value: strings.Join(texts(at(o.body, "spec", "hostnames")), ", ")})
59
+ for _, parent := range items(at(o.body, "spec", "parentRefs")) {
60
+ g.gatewayParent(o, entry, parent, protocol)
61
+ }
62
+ for index, raw := range items(at(o.body, "spec", "rules")) {
63
+ groups := gatewayMatchGroups(raw)
64
+ if o.ref.Kind == "TLSRoute" {
65
+ for _, host := range texts(at(o.body, "spec", "hostnames")) {
66
+ groups = append(groups, []cr.NetworkTopologyMatch{{Type: "sni", Value: host}})
67
+ }
68
+ }
69
+ rule := g.rule(o, "gateway", protocol, index, text(raw["name"]), groups)
70
+ if o.ref.Kind == "TLSRoute" && rule != "" {
71
+ node := g.nodes[rule]
72
+ node.Rule.TLSMode = "Passthrough"
73
+ g.nodes[rule] = node
74
+ }
75
+ g.link(entry, rule, "matches", protocol, "")
76
+ total := int64(0)
77
+ for _, backend := range items(raw["backendRefs"]) {
78
+ total += integer(backend["weight"], 1)
79
+ }
80
+ for backendIndex, backend := range items(raw["backendRefs"]) {
81
+ edge := g.gatewayBackend(o, rule, backend, "weights", protocol)
82
+ edge.FieldPath = fmt.Sprintf("spec.rules[%d].backendRefs[%d]", index, backendIndex)
83
+ edge.SourceRef = &o.ref
84
+ if total >= 0 {
85
+ edge.TotalWeight = &total
86
+ }
87
+ }
88
+ for filterIndex, filter := range items(raw["filters"]) {
89
+ g.gatewayFilter(o, rule, filter, protocol, fmt.Sprintf("spec.rules[%d].filters[%d]", index, filterIndex))
90
+ }
91
+ }
92
+ g.gatewayConditions(o, entry)
93
+ }
94
+
95
+ func gatewayRouteProtocol(kind string) string {
96
+ switch kind {
97
+ case "GRPCRoute":
98
+ return "GRPC"
99
+ case "TCPRoute":
100
+ return "TCP"
101
+ case "TLSRoute":
102
+ return "TLS"
103
+ case "UDPRoute":
104
+ return "UDP"
105
+ default:
106
+ return "HTTP"
107
+ }
108
+ }
109
+
110
+ func gatewayMatchGroups(rule map[string]any) [][]cr.NetworkTopologyMatch {
111
+ result := [][]cr.NetworkTopologyMatch{}
112
+ for _, match := range items(rule["matches"]) {
113
+ group := []cr.NetworkTopologyMatch{}
114
+ if path := object(match["path"]); len(path) > 0 {
115
+ group = append(group, cr.NetworkTopologyMatch{Type: "path", Operator: text(path["type"]), Value: text(path["value"])})
116
+ }
117
+ if method, ok := match["method"].(string); ok {
118
+ group = append(group, cr.NetworkTopologyMatch{Type: "method", Value: method})
119
+ } else if method := object(match["method"]); len(method) > 0 {
120
+ group = append(group, cr.NetworkTopologyMatch{Type: "grpc-method", Operator: text(method["type"]), Name: text(method["service"]), Value: text(method["method"])})
121
+ }
122
+ for _, kind := range []string{"headers", "queryParams"} {
123
+ for _, value := range items(match[kind]) {
124
+ group = append(group, cr.NetworkTopologyMatch{Type: kind, Name: text(value["name"]), Operator: text(value["type"]), Value: "[redacted]"})
125
+ }
126
+ }
127
+ result = append(result, group)
128
+ }
129
+ return result
130
+ }
131
+
132
+ func (g *topologyGraph) gatewayBackend(o topologyObject, rule string, backend map[string]any, relation, protocol string) *cr.NetworkTopologyGraphEdge {
133
+ ns := firstTopology(text(backend["namespace"]), o.ref.Namespace)
134
+ kind := firstTopology(text(backend["kind"]), "Service")
135
+ name := text(backend["name"])
136
+ port := text(backend["port"])
137
+ if kind != "Service" || text(backend["group"]) != "" {
138
+ return g.link(rule, g.unknown(rule, kind, ns, name, "unsupported_fragment"), relation, protocol, port)
139
+ }
140
+ target := ""
141
+ if ns != o.ref.Namespace && !g.grantsBackend(o, ns, name) {
142
+ state := "unknown"
143
+ if g.completeSource("gateway.referencegrants", ns) {
144
+ state = "unresolved"
145
+ }
146
+ target = g.unknown(rule, kind, ns, name, state)
147
+ } else {
148
+ target = g.backend(rule, ns, name, port, protocol)
149
+ }
150
+ edge := g.link(rule, target, relation, protocol, port)
151
+ if service, ok := g.objects[topologyKey("Service", ns, name)]; ok && target == service.id() && port != "" && !servicePortExists(service, port, protocol) {
152
+ edge.ResolutionState = "unresolved"
153
+ edge.Note = "Service port/protocol does not match the route backend"
154
+ }
155
+ weight := integer(backend["weight"], 1)
156
+ if weight >= 0 {
157
+ edge.Weight = &weight
158
+ } else {
159
+ edge.ResolutionState = "unsupported_fragment"
160
+ }
161
+ return edge
162
+ }
163
+
164
+ func (g *topologyGraph) grantsBackend(o topologyObject, namespace, name string) bool {
165
+ return g.referenceAuthorized(o, "", "Service", namespace, name)
166
+ }
167
+
168
+ func (g *topologyGraph) completeSource(source, namespace string) bool {
169
+ for _, coverage := range g.view.Coverage {
170
+ if coverage.Name == "" && coverage.SourceID == source && (coverage.Namespace == "" || coverage.Namespace == namespace) && coverage.State == "available" && !coverage.Truncated {
171
+ return true
172
+ }
173
+ }
174
+ return false
175
+ }
176
+
177
+ func (g *topologyGraph) gatewayParent(o topologyObject, entry string, parent map[string]any, protocol string) {
178
+ ns := firstTopology(text(parent["namespace"]), o.ref.Namespace)
179
+ name := text(parent["name"])
180
+ if (text(parent["kind"]) != "" && text(parent["kind"]) != "Gateway") || (text(parent["group"]) != "" && text(parent["group"]) != "gateway.networking.k8s.io") {
181
+ g.state(entry, "unsupported_fragment")
182
+ return
183
+ }
184
+ gateway, ok := g.objects[topologyKey("Gateway", ns, name)]
185
+ if !ok {
186
+ edge := g.link(g.unknown(entry, "Gateway", ns, name, ""), entry, "matches", protocol, text(parent["port"]))
187
+ edge.SourceRef = &o.ref
188
+ edge.FieldPath = "spec.parentRefs"
189
+ edge.SectionName = text(parent["sectionName"])
190
+ edge.ResolutionState = g.referenceState("Gateway", ns)
191
+ return
192
+ }
193
+ parentID := g.objectNode(gateway, "entry", g.classProvider("GatewayClass", text(at(gateway.body, "spec", "gatewayClassName")), "gateway"), "")
194
+ if !g.expanded["listeners:"+parentID] {
195
+ g.expanded["listeners:"+parentID] = true
196
+ for _, listener := range items(at(gateway.body, "spec", "listeners")) {
197
+ g.properties(parentID, cr.NetworkTopologyProperty{Name: "Listener " + text(listener["name"]), Value: strings.Join([]string{text(listener["protocol"]), text(listener["port"]), text(listener["hostname"]), text(at(listener, "tls", "mode"))}, " · ")})
198
+ }
199
+ }
200
+ section := text(parent["sectionName"])
201
+ port := text(parent["port"])
202
+ state := "unresolved"
203
+ ports := []string{}
204
+ for _, listener := range items(at(gateway.body, "spec", "listeners")) {
205
+ if (section == "" || text(listener["name"]) == section) && (port == "" || text(listener["port"]) == port) {
206
+ if g.listenerAllows(o, ns, listener) && gatewayListenerProtocolAllows(o, listener) {
207
+ state = "resolved"
208
+ ports = append(ports, text(listener["port"]))
209
+ } else if state != "resolved" {
210
+ state = "unknown"
211
+ }
212
+ }
213
+ }
214
+ edge := g.link(parentID, entry, "matches", protocol, firstTopology(port, strings.Join(ports, ",")))
215
+ edge.ResolutionState = state
216
+ edge.SourceRef = &o.ref
217
+ edge.SectionName = section
218
+ edge.FieldPath = "spec.parentRefs"
219
+ edge.Note = "Listener " + section + "; attachment must also be accepted by the controller"
220
+ }
221
+
222
+ func gatewayListenerProtocolAllows(o topologyObject, listener map[string]any) bool {
223
+ protocol := text(listener["protocol"])
224
+ switch o.ref.Kind {
225
+ case "TCPRoute":
226
+ return protocol == "TCP"
227
+ case "UDPRoute":
228
+ return protocol == "UDP"
229
+ case "TLSRoute":
230
+ if protocol != "TLS" || text(at(listener, "tls", "mode")) != "Passthrough" {
231
+ return false
232
+ }
233
+ listenerHost := text(listener["hostname"])
234
+ for _, host := range texts(at(o.body, "spec", "hostnames")) {
235
+ if listenerHost == "" || gatewayHostMatches(host, listenerHost) || gatewayHostMatches(listenerHost, host) {
236
+ return true
237
+ }
238
+ }
239
+ return false
240
+ default:
241
+ return protocol == "HTTP" || protocol == "HTTPS"
242
+ }
243
+ }
244
+
245
+ func gatewayHostMatches(host, pattern string) bool {
246
+ return host == pattern || (strings.HasPrefix(pattern, "*.") && strings.HasSuffix(host, pattern[1:]) && len(host) > len(pattern)-1)
247
+ }
248
+ func (g *topologyGraph) listenerAllows(o topologyObject, namespace string, listener map[string]any) bool {
249
+ from := text(at(listener, "allowedRoutes", "namespaces", "from"))
250
+ if from == "Selector" {
251
+ ns, ok := g.objects[topologyKey("Namespace", "", o.ref.Namespace)]
252
+ if !ok || !g.completeSource("kubernetes.namespaces", "") || !topologySelectorMatches(object(at(listener, "allowedRoutes", "namespaces", "selector")), object(at(ns.body, "metadata", "labels"))) {
253
+ return false
254
+ }
255
+ }
256
+ if o.ref.Namespace != namespace && from != "All" && from != "Selector" {
257
+ return false
258
+ }
259
+ kinds := items(at(listener, "allowedRoutes", "kinds"))
260
+ if len(kinds) == 0 {
261
+ return true
262
+ }
263
+ for _, kind := range kinds {
264
+ if text(kind["kind"]) == o.ref.Kind && (text(kind["group"]) == "" || text(kind["group"]) == "gateway.networking.k8s.io") {
265
+ return true
266
+ }
267
+ }
268
+ return false
269
+ }
270
+ func (g *topologyGraph) gatewayFilter(o topologyObject, rule string, filter map[string]any, protocol, path string) {
271
+ kind := text(filter["type"])
272
+ if kind == "RequestMirror" {
273
+ mirror := object(filter["requestMirror"])
274
+ edge := g.gatewayBackend(o, rule, object(mirror["backendRef"]), "mirrors", protocol)
275
+ edge.FieldPath = path + ".requestMirror.backendRef"
276
+ edge.SourceRef = &o.ref
277
+ if value, ok := mirror["percent"].(float64); ok && value >= 0 && value <= 100 {
278
+ edge.MirrorPercent = &value
279
+ }
280
+ return
281
+ }
282
+ g.properties(rule, cr.NetworkTopologyProperty{Name: "Configured filter", Value: kind + " (execution order not inferred)"})
283
+ if kind != "RequestHeaderModifier" && kind != "ResponseHeaderModifier" {
284
+ g.state(rule, "unsupported_fragment")
285
+ }
286
+ }
287
+ func (g *topologyGraph) gatewayConditions(o topologyObject, id string) {
288
+ node := g.nodes[id]
289
+ for _, parent := range items(at(o.body, "status", "parents")) {
290
+ for _, condition := range items(parent["conditions"]) {
291
+ generation := integer(condition["observedGeneration"], 0)
292
+ node.Conditions = append(node.Conditions, cr.NetworkTopologyCondition{Type: text(condition["type"]), Status: text(condition["status"]), Reason: text(condition["reason"]), ObservedGeneration: &generation, LastTransitionTime: text(condition["lastTransitionTime"])})
293
+ if generation != o.generation || text(condition["status"]) != "True" {
294
+ node.ResolutionState = "unresolved"
295
+ }
296
+ }
297
+ }
298
+ if len(node.Conditions) == 0 {
299
+ node.ResolutionState = "unknown"
300
+ }
301
+ g.nodes[id] = node
302
+ }
@@ -0,0 +1,66 @@
1
+ package runtime
2
+
3
+ import cr "github.com/opensoha/soha-contracts/resource"
4
+
5
+ // Legacy traces are a bounded projection of the graph, never a separately inferred topology.
6
+ func (g *topologyGraph) projectLegacy() {
7
+ outgoing := map[string][]cr.NetworkTopologyGraphEdge{}
8
+ for _, edge := range g.view.Edges {
9
+ outgoing[edge.Source] = append(outgoing[edge.Source], edge)
10
+ }
11
+ for _, node := range g.view.Nodes {
12
+ if node.Type != "rule" {
13
+ continue
14
+ }
15
+ entry := node
16
+ for _, edge := range g.view.Edges {
17
+ if edge.Target == node.ID && g.nodes[edge.Source].Type == "entry" {
18
+ entry = g.nodes[edge.Source]
19
+ break
20
+ }
21
+ }
22
+ g.walkLegacy(entry, node, node.ID, outgoing, map[string]bool{}, 0)
23
+ }
24
+ }
25
+ func (g *topologyGraph) walkLegacy(entry, rule cr.NetworkTopologyGraphNode, id string, outgoing map[string][]cr.NetworkTopologyGraphEdge, seen map[string]bool, depth int) {
26
+ g.legacyWalks++
27
+ if g.legacyWalks > 2000 {
28
+ g.view.Truncated = true
29
+ return
30
+ }
31
+ if depth > 12 || seen[id] || len(g.view.Traces) >= cr.NetworkTopologyDefaultEdges {
32
+ return
33
+ }
34
+ next := cloneTopologySeen(seen)
35
+ next[id] = true
36
+ node := g.nodes[id]
37
+ if node.Type == "service" || len(outgoing[id]) == 0 {
38
+ state := "pending"
39
+ if node.ResolutionState == "resolved" {
40
+ state = "live"
41
+ }
42
+ sourceType := "ingress"
43
+ if rule.Resource != nil && (rule.Resource.Kind == "HTTPRoute" || rule.Resource.Kind == "GRPCRoute") {
44
+ sourceType = "httproute"
45
+ }
46
+ service := legacyTopologyNode(node, "service")
47
+ g.view.Traces = append(g.view.Traces, cr.NetworkTopologyTraceView{ID: rule.ID + ">" + id, SourceType: sourceType, State: state, Entry: legacyTopologyNode(entry, "entry"), Route: legacyTopologyNode(rule, "ingress-route"), Service: &service, Note: "Projected from rule topology; consult graph evidence for ports, weights and resolution state."})
48
+ return
49
+ }
50
+ for _, edge := range outgoing[id] {
51
+ if edge.Relation != "mirrors" {
52
+ g.walkLegacy(entry, rule, edge.Target, outgoing, next, depth+1)
53
+ }
54
+ }
55
+ }
56
+ func legacyTopologyNode(node cr.NetworkTopologyGraphNode, kind string) cr.NetworkTopologyNodeView {
57
+ result := cr.NetworkTopologyNodeView{ID: node.ID, Name: node.Label, Kind: kind, State: "pending", Badge: node.Provider, Subtitle: node.ResolutionState}
58
+ if node.ResolutionState == "resolved" {
59
+ result.State = "live"
60
+ }
61
+ if node.Resource != nil {
62
+ result.Namespace = node.Resource.Namespace
63
+ result.ResourceName = node.Resource.Name
64
+ }
65
+ return result
66
+ }