@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.
- package/COMPATIBILITY.md +24 -0
- package/README.md +1 -1
- package/agent/environment/export.go +1 -0
- package/agent/environment/protocol_test.go +1 -1
- package/agent/guest/protocol_test.go +2 -2
- package/auth/catalog_generated.go +2 -2
- package/auth/gateway_permissions_test.go +48 -0
- package/auth/permission-catalog.json +300 -1
- package/compat/consumer-revisions.json +1 -1
- package/compat/json-schema-ecosystem-0.1.0.json +1 -1
- package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
- package/delivery/document.schema.json +127 -9
- package/examples/relations_compat_test.go +18 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-build-template.json +30 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-deployment-template.json +30 -0
- package/fixtures/openapi/DeliveryDocument/invalid/migration-missing-guide-workflow-template.json +27 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-build-template.json +31 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-deployment-template.json +31 -0
- package/fixtures/openapi/DeliveryDocument/valid/migration-workflow-template.json +28 -0
- package/fixtures/openapi/DeliveryPlanInput/invalid/manual-plan.json +7 -0
- package/fixtures/openapi/DeliveryPlanInput/valid/manual-plan.json +6 -0
- package/fixtures/openapi/DeliveryTargetInput/invalid/candidate-on-runtime-deploy.json +9 -0
- package/fixtures/openapi/DeliveryTargetInput/invalid/config-update-missing-service.json +7 -0
- package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-only.json +8 -0
- package/fixtures/openapi/DeliveryTargetInput/valid/template-configuration-without-runtime-environment.json +1 -0
- package/fixtures/openapi/KubernetesAgentActionCheck/invalid/unproven-state.json +6 -0
- package/fixtures/openapi/KubernetesAgentActionCheck/valid/configured-denied.json +9 -0
- package/fixtures/openapi/KubernetesAgentActionProfile/invalid/wildcard.json +1 -0
- package/fixtures/openapi/KubernetesAgentActionProfile/valid/workbench.json +1 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/action-profile.json +3 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/action-profile.json +5 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-with-spec.json +33 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/invalid/source-without-uid.json +29 -0
- package/fixtures/openapi/KubernetesImportCandidateListEnvelope/valid/stable-source.json +30 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/condition-status-boolean.json +17 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/taint-effect-missing.json +16 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/legacy-fields-absent.json +11 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/no-taints.json +13 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/ready-cordoned-tainted.json +28 -0
- package/fixtures/openapi/KubernetesNodeListEnvelope/valid/unknown-fields-null.json +13 -0
- package/fixtures/openapi/KubernetesPod/invalid/pod-uid.json +9 -0
- package/fixtures/openapi/KubernetesPod/valid/pod-uid.json +9 -0
- package/fixtures/openapi/KubernetesPodDetail/invalid/pod-uid.json +6 -0
- package/fixtures/openapi/KubernetesPodDetail/valid/pod-uid.json +6 -0
- package/fixtures/openapi/KubernetesServiceImportRequest/invalid/empty-source-uid.json +48 -0
- package/fixtures/openapi/KubernetesServiceImportRequest/valid/pinned-workloads-and-cr.json +48 -0
- package/fixtures/openapi/KubernetesTopologyCoverage/invalid/empty-as-success.json +5 -0
- package/fixtures/openapi/KubernetesTopologyCoverage/valid/not-installed.json +6 -0
- package/fixtures/openapi/KubernetesTopologyGraphEdge/invalid/negative-weight.json +8 -0
- package/fixtures/openapi/KubernetesTopologyGraphEdge/valid/zero-weight.json +20 -0
- package/fixtures/openapi/KubernetesTopologyGraphNode/invalid/fake-flow-kind.json +42 -0
- package/fixtures/openapi/KubernetesTopologyGraphNode/valid/rule.json +42 -0
- package/fixtures/openapi/KubernetesTopologySnapshot/invalid/raw-source-field.json +33 -0
- package/fixtures/openapi/KubernetesTopologySnapshot/valid/projected-source.json +32 -0
- package/fixtures/openapi/KubernetesTopologySnapshotRequest/invalid/arbitrary-gvr.json +8 -0
- package/fixtures/openapi/KubernetesTopologySnapshotRequest/valid/bounded-sources.json +11 -0
- package/fixtures/openapi/KubernetesTopologySourceQuery/invalid/cluster-namespace.json +4 -0
- package/fixtures/openapi/KubernetesTopologySourceQuery/valid/all-namespaces.json +4 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/invalid/initialization.json +7 -0
- package/fixtures/openapi/ManifestExecutionTaskPayload/valid/initialization.json +7 -0
- package/fixtures/openapi/ManifestRootResource/invalid/root.json +1 -0
- package/fixtures/openapi/ManifestRootResource/valid/root.json +1 -0
- package/fixtures/openapi/ResourceMonitoringPanel/invalid/unknown-quality.json +7 -0
- package/fixtures/openapi/ResourceMonitoringPanel/valid/not-collected.json +7 -0
- package/fixtures/openapi/ResourceMonitoringPoint/invalid/string-zero.json +4 -0
- package/fixtures/openapi/ResourceMonitoringPoint/valid/missing.json +4 -0
- package/fixtures/openapi/ResourceMonitoringPoint/valid/zero.json +4 -0
- package/fixtures/openapi/ResourceMonitoringView/invalid/raw-query.json +12 -0
- package/fixtures/openapi/ResourceMonitoringView/valid/unconfigured.json +12 -0
- package/fixtures/openapi/ResourceRelationConfig/invalid/executable-rule.json +17 -0
- package/fixtures/openapi/ResourceRelationConfig/valid/unknown-kind.json +13 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/invalid/migration-missing-guide-valid-yaml.json +38 -0
- package/fixtures/openapi/ServiceDeploymentTemplateInput/valid/migration-valid-yaml.json +39 -0
- package/fixtures/openapi/TemplateMigrationAssessment/invalid/initialization.json +9 -0
- package/fixtures/openapi/TemplateMigrationAssessment/valid/initialization.json +9 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/blank-guide.json +5 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/missing-guide.json +4 -0
- package/fixtures/openapi/TemplateMigrationPolicy/invalid/unknown-escape-hatch.json +6 -0
- package/fixtures/openapi/TemplateMigrationPolicy/valid/legacy-safe.json +4 -0
- package/fixtures/openapi/TemplateMigrationPolicy/valid/ordered-data-change.json +5 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/invalid/duplicate-candidates.json +11 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/invalid/empty-identities.json +10 -0
- package/fixtures/openapi/TemplateUpdateBatchInput/valid/staged-adoption.json +10 -0
- package/fixtures/openapi/TemplateUpdatePolicyInput/invalid/unknown-mode.json +8 -0
- package/fixtures/openapi/TemplateUpdatePolicyInput/valid/explicit-automatic.json +8 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-identities.json +10 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/invalid/empty-targets.json +6 -0
- package/fixtures/openapi/TemplateUpdatePreviewInput/valid/cross-version-manual.json +14 -0
- package/gen/go/sohaapi/client.go +1 -1
- package/gen/go/sohaapi/client_test.go +2 -2
- package/gen/go/sohaapi/import_source_compat_test.go +12 -0
- package/gen/go/sohaapi/kubernetes_monitoring_test.go +67 -0
- package/gen/go/sohaapi/types.go +2210 -387
- package/gen/ts/sohaapi/index.ts +1862 -5
- package/go.mod +2 -2
- package/helmrelease/delivery.go +4 -1
- package/helmrelease/initialization.go +10 -0
- package/helmrelease/runtime/delivery.go +40 -10
- package/helmrelease/runtime/delivery_test.go +4 -0
- package/helmrelease/runtime/initialization.go +186 -0
- package/helmrelease/runtime/initialization_test.go +203 -0
- package/openapi/soha-api.json +5514 -1179
- package/openapi/soha-api.yaml +1080 -11
- package/openapi/spec_generated.go +3 -3
- package/openapi/spec_test.go +76 -0
- package/package.json +1 -1
- package/resource/agent_access.go +12 -0
- package/resource/agent_profiles.go +62 -0
- package/resource/agent_profiles_test.go +36 -0
- package/resource/kubernetes_kinds.go +73 -0
- package/resource/kubernetes_kinds_test.go +38 -0
- package/resource/models.go +25 -14
- package/resource/network_topology.go +166 -0
- package/resource/network_topology_projection.go +208 -0
- package/resource/network_topology_projection_test.go +53 -0
- package/resource/network_topology_test.go +51 -0
- package/resource/network_topology_validation.go +29 -0
- package/resource/node_drain.go +34 -0
- package/resource/relations.go +225 -0
- package/resource/relations_test.go +47 -0
- package/resource/resource_monitoring.go +99 -0
- package/resource/root_cr.go +38 -0
- package/resource/runtime/argocd_integration_test.go +1 -1
- package/resource/runtime/initialization.go +66 -0
- package/resource/runtime/initialization_test.go +80 -0
- package/resource/runtime/network_topology.go +232 -0
- package/resource/runtime/network_topology_test.go +62 -0
- package/resource/runtime/node_drain.go +124 -0
- package/resource/runtime/node_drain_test.go +95 -0
- package/resource/runtime/relations.go +161 -0
- package/resource/runtime/relations_test.go +100 -0
- package/resource/runtime/resource_edit.go +128 -0
- package/resource/runtime/resource_edit_test.go +183 -0
- package/resource/runtime/resource_monitoring.go +243 -0
- package/resource/runtime/resource_monitoring_test.go +88 -0
- package/resource/runtime/root_cr.go +79 -0
- 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(©)
|
|
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
|
+
}
|