@opensoha/contracts 0.1.24 → 0.1.26

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 (137) hide show
  1. package/COMPATIBILITY.md +24 -0
  2. package/README.md +1 -1
  3. package/agent/environment/export.go +1 -0
  4. package/agent/environment/protocol_test.go +1 -1
  5. package/agent/guest/protocol_test.go +2 -2
  6. package/auth/catalog_generated.go +2 -2
  7. package/auth/gateway_permissions_test.go +48 -0
  8. package/auth/permission-catalog.json +300 -1
  9. package/compat/consumer-revisions.json +1 -1
  10. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  11. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  12. package/delivery/document.schema.json +127 -9
  13. package/examples/relations_compat_test.go +18 -0
  14. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
  15. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
  16. package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
  17. package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
  18. package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
  19. package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
  20. package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
  21. package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
  22. package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
  23. package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
  24. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
  25. package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
  26. package/fixtures/openapi/KubernetesAgentActionCheck/invalid/unproven-state.json +6 -0
  27. package/fixtures/openapi/KubernetesAgentActionCheck/valid/configured-denied.json +9 -0
  28. package/fixtures/openapi/KubernetesAgentActionProfile/invalid/wildcard.json +1 -0
  29. package/fixtures/openapi/KubernetesAgentActionProfile/valid/workbench.json +1 -0
  30. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/action-profile.json +3 -0
  31. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/action-profile.json +5 -0
  32. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
  33. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
  34. package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
  35. package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/condition-status-boolean.json +17 -0
  36. package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/taint-effect-missing.json +16 -0
  37. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/legacy-fields-absent.json +11 -0
  38. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/no-taints.json +13 -0
  39. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/ready-cordoned-tainted.json +28 -0
  40. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/unknown-fields-null.json +13 -0
  41. package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
  42. package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
  43. package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
  44. package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
  45. package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
  46. package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
  47. package/fixtures/openapi/KubernetesTopologyCoverage/invalid/empty-as-success.json +5 -0
  48. package/fixtures/openapi/KubernetesTopologyCoverage/valid/not-installed.json +6 -0
  49. package/fixtures/openapi/KubernetesTopologyGraphEdge/invalid/negative-weight.json +8 -0
  50. package/fixtures/openapi/KubernetesTopologyGraphEdge/valid/zero-weight.json +20 -0
  51. package/fixtures/openapi/KubernetesTopologyGraphNode/invalid/fake-flow-kind.json +42 -0
  52. package/fixtures/openapi/KubernetesTopologyGraphNode/valid/rule.json +42 -0
  53. package/fixtures/openapi/KubernetesTopologySnapshot/invalid/raw-source-field.json +33 -0
  54. package/fixtures/openapi/KubernetesTopologySnapshot/valid/projected-source.json +32 -0
  55. package/fixtures/openapi/KubernetesTopologySnapshotRequest/invalid/arbitrary-gvr.json +8 -0
  56. package/fixtures/openapi/KubernetesTopologySnapshotRequest/valid/bounded-sources.json +11 -0
  57. package/fixtures/openapi/KubernetesTopologySourceQuery/invalid/cluster-namespace.json +4 -0
  58. package/fixtures/openapi/KubernetesTopologySourceQuery/valid/all-namespaces.json +4 -0
  59. package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
  60. package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
  61. package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
  62. package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
  63. package/fixtures/openapi/ResourceMonitoringPanel/invalid/unknown-quality.json +7 -0
  64. package/fixtures/openapi/ResourceMonitoringPanel/valid/not-collected.json +7 -0
  65. package/fixtures/openapi/ResourceMonitoringPoint/invalid/string-zero.json +4 -0
  66. package/fixtures/openapi/ResourceMonitoringPoint/valid/missing.json +4 -0
  67. package/fixtures/openapi/ResourceMonitoringPoint/valid/zero.json +4 -0
  68. package/fixtures/openapi/ResourceMonitoringView/invalid/raw-query.json +12 -0
  69. package/fixtures/openapi/ResourceMonitoringView/valid/unconfigured.json +12 -0
  70. package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
  71. package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
  72. package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
  73. package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
  74. package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
  75. package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
  76. package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
  77. package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
  78. package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
  79. package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
  80. package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
  81. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
  82. package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
  83. package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
  84. package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
  85. package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
  86. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
  87. package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
  88. package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
  89. package/gen/go/sohaapi/client.go +1 -1
  90. package/gen/go/sohaapi/client_test.go +2 -2
  91. package/gen/go/sohaapi/import_source_compat_test.go +12 -0
  92. package/gen/go/sohaapi/kubernetes_monitoring_test.go +67 -0
  93. package/gen/go/sohaapi/types.go +2210 -387
  94. package/gen/ts/sohaapi/index.ts +1862 -5
  95. package/go.mod +2 -2
  96. package/helmrelease/delivery.go +4 -1
  97. package/helmrelease/initialization.go +10 -0
  98. package/helmrelease/runtime/delivery.go +40 -10
  99. package/helmrelease/runtime/delivery_test.go +4 -0
  100. package/helmrelease/runtime/initialization.go +186 -0
  101. package/helmrelease/runtime/initialization_test.go +203 -0
  102. package/openapi/soha-api.json +5514 -1179
  103. package/openapi/soha-api.yaml +1080 -11
  104. package/openapi/spec_generated.go +3 -3
  105. package/openapi/spec_test.go +76 -0
  106. package/package.json +1 -1
  107. package/resource/agent_access.go +12 -0
  108. package/resource/agent_profiles.go +62 -0
  109. package/resource/agent_profiles_test.go +36 -0
  110. package/resource/kubernetes_kinds.go +73 -0
  111. package/resource/kubernetes_kinds_test.go +38 -0
  112. package/resource/models.go +25 -14
  113. package/resource/network_topology.go +166 -0
  114. package/resource/network_topology_projection.go +208 -0
  115. package/resource/network_topology_projection_test.go +53 -0
  116. package/resource/network_topology_test.go +51 -0
  117. package/resource/network_topology_validation.go +29 -0
  118. package/resource/node_drain.go +34 -0
  119. package/resource/relations.go +225 -0
  120. package/resource/relations_test.go +47 -0
  121. package/resource/resource_monitoring.go +99 -0
  122. package/resource/root_cr.go +38 -0
  123. package/resource/runtime/argocd_integration_test.go +1 -1
  124. package/resource/runtime/initialization.go +66 -0
  125. package/resource/runtime/initialization_test.go +80 -0
  126. package/resource/runtime/network_topology.go +232 -0
  127. package/resource/runtime/network_topology_test.go +62 -0
  128. package/resource/runtime/node_drain.go +124 -0
  129. package/resource/runtime/node_drain_test.go +95 -0
  130. package/resource/runtime/relations.go +161 -0
  131. package/resource/runtime/relations_test.go +100 -0
  132. package/resource/runtime/resource_edit.go +128 -0
  133. package/resource/runtime/resource_edit_test.go +183 -0
  134. package/resource/runtime/resource_monitoring.go +243 -0
  135. package/resource/runtime/resource_monitoring_test.go +88 -0
  136. package/resource/runtime/root_cr.go +79 -0
  137. package/resource/runtime/root_cr_test.go +118 -0
@@ -0,0 +1,208 @@
1
+ package resource
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "sort"
7
+ "strings"
8
+ )
9
+
10
+ // ProjectNetworkTopologyObject is the wire allowlist shared by both Kubernetes readers.
11
+ // Wildcards copy scalar label values only; no arbitrary manifest subtree is transported.
12
+ func ProjectNetworkTopologyObject(source string, object map[string]any) (map[string]any, error) {
13
+ paths, ok := topologyProjectionPaths[source]
14
+ if !ok {
15
+ return nil, fmt.Errorf("unknown topology source")
16
+ }
17
+ paths = append(strings.Fields("metadata.name metadata.namespace metadata.uid metadata.resourceVersion metadata.generation"), paths...)
18
+ tree := map[string]any{}
19
+ for _, path := range paths {
20
+ addTopologyProjectionPath(tree, strings.Split(path, "."))
21
+ }
22
+ projected, _ := projectTopologyValue(object, tree).(map[string]any)
23
+ redactTopologyMatches(projected, "")
24
+ if source == "kubernetes.endpointslices" {
25
+ if metadata, ok := projected["metadata"].(map[string]any); ok {
26
+ if labels, ok := metadata["labels"].(map[string]any); ok {
27
+ metadata["labels"] = map[string]any{"kubernetes.io/service-name": labels["kubernetes.io/service-name"]}
28
+ }
29
+ }
30
+ }
31
+ if source == "traefik.middlewares" || source == "traefik.middlewaretcps" {
32
+ projected["processorTypes"] = topologyMiddlewareTypes(object)
33
+ }
34
+ raw, err := json.Marshal(projected)
35
+ if err != nil || len(raw) > NetworkTopologyMaxObjectBytes {
36
+ return nil, fmt.Errorf("topology object exceeds projection budget")
37
+ }
38
+ return projected, nil
39
+ }
40
+
41
+ func addTopologyProjectionPath(tree map[string]any, path []string) {
42
+ if len(path) == 0 {
43
+ return
44
+ }
45
+ if len(path) == 1 {
46
+ tree[path[0]] = true
47
+ return
48
+ }
49
+ next, _ := tree[path[0]].(map[string]any)
50
+ if next == nil {
51
+ next = map[string]any{}
52
+ tree[path[0]] = next
53
+ }
54
+ addTopologyProjectionPath(next, path[1:])
55
+ }
56
+
57
+ func projectTopologyValue(value any, tree map[string]any) any {
58
+ switch input := value.(type) {
59
+ case map[string]any:
60
+ output := map[string]any{}
61
+ for key, value := range input {
62
+ rule, ok := tree[key]
63
+ if !ok {
64
+ rule, ok = tree["*"]
65
+ }
66
+ if !ok {
67
+ continue
68
+ }
69
+ if children, ok := rule.(map[string]any); ok {
70
+ output[key] = projectTopologyValue(value, children)
71
+ } else if safe, ok := topologyLeaf(value); ok {
72
+ output[key] = safe
73
+ }
74
+ }
75
+ return output
76
+ case []any:
77
+ output := make([]any, 0, len(input))
78
+ for _, value := range input {
79
+ if scalarTopologyValue(value) {
80
+ output = append(output, value)
81
+ } else {
82
+ output = append(output, projectTopologyValue(value, tree))
83
+ }
84
+ }
85
+ return output
86
+ default:
87
+ if scalarTopologyValue(value) {
88
+ return value
89
+ }
90
+ return nil
91
+ }
92
+ }
93
+
94
+ func scalarTopologyValue(value any) bool {
95
+ switch value.(type) {
96
+ case nil, string, bool, float64, json.Number, int, int32, int64:
97
+ return true
98
+ }
99
+ return false
100
+ }
101
+
102
+ func redactTopologyMatches(value any, key string) {
103
+ switch item := value.(type) {
104
+ case map[string]any:
105
+ if key == "headers" || key == "queryParams" || key == "exprs" {
106
+ if _, ok := item["value"]; ok {
107
+ item["value"] = "[redacted]"
108
+ }
109
+ if _, ok := item["set"]; ok {
110
+ item["set"] = []any{"[redacted]"}
111
+ }
112
+ }
113
+ if match, ok := item["match"].(string); ok && (strings.Contains(strings.ToLower(match), "header") || strings.Contains(strings.ToLower(match), "query")) {
114
+ item["match"] = "[redacted match expression]"
115
+ }
116
+ for child, value := range item {
117
+ redactTopologyMatches(value, child)
118
+ }
119
+ case []any:
120
+ for _, value := range item {
121
+ redactTopologyMatches(value, key)
122
+ }
123
+ }
124
+ }
125
+
126
+ var topologyProjectionPaths = map[string][]string{
127
+ "kubernetes.services": strings.Fields("spec.type spec.externalName spec.clusterIP spec.selector.* spec.ports.name spec.ports.port spec.ports.targetPort spec.ports.protocol spec.publishNotReadyAddresses"),
128
+ "kubernetes.pods": strings.Fields("metadata.labels.* status.phase status.conditions.type status.conditions.status"),
129
+ "kubernetes.endpointslices": strings.Fields("metadata.labels.* addressType ports.name ports.port ports.protocol endpoints.addresses endpoints.conditions.ready endpoints.conditions.serving endpoints.conditions.terminating endpoints.targetRef.kind endpoints.targetRef.namespace endpoints.targetRef.name endpoints.targetRef.uid"),
130
+ "kubernetes.ingresses": strings.Fields("spec.ingressClassName spec.defaultBackend.service.name spec.defaultBackend.service.port.name spec.defaultBackend.service.port.number spec.defaultBackend.resource.kind spec.rules.host spec.rules.http.paths.path spec.rules.http.paths.pathType spec.rules.http.paths.backend.service.name spec.rules.http.paths.backend.service.port.name spec.rules.http.paths.backend.service.port.number spec.rules.http.paths.backend.resource.kind spec.tls.hosts"),
131
+ "kubernetes.ingressclasses": strings.Fields("spec.controller"),
132
+ "gateway.gateways": strings.Fields("spec.gatewayClassName spec.listeners.name spec.listeners.port spec.listeners.protocol spec.listeners.hostname spec.listeners.allowedRoutes.namespaces.from spec.listeners.allowedRoutes.namespaces.selector.matchLabels.* spec.listeners.allowedRoutes.kinds.kind spec.listeners.allowedRoutes.kinds.group spec.listeners.tls.mode"),
133
+ "gateway.gatewayclasses": strings.Fields("spec.controllerName"),
134
+ "gateway.httproutes": gatewayTopologyPaths(),
135
+ "gateway.grpcroutes": gatewayTopologyPaths(),
136
+ "gateway.tcproutes": gatewayL4TopologyPaths(),
137
+ "gateway.tlsroutes": gatewayL4TopologyPaths(),
138
+ "gateway.udproutes": gatewayL4TopologyPaths(),
139
+ "gateway.referencegrants": strings.Fields("spec.from.group spec.from.kind spec.from.namespace spec.to.group spec.to.kind spec.to.name"),
140
+ "traefik.ingressroutes": traefikTopologyPaths(),
141
+ "traefik.ingressroutetcps": traefikTopologyPaths(),
142
+ "traefik.ingressrouteudps": traefikTopologyPaths(),
143
+ "traefik.traefikservices": strings.Fields("spec.weighted.services.name spec.weighted.services.namespace spec.weighted.services.kind spec.weighted.services.port spec.weighted.services.weight spec.mirroring.name spec.mirroring.namespace spec.mirroring.kind spec.mirroring.port spec.mirroring.mirrors.name spec.mirroring.mirrors.namespace spec.mirroring.mirrors.kind spec.mirroring.mirrors.port spec.mirroring.mirrors.percent"),
144
+ "traefik.middlewares": strings.Fields("spec.chain.middlewares.name spec.chain.middlewares.namespace"),
145
+ "traefik.middlewaretcps": strings.Fields("spec.chain.middlewares.name spec.chain.middlewares.namespace"),
146
+ "apisix.apisixroutes": apisixTopologyPaths(),
147
+ "apisix.apisixupstreams": strings.Fields("spec.scheme spec.loadbalancer.type spec.externalNodes.type spec.externalNodes.name spec.externalNodes.port spec.externalNodes.weight spec.subsets.name spec.subsets.labels.*"),
148
+ "apisix.apisixtls": strings.Fields("spec.hosts spec.secret.name spec.secret.namespace"),
149
+ }
150
+
151
+ func gatewayTopologyPaths() []string {
152
+ return strings.Fields("spec.hostnames spec.parentRefs.group spec.parentRefs.kind spec.parentRefs.name spec.parentRefs.namespace spec.parentRefs.sectionName spec.parentRefs.port spec.rules.name spec.rules.matches.path.type spec.rules.matches.path.value spec.rules.matches.method spec.rules.matches.method.type spec.rules.matches.method.service spec.rules.matches.method.method spec.rules.matches.headers.name spec.rules.matches.headers.type spec.rules.matches.headers.value spec.rules.matches.queryParams.name spec.rules.matches.queryParams.type spec.rules.matches.queryParams.value spec.rules.backendRefs.group spec.rules.backendRefs.kind spec.rules.backendRefs.name spec.rules.backendRefs.namespace spec.rules.backendRefs.port spec.rules.backendRefs.weight spec.rules.filters.type spec.rules.filters.requestMirror.backendRef.group spec.rules.filters.requestMirror.backendRef.kind spec.rules.filters.requestMirror.backendRef.name spec.rules.filters.requestMirror.backendRef.namespace spec.rules.filters.requestMirror.backendRef.port spec.rules.filters.requestMirror.percent status.parents.parentRef.name status.parents.parentRef.namespace status.parents.parentRef.sectionName status.parents.conditions.type status.parents.conditions.status status.parents.conditions.reason status.parents.conditions.observedGeneration status.parents.conditions.lastTransitionTime")
153
+ }
154
+ func gatewayL4TopologyPaths() []string {
155
+ return strings.Fields("spec.hostnames spec.parentRefs.group spec.parentRefs.kind spec.parentRefs.name spec.parentRefs.namespace spec.parentRefs.sectionName spec.parentRefs.port spec.rules.name spec.rules.backendRefs.group spec.rules.backendRefs.kind spec.rules.backendRefs.name spec.rules.backendRefs.namespace spec.rules.backendRefs.port spec.rules.backendRefs.weight status.parents.parentRef.name status.parents.parentRef.namespace status.parents.parentRef.sectionName status.parents.conditions.type status.parents.conditions.status status.parents.conditions.reason status.parents.conditions.observedGeneration status.parents.conditions.lastTransitionTime")
156
+ }
157
+ func traefikTopologyPaths() []string {
158
+ return strings.Fields("spec.entryPoints spec.tls.passthrough spec.routes.kind spec.routes.match spec.routes.priority spec.routes.middlewares.name spec.routes.middlewares.namespace spec.routes.services.name spec.routes.services.namespace spec.routes.services.kind spec.routes.services.port spec.routes.services.weight spec.routes.services.scheme")
159
+ }
160
+ func apisixTopologyPaths() []string {
161
+ return strings.Fields("spec.ingressClassName spec.http.name spec.http.priority spec.http.match.hosts spec.http.match.paths spec.http.match.methods spec.http.match.remoteAddrs spec.http.match.exprs.subject.scope spec.http.match.exprs.subject.name spec.http.match.exprs.op spec.http.match.exprs.value spec.http.match.exprs.set spec.http.backend.serviceName spec.http.backend.servicePort spec.http.backend.weight spec.http.backends.serviceName spec.http.backends.servicePort spec.http.backends.weight spec.http.plugins.name spec.http.plugins.enable spec.stream.name spec.stream.protocol spec.stream.match.ingressPort spec.stream.backend.serviceName spec.stream.backend.servicePort spec.stream.backend.weight")
162
+ }
163
+
164
+ func topologyLeaf(value any) (any, bool) {
165
+ if scalarTopologyValue(value) {
166
+ return value, true
167
+ }
168
+ if items, ok := value.([]any); ok {
169
+ output := []any{}
170
+ for _, item := range items {
171
+ if !scalarTopologyValue(item) {
172
+ return nil, false
173
+ }
174
+ output = append(output, item)
175
+ }
176
+ return output, true
177
+ }
178
+ return nil, false
179
+ }
180
+
181
+ func topologyMiddlewareTypes(input map[string]any) []string {
182
+ allowed := strings.Fields("addPrefix basicAuth buffer circuitBreaker chain compress contentType digestAuth errors forwardAuth grpcWeb headers inFlightReq ipAllowList ipWhiteList passTLSClientCert rateLimit redirectRegex redirectScheme replacePath replacePathRegex retry stripPrefix stripPrefixRegex inFlightConn ipAllowListTCP")
183
+ found := map[string]bool{}
184
+ if spec, ok := input["spec"].(map[string]any); ok {
185
+ for key := range spec {
186
+ for _, name := range allowed {
187
+ if key == name {
188
+ found[name] = true
189
+ }
190
+ }
191
+ }
192
+ }
193
+ if existing, ok := input["processorTypes"].([]any); ok {
194
+ for _, value := range existing {
195
+ for _, name := range allowed {
196
+ if value == name {
197
+ found[name] = true
198
+ }
199
+ }
200
+ }
201
+ }
202
+ result := []string{}
203
+ for name := range found {
204
+ result = append(result, name)
205
+ }
206
+ sort.Strings(result)
207
+ return result
208
+ }
@@ -0,0 +1,53 @@
1
+ package resource
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ func TestTopologyProjectionExcludesCredentialsAndPreservesAlternatives(t *testing.T) {
10
+ for _, test := range []struct {
11
+ source, input string
12
+ keep []string
13
+ }{
14
+ {"kubernetes.pods", `{"metadata":{"name":"p","namespace":"n","annotations":{"token":"secret-value"},"labels":{"app":"web"}},"spec":{"containers":[{"env":[{"value":"secret-value"}]}]},"status":{"phase":"Running"}}`, []string{"Running", "web"}},
15
+ {"gateway.httproutes", `{"metadata":{"name":"r"},"spec":{"rules":[{"matches":[{"method":"GET","headers":[{"name":"Authorization","value":"secret-value"}],"queryParams":[{"name":"token","value":"secret-value"}]}],"backendRefs":[{"name":"web","weight":0,"port":80}]}]}}`, []string{"GET", "[redacted]", "\"weight\":0"}},
16
+ {"gateway.tcproutes", `{"spec":{"parentRefs":[{"name":"gw","sectionName":"tcp","port":9000}],"rules":[{"backendRefs":[{"name":"web","port":9000,"weight":0}],"filters":[{"secret":"secret-value"}]}]}}`, []string{"tcp", "9000", "\"weight\":0"}},
17
+ {"gateway.tlsroutes", `{"spec":{"hostnames":["secure.test"],"parentRefs":[{"name":"gw","sectionName":"tls"}],"rules":[{"backendRefs":[{"name":"web","port":9443}]}],"tls":{"key":"secret-value"}}}`, []string{"secure.test", "tls", "9443"}},
18
+ {"gateway.udproutes", `{"spec":{"parentRefs":[{"name":"gw","sectionName":"udp"}],"rules":[{"backendRefs":[{"name":"dns","port":53,"weight":2}]}]},"status":{"parents":[{"conditions":[{"type":"Accepted","status":"True","message":"secret-value"}]}]}}`, []string{"udp", "53", "Accepted"}},
19
+ {"apisix.apisixroutes", `{"metadata":{"name":"r"},"spec":{"http":[{"match":{"hosts":["a.test","b.test"],"paths":["/a","/b"],"exprs":[{"subject":{"scope":"Header","name":"token"},"value":"secret-value"}]},"plugins":[{"name":"key-auth","enable":true,"config":{"key":"secret-value"},"secretRef":"secret-value"}]}]}}`, []string{"a.test", "b.test", "/a", "/b", "key-auth"}},
20
+ {"traefik.ingressroutes", `{"metadata":{"name":"r"},"spec":{"routes":[{"match":"Header(authorization,secret-value)","services":[{"name":"web","port":80}]}],"tls":{"secretName":"secret-value"}}}`, []string{"[redacted match expression]", "web"}},
21
+ {"traefik.middlewares", `{"metadata":{"name":"m"},"spec":{"basicAuth":{"users":["secret-value"]},"chain":{"middlewares":[{"name":"next"}]}}}`, []string{"basicAuth", "next"}},
22
+ } {
23
+ t.Run(test.source, func(t *testing.T) {
24
+ var raw map[string]any
25
+ if err := json.Unmarshal([]byte(test.input), &raw); err != nil {
26
+ t.Fatal(err)
27
+ }
28
+ projection, err := ProjectNetworkTopologyObject(test.source, raw)
29
+ if err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ data, _ := json.Marshal(projection)
33
+ if strings.Contains(string(data), "secret-value") {
34
+ t.Fatalf("credential escaped: %s", data)
35
+ }
36
+ for _, value := range test.keep {
37
+ if !strings.Contains(string(data), value) {
38
+ t.Fatalf("lost %q: %s", value, data)
39
+ }
40
+ }
41
+ var again map[string]any
42
+ _ = json.Unmarshal(data, &again)
43
+ second, err := ProjectNetworkTopologyObject(test.source, again)
44
+ if err != nil {
45
+ t.Fatal(err)
46
+ }
47
+ data2, _ := json.Marshal(second)
48
+ if string(data) != string(data2) {
49
+ t.Fatalf("projection is not stable: %s -> %s", data, data2)
50
+ }
51
+ })
52
+ }
53
+ }
@@ -0,0 +1,51 @@
1
+ package resource
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+ )
7
+
8
+ func TestTopologySourceQueriesCannotSelectArbitraryResourcesOrEscapeScope(t *testing.T) {
9
+ valid := NetworkTopologySnapshotRequest{Queries: []NetworkTopologySourceQuery{{SourceID: "traefik.ingressroutes", Namespace: "team-a"}, {SourceID: "kubernetes.ingressclasses"}}}
10
+ if err := ValidateNetworkTopologySnapshotRequest(valid); err != nil {
11
+ t.Fatal(err)
12
+ }
13
+ for _, queries := range [][]NetworkTopologySourceQuery{
14
+ nil,
15
+ {{SourceID: "kubernetes.secrets", Namespace: "team-a"}},
16
+ {{SourceID: "traefik.ingressroutes", Namespace: "../../secrets"}},
17
+ {{SourceID: "kubernetes.ingressclasses", Namespace: "team-a"}},
18
+ {{SourceID: "traefik.ingressroutes", Namespace: strings.Repeat("a", 64)}},
19
+ {{SourceID: "traefik.ingressroutes"}, {SourceID: "traefik.ingressroutes"}},
20
+ make([]NetworkTopologySourceQuery, NetworkTopologyMaxQueries+1),
21
+ } {
22
+ if err := ValidateNetworkTopologySnapshotRequest(NetworkTopologySnapshotRequest{Queries: queries}); err == nil {
23
+ t.Fatalf("unsafe snapshot queries accepted: %+v", queries)
24
+ }
25
+ }
26
+ }
27
+
28
+ func TestTopologySourcesKeepSupportedVersionsAndDedicatedKindsExplicit(t *testing.T) {
29
+ seen := map[string]bool{}
30
+ for _, definition := range NetworkTopologySources() {
31
+ if seen[definition.ID] || definition.Kind == "Secret" || definition.Resource == "secrets" || definition.Resource == "*" || definition.Group == "*" {
32
+ t.Fatalf("unsafe source registry: %+v", definition)
33
+ }
34
+ seen[definition.ID] = true
35
+ }
36
+ for _, id := range []string{"gateway.tcproutes", "gateway.tlsroutes", "gateway.udproutes"} {
37
+ d, ok := LookupNetworkTopologySource(id)
38
+ if !ok || d.Version != "v1" || d.FallbackVersion != "v1alpha2" || !d.RequiresCustomResourceAccess() {
39
+ t.Fatalf("Gateway L4 source must be versioned and explicitly authorized: %+v", d)
40
+ }
41
+ }
42
+ grant, ok := LookupNetworkTopologySource("gateway.referencegrants")
43
+ if !ok || grant.Version != "v1" || grant.FallbackVersion != "v1beta1" {
44
+ t.Fatalf("ReferenceGrant served-version support: %+v", grant)
45
+ }
46
+ definitions := NetworkTopologySources()
47
+ definitions[0].Kind = "Secret"
48
+ if NetworkTopologySources()[0].Kind == "Secret" {
49
+ t.Fatal("source allowlist is mutable by callers")
50
+ }
51
+ }
@@ -0,0 +1,29 @@
1
+ package resource
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "regexp"
7
+ )
8
+
9
+ var topologyNamespacePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
10
+
11
+ func ValidateNetworkTopologySnapshotRequest(request NetworkTopologySnapshotRequest) error {
12
+ if len(request.Queries) == 0 || len(request.Queries) > NetworkTopologyMaxQueries {
13
+ return fmt.Errorf("topology snapshot requires between 1 and 64 source queries")
14
+ }
15
+ seen := make(map[NetworkTopologySourceQuery]bool, len(request.Queries))
16
+ for _, query := range request.Queries {
17
+ definition, ok := LookupNetworkTopologySource(query.SourceID)
18
+ if !ok || seen[query] {
19
+ return fmt.Errorf("unknown or duplicate topology source query")
20
+ }
21
+ seen[query] = true
22
+ if query.Namespace != "" && (!definition.Namespaced || len(query.Namespace) > 63 || !topologyNamespacePattern.MatchString(query.Namespace)) {
23
+ return fmt.Errorf("invalid topology source namespace")
24
+ }
25
+ }
26
+ return nil
27
+ }
28
+
29
+ var ErrNetworkTopologySnapshotUnsupported = errors.New("agent topology snapshot unsupported")
@@ -0,0 +1,34 @@
1
+ package resource
2
+
3
+ // NodeDrainError reports an incomplete drain without exposing provider error text.
4
+ type NodeDrainError struct {
5
+ Code string
6
+ Cause error
7
+ }
8
+
9
+ func (e *NodeDrainError) Error() string {
10
+ switch e.Code {
11
+ case "node_drain_pdb_blocked":
12
+ return "PodDisruptionBudget blocked eviction. The drain did not complete; inspect remaining pods and scheduling before retrying."
13
+ case "node_drain_eviction_blocked":
14
+ return "Eviction was repeatedly refused. Check disruption budgets or API throttling and inspect remaining pods before retrying."
15
+ case "node_drain_timeout":
16
+ return "The drain timed out before all evictable pods terminated. Inspect remaining pods and scheduling before retrying."
17
+ case "node_drain_canceled":
18
+ return "The drain was interrupted. Previously submitted evictions may still finish; inspect remaining pods and scheduling."
19
+ case "node_drain_pod_selection":
20
+ return "Evictable pods could not be selected. Check pod read access and drain options for unmanaged pods or emptyDir data."
21
+ default:
22
+ return "The drain did not complete. Inspect remaining pods, disruption budgets and permissions before retrying."
23
+ }
24
+ }
25
+
26
+ func (e *NodeDrainError) Unwrap() error { return e.Cause }
27
+
28
+ func IsNodeDrainErrorCode(code string) bool {
29
+ switch code {
30
+ case "node_drain_pdb_blocked", "node_drain_eviction_blocked", "node_drain_timeout", "node_drain_canceled", "node_drain_pod_selection", "node_drain_failed":
31
+ return true
32
+ }
33
+ return false
34
+ }
@@ -0,0 +1,225 @@
1
+ package resource
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "regexp"
8
+ "strings"
9
+ )
10
+
11
+ const RelationSnapshotFeature = "resource-relations.metadata.v1"
12
+ const RelationMaxQueries = 32
13
+ const RelationMaxItems = 200
14
+ const RelationMaxNodes = 500
15
+ const RelationMaxDepth = 8
16
+
17
+ var ErrRelationSnapshotUnsupported = errors.New("resource relation metadata snapshot unsupported")
18
+
19
+ func DefaultRelationSources(namespace string) []RelationSource {
20
+ return []RelationSource{
21
+ {APIVersion: "v1", Resource: "pods", Kind: "Pod", Namespace: namespace},
22
+ {APIVersion: "apps/v1", Resource: "deployments", Kind: "Deployment", Namespace: namespace},
23
+ {APIVersion: "apps/v1", Resource: "replicasets", Kind: "ReplicaSet", Namespace: namespace},
24
+ {APIVersion: "apps/v1", Resource: "statefulsets", Kind: "StatefulSet", Namespace: namespace},
25
+ {APIVersion: "apps/v1", Resource: "daemonsets", Kind: "DaemonSet", Namespace: namespace},
26
+ {APIVersion: "batch/v1", Resource: "jobs", Kind: "Job", Namespace: namespace},
27
+ {APIVersion: "batch/v1", Resource: "cronjobs", Kind: "CronJob", Namespace: namespace},
28
+ {APIVersion: "v1", Resource: "services", Kind: "Service", Namespace: namespace},
29
+ {APIVersion: "v1", Resource: "persistentvolumeclaims", Kind: "PersistentVolumeClaim", Namespace: namespace},
30
+ {APIVersion: "v1", Resource: "configmaps", Kind: "ConfigMap", Namespace: namespace},
31
+ {APIVersion: "v1", Resource: "secrets", Kind: "Secret", Namespace: namespace},
32
+ {APIVersion: "networking.k8s.io/v1", Resource: "ingresses", Kind: "Ingress", Namespace: namespace},
33
+ }
34
+ }
35
+
36
+ func IsBuiltinRelationSource(source RelationSource) bool {
37
+ for _, builtin := range DefaultRelationSources(source.Namespace) {
38
+ if builtin == source {
39
+ return true
40
+ }
41
+ }
42
+ return false
43
+ }
44
+
45
+ // RelationSource always names an explicit namespaced API collection. Discovery
46
+ // must verify the resource/kind pair before reading it.
47
+ type RelationSource struct {
48
+ APIVersion string `json:"apiVersion"`
49
+ Resource string `json:"resource"`
50
+ Kind string `json:"kind"`
51
+ Namespace string `json:"namespace"`
52
+ }
53
+
54
+ type RelationRule struct {
55
+ ID string `json:"id"`
56
+ Type string `json:"type"`
57
+ Target RelationSource `json:"target"`
58
+ Name string `json:"name,omitempty"`
59
+ UID string `json:"uid,omitempty"`
60
+ Selector map[string]string `json:"selector,omitempty"`
61
+ }
62
+
63
+ type RelationConfig struct {
64
+ Version string `json:"version"`
65
+ RootUID string `json:"rootUid"`
66
+ Sources []RelationSource `json:"sources"`
67
+ Rules []RelationRule `json:"rules"`
68
+ }
69
+
70
+ type RelationQuery struct {
71
+ ID string `json:"id"`
72
+ Source RelationSource `json:"source"`
73
+ Name string `json:"name,omitempty"`
74
+ Selector map[string]string `json:"selector,omitempty"`
75
+ }
76
+
77
+ type RelationSnapshotRequest struct {
78
+ Queries []RelationQuery `json:"queries"`
79
+ }
80
+ type RelationOwner struct {
81
+ APIVersion string `json:"apiVersion"`
82
+ Kind string `json:"kind"`
83
+ Name string `json:"name"`
84
+ UID string `json:"uid"`
85
+ Controller bool `json:"controller"`
86
+ }
87
+
88
+ type RelationObject struct {
89
+ Resource ResourceRef `json:"resource"`
90
+ Owners []RelationOwner `json:"owners,omitempty"`
91
+ }
92
+ type RelationCoverage struct {
93
+ QueryID string `json:"queryId"`
94
+ State string `json:"state"`
95
+ ReasonCode string `json:"reasonCode,omitempty"`
96
+ Truncated bool `json:"truncated"`
97
+ }
98
+ type RelationSourceResult struct {
99
+ Coverage RelationCoverage `json:"coverage"`
100
+ Objects []RelationObject `json:"objects"`
101
+ }
102
+ type RelationSnapshot struct {
103
+ Version string `json:"version"`
104
+ Sources []RelationSourceResult `json:"sources"`
105
+ }
106
+ type RelationNode struct {
107
+ Resource ResourceRef `json:"resource"`
108
+ Role string `json:"role"`
109
+ Depth int `json:"depth"`
110
+ }
111
+ type RelationEdge struct {
112
+ FromUID string `json:"fromUid"`
113
+ ToUID string `json:"toUid"`
114
+ Type string `json:"type"`
115
+ RuleID string `json:"ruleId,omitempty"`
116
+ }
117
+ type ResourceRelationsView struct {
118
+ Root ResourceRef `json:"root"`
119
+ Config RelationConfig `json:"config"`
120
+ Nodes []RelationNode `json:"nodes"`
121
+ Edges []RelationEdge `json:"edges"`
122
+ Coverage []RelationCoverage `json:"coverage"`
123
+ Partial bool `json:"partial"`
124
+ ObservedAt string `json:"observedAt"`
125
+ }
126
+
127
+ var relationDNS = regexp.MustCompile(`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`)
128
+ var relationKind = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
129
+ var relationToken = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
130
+ var relationLabelKey = regexp.MustCompile(`^[a-zA-Z0-9_.-]+(/[a-zA-Z0-9_.-]+)?$`)
131
+
132
+ func ValidateRelationSource(s RelationSource) error {
133
+ parts := strings.Split(s.APIVersion, "/")
134
+ if len(parts) > 2 || len(parts) == 0 || len(s.APIVersion) > 320 {
135
+ return fmt.Errorf("invalid resource API version")
136
+ }
137
+ for _, part := range parts {
138
+ if !relationDNS.MatchString(part) {
139
+ return fmt.Errorf("invalid resource API version")
140
+ }
141
+ }
142
+ if !relationDNS.MatchString(s.Resource) || len(s.Resource) > 63 || strings.Contains(s.Resource, ".") || !relationKind.MatchString(s.Kind) || len(s.Kind) > 128 {
143
+ return fmt.Errorf("invalid resource definition")
144
+ }
145
+ if !relationDNS.MatchString(s.Namespace) || strings.Contains(s.Namespace, ".") || len(s.Namespace) > 63 {
146
+ return fmt.Errorf("an explicit namespace is required")
147
+ }
148
+ return nil
149
+ }
150
+
151
+ func validateRelationSelector(selector map[string]string) error {
152
+ if len(selector) > 16 {
153
+ return fmt.Errorf("too many label terms")
154
+ }
155
+ for key, value := range selector {
156
+ if len(key) > 317 || !relationLabelKey.MatchString(key) || len(value) > 63 || (value != "" && !relationToken.MatchString(value)) {
157
+ return fmt.Errorf("invalid label selector")
158
+ }
159
+ }
160
+ return nil
161
+ }
162
+
163
+ func ValidateRelationConfig(config RelationConfig) error {
164
+ if config.Sources == nil || config.Rules == nil {
165
+ return fmt.Errorf("sources and rules must be arrays")
166
+ }
167
+ data, err := json.Marshal(config)
168
+ if err != nil || len(data) > 16<<10 || config.Version != "v1" || strings.TrimSpace(config.RootUID) == "" || len(config.RootUID) > 128 || len(config.Sources) > 8 || len(config.Rules) > 8 {
169
+ return fmt.Errorf("invalid relation configuration or budget")
170
+ }
171
+ for _, source := range config.Sources {
172
+ if err := ValidateRelationSource(source); err != nil {
173
+ return err
174
+ }
175
+ }
176
+ seen := map[string]bool{}
177
+ for _, rule := range config.Rules {
178
+ if !relationToken.MatchString(rule.ID) || len(rule.ID) > 64 || seen[rule.ID] {
179
+ return fmt.Errorf("invalid or duplicate rule ID")
180
+ }
181
+ seen[rule.ID] = true
182
+ if err := ValidateRelationSource(rule.Target); err != nil {
183
+ return err
184
+ }
185
+ switch rule.Type {
186
+ case "reference", "manual":
187
+ if !relationDNS.MatchString(rule.Name) || len(rule.Name) > 253 || len(rule.Selector) != 0 || len(rule.UID) > 128 || (rule.Type == "manual" && strings.TrimSpace(rule.UID) == "") {
188
+ return fmt.Errorf("named manual relations require a fixed UID")
189
+ }
190
+ case "selector":
191
+ if rule.Name != "" || rule.UID != "" || len(rule.Selector) == 0 {
192
+ return fmt.Errorf("selector rules must declare labels only")
193
+ }
194
+ default:
195
+ return fmt.Errorf("unknown relation rule type")
196
+ }
197
+ if err := validateRelationSelector(rule.Selector); err != nil {
198
+ return err
199
+ }
200
+ }
201
+ return nil
202
+ }
203
+
204
+ func ValidateRelationSnapshotRequest(input RelationSnapshotRequest) error {
205
+ if len(input.Queries) == 0 || len(input.Queries) > RelationMaxQueries {
206
+ return fmt.Errorf("invalid relation query budget")
207
+ }
208
+ seen := map[string]bool{}
209
+ for _, q := range input.Queries {
210
+ if q.ID == "" || len(q.ID) > 80 || !relationToken.MatchString(q.ID) || seen[q.ID] {
211
+ return fmt.Errorf("invalid query identity")
212
+ }
213
+ seen[q.ID] = true
214
+ if err := ValidateRelationSource(q.Source); err != nil {
215
+ return err
216
+ }
217
+ if q.Name != "" && (!relationDNS.MatchString(q.Name) || len(q.Name) > 253 || len(q.Selector) != 0) {
218
+ return fmt.Errorf("invalid named query")
219
+ }
220
+ if err := validateRelationSelector(q.Selector); err != nil {
221
+ return err
222
+ }
223
+ }
224
+ return nil
225
+ }
@@ -0,0 +1,47 @@
1
+ package resource
2
+
3
+ import "testing"
4
+
5
+ func TestRelationConfigSupportsUnknownKindsWithoutCodeRegistration(t *testing.T) {
6
+ source := RelationSource{APIVersion: "widgets.example.test/v1", Resource: "widgets", Kind: "Widget", Namespace: "team"}
7
+ config := RelationConfig{Version: "v1", RootUID: "root", Sources: []RelationSource{source}, Rules: []RelationRule{{ID: "shared", Type: "reference", Target: source, Name: "runtime"}}}
8
+ if err := ValidateRelationConfig(config); err != nil {
9
+ t.Fatal(err)
10
+ }
11
+ config.Rules[0].Type = "manual"
12
+ if ValidateRelationConfig(config) == nil {
13
+ t.Fatal("manual association accepted without UID")
14
+ }
15
+ config.Rules[0].UID = "fixed-runtime"
16
+ if err := ValidateRelationConfig(config); err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ config.Rules[0].Target.Resource = "widgets/../../secrets"
20
+ if ValidateRelationConfig(config) == nil {
21
+ t.Fatal("path traversal accepted")
22
+ }
23
+ }
24
+
25
+ func TestRelationQueryBoundsAndSelectors(t *testing.T) {
26
+ q := RelationQuery{ID: "pods", Source: RelationSource{APIVersion: "v1", Resource: "pods", Kind: "Pod", Namespace: "team"}, Selector: map[string]string{"app": "demo"}}
27
+ for _, test := range []struct {
28
+ name string
29
+ change func(*RelationQuery)
30
+ }{
31
+ {"all namespaces", func(q *RelationQuery) { q.Source.Namespace = "" }},
32
+ {"selector injection", func(q *RelationQuery) { q.Selector["app"] = "demo,secret=yes" }},
33
+ {"named selector", func(q *RelationQuery) { q.Name = "pod" }},
34
+ } {
35
+ t.Run(test.name, func(t *testing.T) {
36
+ copy := q
37
+ copy.Selector = map[string]string{"app": "demo"}
38
+ test.change(&copy)
39
+ if ValidateRelationSnapshotRequest(RelationSnapshotRequest{Queries: []RelationQuery{copy}}) == nil {
40
+ t.Fatal("invalid query accepted")
41
+ }
42
+ })
43
+ }
44
+ if ValidateRelationSnapshotRequest(RelationSnapshotRequest{Queries: []RelationQuery{q, q}}) == nil {
45
+ t.Fatal("duplicate query accepted")
46
+ }
47
+ }