@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,417 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9
+ "reflect"
10
+ "sort"
11
+ "strings"
12
+
13
+ cr "github.com/opensoha/soha-contracts/resource"
14
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
15
+ "k8s.io/apimachinery/pkg/types"
16
+ )
17
+
18
+ // ResourceGraphEnrichment contains an authorized observation, not a discovery
19
+ // request. Callers must establish the CRD's group/kind/version from its spec.
20
+ // Only the instance's GVK and metadata are read; unknown payload is ignored.
21
+ type ResourceGraphEnrichment struct {
22
+ TargetID string
23
+ Object *unstructured.Unstructured
24
+ Definition cr.ResourceRef
25
+ }
26
+
27
+ var ErrInvalidResourceGraphEnrichment = errors.New("invalid resource graph enrichment")
28
+
29
+ func invalidGraphEnrichment(reason string) error {
30
+ return fmt.Errorf("%w: %s", ErrInvalidResourceGraphEnrichment, reason)
31
+ }
32
+
33
+ // EnrichResourceGraph resolves authorized custom owner observations and adds
34
+ // metadata owner relationships and CRD defines edges. Existing IDs, edges,
35
+ // evidence and statuses are retained; no discovery, payload parsing or I/O occurs.
36
+ func EnrichResourceGraph(ctx context.Context, graph cr.ResourceGraph, enrichments []ResourceGraphEnrichment) (cr.ResourceGraph, error) {
37
+ ctx, cancel := context.WithTimeout(ctx, ResourceGraphTimeout)
38
+ defer cancel()
39
+ if err := ctx.Err(); err != nil {
40
+ return cr.ResourceGraph{}, err
41
+ }
42
+ if len(graph.Nodes) > ResourceGraphNodeLimit || len(graph.Edges) > ResourceGraphEdgeLimit || len(enrichments) > ResourceGraphWorkingNodeLimit {
43
+ return cr.ResourceGraph{}, ErrResourceGraphBudget
44
+ }
45
+ result := cloneEnrichmentGraph(graph)
46
+ nodes, err := enrichmentNodes(result)
47
+ if err != nil {
48
+ return cr.ResourceGraph{}, err
49
+ }
50
+ inputs, err := validateEnrichments(ctx, result.ClusterID, nodes, enrichments)
51
+ if err != nil {
52
+ return cr.ResourceGraph{}, err
53
+ }
54
+ for _, input := range inputs {
55
+ node := nodes[input.TargetID]
56
+ node.Resource = enrichmentRef(result.ClusterID, input.Object)
57
+ if node.Status == "unresolved" {
58
+ node.Status = ""
59
+ }
60
+ nodes[input.TargetID] = node
61
+ }
62
+ builder := enrichmentBuilder(ctx, result.ClusterID, nodes, inputs)
63
+ for _, input := range inputs {
64
+ if err := ctx.Err(); err != nil {
65
+ return cr.ResourceGraph{}, err
66
+ }
67
+ builder.ownerRelations(input.Object, input.TargetID)
68
+ definitionID := builder.node(input.Definition, "")
69
+ builder.edge(definitionID, input.TargetID, "defines")
70
+ }
71
+ if builder.err != nil {
72
+ return cr.ResourceGraph{}, builder.err
73
+ }
74
+ return mergeEnrichment(ctx, result, nodes, builder)
75
+ }
76
+ func cloneEnrichmentGraph(graph cr.ResourceGraph) cr.ResourceGraph {
77
+ graph.Nodes = append([]cr.ResourceGraphNode{}, graph.Nodes...)
78
+ graph.Edges = append([]cr.ResourceGraphEdge{}, graph.Edges...)
79
+ graph.Evidence = append([]cr.ResourceEvidence{}, graph.Evidence...)
80
+ graph.Warnings = append([]string{}, graph.Warnings...)
81
+ return graph
82
+ }
83
+ func enrichmentNodes(graph cr.ResourceGraph) (map[string]cr.ResourceGraphNode, error) {
84
+ nodes := map[string]cr.ResourceGraphNode{}
85
+ for _, node := range graph.Nodes {
86
+ if node.ID == "" || node.Resource.ClusterID != graph.ClusterID {
87
+ return nil, invalidGraphEnrichment("node identity or cluster mismatch")
88
+ }
89
+ if _, exists := nodes[node.ID]; exists {
90
+ return nil, invalidGraphEnrichment("duplicate node ID")
91
+ }
92
+ nodes[node.ID] = node
93
+ }
94
+ if graph.ClusterID == "" || graph.RootID == "" {
95
+ return nil, invalidGraphEnrichment("graph requires cluster and root")
96
+ }
97
+ if _, ok := nodes[graph.RootID]; !ok {
98
+ return nil, invalidGraphEnrichment("root not present")
99
+ }
100
+ return nodes, nil
101
+ }
102
+ func validateEnrichments(ctx context.Context, cluster string, nodes map[string]cr.ResourceGraphNode, inputs []ResourceGraphEnrichment) ([]ResourceGraphEnrichment, error) {
103
+ byID := map[string]ResourceGraphEnrichment{}
104
+ for _, input := range inputs {
105
+ if err := ctx.Err(); err != nil {
106
+ return nil, err
107
+ }
108
+ node, exists := nodes[input.TargetID]
109
+ if !exists {
110
+ return nil, invalidGraphEnrichment("target not present")
111
+ }
112
+ if err := validateEnrichment(cluster, node, input); err != nil {
113
+ return nil, err
114
+ }
115
+ if previous, exists := byID[input.TargetID]; exists {
116
+ if !sameEnrichment(previous, input) {
117
+ return nil, invalidGraphEnrichment("conflicting duplicate observation")
118
+ }
119
+ continue
120
+ }
121
+ byID[input.TargetID] = input
122
+ }
123
+ ids := make([]string, 0, len(byID))
124
+ for id := range byID {
125
+ ids = append(ids, id)
126
+ }
127
+ sort.Strings(ids)
128
+ result := make([]ResourceGraphEnrichment, 0, len(ids))
129
+ for _, id := range ids {
130
+ result = append(result, byID[id])
131
+ }
132
+ return result, nil
133
+ }
134
+ func validateEnrichment(cluster string, node cr.ResourceGraphNode, input ResourceGraphEnrichment) error {
135
+ o := input.Object
136
+ if o == nil {
137
+ return invalidGraphEnrichment("object required")
138
+ }
139
+ if graphBuiltinSpec(o) {
140
+ return invalidGraphEnrichment("built-in GVK is not a custom instance")
141
+ }
142
+ expected := node.Resource
143
+ if expected.APIVersion != o.GetAPIVersion() || expected.Kind != o.GetKind() || expected.Name != o.GetName() {
144
+ return invalidGraphEnrichment("GVK or name mismatch")
145
+ }
146
+ if expected.UID == "" || string(o.GetUID()) != expected.UID {
147
+ return invalidGraphEnrichment("UID mismatch")
148
+ }
149
+ if o.GetNamespace() != expected.Namespace && o.GetNamespace() != "" {
150
+ return invalidGraphEnrichment("namespace mismatch")
151
+ }
152
+ if node.Status != "unresolved" && o.GetNamespace() != expected.Namespace {
153
+ return invalidGraphEnrichment("resolved scope changed")
154
+ }
155
+ return validateEnrichmentDefinition(cluster, o.GetAPIVersion(), input.Definition)
156
+ }
157
+ func validateEnrichmentDefinition(cluster, api string, definition cr.ResourceRef) error {
158
+ group, _, ok := strings.Cut(api, "/")
159
+ if !ok || group == "" {
160
+ return invalidGraphEnrichment("custom API group required")
161
+ }
162
+ if definition.ClusterID != cluster || definition.APIVersion != "apiextensions.k8s.io/v1" || definition.Kind != "CustomResourceDefinition" {
163
+ return invalidGraphEnrichment("definition identity mismatch")
164
+ }
165
+ if definition.Namespace != "" || definition.ScopeMode != cr.ResourceScopeModeCluster || definition.UID == "" {
166
+ return invalidGraphEnrichment("definition requires observed cluster identity")
167
+ }
168
+ suffix := "." + group
169
+ if !strings.HasSuffix(definition.Name, suffix) || len(definition.Name) <= len(suffix) {
170
+ return invalidGraphEnrichment("definition group mismatch")
171
+ }
172
+ return nil
173
+ }
174
+ func sameEnrichment(a, b ResourceGraphEnrichment) bool {
175
+ return a.Definition == b.Definition && a.Object.GetNamespace() == b.Object.GetNamespace() && reflect.DeepEqual(a.Object.GetOwnerReferences(), b.Object.GetOwnerReferences())
176
+ }
177
+ func enrichmentRef(cluster string, o *unstructured.Unstructured) cr.ResourceRef {
178
+ scope := cr.ResourceScopeModeNamespace
179
+ if o.GetNamespace() == "" {
180
+ scope = cr.ResourceScopeModeCluster
181
+ }
182
+ return cr.ResourceRef{ClusterID: cluster, APIVersion: o.GetAPIVersion(), Kind: o.GetKind(), Name: o.GetName(), Namespace: o.GetNamespace(), ScopeMode: scope, UID: string(o.GetUID()), ResourceVersion: o.GetResourceVersion()}
183
+ }
184
+ func enrichmentBuilder(ctx context.Context, cluster string, nodes map[string]cr.ResourceGraphNode, inputs []ResourceGraphEnrichment) *graphRules {
185
+ objects := map[string]*unstructured.Unstructured{}
186
+ for _, node := range nodes {
187
+ ref := node.Resource
188
+ if node.Status == "unresolved" || ref.UID == "" {
189
+ continue
190
+ }
191
+ o := &unstructured.Unstructured{}
192
+ o.SetAPIVersion(ref.APIVersion)
193
+ o.SetKind(ref.Kind)
194
+ o.SetName(ref.Name)
195
+ o.SetNamespace(ref.Namespace)
196
+ o.SetUID(types.UID(ref.UID))
197
+ objects[graphIdentity(ref.APIVersion, ref.Kind, ref.Namespace, ref.Name)] = o
198
+ }
199
+ for _, input := range inputs {
200
+ o := input.Object
201
+ objects[graphIdentity(o.GetAPIVersion(), o.GetKind(), o.GetNamespace(), o.GetName())] = o
202
+ }
203
+ return &graphRules{ctx: ctx, cluster: cluster, objects: objects, nodes: map[string]cr.ResourceGraphNode{}, edges: map[string]cr.ResourceGraphEdge{}}
204
+ }
205
+
206
+ func mergeEnrichment(ctx context.Context, result cr.ResourceGraph, nodes map[string]cr.ResourceGraphNode, builder *graphRules) (cr.ResourceGraph, error) {
207
+ aliases, candidates, err := enrichmentCandidates(nodes, builder.nodes)
208
+ if err != nil {
209
+ return cr.ResourceGraph{}, err
210
+ }
211
+ existingEdges := map[string]bool{}
212
+ edges := append([]cr.ResourceGraphEdge{}, result.Edges...)
213
+ for _, edge := range result.Edges {
214
+ existingEdges[edge.Relation+":"+edge.SourceID+":"+edge.TargetID] = true
215
+ }
216
+ generated := make([]cr.ResourceGraphEdge, 0, len(builder.edges))
217
+ for _, edge := range builder.edges {
218
+ if alias, ok := aliases[edge.SourceID]; ok {
219
+ edge.SourceID = alias
220
+ }
221
+ if alias, ok := aliases[edge.TargetID]; ok {
222
+ edge.TargetID = alias
223
+ }
224
+ if edge.SourceID == edge.TargetID {
225
+ continue
226
+ }
227
+ edge.ID = edge.Relation + ":" + edge.SourceID + ":" + edge.TargetID
228
+ generated = append(generated, edge)
229
+ }
230
+ sort.Slice(generated, func(i, j int) bool { return generated[i].ID < generated[j].ID })
231
+ edges = append(edges, generated...)
232
+ sort.Slice(edges, func(i, j int) bool { return edges[i].ID < edges[j].ID })
233
+ selected, err := graphTraverse(ctx, result.RootID, edges)
234
+ if err != nil {
235
+ return cr.ResourceGraph{}, err
236
+ }
237
+ if err := includeRootScaleDefinitions(ctx, result, nodes, generated, selected); err != nil {
238
+ return cr.ResourceGraph{}, err
239
+ }
240
+ acceptedEdges := append([]cr.ResourceGraphEdge{}, result.Edges...)
241
+ partial := builder.partial
242
+ for _, edge := range generated {
243
+ if err := ctx.Err(); err != nil {
244
+ return cr.ResourceGraph{}, err
245
+ }
246
+ if existingEdges[edge.ID] {
247
+ continue
248
+ }
249
+ if !selected.chosen[edge.ID] {
250
+ partial = true
251
+ continue
252
+ }
253
+ if !includeEnrichmentEdge(edge, nodes, candidates, &acceptedEdges) {
254
+ partial = true
255
+ continue
256
+ }
257
+ existingEdges[edge.Relation+":"+edge.SourceID+":"+edge.TargetID] = true
258
+ }
259
+ result.Nodes = result.Nodes[:0]
260
+ for _, node := range nodes {
261
+ result.Nodes = append(result.Nodes, node)
262
+ }
263
+ result.Edges = acceptedEdges
264
+ sort.Slice(result.Nodes, func(i, j int) bool { return result.Nodes[i].ID < result.Nodes[j].ID })
265
+ sort.Slice(result.Edges, func(i, j int) bool { return result.Edges[i].ID < result.Edges[j].ID })
266
+ if partial || selected.depthTruncated || selected.limitTruncated {
267
+ result.Warnings = appendUniqueWarning(result.Warnings, "resource graph enrichment is partial due to depth or node/edge budget")
268
+ }
269
+ return result, ctx.Err()
270
+ }
271
+
272
+ // A direct HPA scale target also exposes its observed definition, without
273
+ // reversing general traversal or following the definition's other instances.
274
+ func includeRootScaleDefinitions(ctx context.Context, graph cr.ResourceGraph, nodes map[string]cr.ResourceGraphNode, generated []cr.ResourceGraphEdge, selected *graphTraversal) error {
275
+ root := nodes[graph.RootID].Resource
276
+ if root.APIVersion != "autoscaling/v2" || root.Kind != "HorizontalPodAutoscaler" || root.Namespace == "" {
277
+ return nil
278
+ }
279
+ if root.ScopeMode != "" && root.ScopeMode != cr.ResourceScopeModeNamespace {
280
+ return nil
281
+ }
282
+ targets := map[string]bool{}
283
+ for _, edge := range graph.Edges {
284
+ if err := ctx.Err(); err != nil {
285
+ return err
286
+ }
287
+ if edge.Relation == "scales" && edge.SourceID == graph.RootID && selected.chosen[edge.ID] && nodes[edge.TargetID].Resource.Namespace == root.Namespace {
288
+ targets[edge.TargetID] = true
289
+ }
290
+ }
291
+ for _, edge := range generated {
292
+ if err := ctx.Err(); err != nil {
293
+ return err
294
+ }
295
+ if edge.Relation == "defines" && targets[edge.TargetID] {
296
+ selected.include(edge, graphTraversalState{id: edge.SourceID, depth: 2, direction: -1})
297
+ }
298
+ }
299
+ return nil
300
+ }
301
+
302
+ func enrichmentCandidates(existing, generated map[string]cr.ResourceGraphNode) (map[string]string, map[string]cr.ResourceGraphNode, error) {
303
+ aliases := map[string]string{}
304
+ candidates := map[string]cr.ResourceGraphNode{}
305
+ // Sorted existing IDs make alias resolution stable for opaque IDs.
306
+ ids := make([]string, 0, len(existing))
307
+ for id := range existing {
308
+ ids = append(ids, id)
309
+ }
310
+ sort.Strings(ids)
311
+ for _, id := range ids {
312
+ key := graphNodeID(existing[id].Resource)
313
+ if _, found := aliases[key]; !found {
314
+ aliases[key] = id
315
+ }
316
+ }
317
+ for id, node := range generated {
318
+ if _, found := aliases[id]; found {
319
+ continue
320
+ }
321
+ if current, collision := existing[id]; collision && graphNodeID(current.Resource) != graphNodeID(node.Resource) {
322
+ return nil, nil, invalidGraphEnrichment("opaque node ID collision")
323
+ }
324
+ candidates[id] = node
325
+ }
326
+ return aliases, candidates, nil
327
+ }
328
+ func includeEnrichmentEdge(edge cr.ResourceGraphEdge, nodes, candidates map[string]cr.ResourceGraphNode, edges *[]cr.ResourceGraphEdge) bool {
329
+ missing := []cr.ResourceGraphNode{}
330
+ for _, id := range []string{edge.SourceID, edge.TargetID} {
331
+ if _, exists := nodes[id]; exists {
332
+ continue
333
+ }
334
+ node, ok := candidates[id]
335
+ if !ok {
336
+ return false
337
+ }
338
+ missing = append(missing, node)
339
+ }
340
+ if len(nodes)+len(missing) > ResourceGraphNodeLimit || len(*edges) >= ResourceGraphEdgeLimit {
341
+ return false
342
+ }
343
+ for _, node := range missing {
344
+ nodes[node.ID] = node
345
+ }
346
+ *edges = append(*edges, edge)
347
+ return true
348
+ }
349
+ func appendUniqueWarning(warnings []string, message string) []string {
350
+ for _, existing := range warnings {
351
+ if existing == message {
352
+ return warnings
353
+ }
354
+ }
355
+ return append(warnings, message)
356
+ }
357
+
358
+ // ResourceGraphMetadataEnrichment keeps Kubernetes SDK types out of callers.
359
+ type ResourceGraphMetadataEnrichment struct {
360
+ TargetID string
361
+ Content []byte
362
+ Definition cr.ResourceRef
363
+ }
364
+
365
+ const ResourceGraphMetadataEnrichmentLimit = 16
366
+ const ResourceGraphMetadataJSONLimit = 1 << 20
367
+
368
+ // EnrichResourceGraphFromMetadataJSON decodes only the relationship metadata
369
+ // fields. All other JSON, including spec/data/annotations, is ignored.
370
+ func EnrichResourceGraphFromMetadataJSON(ctx context.Context, graph cr.ResourceGraph, inputs []ResourceGraphMetadataEnrichment) (cr.ResourceGraph, error) {
371
+ ctx, cancel := context.WithTimeout(ctx, ResourceGraphTimeout)
372
+ defer cancel()
373
+ if err := ctx.Err(); err != nil {
374
+ return cr.ResourceGraph{}, err
375
+ }
376
+ if len(inputs) > ResourceGraphMetadataEnrichmentLimit {
377
+ return cr.ResourceGraph{}, ErrResourceGraphBudget
378
+ }
379
+ objects := make([]ResourceGraphEnrichment, 0, len(inputs))
380
+ for _, input := range inputs {
381
+ if err := ctx.Err(); err != nil {
382
+ return cr.ResourceGraph{}, err
383
+ }
384
+ if len(input.Content) > ResourceGraphMetadataJSONLimit {
385
+ return cr.ResourceGraph{}, ErrResourceGraphBudget
386
+ }
387
+ object, err := decodeEnrichmentMetadata(input.Content)
388
+ if err != nil {
389
+ return cr.ResourceGraph{}, err
390
+ }
391
+ objects = append(objects, ResourceGraphEnrichment{TargetID: input.TargetID, Object: object, Definition: input.Definition})
392
+ }
393
+ return EnrichResourceGraph(ctx, graph, objects)
394
+ }
395
+ func decodeEnrichmentMetadata(content []byte) (*unstructured.Unstructured, error) {
396
+ var decoded struct {
397
+ APIVersion string `json:"apiVersion"`
398
+ Kind string `json:"kind"`
399
+ Metadata struct {
400
+ Name string `json:"name"`
401
+ Namespace string `json:"namespace"`
402
+ UID types.UID `json:"uid"`
403
+ OwnerReferences []metav1.OwnerReference `json:"ownerReferences"`
404
+ } `json:"metadata"`
405
+ }
406
+ if json.Unmarshal(content, &decoded) != nil {
407
+ return nil, invalidGraphEnrichment("invalid metadata JSON")
408
+ }
409
+ object := &unstructured.Unstructured{}
410
+ object.SetAPIVersion(decoded.APIVersion)
411
+ object.SetKind(decoded.Kind)
412
+ object.SetName(decoded.Metadata.Name)
413
+ object.SetNamespace(decoded.Metadata.Namespace)
414
+ object.SetUID(decoded.Metadata.UID)
415
+ object.SetOwnerReferences(decoded.Metadata.OwnerReferences)
416
+ return object, nil
417
+ }
@@ -0,0 +1,117 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+ "testing"
8
+
9
+ cr "github.com/opensoha/soha-contracts/resource"
10
+ )
11
+
12
+ func graphHPAScaleEnrichmentFixture() (cr.ResourceGraph, ResourceGraphEnrichment) {
13
+ graph, input := graphEnrichmentFixture()
14
+ graph.Nodes[0].Resource.APIVersion = "autoscaling/v2"
15
+ graph.Nodes[0].Resource.Kind = "HorizontalPodAutoscaler"
16
+ graph.Edges[0].SourceID, graph.Edges[0].TargetID = graph.RootID, input.TargetID
17
+ graph.Edges[0].Relation = "scales"
18
+ return graph, input
19
+ }
20
+
21
+ func TestResourceGraphEnrichmentHPADirectScaleDefinition(t *testing.T) {
22
+ graph, input := graphHPAScaleEnrichmentFixture()
23
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
24
+ if err != nil || len(result.Nodes) != 3 || len(result.Edges) != 2 || !graphTestEdge(result, "defines", "widgets.custom.example", "owner") {
25
+ t.Fatalf("direct scale definition missing: graph=%+v err=%v", result, err)
26
+ }
27
+ if strings.Contains(strings.Join(result.Warnings, " "), "enrichment is partial") {
28
+ t.Fatalf("complete definition reported partial: %+v", result.Warnings)
29
+ }
30
+ if result.Edges[0] != graph.Edges[0] && result.Edges[1] != graph.Edges[0] {
31
+ t.Fatal("opaque scales edge changed")
32
+ }
33
+ }
34
+
35
+ func TestResourceGraphEnrichmentHPADefinitionAdjacencyIsLimited(t *testing.T) {
36
+ for _, tc := range []struct {
37
+ name string
38
+ edit func(*cr.ResourceGraph, *ResourceGraphEnrichment)
39
+ }{
40
+ {"other source kind", func(g *cr.ResourceGraph, _ *ResourceGraphEnrichment) {
41
+ g.Nodes[0].Resource.APIVersion, g.Nodes[0].Resource.Kind = "v1", "Pod"
42
+ }},
43
+ {"custom HPA source", func(g *cr.ResourceGraph, _ *ResourceGraphEnrichment) {
44
+ g.Nodes[0].Resource.APIVersion = "custom.example/v1"
45
+ }},
46
+ {"invalid root scope", func(g *cr.ResourceGraph, _ *ResourceGraphEnrichment) {
47
+ g.Nodes[0].Resource.ScopeMode = cr.ResourceScopeModeCluster
48
+ }},
49
+ {"cross namespace target", func(g *cr.ResourceGraph, e *ResourceGraphEnrichment) {
50
+ g.Nodes[1].Resource.Namespace = "other"
51
+ e.Object.SetNamespace("other")
52
+ }},
53
+ {"indirect scale target", func(g *cr.ResourceGraph, _ *ResourceGraphEnrichment) {
54
+ middle := g.Nodes[0]
55
+ middle.ID, middle.Resource.APIVersion, middle.Resource.Kind, middle.Resource.Name = "middle", "v1", "Pod", "middle"
56
+ g.Nodes = append(g.Nodes, middle)
57
+ g.Edges[0].SourceID = "middle"
58
+ g.Edges = append(g.Edges, cr.ResourceGraphEdge{ID: "root-middle", SourceID: g.RootID, TargetID: "middle", Relation: "uses-configmap"})
59
+ }},
60
+ } {
61
+ t.Run(tc.name, func(t *testing.T) {
62
+ graph, input := graphHPAScaleEnrichmentFixture()
63
+ tc.edit(&graph, &input)
64
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ if graphTestHas(result, "widgets.custom.example") || graphTestEdge(result, "defines", "widgets.custom.example", "owner") {
69
+ t.Fatalf("definition adjacency expanded outside direct namespaced HPA target: %+v", result)
70
+ }
71
+ })
72
+ }
73
+ }
74
+
75
+ func TestResourceGraphEnrichmentHPADefinitionDoesNotFanOut(t *testing.T) {
76
+ graph, input := graphHPAScaleEnrichmentFixture()
77
+ other := graphTestObject("custom.example/v1", "Widget", "other-instance", "other-uid")
78
+ graph.Nodes = append(graph.Nodes, cr.ResourceGraphNode{ID: "unrelated-instance", Resource: enrichmentRef(graph.ClusterID, other), Status: "unresolved"})
79
+ unrelated := ResourceGraphEnrichment{TargetID: "unrelated-instance", Object: other, Definition: input.Definition}
80
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input, unrelated})
81
+ if err != nil {
82
+ t.Fatal(err)
83
+ }
84
+ if !graphTestEdge(result, "defines", "widgets.custom.example", "owner") {
85
+ t.Fatal("root's direct scale definition missing")
86
+ }
87
+ if graphTestEdge(result, "defines", "widgets.custom.example", "other-instance") {
88
+ t.Fatalf("definition expanded to an unrelated observed instance: %+v", result)
89
+ }
90
+ }
91
+
92
+ func TestResourceGraphEnrichmentHPADefinitionRespectsOutputBudgets(t *testing.T) {
93
+ for _, budget := range []string{"nodes", "edges"} {
94
+ t.Run(budget, func(t *testing.T) {
95
+ graph, input := graphHPAScaleEnrichmentFixture()
96
+ if budget == "nodes" {
97
+ for len(graph.Nodes) < ResourceGraphNodeLimit {
98
+ id := fmt.Sprintf("unrelated-%03d", len(graph.Nodes))
99
+ graph.Nodes = append(graph.Nodes, cr.ResourceGraphNode{ID: id, Resource: cr.ResourceRef{ClusterID: graph.ClusterID, APIVersion: "v1", Kind: "Pod", Name: id, Namespace: "ns", ScopeMode: cr.ResourceScopeModeNamespace}})
100
+ }
101
+ } else {
102
+ for len(graph.Edges) < ResourceGraphEdgeLimit {
103
+ edge := graph.Edges[0]
104
+ edge.ID = fmt.Sprintf("opaque-scale-%04d", len(graph.Edges))
105
+ graph.Edges = append(graph.Edges, edge)
106
+ }
107
+ }
108
+ result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
109
+ if err != nil || len(result.Nodes) > ResourceGraphNodeLimit || len(result.Edges) > ResourceGraphEdgeLimit {
110
+ t.Fatalf("budget overflow: nodes=%d edges=%d err=%v", len(result.Nodes), len(result.Edges), err)
111
+ }
112
+ if graphTestHas(result, "widgets.custom.example") || !strings.Contains(strings.Join(result.Warnings, " "), "enrichment is partial") {
113
+ t.Fatalf("definition bypassed exhausted %s budget: %+v", budget, result)
114
+ }
115
+ })
116
+ }
117
+ }