@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,627 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
cr "github.com/opensoha/soha-contracts/resource"
|
|
12
|
+
appsv1 "k8s.io/api/apps/v1"
|
|
13
|
+
corev1 "k8s.io/api/core/v1"
|
|
14
|
+
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
15
|
+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
16
|
+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
17
|
+
kruntime "k8s.io/apimachinery/pkg/runtime"
|
|
18
|
+
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
19
|
+
"k8s.io/apimachinery/pkg/types"
|
|
20
|
+
"k8s.io/client-go/kubernetes/fake"
|
|
21
|
+
ktesting "k8s.io/client-go/testing"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
func graphTestObject(api, kind, name, uid string) *unstructured.Unstructured {
|
|
25
|
+
o := &unstructured.Unstructured{Object: map[string]interface{}{"apiVersion": api, "kind": kind, "metadata": map[string]interface{}{"name": name, "namespace": "ns", "uid": uid}}}
|
|
26
|
+
return o
|
|
27
|
+
}
|
|
28
|
+
func graphTestRoot(kind, name string) cr.ResourceRef {
|
|
29
|
+
return cr.ResourceRef{ClusterID: "cluster-a", Kind: kind, Name: name, Namespace: "ns", ScopeMode: cr.ResourceScopeModeNamespace}
|
|
30
|
+
}
|
|
31
|
+
func graphTestEdge(graph cr.ResourceGraph, relation, source, target string) bool {
|
|
32
|
+
nodes := map[string]string{}
|
|
33
|
+
for _, node := range graph.Nodes {
|
|
34
|
+
nodes[node.ID] = node.Resource.Name
|
|
35
|
+
}
|
|
36
|
+
for _, edge := range graph.Edges {
|
|
37
|
+
if edge.Relation == relation && nodes[edge.SourceID] == source && nodes[edge.TargetID] == target {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
func graphTestHas(graph cr.ResourceGraph, name string) bool {
|
|
44
|
+
for _, node := range graph.Nodes {
|
|
45
|
+
if node.Resource.Name == name {
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
func graphTestOwner(o *unstructured.Unstructured, api, kind, name, uid string, controller bool) {
|
|
52
|
+
o.SetOwnerReferences([]metav1.OwnerReference{{APIVersion: api, Kind: kind, Name: name, UID: types.UID(uid), Controller: &controller}})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestResourceGraphOwnerIdentityAndUnknownMetadata(t *testing.T) {
|
|
56
|
+
for _, controller := range []bool{false, true} {
|
|
57
|
+
t.Run(fmt.Sprint(controller), func(t *testing.T) {
|
|
58
|
+
owner := graphTestObject("custom.example/v1", "CustomOwner", "owner", "owner-new")
|
|
59
|
+
pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
|
|
60
|
+
graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-old", controller)
|
|
61
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{owner, pod})
|
|
62
|
+
if err != nil {
|
|
63
|
+
t.Fatal(err)
|
|
64
|
+
}
|
|
65
|
+
relation := "owns"
|
|
66
|
+
if controller {
|
|
67
|
+
relation = "controls"
|
|
68
|
+
}
|
|
69
|
+
if !graphTestEdge(graph, relation, "owner", "pod") {
|
|
70
|
+
t.Fatalf("missing owner edge: %+v", graph)
|
|
71
|
+
}
|
|
72
|
+
for _, node := range graph.Nodes {
|
|
73
|
+
if node.Resource.Name == "owner" && (node.Status != "unresolved" || node.Resource.UID != "owner-old") {
|
|
74
|
+
t.Fatalf("stale owner incorrectly resolved: %+v", node)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-new", controller)
|
|
78
|
+
graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{owner, pod})
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatal(err)
|
|
81
|
+
}
|
|
82
|
+
for _, node := range graph.Nodes {
|
|
83
|
+
if node.Resource.Name == "owner" && node.Status == "unresolved" {
|
|
84
|
+
t.Fatal("matching owner not resolved")
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
func TestResourceGraphEightWorkloadsAndPodSources(t *testing.T) {
|
|
91
|
+
for _, kind := range []string{"Deployment", "Pod", "StatefulSet", "DaemonSet", "Job", "CronJob", "ReplicaSet", "ReplicationController"} {
|
|
92
|
+
t.Run(kind, func(t *testing.T) {
|
|
93
|
+
api := "apps/v1"
|
|
94
|
+
if kind == "Pod" || kind == "ReplicationController" {
|
|
95
|
+
api = "v1"
|
|
96
|
+
}
|
|
97
|
+
if kind == "Job" || kind == "CronJob" {
|
|
98
|
+
api = "batch/v1"
|
|
99
|
+
}
|
|
100
|
+
o := graphTestObject(api, kind, "root", "root-uid")
|
|
101
|
+
spec := map[string]interface{}{
|
|
102
|
+
"serviceAccountName": "account", "nodeName": "node",
|
|
103
|
+
"imagePullSecrets": []interface{}{map[string]interface{}{"name": "pull"}},
|
|
104
|
+
"containers": []interface{}{map[string]interface{}{"name": "main", "env": []interface{}{map[string]interface{}{"valueFrom": map[string]interface{}{"configMapKeyRef": map[string]interface{}{"name": "config"}}}}}},
|
|
105
|
+
"initContainers": []interface{}{map[string]interface{}{"name": "init", "envFrom": []interface{}{map[string]interface{}{"secretRef": map[string]interface{}{"name": "init-secret"}}}}},
|
|
106
|
+
"volumes": []interface{}{map[string]interface{}{"persistentVolumeClaim": map[string]interface{}{"claimName": "claim"}}, map[string]interface{}{"projected": map[string]interface{}{"sources": []interface{}{map[string]interface{}{"secret": map[string]interface{}{"name": "projected"}}, map[string]interface{}{"configMap": map[string]interface{}{"name": "projected-config"}}}}}},
|
|
107
|
+
}
|
|
108
|
+
path := []string{"spec", "template", "spec"}
|
|
109
|
+
if kind == "Pod" {
|
|
110
|
+
path = []string{"spec"}
|
|
111
|
+
}
|
|
112
|
+
if kind == "CronJob" {
|
|
113
|
+
path = []string{"spec", "jobTemplate", "spec", "template", "spec"}
|
|
114
|
+
}
|
|
115
|
+
if err := unstructured.SetNestedMap(o.Object, spec, path...); err != nil {
|
|
116
|
+
t.Fatal(err)
|
|
117
|
+
}
|
|
118
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot(kind, "root"), []*unstructured.Unstructured{o})
|
|
119
|
+
if err != nil {
|
|
120
|
+
t.Fatal(err)
|
|
121
|
+
}
|
|
122
|
+
for _, name := range []string{"config", "init-secret", "claim", "account", "pull", "node", "projected", "projected-config"} {
|
|
123
|
+
if !graphTestHas(graph, name) {
|
|
124
|
+
t.Errorf("missing %s", name)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if !graphTestEdge(graph, "mounts", "root", "claim") {
|
|
128
|
+
t.Fatal("PVC relation token changed")
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
func TestResourceGraphDirectionalBoundsAndSelectors(t *testing.T) {
|
|
134
|
+
root := graphTestObject("apps/v1", "Deployment", "root", "root-uid")
|
|
135
|
+
replica := graphTestObject("apps/v1", "ReplicaSet", "rs", "rs-uid")
|
|
136
|
+
graphTestOwner(replica, "apps/v1", "Deployment", "root", "root-uid", true)
|
|
137
|
+
pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
|
|
138
|
+
graphTestOwner(pod, "apps/v1", "ReplicaSet", "rs", "rs-uid", true)
|
|
139
|
+
pod.SetLabels(map[string]string{"app": "a"})
|
|
140
|
+
other := graphTestObject("v1", "Pod", "unrelated", "other-uid")
|
|
141
|
+
other.SetLabels(map[string]string{"app": "a"})
|
|
142
|
+
spec := map[string]interface{}{"volumes": []interface{}{map[string]interface{}{"configMap": map[string]interface{}{"name": "shared"}}}}
|
|
143
|
+
_ = unstructured.SetNestedMap(pod.Object, spec, "spec")
|
|
144
|
+
_ = unstructured.SetNestedMap(other.Object, spec, "spec")
|
|
145
|
+
service := graphTestObject("v1", "Service", "svc", "svc-uid")
|
|
146
|
+
_ = unstructured.SetNestedStringMap(service.Object, map[string]string{"app": "a"}, "spec", "selector")
|
|
147
|
+
empty := graphTestObject("v1", "Service", "empty", "empty-uid")
|
|
148
|
+
_ = unstructured.SetNestedMap(empty.Object, map[string]interface{}{}, "spec", "selector")
|
|
149
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot("Deployment", "root"), []*unstructured.Unstructured{root, replica, pod, other, service, empty})
|
|
150
|
+
if err != nil {
|
|
151
|
+
t.Fatal(err)
|
|
152
|
+
}
|
|
153
|
+
if graphTestHas(graph, "unrelated") || graphTestHas(graph, "empty") {
|
|
154
|
+
t.Fatal("graph leaked through shared config or selector")
|
|
155
|
+
}
|
|
156
|
+
if !graphTestHas(graph, "shared") || !graphTestHas(graph, "svc") {
|
|
157
|
+
t.Fatalf("missing third-level relationships: %+v", graph)
|
|
158
|
+
}
|
|
159
|
+
// Starting at Pod may ascend owners, but must not turn around into siblings.
|
|
160
|
+
sibling := graphTestObject("v1", "Pod", "sibling", "sibling-uid")
|
|
161
|
+
graphTestOwner(sibling, "apps/v1", "ReplicaSet", "rs", "rs-uid", true)
|
|
162
|
+
graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{root, replica, pod, sibling})
|
|
163
|
+
if err != nil {
|
|
164
|
+
t.Fatal(err)
|
|
165
|
+
}
|
|
166
|
+
if graphTestHas(graph, "sibling") {
|
|
167
|
+
t.Fatal("ancestor traversal reversed into sibling")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
func TestResourceGraphLimitsRootIdentityAndProjection(t *testing.T) {
|
|
171
|
+
root := graphTestObject("v1", "ConfigMap", "root", "root-uid")
|
|
172
|
+
objects := []*unstructured.Unstructured{root}
|
|
173
|
+
for i := 0; i < 600; i++ {
|
|
174
|
+
child := graphTestObject("v1", "Pod", fmt.Sprintf("p-%03d", i), fmt.Sprintf("uid-%d", i))
|
|
175
|
+
graphTestOwner(child, "v1", "ConfigMap", "root", "root-uid", true)
|
|
176
|
+
objects = append(objects, child)
|
|
177
|
+
}
|
|
178
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot("ConfigMap", "root"), objects)
|
|
179
|
+
if err != nil {
|
|
180
|
+
t.Fatal(err)
|
|
181
|
+
}
|
|
182
|
+
if len(graph.Nodes) != 500 || len(graph.Edges) > 1000 || len(graph.Warnings) == 0 {
|
|
183
|
+
t.Fatalf("limits not applied: %d/%d %+v", len(graph.Nodes), len(graph.Edges), graph.Warnings)
|
|
184
|
+
}
|
|
185
|
+
ref := graphTestRoot("ConfigMap", "root")
|
|
186
|
+
ref.UID = "replaced"
|
|
187
|
+
if _, err = BuildResourceGraphFromObjects(ref, objects); err == nil {
|
|
188
|
+
t.Fatal("root UID mismatch accepted")
|
|
189
|
+
}
|
|
190
|
+
ref = graphTestRoot("Pod", "root")
|
|
191
|
+
ref.Namespace = ""
|
|
192
|
+
ref.ScopeMode = cr.ResourceScopeModeCluster
|
|
193
|
+
if _, err = BuildResourceGraphFromObjects(ref, objects); err == nil {
|
|
194
|
+
t.Fatal("namespace bypass accepted")
|
|
195
|
+
}
|
|
196
|
+
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "secret", Namespace: "ns", Annotations: map[string]string{"password": "hidden"}}, Data: map[string][]byte{"password": []byte("sensitive")}}
|
|
197
|
+
projected, err := graphObjectProjection(secret, "v1", "Secret")
|
|
198
|
+
if err != nil {
|
|
199
|
+
t.Fatal(err)
|
|
200
|
+
}
|
|
201
|
+
for _, key := range []string{"data", "stringData", "spec"} {
|
|
202
|
+
if _, ok := projected.Object[key]; ok {
|
|
203
|
+
t.Fatalf("secret payload retained: %s", key)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if len(projected.GetAnnotations()) > 0 {
|
|
207
|
+
t.Fatal("annotations retained")
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
func TestResourceGraphCollectorErrorsWarningsAndLimit(t *testing.T) {
|
|
211
|
+
deployment := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "ns", UID: "root-uid"}}
|
|
212
|
+
client := fake.NewSimpleClientset(deployment)
|
|
213
|
+
client.PrependReactor("*", "secrets", func(ktesting.Action) (bool, kruntime.Object, error) {
|
|
214
|
+
t.Error("Secret API must not be called")
|
|
215
|
+
return true, nil, errors.New("Secret read prohibited")
|
|
216
|
+
})
|
|
217
|
+
client.PrependReactor("list", "pods", func(action ktesting.Action) (bool, kruntime.Object, error) {
|
|
218
|
+
list, ok := action.(interface{ GetListOptions() metav1.ListOptions })
|
|
219
|
+
if !ok || list.GetListOptions().Limit != 500 {
|
|
220
|
+
t.Error("list was not bounded")
|
|
221
|
+
}
|
|
222
|
+
return true, nil, apierrors.NewForbidden(schema.GroupResource{Resource: "pods"}, "", errors.New("denied"))
|
|
223
|
+
})
|
|
224
|
+
_, secretErr := BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Secret", "secret"))
|
|
225
|
+
if secretErr == nil {
|
|
226
|
+
t.Fatal("Secret root must require metadata client")
|
|
227
|
+
}
|
|
228
|
+
// A single-page response may be truncated; the independent root Get must survive.
|
|
229
|
+
client.PrependReactor("list", "deployments", func(action ktesting.Action) (bool, kruntime.Object, error) {
|
|
230
|
+
return true, &appsv1.DeploymentList{ListMeta: metav1.ListMeta{Continue: "next-page"}}, nil
|
|
231
|
+
})
|
|
232
|
+
graph, err := BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Deployment", "root"))
|
|
233
|
+
if err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
if !graphTestHas(graph, "root") {
|
|
237
|
+
t.Fatal("root lost to source truncation")
|
|
238
|
+
}
|
|
239
|
+
warnings := strings.Join(graph.Warnings, "\n")
|
|
240
|
+
if !strings.Contains(warnings, "Pod source Forbidden") || !strings.Contains(warnings, "Deployment source truncated") {
|
|
241
|
+
t.Fatalf("missing warnings: %s", warnings)
|
|
242
|
+
}
|
|
243
|
+
_, err = BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Deployment", "missing"))
|
|
244
|
+
if !apierrors.IsNotFound(err) {
|
|
245
|
+
t.Fatalf("root error not preserved: %v", err)
|
|
246
|
+
}
|
|
247
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
248
|
+
cancel()
|
|
249
|
+
_, err = BuildKubernetesResourceGraph(ctx, client, graphTestRoot("Deployment", "root"))
|
|
250
|
+
if !errors.Is(err, context.Canceled) {
|
|
251
|
+
t.Fatalf("cancellation swallowed: %v", err)
|
|
252
|
+
}
|
|
253
|
+
client.PrependReactor("list", "pods", func(ktesting.Action) (bool, kruntime.Object, error) { return true, nil, context.DeadlineExceeded })
|
|
254
|
+
_, err = BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Deployment", "root"))
|
|
255
|
+
if !errors.Is(err, context.DeadlineExceeded) {
|
|
256
|
+
t.Fatalf("source timeout swallowed: %v", err)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
func TestResourceGraphSupportingRulesAndUnknownSpec(t *testing.T) {
|
|
261
|
+
pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
|
|
262
|
+
pod.SetLabels(map[string]string{"app": "a"})
|
|
263
|
+
service := graphTestObject("v1", "Service", "svc", "svc-uid")
|
|
264
|
+
_ = unstructured.SetNestedStringMap(service.Object, map[string]string{"app": "a"}, "spec", "selector")
|
|
265
|
+
ingress := graphTestObject("networking.k8s.io/v1", "Ingress", "ingress", "ingress-uid")
|
|
266
|
+
_ = unstructured.SetNestedMap(ingress.Object, map[string]interface{}{"service": map[string]interface{}{"name": "svc"}}, "spec", "defaultBackend")
|
|
267
|
+
pdb := graphTestObject("policy/v1", "PodDisruptionBudget", "pdb", "pdb-uid")
|
|
268
|
+
_ = unstructured.SetNestedMap(pdb.Object, map[string]interface{}{"matchLabels": map[string]interface{}{"app": "a"}}, "spec", "selector")
|
|
269
|
+
hpa := graphTestObject("autoscaling/v2", "HorizontalPodAutoscaler", "hpa", "hpa-uid")
|
|
270
|
+
_ = unstructured.SetNestedMap(hpa.Object, map[string]interface{}{"apiVersion": "v1", "kind": "Pod", "name": "pod"}, "spec", "scaleTargetRef")
|
|
271
|
+
event := graphTestObject("v1", "Event", "event", "event-uid")
|
|
272
|
+
event.Object["involvedObject"] = map[string]interface{}{"apiVersion": "v1", "kind": "Pod", "namespace": "ns", "name": "pod", "uid": "pod-uid"}
|
|
273
|
+
event.Object["type"] = "Warning"
|
|
274
|
+
event.Object["reason"] = "BackOff"
|
|
275
|
+
event.Object["message"] = "retrying"
|
|
276
|
+
event.Object["lastTimestamp"] = "2026-11-05T00:00:00Z"
|
|
277
|
+
stale := event.DeepCopy()
|
|
278
|
+
stale.SetName("stale")
|
|
279
|
+
_ = unstructured.SetNestedField(stale.Object, "old-uid", "involvedObject", "uid")
|
|
280
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{pod, service, ingress, pdb, hpa, event, stale})
|
|
281
|
+
if err != nil {
|
|
282
|
+
t.Fatal(err)
|
|
283
|
+
}
|
|
284
|
+
for _, edge := range []struct{ relation, source, target string }{{"selects", "svc", "pod"}, {"routes-to", "ingress", "svc"}, {"protects", "pdb", "pod"}, {"scales", "hpa", "pod"}} {
|
|
285
|
+
if !graphTestEdge(graph, edge.relation, edge.source, edge.target) {
|
|
286
|
+
t.Errorf("missing %s", edge.relation)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if len(graph.Evidence) != 1 || graph.Evidence[0].Severity != "warning" || graph.Evidence[0].Summary != "BackOff retrying" {
|
|
290
|
+
t.Fatalf("events lost identity or semantics: %+v", graph.Evidence)
|
|
291
|
+
}
|
|
292
|
+
custom := graphTestObject("custom.example/v1", "Pod", "custom", "custom-uid")
|
|
293
|
+
_ = unstructured.SetNestedMap(custom.Object, map[string]interface{}{"serviceAccountName": "must-not-infer"}, "spec")
|
|
294
|
+
graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "custom"), []*unstructured.Unstructured{custom})
|
|
295
|
+
if err != nil {
|
|
296
|
+
t.Fatal(err)
|
|
297
|
+
}
|
|
298
|
+
if len(graph.Nodes) != 1 || len(graph.Edges) != 0 {
|
|
299
|
+
t.Fatal("unknown GVK spec inferred")
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func TestResourceGraphEventEvidenceUsesObservedTimeAndSortsNewestFirst(t *testing.T) {
|
|
304
|
+
base := time.Date(2026, time.November, 5, 10, 0, 0, 0, time.UTC)
|
|
305
|
+
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "ns", UID: "pod-uid"}}
|
|
306
|
+
involved := corev1.ObjectReference{APIVersion: "v1", Kind: "Pod", Name: "root", Namespace: "ns", UID: "pod-uid"}
|
|
307
|
+
client := fake.NewSimpleClientset(pod,
|
|
308
|
+
&corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "a-creation", Namespace: "ns", CreationTimestamp: metav1.NewTime(base)}, InvolvedObject: involved, Reason: "Created"},
|
|
309
|
+
&corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "b-event", Namespace: "ns", CreationTimestamp: metav1.NewTime(base)}, InvolvedObject: involved, EventTime: metav1.NewMicroTime(base.Add(2 * time.Second)), Reason: "Scheduled"},
|
|
310
|
+
&corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "c-last", Namespace: "ns", CreationTimestamp: metav1.NewTime(base)}, InvolvedObject: involved, EventTime: metav1.NewMicroTime(base.Add(time.Second)), LastTimestamp: metav1.NewTime(base.Add(3 * time.Second)), Reason: "Started"},
|
|
311
|
+
&corev1.Event{ObjectMeta: metav1.ObjectMeta{Name: "d-unknown", Namespace: "ns"}, InvolvedObject: involved, Reason: "Unknown"},
|
|
312
|
+
)
|
|
313
|
+
graph, err := BuildKubernetesResourceGraph(context.Background(), client, graphTestRoot("Pod", "root"))
|
|
314
|
+
if err != nil {
|
|
315
|
+
t.Fatal(err)
|
|
316
|
+
}
|
|
317
|
+
if len(graph.Evidence) != 3 {
|
|
318
|
+
t.Fatalf("unexpected evidence: %+v", graph.Evidence)
|
|
319
|
+
}
|
|
320
|
+
for i, want := range []struct{ id, observed string }{
|
|
321
|
+
{"event/ns/c-last", "2026-11-05T10:00:03Z"},
|
|
322
|
+
{"event/ns/b-event", "2026-11-05T10:00:02Z"},
|
|
323
|
+
{"event/ns/a-creation", "2026-11-05T10:00:00Z"},
|
|
324
|
+
} {
|
|
325
|
+
if got := graph.Evidence[i]; got.ID != want.id || got.ObservedAt != want.observed {
|
|
326
|
+
t.Errorf("evidence[%d]=%+v, want %+v", i, got, want)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func TestResourceGraphNormalizeAndSafeWarnings(t *testing.T) {
|
|
332
|
+
root, err := NormalizeResourceGraphRoot(cr.ResourceRef{ClusterID: " cluster ", Kind: " deployment ", Name: " root ", Namespace: " ns "})
|
|
333
|
+
if err != nil {
|
|
334
|
+
t.Fatal(err)
|
|
335
|
+
}
|
|
336
|
+
if root.Kind != "Deployment" || root.Namespace != "ns" || root.Name != "root" {
|
|
337
|
+
t.Fatalf("bad normalization: %+v", root)
|
|
338
|
+
}
|
|
339
|
+
root.Namespace = " "
|
|
340
|
+
if _, err = NormalizeResourceGraphRoot(root); err == nil {
|
|
341
|
+
t.Fatal("blank namespace accepted")
|
|
342
|
+
}
|
|
343
|
+
client := fake.NewSimpleClientset(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "root", Namespace: "ns", UID: "root-uid"}})
|
|
344
|
+
client.PrependReactor("list", "pods", func(ktesting.Action) (bool, kruntime.Object, error) {
|
|
345
|
+
return true, nil, fmt.Errorf("sensitive transport response token=must-not-appear")
|
|
346
|
+
})
|
|
347
|
+
remaining := int64(12)
|
|
348
|
+
client.PrependReactor("list", "deployments", func(ktesting.Action) (bool, kruntime.Object, error) {
|
|
349
|
+
return true, &appsv1.DeploymentList{ListMeta: metav1.ListMeta{RemainingItemCount: &remaining}}, nil
|
|
350
|
+
})
|
|
351
|
+
input := graphTestRoot("deployment", "root")
|
|
352
|
+
graph, err := BuildKubernetesResourceGraph(context.Background(), client, input)
|
|
353
|
+
if err != nil {
|
|
354
|
+
t.Fatal(err)
|
|
355
|
+
}
|
|
356
|
+
warnings := strings.Join(graph.Warnings, "\n")
|
|
357
|
+
if strings.Contains(warnings, "token=") || !strings.Contains(warnings, "Pod source unavailable") || !strings.Contains(warnings, "Deployment source truncated") {
|
|
358
|
+
t.Fatalf("unsafe or missing warnings: %s", warnings)
|
|
359
|
+
}
|
|
360
|
+
for _, node := range graph.Nodes {
|
|
361
|
+
if node.Resource.Name == "root" && node.Resource.Kind != "Deployment" {
|
|
362
|
+
t.Fatal("wire kind not canonical")
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
func TestResourceGraphDepthAndReplacementUID(t *testing.T) {
|
|
367
|
+
objects := []*unstructured.Unstructured{}
|
|
368
|
+
for i := 0; i < 5; i++ {
|
|
369
|
+
object := graphTestObject("custom.example/v1", "Owner", fmt.Sprintf("owner-%d", i), fmt.Sprintf("uid-%d", i))
|
|
370
|
+
if i > 0 {
|
|
371
|
+
graphTestOwner(object, "custom.example/v1", "Owner", fmt.Sprintf("owner-%d", i-1), fmt.Sprintf("uid-%d", i-1), true)
|
|
372
|
+
}
|
|
373
|
+
objects = append(objects, object)
|
|
374
|
+
}
|
|
375
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot("Owner", "owner-0"), objects)
|
|
376
|
+
if err != nil {
|
|
377
|
+
t.Fatal(err)
|
|
378
|
+
}
|
|
379
|
+
if graphTestHas(graph, "owner-4") || !strings.Contains(strings.Join(graph.Warnings, " "), "truncated by depth") {
|
|
380
|
+
t.Fatalf("depth omission not explicit: %+v", graph)
|
|
381
|
+
}
|
|
382
|
+
replacement := graphTestObject("apps/v1", "Deployment", "same-name", "new-uid")
|
|
383
|
+
child := graphTestObject("v1", "Pod", "child", "child-uid")
|
|
384
|
+
graphTestOwner(child, "apps/v1", "Deployment", "same-name", "old-uid", true)
|
|
385
|
+
graph, err = BuildResourceGraphFromObjects(graphTestRoot("Deployment", "same-name"), []*unstructured.Unstructured{replacement, child})
|
|
386
|
+
if err != nil {
|
|
387
|
+
t.Fatal(err)
|
|
388
|
+
}
|
|
389
|
+
if graphTestHas(graph, "child") || len(graph.Edges) != 0 {
|
|
390
|
+
t.Fatal("stale owner UID connected to replacement root")
|
|
391
|
+
}
|
|
392
|
+
graph, err = BuildResourceGraphFromObjects(graphTestRoot("Pod", "child"), []*unstructured.Unstructured{replacement, child})
|
|
393
|
+
if err != nil {
|
|
394
|
+
t.Fatal(err)
|
|
395
|
+
}
|
|
396
|
+
for _, node := range graph.Nodes {
|
|
397
|
+
if node.Resource.Name == "same-name" && (node.Resource.UID != "old-uid" || node.Status != "unresolved") {
|
|
398
|
+
t.Fatal("stale owner resolved as replacement")
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
func TestResourceGraphErrorClassification(t *testing.T) {
|
|
404
|
+
_, err := BuildKubernetesResourceGraph(context.Background(), fake.NewSimpleClientset(), graphTestRoot("secret", "secret"))
|
|
405
|
+
if !errors.Is(err, ErrUnsupportedResourceGraphRoot) {
|
|
406
|
+
t.Fatalf("unsupported root unclassified: %v", err)
|
|
407
|
+
}
|
|
408
|
+
invalid := graphTestRoot("pod", "pod")
|
|
409
|
+
invalid.Namespace = ""
|
|
410
|
+
_, err = NormalizeResourceGraphRoot(invalid)
|
|
411
|
+
if !errors.Is(err, ErrInvalidResourceGraphRoot) {
|
|
412
|
+
t.Fatalf("invalid root unclassified: %v", err)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
func TestResourceGraphGenericOwnerScope(t *testing.T) {
|
|
417
|
+
for _, test := range []struct {
|
|
418
|
+
name, ownerNS string
|
|
419
|
+
confirmed bool
|
|
420
|
+
}{{"cluster owner", "", true}, {"same namespace", "ns", true}, {"other namespace", "other", false}} {
|
|
421
|
+
t.Run(test.name, func(t *testing.T) {
|
|
422
|
+
owner := graphTestObject("custom.example/v1", "CustomOwner", "owner", "owner-uid")
|
|
423
|
+
owner.SetNamespace(test.ownerNS)
|
|
424
|
+
pod := graphTestObject("v1", "Pod", "pod", "pod-uid")
|
|
425
|
+
graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-uid", true)
|
|
426
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot("Pod", "pod"), []*unstructured.Unstructured{owner, pod})
|
|
427
|
+
if err != nil {
|
|
428
|
+
t.Fatal(err)
|
|
429
|
+
}
|
|
430
|
+
found := false
|
|
431
|
+
for _, node := range graph.Nodes {
|
|
432
|
+
if node.Resource.Kind != "CustomOwner" {
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
found = true
|
|
436
|
+
if test.confirmed {
|
|
437
|
+
if node.Status == "unresolved" || node.Resource.Namespace != test.ownerNS {
|
|
438
|
+
t.Fatalf("actual owner scope lost: %+v", node)
|
|
439
|
+
}
|
|
440
|
+
} else if node.Status != "unresolved" || node.Resource.Namespace == "other" {
|
|
441
|
+
t.Fatalf("unobserved scope falsely confirmed: %+v", node)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if !found {
|
|
445
|
+
t.Fatal("owner missing")
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
// A cluster-scoped root can traverse its explicitly supplied namespaced children.
|
|
450
|
+
owner := graphTestObject("custom.example/v1", "CustomOwner", "owner", "owner-uid")
|
|
451
|
+
owner.SetNamespace("")
|
|
452
|
+
pod := graphTestObject("v1", "Pod", "child", "child-uid")
|
|
453
|
+
graphTestOwner(pod, "custom.example/v1", "CustomOwner", "owner", "owner-uid", true)
|
|
454
|
+
root := graphTestRoot("CustomOwner", "owner")
|
|
455
|
+
root.Namespace = ""
|
|
456
|
+
root.ScopeMode = cr.ResourceScopeModeCluster
|
|
457
|
+
graph, err := BuildResourceGraphFromObjects(root, []*unstructured.Unstructured{owner, pod})
|
|
458
|
+
if err != nil {
|
|
459
|
+
t.Fatal(err)
|
|
460
|
+
}
|
|
461
|
+
if !graphTestEdge(graph, "controls", "owner", "child") {
|
|
462
|
+
t.Fatal("cluster owner could not reach namespaced dependent")
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Cancel after actual work has started; cancellation still closes Done through
|
|
467
|
+
// a normal cancel function, without sleep-based timing or goroutine races.
|
|
468
|
+
type graphCancelAfterChecks struct {
|
|
469
|
+
context.Context
|
|
470
|
+
cancel context.CancelFunc
|
|
471
|
+
checks, after int
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
func (c *graphCancelAfterChecks) Err() error {
|
|
475
|
+
c.checks++
|
|
476
|
+
if c.checks >= c.after {
|
|
477
|
+
c.cancel()
|
|
478
|
+
}
|
|
479
|
+
return c.Context.Err()
|
|
480
|
+
}
|
|
481
|
+
func graphFanoutObjects(count int) []*unstructured.Unstructured {
|
|
482
|
+
objects := make([]*unstructured.Unstructured, 0, count*3)
|
|
483
|
+
for i := 0; i < count; i++ {
|
|
484
|
+
pod := graphTestObject("v1", "Pod", fmt.Sprintf("pod-%03d", i), fmt.Sprintf("pod-uid-%d", i))
|
|
485
|
+
pod.SetLabels(map[string]string{"app": "shared"})
|
|
486
|
+
objects = append(objects, pod)
|
|
487
|
+
service := graphTestObject("v1", "Service", fmt.Sprintf("svc-%03d", i), fmt.Sprintf("svc-uid-%d", i))
|
|
488
|
+
_ = unstructured.SetNestedStringMap(service.Object, map[string]string{"app": "shared"}, "spec", "selector")
|
|
489
|
+
objects = append(objects, service)
|
|
490
|
+
pdb := graphTestObject("policy/v1", "PodDisruptionBudget", fmt.Sprintf("pdb-%03d", i), fmt.Sprintf("pdb-uid-%d", i))
|
|
491
|
+
_ = unstructured.SetNestedMap(pdb.Object, map[string]interface{}{"matchLabels": map[string]interface{}{"app": "shared"}}, "spec", "selector")
|
|
492
|
+
objects = append(objects, pdb)
|
|
493
|
+
}
|
|
494
|
+
return objects
|
|
495
|
+
}
|
|
496
|
+
func TestResourceGraphWorkingSetAndHighFanout(t *testing.T) {
|
|
497
|
+
objects := graphFanoutObjects(500)
|
|
498
|
+
owner := graphTestObject("custom.example/v1", "Owner", "owner", "owner-uid")
|
|
499
|
+
grand := graphTestObject("custom.example/v1", "Owner", "grand", "grand-uid")
|
|
500
|
+
graphTestOwner(owner, "custom.example/v1", "Owner", "grand", "grand-uid", true)
|
|
501
|
+
for _, o := range objects {
|
|
502
|
+
if o.GetKind() == "Service" && o.GetName() == "svc-000" {
|
|
503
|
+
graphTestOwner(o, "custom.example/v1", "Owner", "owner", "owner-uid", true)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
objects = append(objects, owner, grand)
|
|
507
|
+
root := graphTestRoot("Service", "svc-000")
|
|
508
|
+
graph, err := BuildResourceGraphFromObjects(root, objects)
|
|
509
|
+
if err != nil {
|
|
510
|
+
t.Fatal(err)
|
|
511
|
+
}
|
|
512
|
+
if len(graph.Nodes) > 500 || len(graph.Edges) > 1000 || !graphTestHas(graph, "svc-000") || !graphTestHas(graph, "owner") || !graphTestHas(graph, "grand") {
|
|
513
|
+
t.Fatal("output bounds or root lost")
|
|
514
|
+
}
|
|
515
|
+
if !strings.Contains(strings.Join(graph.Warnings, " "), "working set truncated") {
|
|
516
|
+
t.Fatal("high fanout was silently treated as complete")
|
|
517
|
+
}
|
|
518
|
+
// Internal writes have a hard bound independent of the final output filter.
|
|
519
|
+
b := &graphRules{ctx: context.Background(), nodes: map[string]cr.ResourceGraphNode{}, edges: map[string]cr.ResourceGraphEdge{}}
|
|
520
|
+
for i := 0; i < ResourceGraphWorkingNodeLimit+1; i++ {
|
|
521
|
+
b.node(cr.ResourceRef{Kind: "Pod", Name: fmt.Sprint(i)}, "")
|
|
522
|
+
}
|
|
523
|
+
if len(b.nodes) != ResourceGraphWorkingNodeLimit || !b.partial {
|
|
524
|
+
t.Fatal("working node set is unbounded")
|
|
525
|
+
}
|
|
526
|
+
b = &graphRules{ctx: context.Background(), nodes: map[string]cr.ResourceGraphNode{}, edges: map[string]cr.ResourceGraphEdge{}}
|
|
527
|
+
for i := 0; i < ResourceGraphWorkingEdgeLimit+1; i++ {
|
|
528
|
+
b.edge("source", fmt.Sprint(i), "owns")
|
|
529
|
+
}
|
|
530
|
+
if len(b.edges) != ResourceGraphWorkingEdgeLimit || !b.partial {
|
|
531
|
+
t.Fatal("working edge set is unbounded")
|
|
532
|
+
}
|
|
533
|
+
_, err = BuildResourceGraphFromObjects(root, make([]*unstructured.Unstructured, ResourceGraphWorkingNodeLimit+1))
|
|
534
|
+
if !errors.Is(err, ErrResourceGraphBudget) {
|
|
535
|
+
t.Fatalf("input budget not classified: %v", err)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
func TestResourceGraphCancellationDuringBuildAndTraversal(t *testing.T) {
|
|
539
|
+
objects := graphFanoutObjects(200)
|
|
540
|
+
base, cancel := context.WithCancel(context.Background())
|
|
541
|
+
defer cancel()
|
|
542
|
+
ctx := &graphCancelAfterChecks{Context: base, cancel: cancel, after: 10000}
|
|
543
|
+
_, err := buildResourceGraphWithContext(ctx, graphTestRoot("Service", "svc-000"), objects)
|
|
544
|
+
if !errors.Is(err, context.Canceled) || ctx.checks < 10000 {
|
|
545
|
+
t.Fatalf("processing cancellation ignored: %v (%d checks)", err, ctx.checks)
|
|
546
|
+
}
|
|
547
|
+
edges := make([]cr.ResourceGraphEdge, 3000)
|
|
548
|
+
for i := range edges {
|
|
549
|
+
edges[i] = cr.ResourceGraphEdge{ID: fmt.Sprint(i), SourceID: "root", TargetID: fmt.Sprint(i), Relation: "owns"}
|
|
550
|
+
}
|
|
551
|
+
base, cancel = context.WithCancel(context.Background())
|
|
552
|
+
defer cancel()
|
|
553
|
+
ctx = &graphCancelAfterChecks{Context: base, cancel: cancel, after: 3100}
|
|
554
|
+
_, err = graphTraverse(ctx, "root", edges)
|
|
555
|
+
if !errors.Is(err, context.Canceled) {
|
|
556
|
+
t.Fatalf("traversal cancellation ignored: %v", err)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
func TestResourceGraphStatusAndUnknownEvent(t *testing.T) {
|
|
560
|
+
for _, test := range []struct{ kind, api, ready string }{{"Deployment", "apps/v1", "availableReplicas"}, {"ReplicaSet", "apps/v1", "readyReplicas"}} {
|
|
561
|
+
t.Run(test.kind, func(t *testing.T) {
|
|
562
|
+
object := graphTestObject(test.api, test.kind, "root", "root-uid")
|
|
563
|
+
for _, ready := range []int64{0, 2} {
|
|
564
|
+
_ = unstructured.SetNestedField(object.Object, int64(2), "status", "replicas")
|
|
565
|
+
_ = unstructured.SetNestedField(object.Object, ready, "status", test.ready)
|
|
566
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot(test.kind, "root"), []*unstructured.Unstructured{object})
|
|
567
|
+
if err != nil {
|
|
568
|
+
t.Fatal(err)
|
|
569
|
+
}
|
|
570
|
+
want := "progressing"
|
|
571
|
+
if ready == 2 {
|
|
572
|
+
want = "healthy"
|
|
573
|
+
}
|
|
574
|
+
if graph.Nodes[0].Status != want {
|
|
575
|
+
t.Errorf("got %s want %s", graph.Nodes[0].Status, want)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
delete(object.Object, "status")
|
|
579
|
+
if graphStatus(object) == "healthy" {
|
|
580
|
+
t.Fatal("missing status claimed healthy")
|
|
581
|
+
}
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
ingress := graphTestObject("networking.k8s.io/v1", "Ingress", "ingress", "ingress-uid")
|
|
585
|
+
if graphStatus(ingress) != "pending" {
|
|
586
|
+
t.Fatal("pending Ingress lost")
|
|
587
|
+
}
|
|
588
|
+
_ = unstructured.SetNestedSlice(ingress.Object, []interface{}{map[string]interface{}{"ip": "192.0.2.1"}}, "status", "loadBalancer", "ingress")
|
|
589
|
+
if graphStatus(ingress) != "ready" {
|
|
590
|
+
t.Fatal("ready Ingress lost")
|
|
591
|
+
}
|
|
592
|
+
service := graphTestObject("v1", "Service", "svc", "svc-uid")
|
|
593
|
+
_ = unstructured.SetNestedField(service.Object, "ClusterIP", "spec", "type")
|
|
594
|
+
if graphStatus(service) != "ClusterIP" {
|
|
595
|
+
t.Fatal("service type lost")
|
|
596
|
+
}
|
|
597
|
+
custom := graphTestObject("custom.example/v1", "Event", "custom-event", "custom-event-uid")
|
|
598
|
+
graphTestOwner(custom, "v1", "Service", "svc", "svc-uid", false)
|
|
599
|
+
custom.Object["involvedObject"] = map[string]interface{}{"apiVersion": "v1", "kind": "Service", "namespace": "ns", "name": "svc", "uid": "svc-uid"}
|
|
600
|
+
custom.Object["message"] = "must-not-be-interpreted"
|
|
601
|
+
graph, err := BuildResourceGraphFromObjects(graphTestRoot("Event", "custom-event"), []*unstructured.Unstructured{custom, service})
|
|
602
|
+
if err != nil {
|
|
603
|
+
t.Fatal(err)
|
|
604
|
+
}
|
|
605
|
+
if !graphTestEdge(graph, "owns", "svc", "custom-event") || len(graph.Evidence) != 0 {
|
|
606
|
+
t.Fatal("custom Event metadata lost or spec interpreted")
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
func TestResourceGraphCustomKindClusterScope(t *testing.T) {
|
|
611
|
+
for _, kind := range []string{"Event", "Pod"} {
|
|
612
|
+
object := graphTestObject("custom.example/v1", kind, "custom", "custom-uid")
|
|
613
|
+
object.SetNamespace("")
|
|
614
|
+
root := graphTestRoot(kind, "custom")
|
|
615
|
+
root.APIVersion = "custom.example/v1"
|
|
616
|
+
root.Namespace = ""
|
|
617
|
+
root.ScopeMode = cr.ResourceScopeModeCluster
|
|
618
|
+
graph, err := BuildResourceGraphFromObjects(root, []*unstructured.Unstructured{object})
|
|
619
|
+
if err != nil || len(graph.Nodes) != 1 {
|
|
620
|
+
t.Fatalf("cluster custom %s rejected: %v", kind, err)
|
|
621
|
+
}
|
|
622
|
+
root.APIVersion = "v1"
|
|
623
|
+
if _, err := NormalizeResourceGraphRoot(root); !errors.Is(err, ErrInvalidResourceGraphRoot) {
|
|
624
|
+
t.Fatalf("actual v1/%s namespace bypass accepted", kind)
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package runtime
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"crypto/x509"
|
|
6
|
+
"encoding/pem"
|
|
7
|
+
"time"
|
|
8
|
+
|
|
9
|
+
corev1 "k8s.io/api/core/v1"
|
|
10
|
+
|
|
11
|
+
cr "github.com/opensoha/soha-contracts/resource"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
// SecretCertificateMetadata reads only tls.crt, never a private key. Both the
|
|
15
|
+
// direct and Agent adapters use this bounded projection of the first certificate.
|
|
16
|
+
func SecretCertificateMetadata(name, namespace, secretType string, certificatePEM []byte) cr.SecretCertificateView {
|
|
17
|
+
view := cr.SecretCertificateView{Name: name, Namespace: namespace, Status: "unsupported_type"}
|
|
18
|
+
if secretType != string(corev1.SecretTypeTLS) {
|
|
19
|
+
return view
|
|
20
|
+
}
|
|
21
|
+
view.Status = "missing"
|
|
22
|
+
if len(certificatePEM) == 0 {
|
|
23
|
+
return view
|
|
24
|
+
}
|
|
25
|
+
view.Status = "invalid"
|
|
26
|
+
if len(certificatePEM) > 1<<20 {
|
|
27
|
+
return view
|
|
28
|
+
}
|
|
29
|
+
block, _ := pem.Decode(certificatePEM)
|
|
30
|
+
if block == nil || block.Type != "CERTIFICATE" {
|
|
31
|
+
return view
|
|
32
|
+
}
|
|
33
|
+
certificate, err := x509.ParseCertificate(block.Bytes)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return view
|
|
36
|
+
}
|
|
37
|
+
view.Status = "available"
|
|
38
|
+
view.NotBefore = certificate.NotBefore.UTC().Format(time.RFC3339)
|
|
39
|
+
view.NotAfter = certificate.NotAfter.UTC().Format(time.RFC3339)
|
|
40
|
+
selfSigned := bytes.Equal(certificate.RawIssuer, certificate.RawSubject) &&
|
|
41
|
+
certificate.CheckSignature(certificate.SignatureAlgorithm, certificate.RawTBSCertificate, certificate.Signature) == nil
|
|
42
|
+
view.SelfSigned = &selfSigned
|
|
43
|
+
return view
|
|
44
|
+
}
|