@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,224 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "sort"
7
+ "strings"
8
+ "time"
9
+
10
+ cr "github.com/opensoha/soha-contracts/resource"
11
+ )
12
+
13
+ // BuildNetworkResourceGraph consumes the same projected facts as topology.
14
+ // Callers own source authorization and bounded collection; no network I/O occurs.
15
+ func BuildNetworkResourceGraph(ctx context.Context, root cr.ResourceRef, snapshot cr.NetworkTopologySnapshot) (cr.ResourceGraph, error) {
16
+ if err := ctx.Err(); err != nil {
17
+ return cr.ResourceGraph{}, err
18
+ }
19
+ root, err := NormalizeResourceGraphRoot(root)
20
+ if err != nil {
21
+ return cr.ResourceGraph{}, err
22
+ }
23
+ if _, ok := topologySourceFor(root.Kind, root.APIVersion); !ok {
24
+ return cr.ResourceGraph{}, ErrUnsupportedResourceGraphRoot
25
+ }
26
+ snapshot, err = prioritizeNetworkRoot(ctx, root, snapshot)
27
+ if err != nil {
28
+ return cr.ResourceGraph{}, err
29
+ }
30
+ g := buildNetworkTopology(ctx, root.ClusterID, root.Namespace, snapshot, ResourceGraphWorkingNodeLimit, ResourceGraphWorkingEdgeLimit, &root)
31
+ if err := ctx.Err(); err != nil {
32
+ return cr.ResourceGraph{}, err
33
+ }
34
+ actual := g.objects[topologyKey(root.Kind, root.Namespace, root.Name)]
35
+ g.objectNode(actual, "backend-group", topologyProvider(actual.source), "")
36
+ graph := cr.ResourceGraph{ClusterID: root.ClusterID, Namespace: root.Namespace, GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), RootID: graphNodeID(actual.ref), Nodes: []cr.ResourceGraphNode{}, Edges: []cr.ResourceGraphEdge{}, Evidence: []cr.ResourceEvidence{}, Warnings: []string{}}
37
+ nodes, edges, evidence, err := networkResourceFacts(ctx, g)
38
+ if err != nil {
39
+ return cr.ResourceGraph{}, err
40
+ }
41
+ selected, chosen, depth, limit, err := networkGraphTraverse(ctx, graph.RootID, edges)
42
+ if err != nil {
43
+ return cr.ResourceGraph{}, err
44
+ }
45
+ for id := range selected {
46
+ graph.Nodes = append(graph.Nodes, nodes[id])
47
+ }
48
+ for _, edge := range edges {
49
+ if chosen[edge.ID] {
50
+ graph.Edges = append(graph.Edges, edge)
51
+ }
52
+ }
53
+ for _, e := range evidence {
54
+ if selected[e.ResourceID] {
55
+ graph.Evidence = append(graph.Evidence, e)
56
+ }
57
+ }
58
+ sort.Slice(graph.Nodes, func(i, j int) bool { return graph.Nodes[i].ID < graph.Nodes[j].ID })
59
+ if depth {
60
+ graph.Warnings = append(graph.Warnings, "network resource graph truncated by depth at 3 hops")
61
+ }
62
+ if limit || g.view.Truncated {
63
+ graph.Warnings = append(graph.Warnings, "network resource graph is partial due to source identity or graph budgets")
64
+ }
65
+ seen := map[string]bool{}
66
+ for _, c := range g.view.Coverage {
67
+ if c.State != "available" || c.Truncated {
68
+ w := fmt.Sprintf("network source %s namespace=%s name=%s: %s (%s)", c.SourceID, c.Namespace, c.Name, c.State, c.ReasonCode)
69
+ if !seen[w] {
70
+ graph.Warnings = append(graph.Warnings, w)
71
+ seen[w] = true
72
+ }
73
+ }
74
+ }
75
+ for _, node := range graph.Nodes {
76
+ if node.Resource.APIVersion == "v1" && node.Resource.Kind == "Secret" && node.Status != "observed" {
77
+ graph.Warnings = append(graph.Warnings, "TLS Secret references are declarations; metadata and existence are unverified")
78
+ break
79
+ }
80
+ }
81
+ for _, node := range g.view.Nodes {
82
+ if node.ResolutionState == "unsupported_fragment" {
83
+ graph.Warnings = append(graph.Warnings, "Unsupported network reference fragments could not be resolved")
84
+ break
85
+ }
86
+ }
87
+ sort.Strings(graph.Warnings)
88
+ return graph, ctx.Err()
89
+ }
90
+
91
+ func prioritizeNetworkRoot(ctx context.Context, root cr.ResourceRef, snapshot cr.NetworkTopologySnapshot) (cr.NetworkTopologySnapshot, error) {
92
+ sources := append([]cr.NetworkTopologySourceResult(nil), snapshot.Sources...)
93
+ sort.SliceStable(sources, func(i, j int) bool { return sources[i].Coverage.Name != "" && sources[j].Coverage.Name == "" })
94
+ for _, s := range sources {
95
+ for _, item := range s.Objects {
96
+ if err := ctx.Err(); err != nil {
97
+ return snapshot, err
98
+ }
99
+ if item.Resource.Kind != root.Kind || item.Resource.APIVersion != root.APIVersion || item.Resource.Namespace != root.Namespace || item.Resource.Name != root.Name {
100
+ continue
101
+ }
102
+ if root.UID != "" && item.Resource.UID != root.UID {
103
+ return snapshot, invalidGraphRoot("network root UID changed")
104
+ }
105
+ if _, ok := decodeTopologyObject(root.ClusterID, s, item); !ok {
106
+ return snapshot, invalidGraphRoot("invalid projected network root")
107
+ }
108
+ first := cr.NetworkTopologySourceResult{Coverage: s.Coverage, Objects: []cr.NetworkTopologySourceObject{item}}
109
+ snapshot.Sources = append([]cr.NetworkTopologySourceResult{first}, snapshot.Sources...)
110
+ return snapshot, nil
111
+ }
112
+ }
113
+ return snapshot, invalidGraphRoot("network resource graph root not present")
114
+ }
115
+
116
+ func networkResourceFacts(ctx context.Context, g *topologyGraph) (map[string]cr.ResourceGraphNode, []cr.ResourceGraphEdge, []cr.ResourceEvidence, error) {
117
+ nodes := map[string]cr.ResourceGraphNode{}
118
+ ids := map[string]string{}
119
+ evidence := []cr.ResourceEvidence{}
120
+ keys := make([]string, 0, len(g.nodes))
121
+ for id := range g.nodes {
122
+ keys = append(keys, id)
123
+ }
124
+ sort.Strings(keys)
125
+ for _, id := range keys {
126
+ if err := ctx.Err(); err != nil {
127
+ return nil, nil, nil, err
128
+ }
129
+ n := g.nodes[id]
130
+ if ref, ok := g.hiddenRefs[id]; ok {
131
+ n.Resource = &ref
132
+ }
133
+ if n.Resource == nil || n.Resource.Name == "" || n.Resource.APIVersion == "" {
134
+ continue
135
+ }
136
+ ref := *n.Resource
137
+ rid := graphNodeID(ref)
138
+ ids[id] = rid
139
+ state := "unresolved"
140
+ if o, ok := g.objects[topologyKey(ref.Kind, ref.Namespace, ref.Name)]; ok && o.ref.APIVersion == ref.APIVersion && o.ref.UID == ref.UID {
141
+ state = "observed"
142
+ }
143
+ if previous, ok := nodes[rid]; !ok || previous.Status != "observed" {
144
+ nodes[rid] = cr.ResourceGraphNode{ID: rid, Resource: ref, Status: state}
145
+ }
146
+ if ref.Kind == "NetworkPolicy" && n.Rule == nil {
147
+ for i, p := range n.Properties {
148
+ evidence = append(evidence, cr.ResourceEvidence{ID: fmt.Sprintf("%s:policy:%d", rid, i), Type: "policy-declaration", Severity: "info", Summary: p.Name + ": " + p.Value, ObservedAt: g.view.GeneratedAt, ResourceID: rid})
149
+ }
150
+ }
151
+ }
152
+ edges := []cr.ResourceGraphEdge{}
153
+ seen := map[string]bool{}
154
+ for _, e := range g.view.Edges {
155
+ if err := ctx.Err(); err != nil {
156
+ return nil, nil, nil, err
157
+ }
158
+ source, target := ids[e.Source], ids[e.Target]
159
+ if source == "" || target == "" || source == target {
160
+ continue
161
+ }
162
+ relation := e.Relation
163
+ if relation == "forwards" || relation == "weights" {
164
+ relation = "routes-to"
165
+ }
166
+ if relation == "matches" {
167
+ relation = "attaches-to"
168
+ }
169
+ edge := cr.ResourceGraphEdge{SourceID: source, TargetID: target, Relation: relation, ResolutionState: e.ResolutionState, Protocol: e.Protocol, Port: e.Port, SectionName: e.SectionName, FieldPath: e.FieldPath, Note: e.Note, SourceRef: e.SourceRef}
170
+ edge.ID = strings.Join([]string{relation, source, target, e.Protocol, e.Port, e.SectionName, e.FieldPath}, "|")
171
+ if !seen[edge.ID] {
172
+ seen[edge.ID] = true
173
+ edges = append(edges, edge)
174
+ }
175
+ }
176
+ sort.Slice(edges, func(i, j int) bool { return edges[i].ID < edges[j].ID })
177
+ return nodes, edges, evidence, nil
178
+ }
179
+
180
+ // The two walks never reverse direction at a selector, a class, or shared
181
+ // reference target. Work is bounded by the already bounded relationship set.
182
+ func networkGraphTraverse(ctx context.Context, root string, edges []cr.ResourceGraphEdge) (map[string]bool, map[string]bool, bool, bool, error) {
183
+ adjacency := map[int]map[string][]cr.ResourceGraphEdge{1: {}, -1: {}}
184
+ for _, e := range edges {
185
+ adjacency[1][e.SourceID] = append(adjacency[1][e.SourceID], e)
186
+ adjacency[-1][e.TargetID] = append(adjacency[-1][e.TargetID], e)
187
+ }
188
+ selected, chosen := map[string]bool{root: true}, map[string]bool{}
189
+ depthTruncated, limitTruncated := false, false
190
+ for _, direction := range []int{1, -1} {
191
+ queue := []graphTraversalState{{id: root, direction: direction}}
192
+ visited := map[string]bool{root: true}
193
+ for len(queue) > 0 {
194
+ if err := ctx.Err(); err != nil {
195
+ return nil, nil, false, false, err
196
+ }
197
+ current := queue[0]
198
+ queue = queue[1:]
199
+ for _, edge := range adjacency[direction][current.id] {
200
+ next := edge.TargetID
201
+ if direction == -1 {
202
+ next = edge.SourceID
203
+ }
204
+ if current.depth >= 3 {
205
+ if !visited[next] {
206
+ depthTruncated = true
207
+ }
208
+ continue
209
+ }
210
+ if !selected[next] && len(selected) >= cr.NetworkTopologyMaxNodes || !chosen[edge.ID] && len(chosen) >= cr.NetworkTopologyMaxEdges {
211
+ limitTruncated = true
212
+ continue
213
+ }
214
+ selected[next] = true
215
+ chosen[edge.ID] = true
216
+ if !visited[next] {
217
+ visited[next] = true
218
+ queue = append(queue, graphTraversalState{id: next, depth: current.depth + 1, direction: direction})
219
+ }
220
+ }
221
+ }
222
+ }
223
+ return selected, chosen, depthTruncated, limitTruncated, nil
224
+ }
@@ -0,0 +1,174 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "strings"
9
+
10
+ cr "github.com/opensoha/soha-contracts/resource"
11
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
12
+ "k8s.io/client-go/kubernetes"
13
+ "k8s.io/client-go/metadata"
14
+ "k8s.io/client-go/rest"
15
+ )
16
+
17
+ var errNetworkGraphNamedNotFound = errors.New("named network graph root not found")
18
+
19
+ func networkGraphRootKind(kind string) bool {
20
+ switch kind {
21
+ case "Service", "Ingress", "IngressClass", "GatewayClass", "Gateway", "HTTPRoute", "GRPCRoute", "BackendTLSPolicy", "ReferenceGrant", "EndpointSlice", "NetworkPolicy":
22
+ return true
23
+ }
24
+ return false
25
+ }
26
+
27
+ func networkGraphRootSource(root cr.ResourceRef) (cr.NetworkTopologySourceDefinition, bool) {
28
+ for _, definition := range cr.NetworkTopologySources() {
29
+ if definition.Kind != root.Kind || !networkGraphRootKind(root.Kind) {
30
+ continue
31
+ }
32
+ if root.APIVersion == "" {
33
+ return definition, true
34
+ }
35
+ for _, version := range cr.NetworkTopologySourceVersions(definition) {
36
+ if root.APIVersion == strings.TrimPrefix(definition.Group+"/"+version, "/") {
37
+ return definition, true
38
+ }
39
+ }
40
+
41
+ }
42
+ return cr.NetworkTopologySourceDefinition{}, false
43
+ }
44
+
45
+ // BuildKubernetesResourceGraphWithNetworkSources preserves the ordinary Graph
46
+ // collector and reuses the bounded topology reader for known network roots.
47
+ func BuildKubernetesResourceGraphWithNetworkSources(ctx context.Context, typed kubernetes.Interface, metadataClient metadata.Interface, client rest.Interface, root cr.ResourceRef) (cr.ResourceGraph, error) {
48
+ root, err := NormalizeResourceGraphRoot(root)
49
+ if err != nil {
50
+ return cr.ResourceGraph{}, err
51
+ }
52
+ definition, network := networkGraphRootSource(root)
53
+ if !network {
54
+ return BuildKubernetesResourceGraphWithMetadata(ctx, typed, metadataClient, root)
55
+ }
56
+ if client == nil {
57
+ return cr.ResourceGraph{}, fmt.Errorf("%w: network resource graph reader unavailable", ErrUnsupportedResourceGraphRoot)
58
+ }
59
+ ctx, cancel := context.WithTimeout(ctx, ResourceGraphTimeout)
60
+ defer cancel()
61
+ object, err := getNetworkGraphRoot(ctx, client, definition, root, &topologyReadBudget{remaining: cr.NetworkTopologyMaxTotalBytes})
62
+ if err != nil {
63
+ return cr.ResourceGraph{}, err
64
+ }
65
+ request := cr.NetworkTopologySnapshotRequest{}
66
+ for _, source := range cr.NetworkTopologySources() {
67
+ if source.Provider == "kubernetes" || source.Provider == "gateway" && !source.RequiresCustomResourceAccess() {
68
+ request.Queries = append(request.Queries, cr.NetworkTopologySourceQuery{SourceID: source.ID})
69
+ }
70
+ }
71
+ snapshot, err := ReadNetworkTopologySnapshot(ctx, client, root.ClusterID, request)
72
+ if err != nil {
73
+ return cr.ResourceGraph{}, err
74
+ }
75
+ appendNetworkGraphRoot(&snapshot, definition.ID, object)
76
+ graph, err := BuildNetworkResourceGraph(ctx, object.Resource, snapshot)
77
+ if err != nil {
78
+ return cr.ResourceGraph{}, err
79
+ }
80
+ return graph, ctx.Err()
81
+ }
82
+
83
+ func getNetworkGraphRoot(ctx context.Context, client rest.Interface, definition cr.NetworkTopologySourceDefinition, root cr.ResourceRef, budget *topologyReadBudget) (cr.NetworkTopologySourceObject, error) {
84
+ if err := cr.ValidateNetworkTopologySnapshotRequest(cr.NetworkTopologySnapshotRequest{Queries: []cr.NetworkTopologySourceQuery{{SourceID: definition.ID, Namespace: root.Namespace, Name: root.Name}}}); err != nil {
85
+ return cr.NetworkTopologySourceObject{}, invalidGraphRoot("invalid named network graph scope")
86
+ }
87
+ versions := cr.NetworkTopologySourceVersions(definition)
88
+ if root.APIVersion != "" {
89
+ versions = []string{strings.TrimPrefix(root.APIVersion, definition.Group+"/")}
90
+ }
91
+ version, err := discoverTopologySourceVersions(ctx, client, definition, budget, versions)
92
+ if err != nil {
93
+ return cr.NetworkTopologySourceObject{}, err
94
+ }
95
+ path := topologyAPIBase(definition.Group, version)
96
+ if definition.Namespaced {
97
+ path += "/namespaces/" + root.Namespace
98
+ }
99
+ raw, err := boundedTopologyGET(ctx, client, path+"/"+definition.Resource+"/"+root.Name, false, budget)
100
+ if err != nil {
101
+ if apierrors.IsNotFound(err) {
102
+ return cr.NetworkTopologySourceObject{}, fmt.Errorf("%w: %w", errNetworkGraphNamedNotFound, err)
103
+ }
104
+ return cr.NetworkTopologySourceObject{}, err
105
+ }
106
+ var input map[string]any
107
+ api := strings.TrimPrefix(definition.Group+"/"+version, "/")
108
+ if json.Unmarshal(raw, &input) != nil || input["apiVersion"] != api || input["kind"] != root.Kind {
109
+ return cr.NetworkTopologySourceObject{}, invalidGraphRoot("network graph root GVK changed")
110
+ }
111
+ projected, err := cr.ProjectNetworkTopologyObject(definition.ID, input)
112
+ if err != nil {
113
+ return cr.NetworkTopologySourceObject{}, err
114
+ }
115
+ meta, _ := projected["metadata"].(map[string]any)
116
+ namespace, _ := meta["namespace"].(string)
117
+ uid, _ := meta["uid"].(string)
118
+ if meta["name"] != root.Name || namespace != root.Namespace || uid == "" || root.UID != "" && root.UID != uid {
119
+ return cr.NetworkTopologySourceObject{}, invalidGraphRoot("network graph root identity changed")
120
+ }
121
+ root.APIVersion, root.UID, root.ScopeMode = api, uid, topologyScope(definition.Namespaced)
122
+ root.ResourceVersion, _ = meta["resourceVersion"].(string)
123
+ content, _ := json.Marshal(projected)
124
+ generation, _ := meta["generation"].(float64)
125
+ return cr.NetworkTopologySourceObject{Resource: root, Generation: int64(generation), Content: string(content)}, nil
126
+ }
127
+
128
+ func appendNetworkGraphRoot(snapshot *cr.NetworkTopologySnapshot, sourceID string, root cr.NetworkTopologySourceObject) {
129
+ // The named root must survive a truncated page and stale list observations.
130
+ total := len(root.Content)
131
+ for index := range snapshot.Sources {
132
+ source := &snapshot.Sources[index]
133
+ objects := source.Objects[:0]
134
+ for _, object := range source.Objects {
135
+ if object.Resource.Kind == root.Resource.Kind && object.Resource.Namespace == root.Resource.Namespace && object.Resource.Name == root.Resource.Name {
136
+ continue
137
+ }
138
+ if total+len(object.Content) > cr.NetworkTopologyMaxTotalBytes {
139
+ source.Coverage.State, source.Coverage.Truncated, source.Coverage.ReasonCode = "partial", true, "total_budget"
140
+ continue
141
+ }
142
+ total += len(object.Content)
143
+ objects = append(objects, object)
144
+ }
145
+ source.Objects = objects
146
+ if source.Coverage.SourceID == sourceID {
147
+ if len(source.Objects) >= cr.NetworkTopologyMaxSourceItems {
148
+ source.Objects = source.Objects[:cr.NetworkTopologyMaxSourceItems-1]
149
+ source.Coverage.Truncated = true
150
+ }
151
+ source.Objects = append(source.Objects, root)
152
+ if source.Coverage.State != "available" {
153
+ source.Coverage.State = "partial"
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ func readNetworkGraphNamedSource(ctx context.Context, client rest.Interface, clusterID string, query cr.NetworkTopologySourceQuery, budget *topologyReadBudget) cr.NetworkTopologySourceResult {
160
+ definition, _ := cr.LookupNetworkTopologySource(query.SourceID)
161
+ result := topologySourceFailure(query, "available", "named_get")
162
+ // Named reads use the same fixed catalog and projection, never arbitrary GVRs.
163
+ root := cr.ResourceRef{ClusterID: clusterID, Kind: definition.Kind, Namespace: query.Namespace, Name: query.Name}
164
+ object, err := getNetworkGraphRoot(ctx, client, definition, root, budget)
165
+ if err != nil {
166
+ result.Coverage.State, result.Coverage.ReasonCode = topologyReadError(err)
167
+ if errors.Is(err, errNetworkGraphNamedNotFound) {
168
+ result.Coverage.ReasonCode = "named_not_found"
169
+ }
170
+ return result
171
+ }
172
+ result.Objects = []cr.NetworkTopologySourceObject{object}
173
+ return result
174
+ }
@@ -0,0 +1,147 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "testing"
12
+
13
+ cr "github.com/opensoha/soha-contracts/resource"
14
+ corev1 "k8s.io/api/core/v1"
15
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16
+ "k8s.io/client-go/kubernetes"
17
+ "k8s.io/client-go/kubernetes/fake"
18
+ "k8s.io/client-go/rest"
19
+ )
20
+
21
+ func TestNetworkGraphNamedRootOutsideBoundedPage(t *testing.T) {
22
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23
+ w.Header().Set("Content-Type", "application/json")
24
+ switch r.URL.Path {
25
+ case "/api/v1":
26
+ _, _ = fmt.Fprint(w, `{"resources":[{"name":"services","kind":"Service","namespaced":true}]}`)
27
+ case "/api/v1/namespaces/team/services/root":
28
+ _, _ = fmt.Fprint(w, `{"apiVersion":"v1","kind":"Service","metadata":{"name":"root","namespace":"team","uid":"root-uid"},"spec":{"type":"ClusterIP"}}`)
29
+ case "/api/v1/services":
30
+ if r.URL.Query().Get("limit") != "200" {
31
+ t.Error("unbounded list")
32
+ }
33
+ items := []any{}
34
+ for i := 0; i < 201; i++ {
35
+ items = append(items, map[string]any{"metadata": map[string]any{"name": fmt.Sprint("svc-", i), "namespace": "team", "uid": fmt.Sprint("uid-", i)}})
36
+ }
37
+ _ = json.NewEncoder(w).Encode(map[string]any{"metadata": map[string]any{"continue": "next"}, "items": items})
38
+ default:
39
+ if strings.Contains(r.URL.Path, "secrets") {
40
+ t.Error("Secret data read")
41
+ }
42
+ http.NotFound(w, r)
43
+ }
44
+ }))
45
+ defer server.Close()
46
+ client, _ := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
47
+ graph, err := BuildKubernetesResourceGraphWithNetworkSources(context.Background(), client, nil, client.CoreV1().RESTClient(), cr.ResourceRef{ClusterID: "c", Namespace: "team", Kind: "service", Name: "root"})
48
+ if err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ found := false
52
+ for _, node := range graph.Nodes {
53
+ if node.ID == graph.RootID && node.Resource.Name == "root" && node.Resource.UID == "root-uid" {
54
+ found = true
55
+ }
56
+ }
57
+ if !found || len(graph.Warnings) == 0 {
58
+ t.Fatalf("named root or truncation missing: %+v", graph)
59
+ }
60
+ }
61
+
62
+ func TestNetworkGraphNamedSnapshotIdentityErrors(t *testing.T) {
63
+ for _, tc := range []struct{ name, body string }{
64
+ {"GVK", `{"apiVersion":"example.io/v1","kind":"Service","metadata":{"name":"root","namespace":"team","uid":"uid"}}`},
65
+ {"namespace", `{"apiVersion":"v1","kind":"Service","metadata":{"name":"root","namespace":"other","uid":"uid"}}`},
66
+ {"UID", `{"apiVersion":"v1","kind":"Service","metadata":{"name":"root","namespace":"team","uid":""}}`},
67
+ } {
68
+ t.Run(tc.name, func(t *testing.T) {
69
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70
+ if r.URL.Path == "/api/v1" {
71
+ _, _ = fmt.Fprint(w, `{"resources":[{"name":"services","kind":"Service","namespaced":true}]}`)
72
+ } else {
73
+ _, _ = fmt.Fprint(w, tc.body)
74
+ }
75
+ }))
76
+ defer server.Close()
77
+ client, _ := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
78
+ _, err := BuildKubernetesResourceGraphWithNetworkSources(context.Background(), client, nil, client.CoreV1().RESTClient(), cr.ResourceRef{ClusterID: "c", Namespace: "team", Kind: "Service", Name: "root"})
79
+ if !errors.Is(err, ErrInvalidResourceGraphRoot) {
80
+ t.Fatalf("identity accepted: %v", err)
81
+ }
82
+ })
83
+ }
84
+ }
85
+
86
+ func TestNetworkGraphNamedSnapshotCoverageAndCancellation(t *testing.T) {
87
+ for _, tc := range []struct {
88
+ name string
89
+ status int
90
+ reason string
91
+ }{{"missing", 404, "named_not_found"}, {"denied", 403, "kubernetes_rbac"}} {
92
+ t.Run(tc.name, func(t *testing.T) {
93
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94
+ w.Header().Set("Content-Type", "application/json")
95
+ if r.URL.Path == "/api/v1" {
96
+ _, _ = fmt.Fprint(w, `{"resources":[{"name":"services","kind":"Service","namespaced":true}]}`)
97
+ return
98
+ }
99
+ w.WriteHeader(tc.status)
100
+ _ = json.NewEncoder(w).Encode(map[string]any{"kind": "Status", "apiVersion": "v1", "code": tc.status, "reason": map[int]string{404: "NotFound", 403: "Forbidden"}[tc.status], "message": "private details"})
101
+ }))
102
+ defer server.Close()
103
+ client, _ := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
104
+ query := cr.NetworkTopologySourceQuery{SourceID: "kubernetes.services", Namespace: "team", Name: "root"}
105
+ snapshot, err := ReadNetworkTopologySnapshot(context.Background(), client.CoreV1().RESTClient(), "c", cr.NetworkTopologySnapshotRequest{Queries: []cr.NetworkTopologySourceQuery{query}})
106
+ if err != nil || snapshot.Version != cr.NetworkTopologyNamedRootFeature || len(snapshot.Sources) != 1 || snapshot.Sources[0].Coverage.Name != "root" || snapshot.Sources[0].Coverage.ReasonCode != tc.reason {
107
+ t.Fatalf("coverage lost: %+v %v", snapshot, err)
108
+ }
109
+ raw, _ := json.Marshal(snapshot)
110
+ if strings.Contains(string(raw), "private details") {
111
+ t.Fatal("provider detail leaked")
112
+ }
113
+ ctx, cancel := context.WithCancel(context.Background())
114
+ cancel()
115
+ if _, err := ReadNetworkTopologySnapshot(ctx, client.CoreV1().RESTClient(), "c", cr.NetworkTopologySnapshotRequest{Queries: []cr.NetworkTopologySourceQuery{query}}); !errors.Is(err, context.Canceled) {
116
+ t.Fatalf("cancel lost: %v", err)
117
+ }
118
+ })
119
+ }
120
+ }
121
+
122
+ func TestNetworkGraphNonNetworkFakeKeepsExistingCollector(t *testing.T) {
123
+ client := fake.NewClientset(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod", Namespace: "team", UID: "uid"}})
124
+ graph, err := BuildKubernetesResourceGraphWithNetworkSources(context.Background(), client, nil, nil, cr.ResourceRef{ClusterID: "c", Namespace: "team", Kind: "Pod", Name: "pod"})
125
+ if err != nil || len(graph.Nodes) == 0 {
126
+ t.Fatalf("nonnetwork fallback: %+v %v", graph, err)
127
+ }
128
+ if _, err := BuildKubernetesResourceGraphWithNetworkSources(context.Background(), client, nil, nil, cr.ResourceRef{ClusterID: "c", Namespace: "team", Kind: "Service", Name: "root"}); !errors.Is(err, ErrUnsupportedResourceGraphRoot) {
129
+ t.Fatalf("missing network transport: %v", err)
130
+ }
131
+ }
132
+
133
+ func TestNetworkGraphNamedRootByteBudget(t *testing.T) {
134
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135
+ if r.URL.Path == "/api/v1" {
136
+ _, _ = fmt.Fprint(w, `{"resources":[{"name":"services","kind":"Service","namespaced":true}]}`)
137
+ return
138
+ }
139
+ _, _ = fmt.Fprint(w, `{"apiVersion":"v1","kind":"Service","metadata":{"name":"root","namespace":"team","uid":"uid","annotations":{"oversized":"`+strings.Repeat("x", cr.NetworkTopologyMaxSourceBytes)+`"}}}`)
140
+ }))
141
+ defer server.Close()
142
+ client, _ := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
143
+ snapshot, err := ReadNetworkTopologySnapshot(context.Background(), client.CoreV1().RESTClient(), "c", cr.NetworkTopologySnapshotRequest{Queries: []cr.NetworkTopologySourceQuery{{SourceID: "kubernetes.services", Namespace: "team", Name: "root"}}})
144
+ if err != nil || len(snapshot.Sources) != 1 || snapshot.Sources[0].Coverage.State != "partial" || snapshot.Sources[0].Coverage.ReasonCode != "byte_budget" || len(snapshot.Sources[0].Objects) != 0 {
145
+ t.Fatalf("named byte limit lost: %+v %v", snapshot, err)
146
+ }
147
+ }