@opensoha/contracts 0.1.24 → 0.1.25
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/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/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/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/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/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/gen/go/sohaapi/client.go +1 -1
- package/gen/go/sohaapi/kubernetes_monitoring_test.go +67 -0
- package/gen/go/sohaapi/types.go +1265 -228
- package/gen/ts/sohaapi/index.ts +1116 -1
- package/openapi/soha-api.json +2497 -302
- package/openapi/soha-api.yaml +732 -3
- package/openapi/spec_generated.go +3 -3
- package/openapi/spec_test.go +34 -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 +23 -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/resource_monitoring.go +99 -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/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
|
@@ -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,99 @@
|
|
|
1
|
+
package resource
|
|
2
|
+
|
|
3
|
+
const ResourceMonitoringSnapshotFeature = "resource.monitoring.snapshot.v1"
|
|
4
|
+
const ResourceMonitoringMaxNodes = 500
|
|
5
|
+
const ResourceMonitoringMaxPoints = 1440
|
|
6
|
+
const ResourceMonitoringMaxTotalPoints = 60000
|
|
7
|
+
|
|
8
|
+
type ResourceMonitoringQuery struct {
|
|
9
|
+
View string `json:"view"`
|
|
10
|
+
Node string `json:"node,omitempty"`
|
|
11
|
+
From string `json:"from,omitempty"`
|
|
12
|
+
To string `json:"to,omitempty"`
|
|
13
|
+
RangeMinutes int `json:"rangeMinutes,omitempty"`
|
|
14
|
+
StepSeconds int `json:"stepSeconds,omitempty"`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ResourceMonitoringQuantity struct {
|
|
18
|
+
CPUCores *float64 `json:"cpuCores"`
|
|
19
|
+
MemoryBytes *float64 `json:"memoryBytes"`
|
|
20
|
+
PodSlots *float64 `json:"podSlots"`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ResourceMonitoringNode struct {
|
|
24
|
+
NodeRef ResourceRef `json:"nodeRef"`
|
|
25
|
+
InternalIP string `json:"internalIP,omitempty"`
|
|
26
|
+
OS string `json:"os,omitempty"`
|
|
27
|
+
CreatedAt string `json:"createdAt,omitempty"`
|
|
28
|
+
Ready string `json:"ready"`
|
|
29
|
+
Cordoned bool `json:"cordoned"`
|
|
30
|
+
MemoryPressure string `json:"memoryPressure"`
|
|
31
|
+
DiskPressure string `json:"diskPressure"`
|
|
32
|
+
PIDPressure string `json:"pidPressure"`
|
|
33
|
+
Capacity ResourceMonitoringQuantity `json:"capacity"`
|
|
34
|
+
Allocatable ResourceMonitoringQuantity `json:"allocatable"`
|
|
35
|
+
Requests ResourceMonitoringQuantity `json:"requests"`
|
|
36
|
+
Limits ResourceMonitoringQuantity `json:"limits"`
|
|
37
|
+
CPULimitsComplete bool `json:"cpuLimitsComplete"`
|
|
38
|
+
MemoryLimitsComplete bool `json:"memoryLimitsComplete"`
|
|
39
|
+
BoundPods *float64 `json:"boundPods"`
|
|
40
|
+
PendingPods *float64 `json:"pendingPods"`
|
|
41
|
+
RemainingPodSlots *float64 `json:"remainingPodSlots"`
|
|
42
|
+
OverflowPodSlots *float64 `json:"overflowPodSlots"`
|
|
43
|
+
Quality string `json:"quality"`
|
|
44
|
+
ObservedAt string `json:"observedAt,omitempty"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ResourceMonitoringInventory struct {
|
|
48
|
+
Nodes []ResourceMonitoringNode `json:"nodes"`
|
|
49
|
+
UnscheduledPendingPods *float64 `json:"unscheduledPendingPods"`
|
|
50
|
+
Quality string `json:"quality"`
|
|
51
|
+
ReasonCode string `json:"reasonCode,omitempty"`
|
|
52
|
+
ObservedAt string `json:"observedAt"`
|
|
53
|
+
Truncated bool `json:"truncated,omitempty"`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type ResourceMonitoringPoint struct {
|
|
57
|
+
Timestamp string `json:"timestamp"`
|
|
58
|
+
Value *float64 `json:"value"`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ResourceMonitoringSeries struct {
|
|
62
|
+
NodeRef ResourceRef `json:"nodeRef"`
|
|
63
|
+
MetricKey string `json:"metricKey"`
|
|
64
|
+
Unit string `json:"unit"`
|
|
65
|
+
Quality string `json:"quality"`
|
|
66
|
+
ReasonCode string `json:"reasonCode,omitempty"`
|
|
67
|
+
Source string `json:"source"`
|
|
68
|
+
SampledAt string `json:"sampledAt,omitempty"`
|
|
69
|
+
Latest *float64 `json:"latest"`
|
|
70
|
+
Points []ResourceMonitoringPoint `json:"points"`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type ResourceMonitoringPanel struct {
|
|
74
|
+
Key string `json:"key"`
|
|
75
|
+
Label string `json:"label"`
|
|
76
|
+
Unit string `json:"unit"`
|
|
77
|
+
Quality string `json:"quality"`
|
|
78
|
+
ReasonCode string `json:"reasonCode,omitempty"`
|
|
79
|
+
Estimated bool `json:"estimated,omitempty"`
|
|
80
|
+
WindowSeconds int `json:"windowSeconds,omitempty"`
|
|
81
|
+
Series []ResourceMonitoringSeries `json:"series"`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type ResourceMonitoringView struct {
|
|
85
|
+
NodeOptions []ResourceRef `json:"nodeOptions,omitempty"`
|
|
86
|
+
ClusterID string `json:"clusterId"`
|
|
87
|
+
View string `json:"view"`
|
|
88
|
+
Node string `json:"node,omitempty"`
|
|
89
|
+
From string `json:"from"`
|
|
90
|
+
To string `json:"to"`
|
|
91
|
+
StepSeconds int `json:"stepSeconds"`
|
|
92
|
+
GeneratedAt string `json:"generatedAt"`
|
|
93
|
+
SnapshotObservedAt string `json:"snapshotObservedAt,omitempty"`
|
|
94
|
+
SnapshotQuality string `json:"snapshotQuality"`
|
|
95
|
+
UnscheduledPendingPods *float64 `json:"unscheduledPendingPods"`
|
|
96
|
+
Nodes []ResourceMonitoringNode `json:"nodes"`
|
|
97
|
+
Panels []ResourceMonitoringPanel `json:"panels"`
|
|
98
|
+
Truncated bool `json:"truncated,omitempty"`
|
|
99
|
+
}
|