@opensoha/contracts 0.1.26 → 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 (213) hide show
  1. package/COMPATIBILITY.md +14 -3
  2. package/README.md +8 -9
  3. package/auth/catalog_generated.go +2 -2
  4. package/auth/permission-catalog.json +481 -1
  5. package/compat/consumer-revisions.json +1 -1
  6. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  7. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  8. package/endpointcontrol/decode.go +64 -0
  9. package/endpointcontrol/models.go +138 -0
  10. package/endpointcontrol/models_test.go +51 -0
  11. package/endpointcontrol/protocol.schema.json +397 -0
  12. package/fixtures/json-schema/endpoint-controls/invalid/controls.json +16 -0
  13. package/fixtures/json-schema/endpoint-controls/invalid/missing-channel-decision.json +15 -0
  14. package/fixtures/json-schema/endpoint-controls/invalid/receipt-content.json +36 -0
  15. package/fixtures/json-schema/endpoint-controls/valid/controls.json +16 -0
  16. package/fixtures/json-schema/endpoint-controls/valid/receipt.json +35 -0
  17. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-activate-bad-fingerprint.json +14 -0
  18. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-gateway-request.json +14 -0
  19. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-private-key-request.json +15 -0
  20. package/fixtures/json-schema/network-runtime-protocol/invalid/eap-profile-without-eap-credential.json +22 -0
  21. package/fixtures/json-schema/network-runtime-protocol/invalid/endpoint-dns-observation.json +25 -0
  22. package/fixtures/json-schema/network-runtime-protocol/invalid/gateway-presence.json +22 -0
  23. package/fixtures/json-schema/network-runtime-protocol/invalid/mihomo-protection-zero-version.json +45 -0
  24. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-raw-key.json +23 -0
  25. package/fixtures/json-schema/network-runtime-protocol/invalid/presence-zero-sequence.json +22 -0
  26. package/fixtures/json-schema/network-runtime-protocol/invalid/workload-wide-vpn.json +17 -0
  27. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-activate.json +14 -0
  28. package/fixtures/json-schema/network-runtime-protocol/valid/eap-certificate-request.json +14 -0
  29. package/fixtures/json-schema/network-runtime-protocol/valid/eap-profile-enrollment.json +24 -0
  30. package/fixtures/json-schema/network-runtime-protocol/valid/endpoint-presence.json +22 -0
  31. package/fixtures/json-schema/network-runtime-protocol/valid/ipv6-configuration.json +102 -0
  32. package/fixtures/json-schema/network-runtime-protocol/valid/mihomo-protected-policy.json +45 -0
  33. package/fixtures/json-schema/network-runtime-protocol/valid/site-dns-ipv6.json +25 -0
  34. package/fixtures/json-schema/network-runtime-protocol/valid/workload-direct-ztna.json +20 -0
  35. package/fixtures/openapi/DLPShadowDecision/invalid/step-up-type.json +14 -0
  36. package/fixtures/openapi/DLPShadowDecision/valid/archive-v2.json +14 -0
  37. package/fixtures/openapi/DLPShadowDecision/valid/step-up.json +14 -0
  38. package/fixtures/openapi/DLPShadowInput/invalid/self-reported-result.json +7 -0
  39. package/fixtures/openapi/DLPShadowInput/invalid/too-many-samples.json +14 -0
  40. package/fixtures/openapi/DLPShadowInput/valid/bounded.json +7 -0
  41. package/fixtures/openapi/DockerVolumeDLPAppealInput/invalid/p4-response.json +3 -0
  42. package/fixtures/openapi/DockerVolumeDLPAppealInput/valid/p4-response.json +3 -0
  43. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/invalid/p4-response.json +5 -0
  44. package/fixtures/openapi/DockerVolumeDLPAppealReviewInput/valid/p4-response.json +5 -0
  45. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/invalid/p4-response.json +3 -0
  46. package/fixtures/openapi/DockerVolumeDLPAuthorizationInput/valid/p4-response.json +3 -0
  47. package/fixtures/openapi/DockerVolumeDLPContent/invalid/archive-unsupported-channel.json +9 -0
  48. package/fixtures/openapi/DockerVolumeDLPContent/invalid/p4.json +6 -0
  49. package/fixtures/openapi/DockerVolumeDLPContent/invalid/step-up-audit.json +9 -0
  50. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unavailable-engine.json +9 -0
  51. package/fixtures/openapi/DockerVolumeDLPContent/invalid/unknown-data-level.json +12 -0
  52. package/fixtures/openapi/DockerVolumeDLPContent/valid/classified-rule.json +16 -0
  53. package/fixtures/openapi/DockerVolumeDLPContent/valid/p4.json +8 -0
  54. package/fixtures/openapi/DockerVolumeDLPContent/valid/step-up.json +9 -0
  55. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/p4.json +9 -0
  56. package/fixtures/openapi/DockerVolumeDLPDecision/invalid/step-up-type.json +12 -0
  57. package/fixtures/openapi/DockerVolumeDLPDecision/valid/archive-v2.json +12 -0
  58. package/fixtures/openapi/DockerVolumeDLPDecision/valid/p4.json +11 -0
  59. package/fixtures/openapi/DockerVolumeDLPDecision/valid/step-up.json +12 -0
  60. package/fixtures/openapi/DockerVolumeDLPSaveInput/invalid/p4.json +11 -0
  61. package/fixtures/openapi/DockerVolumeDLPSaveInput/valid/p4.json +11 -0
  62. package/fixtures/openapi/DockerVolumeFileReadInput/invalid/p4-response.json +5 -0
  63. package/fixtures/openapi/DockerVolumeFileReadInput/valid/p4-response.json +4 -0
  64. package/fixtures/openapi/EndpointDeviceInput/invalid/reported-management.json +5 -0
  65. package/fixtures/openapi/EndpointDeviceInput/valid/management-state.json +5 -0
  66. package/fixtures/openapi/EndpointDeviceRegistrationInput/invalid/self-promote-management.json +5 -0
  67. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-length.json +3 -0
  68. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-type.json +3 -0
  69. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-alias.json +5 -0
  70. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-reset.json +3 -0
  71. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/invalid/revision-overflow.json +4 -0
  72. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/legacy.json +3 -0
  73. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/observed-revision.json +4 -0
  74. package/fixtures/openapi/KubernetesJobDetail/invalid/execution-observation.json +23 -0
  75. package/fixtures/openapi/KubernetesJobDetail/valid/execution-observation.json +23 -0
  76. package/fixtures/openapi/KubernetesNode/invalid/labels.json +10 -0
  77. package/fixtures/openapi/KubernetesNode/valid/labels.json +11 -0
  78. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/empty-uid.json +13 -0
  79. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/invalid-container-state.json +20 -0
  80. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/missing-container-ready.json +19 -0
  81. package/fixtures/openapi/KubernetesPodListEnvelope/valid/container-statuses.json +67 -0
  82. package/fixtures/openapi/KubernetesPodListEnvelope/valid/running-pod.json +1 -0
  83. package/fixtures/openapi/KubernetesPrometheusDetail/invalid/metric-cluster-value-type.json +4 -0
  84. package/fixtures/openapi/KubernetesPrometheusDetail/valid/metric-cluster-alias.json +5 -0
  85. package/fixtures/openapi/KubernetesResourceGraphEnvelope/invalid/network-edge-source-payload.json +73 -0
  86. package/fixtures/openapi/KubernetesResourceGraphEnvelope/valid/network-edge-facts.json +67 -0
  87. package/fixtures/openapi/KubernetesSecretCertificate/invalid/secret-data.json +1 -0
  88. package/fixtures/openapi/KubernetesSecretCertificate/invalid/status.json +1 -0
  89. package/fixtures/openapi/KubernetesSecretCertificate/valid/available.json +1 -0
  90. package/fixtures/openapi/KubernetesSecretCertificate/valid/missing.json +1 -0
  91. package/fixtures/openapi/ManagedContentPasteInput/invalid/boundary.json +5 -0
  92. package/fixtures/openapi/ManagedContentPasteInput/valid/boundary.json +6 -0
  93. package/fixtures/openapi/ManagedContentPolicy/invalid/self-reported-allow.json +8 -0
  94. package/fixtures/openapi/ManagedContentPolicy/invalid/step-up-audit.json +8 -0
  95. package/fixtures/openapi/ManagedContentPolicy/invalid/unknown-extractor.json +8 -0
  96. package/fixtures/openapi/ManagedContentPolicy/valid/archive-v2.json +8 -0
  97. package/fixtures/openapi/ManagedContentPolicy/valid/network-and-level.json +10 -0
  98. package/fixtures/openapi/ManagedContentPolicy/valid/step-up.json +8 -0
  99. package/fixtures/openapi/ManagedContentReviewInput/invalid/boundary.json +5 -0
  100. package/fixtures/openapi/ManagedContentReviewInput/valid/boundary.json +5 -0
  101. package/fixtures/openapi/ManagedContentSaveInput/invalid/boundary.json +14 -0
  102. package/fixtures/openapi/ManagedContentSaveInput/valid/boundary.json +16 -0
  103. package/fixtures/openapi/ManagedContentUploadInput/invalid/boundary.json +7 -0
  104. package/fixtures/openapi/ManagedContentUploadInput/valid/boundary.json +6 -0
  105. package/fixtures/openapi/NetworkAccessPolicyInput/invalid/unknown-management-state.json +35 -0
  106. package/fixtures/openapi/NetworkAccessPolicyInput/valid/managed-device.json +35 -0
  107. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/deviceId-characters.json +4 -0
  108. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/nasBindingId-characters.json +4 -0
  109. package/fixtures/openapi/NetworkAdmissionPreviewRequest/invalid/subject-override.json +1 -0
  110. package/fixtures/openapi/NetworkAdmissionPreviewRequest/valid/device-nas.json +1 -0
  111. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-attributeVersion.json +13 -0
  112. package/fixtures/openapi/NetworkAdmissionPreviewResult/invalid/missing-evaluatedAt.json +13 -0
  113. package/fixtures/openapi/NetworkAdmissionPreviewResult/valid/versioned.json +14 -0
  114. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/missing-runtime-ranges.json +1 -0
  115. package/fixtures/openapi/NetworkConflictAnalysisRequest/invalid/unknown-source-type.json +9 -0
  116. package/fixtures/openapi/NetworkConflictAnalysisRequest/valid/empty-runtime-ranges.json +3 -0
  117. package/fixtures/openapi/NetworkConflictAnalysisRequest/{invalid → valid}/ipv6-range.json +1 -1
  118. package/fixtures/openapi/NetworkEAPServerTrust/invalid/no-server-validation.json +5 -0
  119. package/fixtures/openapi/NetworkEAPServerTrust/valid/named-server.json +9 -0
  120. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/all-sites-implicit.json +45 -0
  121. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/unbounded-stages.json +54 -0
  122. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/invalid/zero-heartbeat.json +47 -0
  123. package/fixtures/openapi/NetworkEndpointResponsePolicyInput/valid/staged-protection.json +47 -0
  124. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/invalid/missing-reason.json +3 -0
  125. package/fixtures/openapi/NetworkEndpointResponseRestoreInput/valid/expected-current.json +4 -0
  126. package/fixtures/openapi/NetworkPolicyPreviewResult/invalid/attribute-version.json +1 -0
  127. package/fixtures/openapi/NetworkPolicyPreviewResult/valid/admission-version.json +1 -0
  128. package/fixtures/openapi/NetworkPolicySubjects/invalid/duplicate-role.json +1 -0
  129. package/fixtures/openapi/NetworkPolicySubjects/invalid/role-characters.json +5 -0
  130. package/fixtures/openapi/NetworkPolicySubjects/valid/role-bound.json +1 -0
  131. package/fixtures/openapi/NetworkProxyConfigurationInput/invalid/missing-content.json +1 -0
  132. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/bad-binding-id.json +9 -0
  133. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/invalid/empty-content.json +1 -0
  134. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/binding-only.json +11 -0
  135. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/clear-binding-disabled.json +5 -0
  136. package/fixtures/openapi/NetworkProxyConfigurationPatchInput/valid/enabled.json +1 -0
  137. package/fixtures/openapi/NetworkSiteInput/invalid/unknown-governance.json +6 -0
  138. package/fixtures/openapi/NetworkSiteInput/valid/restricted-unmanaged.json +6 -0
  139. package/gen/go/sohaapi/client.go +1 -1
  140. package/gen/go/sohaapi/client_test.go +13 -0
  141. package/gen/go/sohaapi/kubernetes_workload_optional_test.go +40 -0
  142. package/gen/go/sohaapi/pod_status_compat_test.go +36 -0
  143. package/gen/go/sohaapi/proxy_configuration_test.go +38 -0
  144. package/gen/go/sohaapi/types.go +3222 -260
  145. package/gen/ts/sohaapi/index.ts +15506 -10104
  146. package/helmrelease/runtime/values_upgrade.go +78 -0
  147. package/helmrelease/runtime/values_upgrade_test.go +123 -0
  148. package/helmrelease/values_update.go +31 -0
  149. package/network/README.md +34 -0
  150. package/network/network-radius-accounting.schema.json +4 -1
  151. package/network/network-runtime-protocol.schema.json +2166 -317
  152. package/network/proxy-runtime.schema.json +482 -67
  153. package/openapi/network_control_test.go +64 -0
  154. package/openapi/soha-api.json +50412 -41310
  155. package/openapi/soha-api.yaml +16859 -10699
  156. package/openapi/spec_generated.go +3 -3
  157. package/package.json +6 -3
  158. package/proxytransport/handler.go +271 -0
  159. package/proxytransport/probe.go +30 -0
  160. package/proxytransport/scope.go +89 -0
  161. package/proxytransport/transport_test.go +60 -0
  162. package/resource/agent_profiles.go +9 -15
  163. package/resource/agent_profiles_test.go +9 -3
  164. package/resource/custom_graph_page.go +18 -0
  165. package/resource/models.go +102 -57
  166. package/resource/network_topology.go +22 -0
  167. package/resource/network_topology_projection.go +36 -22
  168. package/resource/network_topology_projection_test.go +4 -0
  169. package/resource/network_topology_test.go +20 -0
  170. package/resource/network_topology_validation.go +5 -0
  171. package/resource/observed_workload.go +18 -0
  172. package/resource/pod_status_test.go +22 -0
  173. package/resource/rbac_graph.go +4 -0
  174. package/resource/rollout_optional_test.go +33 -0
  175. package/resource/runtime/network_relationship_primitives.go +66 -0
  176. package/resource/runtime/network_relationship_primitives_test.go +147 -0
  177. package/resource/runtime/network_topology.go +17 -5
  178. package/resource/runtime/network_topology_builder.go +405 -0
  179. package/resource/runtime/network_topology_builder_backends.go +195 -0
  180. package/resource/runtime/network_topology_builder_gateway_l4_test.go +86 -0
  181. package/resource/runtime/network_topology_builder_ingress.go +302 -0
  182. package/resource/runtime/network_topology_builder_legacy.go +66 -0
  183. package/resource/runtime/network_topology_builder_providers.go +242 -0
  184. package/resource/runtime/network_topology_builder_test.go +186 -0
  185. package/resource/runtime/network_topology_declarations.go +329 -0
  186. package/resource/runtime/resource_edit.go +22 -0
  187. package/resource/runtime/resource_edit_test.go +32 -0
  188. package/resource/runtime/resource_graph_collect.go +325 -0
  189. package/resource/runtime/resource_graph_configuration.go +59 -0
  190. package/resource/runtime/resource_graph_configuration_collect.go +198 -0
  191. package/resource/runtime/resource_graph_configuration_test.go +470 -0
  192. package/resource/runtime/resource_graph_custom_page.go +159 -0
  193. package/resource/runtime/resource_graph_custom_root.go +132 -0
  194. package/resource/runtime/resource_graph_custom_root_test.go +197 -0
  195. package/resource/runtime/resource_graph_enrichment.go +417 -0
  196. package/resource/runtime/resource_graph_enrichment_hpa_test.go +117 -0
  197. package/resource/runtime/resource_graph_enrichment_test.go +201 -0
  198. package/resource/runtime/resource_graph_metadata.go +125 -0
  199. package/resource/runtime/resource_graph_metadata_test.go +204 -0
  200. package/resource/runtime/resource_graph_network.go +224 -0
  201. package/resource/runtime/resource_graph_network_collect.go +174 -0
  202. package/resource/runtime/resource_graph_network_collect_test.go +147 -0
  203. package/resource/runtime/resource_graph_network_test.go +342 -0
  204. package/resource/runtime/resource_graph_rbac.go +278 -0
  205. package/resource/runtime/resource_graph_rbac_test.go +372 -0
  206. package/resource/runtime/resource_graph_rules.go +834 -0
  207. package/resource/runtime/resource_graph_storage.go +144 -0
  208. package/resource/runtime/resource_graph_storage_collect.go +163 -0
  209. package/resource/runtime/resource_graph_storage_test.go +397 -0
  210. package/resource/runtime/resource_graph_test.go +627 -0
  211. package/resource/runtime/secret_certificate.go +44 -0
  212. package/resource/runtime/secret_certificate_test.go +87 -0
  213. 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
+ }