@opensoha/contracts 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPATIBILITY.md +4 -3
- package/README.md +8 -9
- 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/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-length.json +3 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-type.json +3 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-alias.json +5 -0
- package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-reset.json +3 -0
- package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/invalid/revision-overflow.json +4 -0
- package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/legacy.json +3 -0
- package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/observed-revision.json +4 -0
- package/fixtures/openapi/KubernetesJobDetail/invalid/execution-observation.json +23 -0
- package/fixtures/openapi/KubernetesJobDetail/valid/execution-observation.json +23 -0
- package/fixtures/openapi/KubernetesNode/invalid/labels.json +10 -0
- package/fixtures/openapi/KubernetesNode/valid/labels.json +11 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/invalid/empty-uid.json +13 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/invalid/invalid-container-state.json +20 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/invalid/missing-container-ready.json +19 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/valid/container-statuses.json +67 -0
- package/fixtures/openapi/KubernetesPodListEnvelope/valid/running-pod.json +1 -0
- package/fixtures/openapi/KubernetesPrometheusDetail/invalid/metric-cluster-value-type.json +4 -0
- package/fixtures/openapi/KubernetesPrometheusDetail/valid/metric-cluster-alias.json +5 -0
- package/fixtures/openapi/KubernetesResourceGraphEnvelope/invalid/network-edge-source-payload.json +73 -0
- package/fixtures/openapi/KubernetesResourceGraphEnvelope/valid/network-edge-facts.json +67 -0
- package/fixtures/openapi/KubernetesSecretCertificate/invalid/secret-data.json +1 -0
- package/fixtures/openapi/KubernetesSecretCertificate/invalid/status.json +1 -0
- package/fixtures/openapi/KubernetesSecretCertificate/valid/available.json +1 -0
- package/fixtures/openapi/KubernetesSecretCertificate/valid/missing.json +1 -0
- package/gen/go/sohaapi/client.go +1 -1
- package/gen/go/sohaapi/client_test.go +13 -0
- package/gen/go/sohaapi/kubernetes_workload_optional_test.go +40 -0
- package/gen/go/sohaapi/pod_status_compat_test.go +36 -0
- package/gen/go/sohaapi/types.go +421 -88
- package/gen/ts/sohaapi/index.ts +756 -4
- package/helmrelease/runtime/values_upgrade.go +78 -0
- package/helmrelease/runtime/values_upgrade_test.go +123 -0
- package/helmrelease/values_update.go +31 -0
- package/openapi/soha-api.json +1191 -14
- package/openapi/soha-api.yaml +588 -28
- package/openapi/spec_generated.go +3 -3
- package/package.json +1 -1
- package/resource/agent_profiles.go +9 -15
- package/resource/agent_profiles_test.go +9 -3
- package/resource/custom_graph_page.go +18 -0
- package/resource/models.go +102 -57
- package/resource/network_topology.go +22 -0
- package/resource/network_topology_projection.go +36 -22
- package/resource/network_topology_projection_test.go +4 -0
- package/resource/network_topology_test.go +20 -0
- package/resource/network_topology_validation.go +5 -0
- package/resource/observed_workload.go +18 -0
- package/resource/pod_status_test.go +22 -0
- package/resource/rbac_graph.go +4 -0
- package/resource/rollout_optional_test.go +33 -0
- package/resource/runtime/network_relationship_primitives.go +66 -0
- package/resource/runtime/network_relationship_primitives_test.go +147 -0
- package/resource/runtime/network_topology.go +17 -5
- package/resource/runtime/network_topology_builder.go +405 -0
- package/resource/runtime/network_topology_builder_backends.go +195 -0
- package/resource/runtime/network_topology_builder_gateway_l4_test.go +86 -0
- package/resource/runtime/network_topology_builder_ingress.go +302 -0
- package/resource/runtime/network_topology_builder_legacy.go +66 -0
- package/resource/runtime/network_topology_builder_providers.go +242 -0
- package/resource/runtime/network_topology_builder_test.go +186 -0
- package/resource/runtime/network_topology_declarations.go +329 -0
- package/resource/runtime/resource_edit.go +22 -0
- package/resource/runtime/resource_edit_test.go +32 -0
- package/resource/runtime/resource_graph_collect.go +325 -0
- package/resource/runtime/resource_graph_configuration.go +59 -0
- package/resource/runtime/resource_graph_configuration_collect.go +198 -0
- package/resource/runtime/resource_graph_configuration_test.go +470 -0
- package/resource/runtime/resource_graph_custom_page.go +159 -0
- package/resource/runtime/resource_graph_custom_root.go +132 -0
- package/resource/runtime/resource_graph_custom_root_test.go +197 -0
- package/resource/runtime/resource_graph_enrichment.go +417 -0
- package/resource/runtime/resource_graph_enrichment_hpa_test.go +117 -0
- package/resource/runtime/resource_graph_enrichment_test.go +201 -0
- package/resource/runtime/resource_graph_metadata.go +125 -0
- package/resource/runtime/resource_graph_metadata_test.go +204 -0
- package/resource/runtime/resource_graph_network.go +224 -0
- package/resource/runtime/resource_graph_network_collect.go +174 -0
- package/resource/runtime/resource_graph_network_collect_test.go +147 -0
- package/resource/runtime/resource_graph_network_test.go +342 -0
- package/resource/runtime/resource_graph_rbac.go +278 -0
- package/resource/runtime/resource_graph_rbac_test.go +372 -0
- package/resource/runtime/resource_graph_rules.go +834 -0
- package/resource/runtime/resource_graph_storage.go +144 -0
- package/resource/runtime/resource_graph_storage_collect.go +163 -0
- package/resource/runtime/resource_graph_storage_test.go +397 -0
- package/resource/runtime/resource_graph_test.go +627 -0
- package/resource/runtime/secret_certificate.go +44 -0
- package/resource/runtime/secret_certificate_test.go +87 -0
- package/resource/storage_graph.go +4 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
cr "github.com/opensoha/soha-contracts/resource"
|
|
9
|
+
"reflect"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func graphEnrichmentFixture() (cr.ResourceGraph, ResourceGraphEnrichment) {
|
|
15
|
+
object := graphTestObject("custom.example/v1", "Widget", "owner", "owner-uid")
|
|
16
|
+
ref := enrichmentRef("cluster-a", object)
|
|
17
|
+
graph := cr.ResourceGraph{ClusterID: "cluster-a", Namespace: "ns", RootID: "opaque-pod", Nodes: []cr.ResourceGraphNode{{ID: "opaque-pod", Resource: cr.ResourceRef{ClusterID: "cluster-a", APIVersion: "v1", Kind: "Pod", Namespace: "ns", Name: "pod", UID: "pod-uid", ScopeMode: cr.ResourceScopeModeNamespace}, Status: "Running"}, {ID: "opaque-owner", Resource: ref, Status: "unresolved"}}, Edges: []cr.ResourceGraphEdge{{ID: "opaque-edge", SourceID: "opaque-owner", TargetID: "opaque-pod", Relation: "controls"}}, Warnings: []string{"existing warning"}, Evidence: []cr.ResourceEvidence{{ID: "evidence", Summary: "preserve evidence"}}}
|
|
18
|
+
input := ResourceGraphEnrichment{TargetID: "opaque-owner", Object: object, Definition: cr.ResourceRef{ClusterID: "cluster-a", APIVersion: "apiextensions.k8s.io/v1", Kind: "CustomResourceDefinition", Name: "widgets.custom.example", UID: "crd-uid", ScopeMode: cr.ResourceScopeModeCluster}}
|
|
19
|
+
return graph, input
|
|
20
|
+
}
|
|
21
|
+
func TestResourceGraphEnrichmentIdentityAndScope(t *testing.T) {
|
|
22
|
+
for _, test := range []struct {
|
|
23
|
+
name string
|
|
24
|
+
edit func(*ResourceGraphEnrichment)
|
|
25
|
+
}{
|
|
26
|
+
{"wrong group", func(e *ResourceGraphEnrichment) { e.Object.SetAPIVersion("apps/v1") }},
|
|
27
|
+
{"replacement", func(e *ResourceGraphEnrichment) { e.Object.SetUID("new-uid") }},
|
|
28
|
+
{"wrong kind", func(e *ResourceGraphEnrichment) { e.Object.SetKind("Deployment") }},
|
|
29
|
+
{"wrong name", func(e *ResourceGraphEnrichment) { e.Object.SetName("other") }},
|
|
30
|
+
{"cross namespace", func(e *ResourceGraphEnrichment) { e.Object.SetNamespace("other") }},
|
|
31
|
+
{"missing target", func(e *ResourceGraphEnrichment) { e.TargetID = "missing" }},
|
|
32
|
+
{"unobserved definition", func(e *ResourceGraphEnrichment) { e.Definition.UID = "" }},
|
|
33
|
+
} {
|
|
34
|
+
t.Run(test.name, func(t *testing.T) {
|
|
35
|
+
graph, input := graphEnrichmentFixture()
|
|
36
|
+
test.edit(&input)
|
|
37
|
+
_, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
|
|
38
|
+
if !errors.Is(err, ErrInvalidResourceGraphEnrichment) {
|
|
39
|
+
t.Fatalf("identity accepted: %v", err)
|
|
40
|
+
}
|
|
41
|
+
if graph.Nodes[1].Status != "unresolved" {
|
|
42
|
+
t.Fatal("input graph mutated")
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
graph, input := graphEnrichmentFixture()
|
|
47
|
+
input.Object.SetNamespace("")
|
|
48
|
+
result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
|
|
49
|
+
if err != nil {
|
|
50
|
+
t.Fatal(err)
|
|
51
|
+
}
|
|
52
|
+
found := false
|
|
53
|
+
for _, node := range result.Nodes {
|
|
54
|
+
if node.ID == "opaque-owner" {
|
|
55
|
+
found = true
|
|
56
|
+
if node.Status == "unresolved" || node.Resource.Namespace != "" || node.Resource.ScopeMode != cr.ResourceScopeModeCluster {
|
|
57
|
+
t.Fatalf("scope not corrected: %+v", node)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if !found {
|
|
62
|
+
t.Fatal("opaque target ID changed")
|
|
63
|
+
}
|
|
64
|
+
if !graphTestEdge(result, "defines", "widgets.custom.example", "owner") {
|
|
65
|
+
t.Fatal("CRD defines direction incorrect")
|
|
66
|
+
}
|
|
67
|
+
if !reflect.DeepEqual(result.Evidence, graph.Evidence) || result.Warnings[0] != "existing warning" {
|
|
68
|
+
t.Fatal("evidence/warnings changed")
|
|
69
|
+
}
|
|
70
|
+
for _, edge := range result.Edges {
|
|
71
|
+
if edge.Relation == "controls" && edge.ID != "opaque-edge" {
|
|
72
|
+
t.Fatal("existing edge ID rewritten")
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
func TestResourceGraphEnrichmentOwnerMetadataAndIdempotency(t *testing.T) {
|
|
77
|
+
for _, controller := range []bool{false, true} {
|
|
78
|
+
t.Run(fmt.Sprint(controller), func(t *testing.T) {
|
|
79
|
+
graph, input := graphEnrichmentFixture()
|
|
80
|
+
graphTestOwner(input.Object, "custom.example/v1", "Parent", "parent", "parent-uid", controller)
|
|
81
|
+
input.Object.Object["spec"] = map[string]interface{}{"secret": "MUST-NOT-LEAK"}
|
|
82
|
+
input.Object.Object["data"] = map[string]interface{}{"token": "MUST-NOT-LEAK"}
|
|
83
|
+
result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input, input})
|
|
84
|
+
if err != nil {
|
|
85
|
+
t.Fatal(err)
|
|
86
|
+
}
|
|
87
|
+
relation := "owns"
|
|
88
|
+
if controller {
|
|
89
|
+
relation = "controls"
|
|
90
|
+
}
|
|
91
|
+
if !graphTestEdge(result, relation, "parent", "owner") {
|
|
92
|
+
t.Fatal("owner/controller relation missing")
|
|
93
|
+
}
|
|
94
|
+
for _, node := range result.Nodes {
|
|
95
|
+
if node.Resource.Name == "parent" && node.Status != "unresolved" {
|
|
96
|
+
t.Fatal("unobserved parent confirmed")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
encoded, _ := json.Marshal(result)
|
|
100
|
+
if strings.Contains(string(encoded), "MUST-NOT-LEAK") {
|
|
101
|
+
t.Fatal("unknown payload exposed")
|
|
102
|
+
}
|
|
103
|
+
again, err := EnrichResourceGraph(context.Background(), result, []ResourceGraphEnrichment{input})
|
|
104
|
+
if err != nil {
|
|
105
|
+
t.Fatal(err)
|
|
106
|
+
}
|
|
107
|
+
if !reflect.DeepEqual(result, again) {
|
|
108
|
+
t.Fatalf("repeated enrichment not stable\n%+v\n%+v", result, again)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
func TestResourceGraphEnrichmentCancellationAndBudget(t *testing.T) {
|
|
114
|
+
graph, input := graphEnrichmentFixture()
|
|
115
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
116
|
+
cancel()
|
|
117
|
+
_, err := EnrichResourceGraph(ctx, graph, []ResourceGraphEnrichment{input})
|
|
118
|
+
if !errors.Is(err, context.Canceled) {
|
|
119
|
+
t.Fatalf("cancellation ignored: %v", err)
|
|
120
|
+
}
|
|
121
|
+
for len(graph.Nodes) < ResourceGraphNodeLimit {
|
|
122
|
+
index := len(graph.Nodes)
|
|
123
|
+
graph.Nodes = append(graph.Nodes, cr.ResourceGraphNode{ID: fmt.Sprintf("extra-%d", index), Resource: cr.ResourceRef{ClusterID: graph.ClusterID, APIVersion: "v1", Kind: "Pod", Name: fmt.Sprint(index), Namespace: "ns", ScopeMode: cr.ResourceScopeModeNamespace}})
|
|
124
|
+
}
|
|
125
|
+
result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
|
|
126
|
+
if err != nil {
|
|
127
|
+
t.Fatal(err)
|
|
128
|
+
}
|
|
129
|
+
if len(result.Nodes) > 500 || len(result.Edges) > 1000 || !strings.Contains(strings.Join(result.Warnings, " "), "enrichment is partial") {
|
|
130
|
+
t.Fatal("budget overflow silent")
|
|
131
|
+
}
|
|
132
|
+
graph.Nodes = append(graph.Nodes, graph.Nodes[0])
|
|
133
|
+
_, err = EnrichResourceGraph(context.Background(), graph, nil)
|
|
134
|
+
if !errors.Is(err, ErrResourceGraphBudget) {
|
|
135
|
+
t.Fatalf("invalid initial budget accepted: %v", err)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
func TestResourceGraphMetadataJSONBoundaries(t *testing.T) {
|
|
139
|
+
graph, input := graphEnrichmentFixture()
|
|
140
|
+
content := []byte(`{"apiVersion":"custom.example/v1","kind":"Widget","metadata":{"name":"owner","namespace":"ns","uid":"owner-uid","annotations":{"secret":"MUST-NOT-LEAK"}},"spec":{"credential":"MUST-NOT-LEAK"},"data":{"password":"MUST-NOT-LEAK"}}`)
|
|
141
|
+
metadata := ResourceGraphMetadataEnrichment{TargetID: input.TargetID, Content: content, Definition: input.Definition}
|
|
142
|
+
result, err := EnrichResourceGraphFromMetadataJSON(context.Background(), graph, []ResourceGraphMetadataEnrichment{metadata})
|
|
143
|
+
if err != nil {
|
|
144
|
+
t.Fatal(err)
|
|
145
|
+
}
|
|
146
|
+
encoded, _ := json.Marshal(result)
|
|
147
|
+
if strings.Contains(string(encoded), "MUST-NOT-LEAK") {
|
|
148
|
+
t.Fatal("JSON payload exposed")
|
|
149
|
+
}
|
|
150
|
+
metadata.Content = make([]byte, ResourceGraphMetadataJSONLimit+1)
|
|
151
|
+
_, err = EnrichResourceGraphFromMetadataJSON(context.Background(), graph, []ResourceGraphMetadataEnrichment{metadata})
|
|
152
|
+
if !errors.Is(err, ErrResourceGraphBudget) {
|
|
153
|
+
t.Fatal("JSON size unbounded")
|
|
154
|
+
}
|
|
155
|
+
_, err = EnrichResourceGraphFromMetadataJSON(context.Background(), graph, make([]ResourceGraphMetadataEnrichment, 17))
|
|
156
|
+
if !errors.Is(err, ErrResourceGraphBudget) {
|
|
157
|
+
t.Fatal("JSON item count unbounded")
|
|
158
|
+
}
|
|
159
|
+
metadata.Content = []byte(`{"metadata":`)
|
|
160
|
+
_, err = EnrichResourceGraphFromMetadataJSON(context.Background(), graph, []ResourceGraphMetadataEnrichment{metadata})
|
|
161
|
+
if !errors.Is(err, ErrInvalidResourceGraphEnrichment) {
|
|
162
|
+
t.Fatal("malformed JSON accepted")
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func TestResourceGraphEnrichmentDepthAndStableEdgeIDs(t *testing.T) {
|
|
167
|
+
graph, input := graphEnrichmentFixture()
|
|
168
|
+
middle := graph.Nodes[1]
|
|
169
|
+
middle.ID = "middle"
|
|
170
|
+
middle.Resource.Name = "middle"
|
|
171
|
+
middle.Resource.UID = "middle-uid"
|
|
172
|
+
middle.Status = ""
|
|
173
|
+
far := middle
|
|
174
|
+
far.ID = "far"
|
|
175
|
+
far.Resource.Name = "far"
|
|
176
|
+
far.Resource.UID = "far-uid"
|
|
177
|
+
graph.Nodes = append(graph.Nodes, middle, far)
|
|
178
|
+
graph.Edges = []cr.ResourceGraphEdge{{ID: "edge-one", SourceID: "middle", TargetID: "opaque-pod", Relation: "controls"}, {ID: "edge-two", SourceID: "far", TargetID: "middle", Relation: "controls"}, {ID: "edge-three", SourceID: "opaque-owner", TargetID: "far", Relation: "controls"}}
|
|
179
|
+
graphTestOwner(input.Object, "custom.example/v1", "Parent", "beyond-depth", "parent-uid", true)
|
|
180
|
+
result, err := EnrichResourceGraph(context.Background(), graph, []ResourceGraphEnrichment{input})
|
|
181
|
+
if err != nil {
|
|
182
|
+
t.Fatal(err)
|
|
183
|
+
}
|
|
184
|
+
if graphTestHas(result, "beyond-depth") || graphTestHas(result, "widgets.custom.example") {
|
|
185
|
+
t.Fatal("enrichment crossed three-hop boundary")
|
|
186
|
+
}
|
|
187
|
+
if !strings.Contains(strings.Join(result.Warnings, " "), "enrichment is partial") {
|
|
188
|
+
t.Fatal("depth omission silent")
|
|
189
|
+
}
|
|
190
|
+
for _, original := range graph.Edges {
|
|
191
|
+
found := false
|
|
192
|
+
for _, edge := range result.Edges {
|
|
193
|
+
if edge == original {
|
|
194
|
+
found = true
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if !found {
|
|
198
|
+
t.Fatalf("existing opaque edge modified: %+v", original)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"mime"
|
|
9
|
+
"net/http"
|
|
10
|
+
|
|
11
|
+
"k8s.io/client-go/metadata"
|
|
12
|
+
"k8s.io/client-go/rest"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const resourceGraphMetadataAccept = "application/json;as=PartialObjectMetadata;g=meta.k8s.io;v=v1"
|
|
16
|
+
const resourceGraphMetadataResponseLimit = 1 << 20
|
|
17
|
+
|
|
18
|
+
// NewResourceGraphMetadataClient creates a metadata-only Graph client. It rejects
|
|
19
|
+
// full-object fallback and validates bounded metadata envelopes before SDK decoding.
|
|
20
|
+
// The caller's REST configuration and transport wrappers remain intact.
|
|
21
|
+
func NewResourceGraphMetadataClient(config *rest.Config) (metadata.Interface, error) {
|
|
22
|
+
if config == nil {
|
|
23
|
+
return nil, fmt.Errorf("resource graph metadata REST configuration is unavailable")
|
|
24
|
+
}
|
|
25
|
+
strict := rest.CopyConfig(config)
|
|
26
|
+
strict.Wrap(func(base http.RoundTripper) http.RoundTripper {
|
|
27
|
+
return resourceGraphMetadataTransport{RoundTripper: base}
|
|
28
|
+
})
|
|
29
|
+
return metadata.NewForConfig(strict)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type resourceGraphMetadataTransport struct{ http.RoundTripper }
|
|
33
|
+
|
|
34
|
+
func (t resourceGraphMetadataTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
35
|
+
request = request.Clone(request.Context())
|
|
36
|
+
request.Header.Set("Accept", resourceGraphMetadataAccept)
|
|
37
|
+
response, err := t.RoundTripper.RoundTrip(request)
|
|
38
|
+
if err != nil {
|
|
39
|
+
return response, err
|
|
40
|
+
}
|
|
41
|
+
body := response.Body
|
|
42
|
+
defer func() { _ = body.Close() }()
|
|
43
|
+
// A redirected request would pass through the Kubernetes authentication
|
|
44
|
+
// transport again, which can attach credentials to the new endpoint.
|
|
45
|
+
if response.StatusCode >= 300 && response.StatusCode < 400 {
|
|
46
|
+
return nil, fmt.Errorf("resource graph metadata endpoint redirected")
|
|
47
|
+
}
|
|
48
|
+
var payload []byte
|
|
49
|
+
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
|
50
|
+
mediaType, _, contentTypeErr := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
|
51
|
+
if contentTypeErr != nil || mediaType != "application/json" {
|
|
52
|
+
return nil, fmt.Errorf("resource graph metadata response is not JSON metadata")
|
|
53
|
+
}
|
|
54
|
+
payload, err = readResourceGraphMetadataResponse(body)
|
|
55
|
+
} else {
|
|
56
|
+
payload, err = io.ReadAll(io.LimitReader(body, resourceGraphMetadataResponseLimit+1))
|
|
57
|
+
if len(payload) > resourceGraphMetadataResponseLimit {
|
|
58
|
+
return nil, fmt.Errorf("resource graph metadata response exceeds its size limit")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if err != nil {
|
|
62
|
+
if canceled := request.Context().Err(); canceled != nil {
|
|
63
|
+
return nil, canceled
|
|
64
|
+
}
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
response.Body = io.NopCloser(bytes.NewReader(payload))
|
|
68
|
+
response.ContentLength = int64(len(payload))
|
|
69
|
+
return response, nil
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Reject ordinary objects before the SDK's permissive metadata conversion runs.
|
|
73
|
+
func readResourceGraphMetadataResponse(reader io.Reader) ([]byte, error) {
|
|
74
|
+
var payload bytes.Buffer
|
|
75
|
+
decoder := json.NewDecoder(io.TeeReader(io.LimitReader(reader, resourceGraphMetadataResponseLimit+1), &payload))
|
|
76
|
+
token, err := decoder.Token()
|
|
77
|
+
if err != nil || token != json.Delim('{') {
|
|
78
|
+
return nil, fmt.Errorf("invalid resource graph metadata envelope")
|
|
79
|
+
}
|
|
80
|
+
seen := map[string]bool{}
|
|
81
|
+
for decoder.More() {
|
|
82
|
+
token, err = decoder.Token()
|
|
83
|
+
key, ok := token.(string)
|
|
84
|
+
if err != nil || !ok || seen[key] {
|
|
85
|
+
return nil, fmt.Errorf("invalid resource graph metadata field")
|
|
86
|
+
}
|
|
87
|
+
seen[key] = true
|
|
88
|
+
if err = readResourceGraphMetadataField(decoder, key); err != nil {
|
|
89
|
+
return nil, err
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if _, err = decoder.Token(); err != nil {
|
|
93
|
+
return nil, fmt.Errorf("invalid resource graph metadata envelope")
|
|
94
|
+
}
|
|
95
|
+
if !seen["kind"] || !seen["apiVersion"] || !seen["metadata"] {
|
|
96
|
+
return nil, fmt.Errorf("resource graph response is not PartialObjectMetadata")
|
|
97
|
+
}
|
|
98
|
+
var extra any
|
|
99
|
+
if decoder.Decode(&extra) != io.EOF || payload.Len() > resourceGraphMetadataResponseLimit {
|
|
100
|
+
return nil, fmt.Errorf("resource graph metadata response exceeds its envelope or size limit")
|
|
101
|
+
}
|
|
102
|
+
return payload.Bytes(), nil
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func readResourceGraphMetadataField(decoder *json.Decoder, key string) error {
|
|
106
|
+
switch key {
|
|
107
|
+
case "kind", "apiVersion":
|
|
108
|
+
want := "PartialObjectMetadata"
|
|
109
|
+
if key == "apiVersion" {
|
|
110
|
+
want = "meta.k8s.io/v1"
|
|
111
|
+
}
|
|
112
|
+
var value string
|
|
113
|
+
if err := decoder.Decode(&value); err != nil || value != want {
|
|
114
|
+
return fmt.Errorf("resource graph response is not PartialObjectMetadata")
|
|
115
|
+
}
|
|
116
|
+
case "metadata":
|
|
117
|
+
var value json.RawMessage
|
|
118
|
+
if err := decoder.Decode(&value); err != nil || len(value) == 0 || value[0] != '{' {
|
|
119
|
+
return fmt.Errorf("invalid resource graph object metadata")
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return fmt.Errorf("resource graph response contains a non-metadata field")
|
|
123
|
+
}
|
|
124
|
+
return nil
|
|
125
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/http/httptest"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync/atomic"
|
|
12
|
+
"testing"
|
|
13
|
+
|
|
14
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
15
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
16
|
+
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
17
|
+
"k8s.io/client-go/rest"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func TestResourceGraphMetadataTransportNeverFallsBackToSecretPayload(t *testing.T) {
|
|
21
|
+
for _, status := range []int32{http.StatusOK, http.StatusForbidden, http.StatusNotAcceptable} {
|
|
22
|
+
t.Run(http.StatusText(int(status)), func(t *testing.T) {
|
|
23
|
+
var requests atomic.Int32
|
|
24
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
25
|
+
requests.Add(1)
|
|
26
|
+
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/namespaces/team-a/secrets/credentials" {
|
|
27
|
+
t.Errorf("unexpected metadata request: %s %s", r.Method, r.URL.Path)
|
|
28
|
+
}
|
|
29
|
+
if r.Header.Get("Accept") != resourceGraphMetadataAccept {
|
|
30
|
+
t.Errorf("unsafe Secret negotiation: %q", r.Header.Get("Accept"))
|
|
31
|
+
}
|
|
32
|
+
w.Header().Set("Content-Type", "application/json")
|
|
33
|
+
w.WriteHeader(int(status))
|
|
34
|
+
if status == http.StatusOK {
|
|
35
|
+
_ = json.NewEncoder(w).Encode(metav1.PartialObjectMetadata{TypeMeta: metav1.TypeMeta{APIVersion: "meta.k8s.io/v1", Kind: "PartialObjectMetadata"}, ObjectMeta: metav1.ObjectMeta{Name: "credentials", Namespace: "team-a", UID: "secret-uid"}})
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
reason := metav1.StatusReasonForbidden
|
|
39
|
+
if status == http.StatusNotAcceptable {
|
|
40
|
+
reason = metav1.StatusReasonNotAcceptable
|
|
41
|
+
}
|
|
42
|
+
_ = json.NewEncoder(w).Encode(metav1.Status{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Status"}, Status: "Failure", Reason: reason, Code: status})
|
|
43
|
+
}))
|
|
44
|
+
defer server.Close()
|
|
45
|
+
config := &rest.Config{Host: server.URL}
|
|
46
|
+
client, err := NewResourceGraphMetadataClient(config)
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
result, err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(context.Background(), "credentials", metav1.GetOptions{})
|
|
51
|
+
switch status {
|
|
52
|
+
case http.StatusOK:
|
|
53
|
+
if err != nil || result.UID != "secret-uid" {
|
|
54
|
+
t.Fatalf("metadata=%+v err=%v", result, err)
|
|
55
|
+
}
|
|
56
|
+
case http.StatusForbidden:
|
|
57
|
+
if !apierrors.IsForbidden(err) {
|
|
58
|
+
t.Fatalf("forbidden mapping lost: %v", err)
|
|
59
|
+
}
|
|
60
|
+
case http.StatusNotAcceptable:
|
|
61
|
+
if !apierrors.IsNotAcceptable(err) {
|
|
62
|
+
t.Fatalf("metadata rejection lost: %v", err)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if requests.Load() != 1 {
|
|
66
|
+
t.Fatalf("metadata request fell back or retried: %d", requests.Load())
|
|
67
|
+
}
|
|
68
|
+
if config.WrapTransport != nil {
|
|
69
|
+
t.Fatal("shared REST configuration was mutated")
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestResourceGraphMetadataTransportRejectsOrdinaryObjects(t *testing.T) {
|
|
76
|
+
for _, tc := range []struct{ name, contentType, payload string }{
|
|
77
|
+
{"ordinary Secret", "application/json", `{"kind":"Secret","apiVersion":"v1","metadata":{"name":"credentials","namespace":"team-a"},"data":{"password":"must-not-read"}}`},
|
|
78
|
+
{"disguised Secret", resourceGraphMetadataAccept, `{"kind":"Secret","apiVersion":"v1","metadata":{"name":"credentials"},"data":{"password":"must-not-read"}}`},
|
|
79
|
+
{"payload on metadata", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"credentials"},"data":{"password":"must-not-read"}}`},
|
|
80
|
+
{"wrong metadata version", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"v1","metadata":{"name":"credentials"}}`},
|
|
81
|
+
{"missing identity", "application/json", `{"metadata":{"name":"credentials"}}`},
|
|
82
|
+
{"duplicate identity", "application/json", `{"kind":"PartialObjectMetadata","kind":"Secret","apiVersion":"meta.k8s.io/v1","metadata":{"name":"credentials"}}`},
|
|
83
|
+
{"trailing object", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{}} {"kind":"Secret"}`},
|
|
84
|
+
{"non-json metadata", "application/yaml", "kind: PartialObjectMetadata"},
|
|
85
|
+
{"oversized metadata", "application/json", `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":{"name":"` + strings.Repeat("a", resourceGraphMetadataResponseLimit) + `"}}`},
|
|
86
|
+
} {
|
|
87
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
88
|
+
var requests atomic.Int32
|
|
89
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
90
|
+
requests.Add(1)
|
|
91
|
+
w.Header().Set("Content-Type", tc.contentType)
|
|
92
|
+
_, _ = io.WriteString(w, tc.payload)
|
|
93
|
+
}))
|
|
94
|
+
defer server.Close()
|
|
95
|
+
client, err := NewResourceGraphMetadataClient(&rest.Config{Host: server.URL})
|
|
96
|
+
if err != nil {
|
|
97
|
+
t.Fatal(err)
|
|
98
|
+
}
|
|
99
|
+
result, err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(context.Background(), "credentials", metav1.GetOptions{})
|
|
100
|
+
if err == nil || result != nil || strings.Contains(err.Error(), "must-not-read") {
|
|
101
|
+
t.Fatalf("ordinary payload accepted or exposed: result=%+v err=%v", result, err)
|
|
102
|
+
}
|
|
103
|
+
if requests.Load() != 1 {
|
|
104
|
+
t.Fatalf("unsafe payload retried or fell back: %d", requests.Load())
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
func TestResourceGraphMetadataTransportRejectsCredentialRedirect(t *testing.T) {
|
|
111
|
+
var redirected atomic.Bool
|
|
112
|
+
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
113
|
+
redirected.Store(true)
|
|
114
|
+
w.WriteHeader(http.StatusForbidden)
|
|
115
|
+
}))
|
|
116
|
+
defer target.Close()
|
|
117
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
118
|
+
if r.Header.Get("Authorization") != "Bearer synthetic-graph-token" {
|
|
119
|
+
t.Error("authenticated metadata request did not carry its credential")
|
|
120
|
+
}
|
|
121
|
+
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
|
122
|
+
}))
|
|
123
|
+
defer server.Close()
|
|
124
|
+
client, err := NewResourceGraphMetadataClient(&rest.Config{Host: server.URL, BearerToken: "synthetic-graph-token"})
|
|
125
|
+
if err != nil {
|
|
126
|
+
t.Fatal(err)
|
|
127
|
+
}
|
|
128
|
+
_, err = client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(context.Background(), "credentials", metav1.GetOptions{})
|
|
129
|
+
if err == nil || !strings.Contains(err.Error(), "endpoint redirected") || redirected.Load() {
|
|
130
|
+
t.Fatalf("redirect was not rejected: requested=%v err=%v", redirected.Load(), err)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func TestResourceGraphMetadataTransportBoundsErrorPayload(t *testing.T) {
|
|
135
|
+
for _, status := range []int{http.StatusForbidden, http.StatusNotAcceptable, http.StatusInternalServerError} {
|
|
136
|
+
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
137
|
+
transport := resourceGraphMetadataTransport{RoundTripper: resourceGraphCancelRoundTripper(func(*http.Request) (*http.Response, error) {
|
|
138
|
+
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(strings.Repeat("a", resourceGraphMetadataResponseLimit+1)))}, nil
|
|
139
|
+
})}
|
|
140
|
+
request := httptest.NewRequest(http.MethodGet, "https://cluster.invalid/api/v1/namespaces/team-a/secrets/credentials", nil)
|
|
141
|
+
response, err := transport.RoundTrip(request)
|
|
142
|
+
if response != nil {
|
|
143
|
+
if closeErr := response.Body.Close(); closeErr != nil {
|
|
144
|
+
t.Errorf("close unexpected response body: %v", closeErr)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if err == nil || !strings.Contains(err.Error(), "size limit") || response != nil {
|
|
148
|
+
t.Fatalf("oversized error reached the metadata client: response=%v err=%v", response, err)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type resourceGraphCancelRoundTripper func(*http.Request) (*http.Response, error)
|
|
155
|
+
|
|
156
|
+
func (f resourceGraphCancelRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
157
|
+
return f(request)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type resourceGraphCancelBody struct {
|
|
161
|
+
io.ReadCloser
|
|
162
|
+
cancel context.CancelFunc
|
|
163
|
+
read *atomic.Bool
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func (b resourceGraphCancelBody) Read(payload []byte) (int, error) {
|
|
167
|
+
b.read.Store(true)
|
|
168
|
+
b.cancel()
|
|
169
|
+
return b.ReadCloser.Read(payload)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func TestResourceGraphMetadataTransportRetainsBodyReadCancellation(t *testing.T) {
|
|
173
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
174
|
+
w.Header().Set("Content-Type", "application/json")
|
|
175
|
+
w.WriteHeader(http.StatusOK)
|
|
176
|
+
_, _ = io.WriteString(w, `{"kind":"PartialObjectMetadata","apiVersion":"meta.k8s.io/v1","metadata":`)
|
|
177
|
+
if err := http.NewResponseController(w).Flush(); err != nil {
|
|
178
|
+
t.Errorf("flush metadata response: %v", err)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
<-r.Context().Done()
|
|
182
|
+
}))
|
|
183
|
+
defer server.Close()
|
|
184
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
185
|
+
defer cancel()
|
|
186
|
+
var bodyRead atomic.Bool
|
|
187
|
+
config := &rest.Config{Host: server.URL, WrapTransport: func(base http.RoundTripper) http.RoundTripper {
|
|
188
|
+
return resourceGraphCancelRoundTripper(func(request *http.Request) (*http.Response, error) {
|
|
189
|
+
response, err := base.RoundTrip(request)
|
|
190
|
+
if err == nil {
|
|
191
|
+
response.Body = resourceGraphCancelBody{ReadCloser: response.Body, cancel: cancel, read: &bodyRead}
|
|
192
|
+
}
|
|
193
|
+
return response, err
|
|
194
|
+
})
|
|
195
|
+
}}
|
|
196
|
+
client, err := NewResourceGraphMetadataClient(config)
|
|
197
|
+
if err != nil {
|
|
198
|
+
t.Fatal(err)
|
|
199
|
+
}
|
|
200
|
+
_, err = client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}).Namespace("team-a").Get(ctx, "credentials", metav1.GetOptions{})
|
|
201
|
+
if !bodyRead.Load() || !errors.Is(err, context.Canceled) {
|
|
202
|
+
t.Fatalf("HTTP 200 body cancellation lost: bodyRead=%v err=%v", bodyRead.Load(), err)
|
|
203
|
+
}
|
|
204
|
+
}
|