@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.
Files changed (94) hide show
  1. package/COMPATIBILITY.md +4 -3
  2. package/README.md +8 -9
  3. package/compat/consumer-revisions.json +1 -1
  4. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  5. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  6. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-length.json +3 -0
  7. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/metric-cluster-value-type.json +3 -0
  8. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-alias.json +5 -0
  9. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/metric-cluster-reset.json +3 -0
  10. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/invalid/revision-overflow.json +4 -0
  11. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/legacy.json +3 -0
  12. package/fixtures/openapi/KubernetesHelmReleaseValuesUpdateInput/valid/observed-revision.json +4 -0
  13. package/fixtures/openapi/KubernetesJobDetail/invalid/execution-observation.json +23 -0
  14. package/fixtures/openapi/KubernetesJobDetail/valid/execution-observation.json +23 -0
  15. package/fixtures/openapi/KubernetesNode/invalid/labels.json +10 -0
  16. package/fixtures/openapi/KubernetesNode/valid/labels.json +11 -0
  17. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/empty-uid.json +13 -0
  18. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/invalid-container-state.json +20 -0
  19. package/fixtures/openapi/KubernetesPodListEnvelope/invalid/missing-container-ready.json +19 -0
  20. package/fixtures/openapi/KubernetesPodListEnvelope/valid/container-statuses.json +67 -0
  21. package/fixtures/openapi/KubernetesPodListEnvelope/valid/running-pod.json +1 -0
  22. package/fixtures/openapi/KubernetesPrometheusDetail/invalid/metric-cluster-value-type.json +4 -0
  23. package/fixtures/openapi/KubernetesPrometheusDetail/valid/metric-cluster-alias.json +5 -0
  24. package/fixtures/openapi/KubernetesResourceGraphEnvelope/invalid/network-edge-source-payload.json +73 -0
  25. package/fixtures/openapi/KubernetesResourceGraphEnvelope/valid/network-edge-facts.json +67 -0
  26. package/fixtures/openapi/KubernetesSecretCertificate/invalid/secret-data.json +1 -0
  27. package/fixtures/openapi/KubernetesSecretCertificate/invalid/status.json +1 -0
  28. package/fixtures/openapi/KubernetesSecretCertificate/valid/available.json +1 -0
  29. package/fixtures/openapi/KubernetesSecretCertificate/valid/missing.json +1 -0
  30. package/gen/go/sohaapi/client.go +1 -1
  31. package/gen/go/sohaapi/client_test.go +13 -0
  32. package/gen/go/sohaapi/kubernetes_workload_optional_test.go +40 -0
  33. package/gen/go/sohaapi/pod_status_compat_test.go +36 -0
  34. package/gen/go/sohaapi/types.go +421 -88
  35. package/gen/ts/sohaapi/index.ts +756 -4
  36. package/helmrelease/runtime/values_upgrade.go +78 -0
  37. package/helmrelease/runtime/values_upgrade_test.go +123 -0
  38. package/helmrelease/values_update.go +31 -0
  39. package/openapi/soha-api.json +1191 -14
  40. package/openapi/soha-api.yaml +588 -28
  41. package/openapi/spec_generated.go +3 -3
  42. package/package.json +1 -1
  43. package/resource/agent_profiles.go +9 -15
  44. package/resource/agent_profiles_test.go +9 -3
  45. package/resource/custom_graph_page.go +18 -0
  46. package/resource/models.go +102 -57
  47. package/resource/network_topology.go +22 -0
  48. package/resource/network_topology_projection.go +36 -22
  49. package/resource/network_topology_projection_test.go +4 -0
  50. package/resource/network_topology_test.go +20 -0
  51. package/resource/network_topology_validation.go +5 -0
  52. package/resource/observed_workload.go +18 -0
  53. package/resource/pod_status_test.go +22 -0
  54. package/resource/rbac_graph.go +4 -0
  55. package/resource/rollout_optional_test.go +33 -0
  56. package/resource/runtime/network_relationship_primitives.go +66 -0
  57. package/resource/runtime/network_relationship_primitives_test.go +147 -0
  58. package/resource/runtime/network_topology.go +17 -5
  59. package/resource/runtime/network_topology_builder.go +405 -0
  60. package/resource/runtime/network_topology_builder_backends.go +195 -0
  61. package/resource/runtime/network_topology_builder_gateway_l4_test.go +86 -0
  62. package/resource/runtime/network_topology_builder_ingress.go +302 -0
  63. package/resource/runtime/network_topology_builder_legacy.go +66 -0
  64. package/resource/runtime/network_topology_builder_providers.go +242 -0
  65. package/resource/runtime/network_topology_builder_test.go +186 -0
  66. package/resource/runtime/network_topology_declarations.go +329 -0
  67. package/resource/runtime/resource_edit.go +22 -0
  68. package/resource/runtime/resource_edit_test.go +32 -0
  69. package/resource/runtime/resource_graph_collect.go +325 -0
  70. package/resource/runtime/resource_graph_configuration.go +59 -0
  71. package/resource/runtime/resource_graph_configuration_collect.go +198 -0
  72. package/resource/runtime/resource_graph_configuration_test.go +470 -0
  73. package/resource/runtime/resource_graph_custom_page.go +159 -0
  74. package/resource/runtime/resource_graph_custom_root.go +132 -0
  75. package/resource/runtime/resource_graph_custom_root_test.go +197 -0
  76. package/resource/runtime/resource_graph_enrichment.go +417 -0
  77. package/resource/runtime/resource_graph_enrichment_hpa_test.go +117 -0
  78. package/resource/runtime/resource_graph_enrichment_test.go +201 -0
  79. package/resource/runtime/resource_graph_metadata.go +125 -0
  80. package/resource/runtime/resource_graph_metadata_test.go +204 -0
  81. package/resource/runtime/resource_graph_network.go +224 -0
  82. package/resource/runtime/resource_graph_network_collect.go +174 -0
  83. package/resource/runtime/resource_graph_network_collect_test.go +147 -0
  84. package/resource/runtime/resource_graph_network_test.go +342 -0
  85. package/resource/runtime/resource_graph_rbac.go +278 -0
  86. package/resource/runtime/resource_graph_rbac_test.go +372 -0
  87. package/resource/runtime/resource_graph_rules.go +834 -0
  88. package/resource/runtime/resource_graph_storage.go +144 -0
  89. package/resource/runtime/resource_graph_storage_collect.go +163 -0
  90. package/resource/runtime/resource_graph_storage_test.go +397 -0
  91. package/resource/runtime/resource_graph_test.go +627 -0
  92. package/resource/runtime/secret_certificate.go +44 -0
  93. package/resource/runtime/secret_certificate_test.go +87 -0
  94. package/resource/storage_graph.go +4 -0
@@ -0,0 +1,834 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ cr "github.com/opensoha/soha-contracts/resource"
8
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
9
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
10
+ "k8s.io/apimachinery/pkg/labels"
11
+ kruntime "k8s.io/apimachinery/pkg/runtime"
12
+ "sort"
13
+ "strings"
14
+ "time"
15
+ )
16
+
17
+ // ErrInvalidResourceGraphRoot identifies invalid scope or stale root identity.
18
+ var ErrInvalidResourceGraphRoot = errors.New("invalid resource graph root")
19
+
20
+ // ErrUnsupportedResourceGraphRoot identifies roots requiring another collector.
21
+ var ErrUnsupportedResourceGraphRoot = errors.New("unsupported resource graph root")
22
+
23
+ func invalidGraphRoot(message string) error {
24
+ return fmt.Errorf("%w: %s", ErrInvalidResourceGraphRoot, message)
25
+ }
26
+
27
+ var graphBuiltinAPIs = map[string]string{
28
+ "Pod": "v1", "ReplicationController": "v1", "Service": "v1", "Secret": "v1", "ConfigMap": "v1", "PersistentVolumeClaim": "v1", "PersistentVolume": "v1", "ServiceAccount": "v1", "Node": "v1", "Namespace": "v1", "Event": "v1",
29
+ "Deployment": "apps/v1", "StatefulSet": "apps/v1", "DaemonSet": "apps/v1", "ReplicaSet": "apps/v1", "Job": "batch/v1", "CronJob": "batch/v1",
30
+ "Ingress": "networking.k8s.io/v1", "HorizontalPodAutoscaler": "autoscaling/v2", "PodDisruptionBudget": "policy/v1",
31
+ "ResourceQuota": "v1", "LimitRange": "v1", "PriorityClass": "scheduling.k8s.io/v1", "RuntimeClass": "node.k8s.io/v1", "Lease": "coordination.k8s.io/v1",
32
+ "MutatingWebhookConfiguration": "admissionregistration.k8s.io/v1", "ValidatingWebhookConfiguration": "admissionregistration.k8s.io/v1",
33
+ "StorageClass": "storage.k8s.io/v1",
34
+ "Role": graphRBACAPI, "RoleBinding": graphRBACAPI, "ClusterRole": graphRBACAPI, "ClusterRoleBinding": graphRBACAPI,
35
+ }
36
+
37
+ // NormalizeResourceGraphRoot canonicalizes known wire kinds and trims user input.
38
+ // It never supplies a default namespace or relaxes namespaced scope validation.
39
+ func NormalizeResourceGraphRoot(root cr.ResourceRef) (cr.ResourceRef, error) {
40
+ root.ClusterID = strings.TrimSpace(root.ClusterID)
41
+ root.APIVersion = strings.TrimSpace(root.APIVersion)
42
+ root.Kind = strings.TrimSpace(root.Kind)
43
+ root.Name = strings.TrimSpace(root.Name)
44
+ root.Namespace = strings.TrimSpace(root.Namespace)
45
+ for _, definition := range cr.NetworkTopologySources() {
46
+ if networkGraphRootKind(definition.Kind) && strings.EqualFold(root.Kind, definition.Kind) {
47
+ root.Kind = definition.Kind
48
+ break
49
+ }
50
+ }
51
+ for kind := range graphBuiltinAPIs {
52
+ if strings.EqualFold(root.Kind, kind) {
53
+ root.Kind = kind
54
+ break
55
+ }
56
+ }
57
+ return root, validateGraphRoot(root)
58
+ }
59
+ func validateGraphRoot(root cr.ResourceRef) error {
60
+ if root.ClusterID == "" || root.Kind == "" || root.Name == "" {
61
+ return invalidGraphRoot("resource graph root requires cluster, kind and name")
62
+ }
63
+ if definition, ok := networkGraphRootSource(root); ok {
64
+ return validateNetworkGraphRootScope(root, definition)
65
+ }
66
+ builtinAPI, kindKnown := graphBuiltinAPIs[root.Kind]
67
+ known := kindKnown && (root.APIVersion == "" || root.APIVersion == builtinAPI)
68
+ if known && graphStorageRootKind(root.Kind) && root.ScopeMode != "" && root.ScopeMode != topologyScope(root.Kind == "PersistentVolumeClaim") {
69
+ return invalidGraphRoot("storage resource graph root scope mismatch")
70
+ }
71
+ if known && graphRBACRootKind(root.Kind) && root.ScopeMode != "" && root.ScopeMode != topologyScope(!graphRBACClusterKind(root.Kind)) {
72
+ return invalidGraphRoot("RBAC resource graph root scope mismatch")
73
+ }
74
+ coreAPI := root.APIVersion == "" || root.APIVersion == "v1"
75
+ clusterKind := coreAPI && (root.Kind == "Node" || root.Kind == "Namespace") || known && (graphClusterConfigurationKind(root.Kind) || root.Kind == "PersistentVolume" || root.Kind == "StorageClass" || graphRBACClusterKind(root.Kind))
76
+ if root.Namespace == "" && !clusterKind && (known || root.ScopeMode != cr.ResourceScopeModeCluster) {
77
+ return invalidGraphRoot("namespaced resource graph root requires namespace")
78
+ }
79
+ if clusterKind && root.Namespace != "" {
80
+ return invalidGraphRoot("cluster resource graph root cannot have namespace")
81
+ }
82
+ return nil
83
+ }
84
+
85
+ const ResourceGraphWorkingNodeLimit = 10000
86
+ const ResourceGraphWorkingEdgeLimit = 10000
87
+
88
+ var ErrResourceGraphBudget = errors.New("resource graph working set budget exceeded")
89
+
90
+ type graphRules struct {
91
+ ctx context.Context
92
+ err error
93
+ partial bool
94
+ pods []*unstructured.Unstructured
95
+ clusterRoles []*unstructured.Unstructured
96
+
97
+ cluster string
98
+ objects map[string]*unstructured.Unstructured
99
+ nodes map[string]cr.ResourceGraphNode
100
+ edges map[string]cr.ResourceGraphEdge
101
+ }
102
+
103
+ func graphIdentity(api, kind, namespace, name string) string {
104
+ return api + ":" + kind + ":" + namespace + "/" + name
105
+ }
106
+
107
+ var graphIDSegmentEscaper = strings.NewReplacer("%", "%25", ":", "%3A")
108
+
109
+ func graphNodeID(ref cr.ResourceRef) string {
110
+ id := graphIdentity(ref.APIVersion, ref.Kind, ref.Namespace, graphIDSegmentEscaper.Replace(ref.Name))
111
+ if ref.UID != "" {
112
+ id += ":" + graphIDSegmentEscaper.Replace(ref.UID)
113
+ }
114
+ return id
115
+ }
116
+ func (b *graphRules) ref(o *unstructured.Unstructured) cr.ResourceRef {
117
+ scope := cr.ResourceScopeModeNamespace
118
+ if o.GetNamespace() == "" {
119
+ scope = cr.ResourceScopeModeCluster
120
+ }
121
+ return cr.ResourceRef{ClusterID: b.cluster, APIVersion: o.GetAPIVersion(), Kind: o.GetKind(), Namespace: o.GetNamespace(), Name: o.GetName(), UID: string(o.GetUID()), ResourceVersion: o.GetResourceVersion(), ScopeMode: scope}
122
+ }
123
+ func (b *graphRules) active() bool {
124
+ if b.err != nil {
125
+ return false
126
+ }
127
+ if err := b.ctx.Err(); err != nil {
128
+ b.err = err
129
+ return false
130
+ }
131
+ return !b.partial
132
+ }
133
+ func (b *graphRules) node(ref cr.ResourceRef, status string) string {
134
+ return b.addNode(graphNodeID(ref), ref, status)
135
+ }
136
+ func (b *graphRules) addNode(id string, ref cr.ResourceRef, status string) string {
137
+ if !b.active() {
138
+ return ""
139
+ }
140
+ if _, ok := b.nodes[id]; !ok {
141
+ if len(b.nodes) >= ResourceGraphWorkingNodeLimit {
142
+ b.partial = true
143
+ return ""
144
+ }
145
+ b.nodes[id] = cr.ResourceGraphNode{ID: id, Resource: ref, Status: status}
146
+ }
147
+ return id
148
+ }
149
+ func (b *graphRules) target(api, kind, namespace, name, uid string) string {
150
+ if name == "" {
151
+ return ""
152
+ }
153
+ ref := cr.ResourceRef{ClusterID: b.cluster, APIVersion: api, Kind: kind, Namespace: namespace, Name: name, UID: uid, ScopeMode: cr.ResourceScopeModeNamespace}
154
+ if namespace == "" {
155
+ ref.ScopeMode = cr.ResourceScopeModeCluster
156
+ }
157
+ if o := b.objects[graphIdentity(api, kind, namespace, name)]; o != nil && (uid == "" || string(o.GetUID()) == uid) {
158
+ return b.node(b.ref(o), graphStatus(o))
159
+ }
160
+ return b.node(ref, "unresolved")
161
+ }
162
+ func (b *graphRules) edge(source, target, relation string) {
163
+ b.addEdge(cr.ResourceGraphEdge{SourceID: source, TargetID: target, Relation: relation})
164
+ }
165
+ func (b *graphRules) addEdge(edge cr.ResourceGraphEdge) {
166
+ if !b.active() {
167
+ return
168
+ }
169
+ if edge.SourceID == "" || edge.TargetID == "" || edge.SourceID == edge.TargetID {
170
+ return
171
+ }
172
+ edge.ID = edge.Relation + ":" + edge.SourceID + ":" + edge.TargetID
173
+ if _, exists := b.edges[edge.ID]; !exists && len(b.edges) >= ResourceGraphWorkingEdgeLimit {
174
+ b.partial = true
175
+ return
176
+ }
177
+ b.edges[edge.ID] = edge
178
+ }
179
+ func graphIsEvent(o *unstructured.Unstructured) bool {
180
+ return o.GetAPIVersion() == "v1" && o.GetKind() == "Event"
181
+ }
182
+ func graphStatus(o *unstructured.Unstructured) string {
183
+ if !graphBuiltinSpec(o) {
184
+ return ""
185
+ }
186
+ switch o.GetKind() {
187
+ case "Deployment":
188
+ return graphReplicaStatus(o, "availableReplicas")
189
+ case "ReplicaSet":
190
+ return graphReplicaStatus(o, "readyReplicas")
191
+ case "Ingress":
192
+ ingress, _, _ := graphNestedSlice(o.Object, "status", "loadBalancer", "ingress")
193
+ if len(ingress) > 0 {
194
+ return "ready"
195
+ }
196
+ return "pending"
197
+ case "Service":
198
+ value, _, _ := unstructured.NestedString(o.Object, "spec", "type")
199
+ return value
200
+ default:
201
+ phase, _, _ := unstructured.NestedString(o.Object, "status", "phase")
202
+ return phase
203
+ }
204
+ }
205
+ func graphReplicaStatus(o *unstructured.Unstructured, readyField string) string {
206
+ replicas, found, _ := unstructured.NestedInt64(o.Object, "status", "replicas")
207
+ ready, readyFound, _ := unstructured.NestedInt64(o.Object, "status", readyField)
208
+ if found && readyFound && replicas > 0 && ready >= replicas {
209
+ return "healthy"
210
+ }
211
+ return "progressing"
212
+ }
213
+
214
+ // BuildResourceGraphFromObjects applies the same pure rules to typed projections
215
+ // or arbitrary Kubernetes objects. Unknown kinds contribute only metadata
216
+ // ownerReferences; their spec is never inferred. Callers own discovery and scope.
217
+ func BuildResourceGraphFromObjects(root cr.ResourceRef, objects []*unstructured.Unstructured) (cr.ResourceGraph, error) {
218
+ ctx, cancel := context.WithTimeout(context.Background(), ResourceGraphTimeout)
219
+ defer cancel()
220
+ return buildResourceGraphWithContext(ctx, root, objects)
221
+ }
222
+ func buildResourceGraphWithContext(ctx context.Context, root cr.ResourceRef, objects []*unstructured.Unstructured) (cr.ResourceGraph, error) {
223
+ if err := ctx.Err(); err != nil {
224
+ return cr.ResourceGraph{}, err
225
+ }
226
+ if len(objects) > ResourceGraphWorkingNodeLimit {
227
+ return cr.ResourceGraph{}, ErrResourceGraphBudget
228
+ }
229
+ root, err := NormalizeResourceGraphRoot(root)
230
+ if err != nil {
231
+ return cr.ResourceGraph{}, err
232
+ }
233
+ index, err := graphObjectIndex(ctx, root.Namespace, objects)
234
+ if err != nil {
235
+ return cr.ResourceGraph{}, err
236
+ }
237
+ b := &graphRules{ctx: ctx, cluster: root.ClusterID, objects: index, nodes: map[string]cr.ResourceGraphNode{}, edges: map[string]cr.ResourceGraphEdge{}}
238
+ rootObject, err := b.rootObject(root)
239
+ if err != nil {
240
+ return cr.ResourceGraph{}, err
241
+ }
242
+ rootID := b.node(b.ref(rootObject), graphStatus(rootObject))
243
+ keys := b.objectKeys(rootObject)
244
+ storage := graphBuiltinSpec(rootObject) && graphStorageRootKind(root.Kind)
245
+ rbac := graphBuiltinSpec(rootObject) && graphRBACRootKind(root.Kind)
246
+ if err := indexGraphCrossNamespaceAccounts(ctx, rootObject, objects, index); err != nil {
247
+ return cr.ResourceGraph{}, err
248
+ }
249
+ if !storage {
250
+ b.priorityOwners(rootObject)
251
+ for _, key := range keys {
252
+ if !b.active() {
253
+ break
254
+ }
255
+ o := b.objects[key]
256
+ if graphIsEvent(o) {
257
+ continue
258
+ }
259
+ id := b.node(b.ref(o), graphStatus(o))
260
+ b.ownerRelations(o, id)
261
+ }
262
+ }
263
+ for _, key := range keys {
264
+ if !b.active() {
265
+ break
266
+ }
267
+ o := b.objects[key]
268
+ if graphIsEvent(o) {
269
+ continue
270
+ }
271
+ id := b.node(b.ref(o), graphStatus(o))
272
+ if storage {
273
+ b.storageRelations(o, id)
274
+ continue
275
+ }
276
+ if rbac {
277
+ b.rbacRelations(o, id)
278
+ continue
279
+ }
280
+ b.podReferences(o, id)
281
+ b.selectorRelations(o, id)
282
+ b.configurationRelations(o, id)
283
+ }
284
+ if b.err != nil {
285
+ return cr.ResourceGraph{}, b.err
286
+ }
287
+ graph, err := b.bounded(rootID, root.Namespace)
288
+ if err != nil {
289
+ return cr.ResourceGraph{}, err
290
+ }
291
+ if b.partial {
292
+ graph.Warnings = append(graph.Warnings, "resource graph working set truncated at 10000 nodes or 10000 edges")
293
+ }
294
+ if rbac && root.Kind == "ServiceAccount" {
295
+ graph.Warnings = append(graph.Warnings, "RoleBinding source limited to the ServiceAccount namespace")
296
+ }
297
+ for _, key := range keys {
298
+ if err := ctx.Err(); err != nil {
299
+ return cr.ResourceGraph{}, err
300
+ }
301
+ o := b.objects[key]
302
+ if graphIsEvent(o) {
303
+ b.event(&graph, o)
304
+ }
305
+ }
306
+ sort.SliceStable(graph.Evidence, func(i, j int) bool {
307
+ left, _ := time.Parse(time.RFC3339Nano, graph.Evidence[i].ObservedAt)
308
+ right, _ := time.Parse(time.RFC3339Nano, graph.Evidence[j].ObservedAt)
309
+ return left.After(right)
310
+ })
311
+ return graph, ctx.Err()
312
+ }
313
+ func (b *graphRules) objectKeys(root *unstructured.Unstructured) []string {
314
+ rootKey := graphIdentity(root.GetAPIVersion(), root.GetKind(), root.GetNamespace(), root.GetName())
315
+ keys := make([]string, 0, len(b.objects))
316
+ for key := range b.objects {
317
+ if key != rootKey {
318
+ keys = append(keys, key)
319
+ }
320
+ }
321
+ sort.Strings(keys)
322
+ // Pod selector iteration is sorted separately from the root-first rule order.
323
+ for _, key := range keys {
324
+ o := b.objects[key]
325
+ if o.GetKind() == "Pod" && o.GetAPIVersion() == "v1" {
326
+ b.pods = append(b.pods, o)
327
+ }
328
+ if o.GetKind() == "ClusterRole" && o.GetAPIVersion() == graphRBACAPI && o.GetNamespace() == "" {
329
+ b.clusterRoles = append(b.clusterRoles, o)
330
+ }
331
+ }
332
+ if root.GetKind() == "Pod" && root.GetAPIVersion() == "v1" {
333
+ b.pods = append(b.pods, root)
334
+ }
335
+ if root.GetKind() == "ClusterRole" && root.GetAPIVersion() == graphRBACAPI && root.GetNamespace() == "" {
336
+ b.clusterRoles = append(b.clusterRoles, root)
337
+ }
338
+ return append([]string{rootKey}, keys...)
339
+ }
340
+ func (b *graphRules) priorityOwners(root *unstructured.Unstructured) {
341
+ queue := []*unstructured.Unstructured{root}
342
+ seen := map[string]bool{}
343
+ for len(queue) > 0 {
344
+ if !b.active() {
345
+ return
346
+ }
347
+ current := queue[0]
348
+ queue = queue[1:]
349
+ id := graphNodeID(b.ref(current))
350
+ if seen[id] {
351
+ continue
352
+ }
353
+ seen[id] = true
354
+ b.ownerRelations(current, b.node(b.ref(current), graphStatus(current)))
355
+ for _, owner := range current.GetOwnerReferences() {
356
+ if !b.active() {
357
+ return
358
+ }
359
+ if actual := b.observedOwner(current.GetNamespace(), owner); actual != nil {
360
+ queue = append(queue, actual)
361
+ }
362
+ }
363
+ }
364
+ }
365
+ func graphObjectIndex(ctx context.Context, namespace string, objects []*unstructured.Unstructured) (map[string]*unstructured.Unstructured, error) {
366
+ index := map[string]*unstructured.Unstructured{}
367
+ for _, o := range objects {
368
+ if err := ctx.Err(); err != nil {
369
+ return nil, err
370
+ }
371
+ if o == nil || o.GetName() == "" || o.GetKind() == "" || o.GetAPIVersion() == "" {
372
+ continue
373
+ }
374
+ if namespace != "" && o.GetNamespace() != "" && o.GetNamespace() != namespace {
375
+ continue
376
+ }
377
+ key := graphIdentity(o.GetAPIVersion(), o.GetKind(), o.GetNamespace(), o.GetName())
378
+ if _, exists := index[key]; !exists {
379
+ index[key] = o
380
+ }
381
+ }
382
+ return index, nil
383
+ }
384
+ func (b *graphRules) rootObject(root cr.ResourceRef) (*unstructured.Unstructured, error) {
385
+ var result *unstructured.Unstructured
386
+ for _, o := range b.objects {
387
+ if err := b.ctx.Err(); err != nil {
388
+ return nil, err
389
+ }
390
+ if o.GetKind() != root.Kind || o.GetName() != root.Name || o.GetNamespace() != root.Namespace {
391
+ continue
392
+ }
393
+ if root.APIVersion != "" && o.GetAPIVersion() != root.APIVersion {
394
+ continue
395
+ }
396
+ if result != nil {
397
+ return nil, invalidGraphRoot("ambiguous root apiVersion")
398
+ }
399
+ result = o
400
+ }
401
+ if result == nil {
402
+ return nil, invalidGraphRoot("resource graph root not present")
403
+ }
404
+ if root.UID != "" && string(result.GetUID()) != root.UID {
405
+ return nil, invalidGraphRoot("resource graph root UID changed")
406
+ }
407
+ return result, nil
408
+ }
409
+ func (b *graphRules) ownerRelations(o *unstructured.Unstructured, id string) {
410
+ for _, owner := range o.GetOwnerReferences() {
411
+ if !b.active() {
412
+ return
413
+ }
414
+ relation := "owns"
415
+ if owner.Controller != nil && *owner.Controller {
416
+ relation = "controls"
417
+ }
418
+ b.edge(b.ownerTarget(o.GetNamespace(), owner), id, relation)
419
+ }
420
+ }
421
+
422
+ // Kubernetes permits a cluster-scoped owner of a namespaced dependent. Only
423
+ // observed exact identities confirm this; another namespace is never searched.
424
+ func (b *graphRules) observedOwner(namespace string, owner metav1.OwnerReference) *unstructured.Unstructured {
425
+ if owner.UID == "" {
426
+ return nil
427
+ }
428
+ for _, scope := range []string{namespace, ""} {
429
+ candidate := b.objects[graphIdentity(owner.APIVersion, owner.Kind, scope, owner.Name)]
430
+ if candidate != nil && candidate.GetUID() == owner.UID {
431
+ return candidate
432
+ }
433
+ }
434
+ return nil
435
+ }
436
+ func (b *graphRules) ownerTarget(namespace string, owner metav1.OwnerReference) string {
437
+ if actual := b.observedOwner(namespace, owner); actual != nil {
438
+ return b.node(b.ref(actual), graphStatus(actual))
439
+ }
440
+ ref := cr.ResourceRef{ClusterID: b.cluster, APIVersion: owner.APIVersion, Kind: owner.Kind, Namespace: namespace, Name: owner.Name, UID: string(owner.UID), ScopeMode: cr.ResourceScopeModeNamespace}
441
+ // The wire requires a scope enum. Use the dependent lookup context for this
442
+ // unresolved candidate; it does not confirm the target or its actual scope.
443
+ if namespace == "" {
444
+ ref.ScopeMode = cr.ResourceScopeModeCluster
445
+ }
446
+ return b.node(ref, "unresolved")
447
+ }
448
+
449
+ // A custom object may reuse a built-in kind name; spec rules require exact GVK.
450
+ func graphBuiltinSpec(o *unstructured.Unstructured) bool {
451
+ return graphBuiltinAPIs[o.GetKind()] != "" && graphBuiltinAPIs[o.GetKind()] == o.GetAPIVersion()
452
+ }
453
+ func graphNestedMap(object map[string]interface{}, fields ...string) (map[string]interface{}, bool, error) {
454
+ value, found, err := unstructured.NestedFieldNoCopy(object, fields...)
455
+ result, _ := value.(map[string]interface{})
456
+ return result, found, err
457
+ }
458
+ func graphNestedSlice(object map[string]interface{}, fields ...string) ([]interface{}, bool, error) {
459
+ value, found, err := unstructured.NestedFieldNoCopy(object, fields...)
460
+ result, _ := value.([]interface{})
461
+ return result, found, err
462
+ }
463
+ func graphMap(m map[string]interface{}, key string) map[string]interface{} {
464
+ value, _ := m[key].(map[string]interface{})
465
+ return value
466
+ }
467
+ func graphString(m map[string]interface{}, key string) string {
468
+ value, _ := m[key].(string)
469
+ return value
470
+ }
471
+ func graphSlice(m map[string]interface{}, key string) []interface{} {
472
+ value, _ := m[key].([]interface{})
473
+ return value
474
+ }
475
+ func graphPodSpec(o *unstructured.Unstructured) map[string]interface{} {
476
+ if !graphBuiltinSpec(o) {
477
+ return nil
478
+ }
479
+ path := []string{"spec", "template", "spec"}
480
+ switch o.GetKind() {
481
+ case "Pod":
482
+ path = []string{"spec"}
483
+ case "CronJob":
484
+ path = []string{"spec", "jobTemplate", "spec", "template", "spec"}
485
+ case "Deployment", "StatefulSet", "DaemonSet", "Job", "ReplicaSet", "ReplicationController":
486
+ default:
487
+ return nil
488
+ }
489
+ spec, _, _ := graphNestedMap(o.Object, path...)
490
+ return spec
491
+ }
492
+ func (b *graphRules) podReferences(o *unstructured.Unstructured, id string) {
493
+ spec := graphPodSpec(o)
494
+ if spec == nil {
495
+ return
496
+ }
497
+ ref := func(kind, name, relation string) {
498
+ b.edge(id, b.target("v1", kind, o.GetNamespace(), name, ""), relation)
499
+ }
500
+ for _, field := range []string{"containers", "initContainers", "ephemeralContainers"} {
501
+ for _, raw := range graphSlice(spec, field) {
502
+ if !b.active() {
503
+ return
504
+ }
505
+ container, _ := raw.(map[string]interface{})
506
+ graphContainerRefs(container, ref, b.active)
507
+ }
508
+ }
509
+ for _, raw := range graphSlice(spec, "volumes") {
510
+ if !b.active() {
511
+ return
512
+ }
513
+ volume, _ := raw.(map[string]interface{})
514
+ graphVolumeRefs(volume, ref, b.active)
515
+ }
516
+ ref("ServiceAccount", graphString(spec, "serviceAccountName"), ResourceGraphRelationUsesServiceAccount)
517
+ for _, raw := range graphSlice(spec, "imagePullSecrets") {
518
+ if !b.active() {
519
+ return
520
+ }
521
+ entry, _ := raw.(map[string]interface{})
522
+ ref("Secret", graphString(entry, "name"), "uses-secret")
523
+ }
524
+ if node := graphString(spec, "nodeName"); node != "" {
525
+ b.edge(id, b.target("v1", "Node", "", node, ""), "scheduled-on")
526
+ }
527
+ b.edge(id, b.target("scheduling.k8s.io/v1", "PriorityClass", "", graphString(spec, "priorityClassName"), ""), ResourceGraphRelationUsesPriorityClass)
528
+ b.edge(id, b.target("node.k8s.io/v1", "RuntimeClass", "", graphString(spec, "runtimeClassName"), ""), ResourceGraphRelationUsesRuntimeClass)
529
+ }
530
+ func graphContainerRefs(container map[string]interface{}, ref func(string, string, string), active func() bool) {
531
+ for _, raw := range graphSlice(container, "env") {
532
+ if !active() {
533
+ return
534
+ }
535
+ entry, _ := raw.(map[string]interface{})
536
+ value := graphMap(entry, "valueFrom")
537
+ ref("ConfigMap", graphString(graphMap(value, "configMapKeyRef"), "name"), "uses-config")
538
+ ref("Secret", graphString(graphMap(value, "secretKeyRef"), "name"), "uses-secret")
539
+ }
540
+ for _, raw := range graphSlice(container, "envFrom") {
541
+ if !active() {
542
+ return
543
+ }
544
+ entry, _ := raw.(map[string]interface{})
545
+ ref("ConfigMap", graphString(graphMap(entry, "configMapRef"), "name"), "uses-config")
546
+ ref("Secret", graphString(graphMap(entry, "secretRef"), "name"), "uses-secret")
547
+ }
548
+ }
549
+ func graphVolumeRefs(volume map[string]interface{}, ref func(string, string, string), active func() bool) {
550
+ ref("ConfigMap", graphString(graphMap(volume, "configMap"), "name"), "uses-config")
551
+ ref("Secret", graphString(graphMap(volume, "secret"), "secretName"), "uses-secret")
552
+ ref("Secret", graphString(graphMap(graphMap(volume, "csi"), "nodePublishSecretRef"), "name"), "uses-secret")
553
+ ref("PersistentVolumeClaim", graphVolumeClaimName(volume), ResourceGraphRelationMounts)
554
+ for _, raw := range graphSlice(graphMap(volume, "projected"), "sources") {
555
+ if !active() {
556
+ return
557
+ }
558
+ source, _ := raw.(map[string]interface{})
559
+ ref("ConfigMap", graphString(graphMap(source, "configMap"), "name"), "uses-config")
560
+ ref("Secret", graphString(graphMap(source, "secret"), "name"), "uses-secret")
561
+ }
562
+ }
563
+
564
+ func (b *graphRules) selectorRelations(o *unstructured.Unstructured, id string) {
565
+ if !graphBuiltinSpec(o) {
566
+ return
567
+ }
568
+ switch o.GetKind() {
569
+ case "Service", "PodDisruptionBudget":
570
+ b.podSelectorRelations(o, id)
571
+ case "HorizontalPodAutoscaler":
572
+ target, _, _ := graphNestedMap(o.Object, "spec", "scaleTargetRef")
573
+ if graphString(target, "apiVersion") != "" && graphString(target, "kind") != "" {
574
+ b.edge(id, b.target(graphString(target, "apiVersion"), graphString(target, "kind"), o.GetNamespace(), graphString(target, "name"), ""), "scales")
575
+ }
576
+ case "Ingress":
577
+ b.ingressRelations(o, id)
578
+ }
579
+ }
580
+ func graphPodSelector(o *unstructured.Unstructured) labels.Selector {
581
+ selector, found, _ := graphNestedMap(o.Object, "spec", "selector")
582
+ if !found || selector == nil {
583
+ return nil
584
+ }
585
+ if o.GetKind() == "Service" {
586
+ return servicePodSelector(selector)
587
+ }
588
+ var parsed metav1.LabelSelector
589
+ if kruntime.DefaultUnstructuredConverter.FromUnstructured(selector, &parsed) != nil {
590
+ return nil
591
+ }
592
+ query, err := metav1.LabelSelectorAsSelector(&parsed)
593
+ if err != nil {
594
+ return nil
595
+ }
596
+ return query
597
+ }
598
+ func (b *graphRules) podSelectorRelations(o *unstructured.Unstructured, id string) {
599
+ query := graphPodSelector(o)
600
+ if query == nil {
601
+ return
602
+ }
603
+ relation := "selects"
604
+ if o.GetKind() == "PodDisruptionBudget" {
605
+ relation = "protects"
606
+ }
607
+ for _, pod := range b.pods {
608
+ if !b.active() {
609
+ return
610
+ }
611
+ if pod.GetAPIVersion() == "v1" && pod.GetKind() == "Pod" && pod.GetNamespace() == o.GetNamespace() && query.Matches(labels.Set(pod.GetLabels())) {
612
+ b.edge(id, b.node(b.ref(pod), graphStatus(pod)), relation)
613
+ }
614
+ }
615
+ }
616
+ func (b *graphRules) ingressRelations(o *unstructured.Unstructured, id string) {
617
+ forEachIngressBackendDeclaration(o.Object, b.active, func(ref ingressBackendDeclaration) {
618
+ b.edge(id, b.target("v1", "Service", o.GetNamespace(), ref.serviceName, ""), "routes-to")
619
+ })
620
+ }
621
+
622
+ // Traverse each direction independently. In particular, arriving at a shared
623
+ // config/Secret never reverses direction and discovers unrelated consumers.
624
+ type graphTraversalState struct {
625
+ id string
626
+ depth, direction int
627
+ }
628
+ type graphTraversal struct {
629
+ selected, chosen map[string]bool
630
+ depthTruncated, limitTruncated bool
631
+ }
632
+
633
+ func graphNext(current graphTraversalState, edge cr.ResourceGraphEdge) (graphTraversalState, bool) {
634
+ next := graphTraversalState{depth: current.depth + 1, direction: current.direction}
635
+ if current.direction == 1 && edge.SourceID == current.id {
636
+ next.id = edge.TargetID
637
+ }
638
+ if current.direction == -1 && edge.TargetID == current.id {
639
+ next.id = edge.SourceID
640
+ }
641
+ supporting := edge.Relation == "selects" || edge.Relation == "protects" || edge.Relation == "scales"
642
+ if current.direction == 1 && edge.TargetID == current.id && supporting {
643
+ next.id = edge.SourceID
644
+ next.direction = -1
645
+ }
646
+ return next, next.id != ""
647
+ }
648
+ func (t *graphTraversal) include(edge cr.ResourceGraphEdge, next graphTraversalState) bool {
649
+ if (!t.selected[next.id] && len(t.selected) >= ResourceGraphNodeLimit) || (!t.chosen[edge.ID] && len(t.chosen) >= ResourceGraphEdgeLimit) {
650
+ t.limitTruncated = true
651
+ return false
652
+ }
653
+ t.selected[next.id] = true
654
+ t.chosen[edge.ID] = true
655
+ return true
656
+ }
657
+ func graphAdjacency(ctx context.Context, edges []cr.ResourceGraphEdge) (map[string][]cr.ResourceGraphEdge, map[string][]cr.ResourceGraphEdge, error) {
658
+ forward := map[string][]cr.ResourceGraphEdge{}
659
+ reverse := map[string][]cr.ResourceGraphEdge{}
660
+ for _, edge := range edges {
661
+ if err := ctx.Err(); err != nil {
662
+ return nil, nil, err
663
+ }
664
+ forward[edge.SourceID] = append(forward[edge.SourceID], edge)
665
+ reverse[edge.TargetID] = append(reverse[edge.TargetID], edge)
666
+ if edge.Relation == "selects" || edge.Relation == "protects" || edge.Relation == "scales" {
667
+ forward[edge.TargetID] = append(forward[edge.TargetID], edge)
668
+ }
669
+ }
670
+ return forward, reverse, nil
671
+ }
672
+
673
+ // Reserve the root's owner chain before selector fanout consumes output slots.
674
+ func graphSeedOwners(ctx context.Context, rootID string, reverse map[string][]cr.ResourceGraphEdge, result *graphTraversal) error {
675
+ queue := []graphTraversalState{{id: rootID, direction: -1}}
676
+ seen := map[string]bool{}
677
+ for len(queue) > 0 {
678
+ if err := ctx.Err(); err != nil {
679
+ return err
680
+ }
681
+ current := queue[0]
682
+ queue = queue[1:]
683
+ if seen[current.id] || current.depth >= ResourceGraphDepth {
684
+ continue
685
+ }
686
+ seen[current.id] = true
687
+ for _, edge := range reverse[current.id] {
688
+ if err := ctx.Err(); err != nil {
689
+ return err
690
+ }
691
+ if edge.Relation != "owns" && edge.Relation != "controls" {
692
+ continue
693
+ }
694
+ next, ok := graphNext(current, edge)
695
+ if ok && result.include(edge, next) {
696
+ queue = append(queue, next)
697
+ }
698
+ }
699
+ }
700
+ return nil
701
+ }
702
+ func graphTraverse(ctx context.Context, rootID string, edges []cr.ResourceGraphEdge) (*graphTraversal, error) {
703
+ forward, reverse, err := graphAdjacency(ctx, edges)
704
+ if err != nil {
705
+ return nil, err
706
+ }
707
+ result := &graphTraversal{selected: map[string]bool{rootID: true}, chosen: map[string]bool{}}
708
+ if err := graphSeedOwners(ctx, rootID, reverse, result); err != nil {
709
+ return nil, err
710
+ }
711
+ queue := []graphTraversalState{{id: rootID, direction: 1}, {id: rootID, direction: -1}}
712
+ seen := map[graphTraversalState]bool{}
713
+ omitted := map[string]bool{}
714
+ for len(queue) > 0 {
715
+ if err := ctx.Err(); err != nil {
716
+ return nil, err
717
+ }
718
+ current := queue[0]
719
+ queue = queue[1:]
720
+ if seen[current] {
721
+ continue
722
+ }
723
+ seen[current] = true
724
+ adjacent := forward[current.id]
725
+ if current.direction == -1 {
726
+ adjacent = reverse[current.id]
727
+ }
728
+ for _, edge := range adjacent {
729
+ if err := ctx.Err(); err != nil {
730
+ return nil, err
731
+ }
732
+ next, ok := graphNext(current, edge)
733
+ if !ok {
734
+ continue
735
+ }
736
+ if current.depth >= ResourceGraphDepth {
737
+ omitted[edge.ID] = true
738
+ continue
739
+ }
740
+ if result.include(edge, next) {
741
+ queue = append(queue, next)
742
+ }
743
+ }
744
+ }
745
+ for id := range omitted {
746
+ if !result.chosen[id] {
747
+ result.depthTruncated = true
748
+ break
749
+ }
750
+ }
751
+ return result, nil
752
+ }
753
+ func (b *graphRules) bounded(rootID, namespace string) (cr.ResourceGraph, error) {
754
+ graph := cr.ResourceGraph{ClusterID: b.cluster, Namespace: namespace, GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), RootID: rootID, Nodes: []cr.ResourceGraphNode{}, Edges: []cr.ResourceGraphEdge{}, Evidence: []cr.ResourceEvidence{}, Warnings: []string{}}
755
+ edges := make([]cr.ResourceGraphEdge, 0, len(b.edges))
756
+ for _, edge := range b.edges {
757
+ edges = append(edges, edge)
758
+ }
759
+ sort.Slice(edges, func(i, j int) bool { return edges[i].ID < edges[j].ID })
760
+ selected, err := graphTraverse(b.ctx, rootID, edges)
761
+ if err != nil {
762
+ return cr.ResourceGraph{}, err
763
+ }
764
+ for id := range selected.selected {
765
+ graph.Nodes = append(graph.Nodes, b.nodes[id])
766
+ }
767
+ for _, edge := range edges {
768
+ if selected.chosen[edge.ID] {
769
+ graph.Edges = append(graph.Edges, edge)
770
+ }
771
+ }
772
+ sort.Slice(graph.Nodes, func(i, j int) bool { return graph.Nodes[i].ID < graph.Nodes[j].ID })
773
+ if selected.limitTruncated {
774
+ graph.Warnings = append(graph.Warnings, "resource graph truncated at 500 nodes or 1000 edges")
775
+ }
776
+ if selected.depthTruncated {
777
+ graph.Warnings = append(graph.Warnings, "resource graph truncated by depth at 3 hops")
778
+ }
779
+ return graph, b.ctx.Err()
780
+ }
781
+ func (b *graphRules) event(graph *cr.ResourceGraph, o *unstructured.Unstructured) {
782
+ involved, _, _ := graphNestedMap(o.Object, "involvedObject")
783
+ str := func(key string) string { s, _ := involved[key].(string); return s }
784
+ var id string
785
+ for _, node := range graph.Nodes {
786
+ if err := b.ctx.Err(); err != nil {
787
+ b.err = err
788
+ return
789
+ }
790
+ r := node.Resource
791
+ if r.Kind == str("kind") && r.Name == str("name") && r.Namespace == str("namespace") && (str("apiVersion") == "" || r.APIVersion == str("apiVersion")) && (str("uid") == "" || r.UID == str("uid")) {
792
+ id = node.ID
793
+ break
794
+ }
795
+ }
796
+ if id == "" {
797
+ return
798
+ }
799
+ reason, _, _ := unstructured.NestedString(o.Object, "reason")
800
+ message, _, _ := unstructured.NestedString(o.Object, "message")
801
+ typ, _, _ := unstructured.NestedString(o.Object, "type")
802
+ severity := "info"
803
+ if typ == "Warning" {
804
+ severity = "warning"
805
+ }
806
+ observed := graphEventObservedAt(o)
807
+ if observed.IsZero() {
808
+ return
809
+ }
810
+ graph.Evidence = append(graph.Evidence, cr.ResourceEvidence{ID: "event/" + o.GetNamespace() + "/" + o.GetName(), Type: "kubernetes-event", Severity: severity, Summary: strings.TrimSpace(reason + " " + message), ObservedAt: observed.UTC().Format(time.RFC3339Nano), ResourceID: id, SourceRef: "Event/" + o.GetNamespace() + "/" + o.GetName()})
811
+ }
812
+
813
+ func graphEventObservedAt(o *unstructured.Unstructured) time.Time {
814
+ for _, field := range []string{"lastTimestamp", "eventTime"} {
815
+ value, _, _ := unstructured.NestedString(o.Object, field)
816
+ if observed, err := time.Parse(time.RFC3339Nano, value); err == nil && !observed.IsZero() {
817
+ return observed
818
+ }
819
+ }
820
+ return o.GetCreationTimestamp().Time
821
+ }
822
+
823
+ func validateNetworkGraphRootScope(root cr.ResourceRef, definition cr.NetworkTopologySourceDefinition) error {
824
+ if root.ScopeMode != "" && root.ScopeMode != topologyScope(definition.Namespaced) {
825
+ return invalidGraphRoot("network resource graph root scope mismatch")
826
+ }
827
+ if definition.Namespaced && root.Namespace == "" {
828
+ return invalidGraphRoot("namespaced resource graph root requires namespace")
829
+ }
830
+ if !definition.Namespaced && root.Namespace != "" {
831
+ return invalidGraphRoot("cluster resource graph root cannot have namespace")
832
+ }
833
+ return nil
834
+ }