@opensoha/contracts 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/auth/catalog_generated.go +2 -2
  2. package/auth/gateway_permissions_test.go +48 -0
  3. package/auth/permission-catalog.json +300 -1
  4. package/compat/consumer-revisions.json +1 -1
  5. package/compat/json-schema-ecosystem-0.1.0.json +1 -1
  6. package/compat/openapi-loose-boundaries-0.1.0.json +1 -1
  7. package/fixtures/openapi/KubernetesAgentActionCheck/invalid/unproven-state.json +6 -0
  8. package/fixtures/openapi/KubernetesAgentActionCheck/valid/configured-denied.json +9 -0
  9. package/fixtures/openapi/KubernetesAgentActionProfile/invalid/wildcard.json +1 -0
  10. package/fixtures/openapi/KubernetesAgentActionProfile/valid/workbench.json +1 -0
  11. package/fixtures/openapi/KubernetesClusterRegistrationRequest/invalid/action-profile.json +3 -0
  12. package/fixtures/openapi/KubernetesClusterRegistrationRequest/valid/action-profile.json +5 -0
  13. package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/condition-status-boolean.json +17 -0
  14. package/fixtures/openapi/KubernetesNodeListEnvelope/invalid/taint-effect-missing.json +16 -0
  15. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/legacy-fields-absent.json +11 -0
  16. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/no-taints.json +13 -0
  17. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/ready-cordoned-tainted.json +28 -0
  18. package/fixtures/openapi/KubernetesNodeListEnvelope/valid/unknown-fields-null.json +13 -0
  19. package/fixtures/openapi/KubernetesTopologyCoverage/invalid/empty-as-success.json +5 -0
  20. package/fixtures/openapi/KubernetesTopologyCoverage/valid/not-installed.json +6 -0
  21. package/fixtures/openapi/KubernetesTopologyGraphEdge/invalid/negative-weight.json +8 -0
  22. package/fixtures/openapi/KubernetesTopologyGraphEdge/valid/zero-weight.json +20 -0
  23. package/fixtures/openapi/KubernetesTopologyGraphNode/invalid/fake-flow-kind.json +42 -0
  24. package/fixtures/openapi/KubernetesTopologyGraphNode/valid/rule.json +42 -0
  25. package/fixtures/openapi/KubernetesTopologySnapshot/invalid/raw-source-field.json +33 -0
  26. package/fixtures/openapi/KubernetesTopologySnapshot/valid/projected-source.json +32 -0
  27. package/fixtures/openapi/KubernetesTopologySnapshotRequest/invalid/arbitrary-gvr.json +8 -0
  28. package/fixtures/openapi/KubernetesTopologySnapshotRequest/valid/bounded-sources.json +11 -0
  29. package/fixtures/openapi/KubernetesTopologySourceQuery/invalid/cluster-namespace.json +4 -0
  30. package/fixtures/openapi/KubernetesTopologySourceQuery/valid/all-namespaces.json +4 -0
  31. package/fixtures/openapi/ResourceMonitoringPanel/invalid/unknown-quality.json +7 -0
  32. package/fixtures/openapi/ResourceMonitoringPanel/valid/not-collected.json +7 -0
  33. package/fixtures/openapi/ResourceMonitoringPoint/invalid/string-zero.json +4 -0
  34. package/fixtures/openapi/ResourceMonitoringPoint/valid/missing.json +4 -0
  35. package/fixtures/openapi/ResourceMonitoringPoint/valid/zero.json +4 -0
  36. package/fixtures/openapi/ResourceMonitoringView/invalid/raw-query.json +12 -0
  37. package/fixtures/openapi/ResourceMonitoringView/valid/unconfigured.json +12 -0
  38. package/gen/go/sohaapi/client.go +1 -1
  39. package/gen/go/sohaapi/kubernetes_monitoring_test.go +67 -0
  40. package/gen/go/sohaapi/types.go +1265 -228
  41. package/gen/ts/sohaapi/index.ts +1116 -1
  42. package/openapi/soha-api.json +2497 -302
  43. package/openapi/soha-api.yaml +732 -3
  44. package/openapi/spec_generated.go +3 -3
  45. package/openapi/spec_test.go +34 -0
  46. package/package.json +1 -1
  47. package/resource/agent_access.go +12 -0
  48. package/resource/agent_profiles.go +62 -0
  49. package/resource/agent_profiles_test.go +36 -0
  50. package/resource/kubernetes_kinds.go +73 -0
  51. package/resource/kubernetes_kinds_test.go +38 -0
  52. package/resource/models.go +23 -14
  53. package/resource/network_topology.go +166 -0
  54. package/resource/network_topology_projection.go +208 -0
  55. package/resource/network_topology_projection_test.go +53 -0
  56. package/resource/network_topology_test.go +51 -0
  57. package/resource/network_topology_validation.go +29 -0
  58. package/resource/node_drain.go +34 -0
  59. package/resource/resource_monitoring.go +99 -0
  60. package/resource/runtime/network_topology.go +232 -0
  61. package/resource/runtime/network_topology_test.go +62 -0
  62. package/resource/runtime/node_drain.go +124 -0
  63. package/resource/runtime/node_drain_test.go +95 -0
  64. package/resource/runtime/resource_edit.go +128 -0
  65. package/resource/runtime/resource_edit_test.go +183 -0
  66. package/resource/runtime/resource_monitoring.go +243 -0
  67. package/resource/runtime/resource_monitoring_test.go +88 -0
@@ -0,0 +1,183 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "testing"
9
+
10
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
11
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13
+ k8sruntime "k8s.io/apimachinery/pkg/runtime"
14
+ "k8s.io/apimachinery/pkg/runtime/schema"
15
+ "k8s.io/apimachinery/pkg/types"
16
+ "k8s.io/apimachinery/pkg/util/validation/field"
17
+ dynamicfake "k8s.io/client-go/dynamic/fake"
18
+ clienttesting "k8s.io/client-go/testing"
19
+ )
20
+
21
+ const testEditManager = "opensoha-resource-edit/v1"
22
+
23
+ func editFixture() *unstructured.Unstructured {
24
+ item := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "sample", "namespace": "demo", "uid": "original", "resourceVersion": "1"}, "data": map[string]any{"phase": "created"}}}
25
+ item.SetManagedFields([]metav1.ManagedFieldsEntry{{Manager: testEditManager, Operation: metav1.ManagedFieldsOperationUpdate, APIVersion: "v1", FieldsType: "FieldsV1", FieldsV1: metav1.NewFieldsV1(`{"f:data":{"f:phase":{}}}`)}})
26
+ return item
27
+ }
28
+
29
+ func editConflict(manager string) error {
30
+ return apierrors.NewApplyConflict([]metav1.StatusCause{{Type: metav1.CauseTypeFieldManagerConflict, Field: ".data.phase", Message: fmt.Sprintf("conflict with %q using v1", manager)}}, "field conflict")
31
+ }
32
+
33
+ func TestApplyResourceEditMigratesOnlyItsOwnCreationFields(t *testing.T) {
34
+ for _, test := range []struct {
35
+ name string
36
+ dryRun, foreign, race, retryFail, probePass, portInvalid bool
37
+ }{
38
+ {name: "write"}, {name: "dry-run", dryRun: true}, {name: "successful-ssa-preview", dryRun: true, probePass: true}, {name: "port-list-preview", dryRun: true, portInvalid: true},
39
+ {name: "external", foreign: true}, {name: "external-dry-run", foreign: true, dryRun: true},
40
+ {name: "concurrent-migration", race: true}, {name: "concurrent-retry", retryFail: true},
41
+ } {
42
+ t.Run(test.name, func(t *testing.T) {
43
+ live := editFixture()
44
+ if test.portInvalid {
45
+ live.SetKind("Service")
46
+ }
47
+ if test.foreign {
48
+ owners := live.GetManagedFields()
49
+ owners = append(owners, metav1.ManagedFieldsEntry{Manager: "helm", Operation: metav1.ManagedFieldsOperationApply, APIVersion: "v1", FieldsType: "FieldsV1", FieldsV1: metav1.NewFieldsV1(`{"f:data":{"f:external":{}}}`)})
50
+ live.SetManagedFields(owners)
51
+ }
52
+ desired := live.DeepCopy()
53
+ desired.SetManagedFields(nil)
54
+ desired.Object["data"] = map[string]any{"phase": "updated"}
55
+ client := dynamicfake.NewSimpleDynamicClient(k8sruntime.NewScheme(), live)
56
+ applyCalls, migrations, updates := 0, 0, 0
57
+ client.PrependReactor("patch", "configmaps", func(action clienttesting.Action) (bool, k8sruntime.Object, error) {
58
+ patch, ok := action.(clienttesting.PatchActionImpl)
59
+ if !ok {
60
+ t.Fatalf("unexpected action %T", action)
61
+ }
62
+ if patch.GetPatchType() == types.JSONPatchType {
63
+ migrations++
64
+ assertEditMigration(t, patch.GetPatch(), test.foreign)
65
+ if test.race {
66
+ return true, nil, apierrors.NewConflict(schema.GroupResource{Resource: "configmaps"}, "sample", errors.New("concurrent update"))
67
+ }
68
+ moved := live.DeepCopy()
69
+ moved.SetResourceVersion("2")
70
+ return true, moved, nil
71
+ }
72
+ applyCalls++
73
+ options := patch.GetPatchOptions()
74
+ if options.Force == nil || *options.Force || options.FieldManager != testEditManager || (len(options.DryRun) > 0) != test.dryRun {
75
+ t.Fatalf("unsafe apply options: %+v", options)
76
+ }
77
+ if test.probePass {
78
+ return true, desired, nil
79
+ }
80
+ if test.portInvalid {
81
+ return true, nil, apierrors.NewInvalid(schema.GroupKind{Kind: "Service"}, "sample", field.ErrorList{field.Required(field.NewPath("spec", "ports").Index(0).Child("name"), "")})
82
+ }
83
+ if test.dryRun || test.foreign {
84
+ manager := testEditManager
85
+ if test.foreign {
86
+ manager = "helm"
87
+ }
88
+ return true, nil, editConflict(manager)
89
+ }
90
+ if test.retryFail {
91
+ return true, nil, editConflict("external-controller")
92
+ }
93
+ var body unstructured.Unstructured
94
+ if err := json.Unmarshal(patch.GetPatch(), &body); err != nil || body.GetResourceVersion() != "2" || body.GetUID() != live.GetUID() {
95
+ t.Fatalf("retry lost observed identity: %s %v", patch.GetPatch(), err)
96
+ }
97
+ return true, &body, nil
98
+ })
99
+ client.PrependReactor("update", "configmaps", func(action clienttesting.Action) (bool, k8sruntime.Object, error) {
100
+ update, ok := action.(clienttesting.UpdateActionImpl)
101
+ if !ok {
102
+ t.Fatalf("unexpected update %T", action)
103
+ }
104
+ options := update.GetUpdateOptions()
105
+ if !test.dryRun || len(options.DryRun) != 1 || options.DryRun[0] != metav1.DryRunAll || options.FieldManager != testEditManager {
106
+ t.Fatalf("migration validation wrote live data: %+v", options)
107
+ }
108
+ updates++
109
+ return true, update.GetObject(), nil
110
+ })
111
+ resource := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}).Namespace("demo")
112
+ _, err := ApplyResourceEdit(context.Background(), resource, live, desired, testEditManager, test.dryRun)
113
+ if (err != nil) != (test.foreign || test.race || test.retryFail) {
114
+ t.Fatalf("result: %v", err)
115
+ }
116
+ wantMigrations, wantUpdates, wantApply := 1, 0, 1
117
+ if test.foreign {
118
+ wantMigrations, wantApply = 1, 1
119
+ }
120
+ if test.dryRun {
121
+ wantMigrations, wantApply = 0, 1
122
+ if !test.foreign && !test.probePass {
123
+ wantUpdates = 1
124
+ }
125
+ }
126
+ if test.race {
127
+ wantApply = 0
128
+ }
129
+ if migrations != wantMigrations || updates != wantUpdates || applyCalls != wantApply || desired.GetResourceVersion() != "1" {
130
+ t.Fatalf("apply=%d migrate=%d validate=%d; caller RV=%s", applyCalls, migrations, updates, desired.GetResourceVersion())
131
+ }
132
+ })
133
+ }
134
+ }
135
+
136
+ func assertEditMigration(t *testing.T, payload []byte, foreign bool) {
137
+ t.Helper()
138
+ var ops []struct {
139
+ Op, Path string
140
+ Value json.RawMessage
141
+ }
142
+ if err := json.Unmarshal(payload, &ops); err != nil || len(ops) != 3 {
143
+ t.Fatalf("invalid migration: %s %v", payload, err)
144
+ }
145
+ if ops[0].Op != "test" || ops[0].Path != "/metadata/uid" || string(ops[0].Value) != `"original"` || ops[2].Path != "/metadata/resourceVersion" || string(ops[2].Value) != `"1"` {
146
+ t.Fatalf("migration lacks UID/RV protection: %s", payload)
147
+ }
148
+ var owners []metav1.ManagedFieldsEntry
149
+ if err := json.Unmarshal(ops[1].Value, &owners); err != nil || len(owners) < 1 || owners[0].Manager != testEditManager || owners[0].Operation != metav1.ManagedFieldsOperationApply {
150
+ t.Fatalf("unexpected ownership migration: %s %v", payload, err)
151
+ }
152
+ if foreign && (len(owners) != 2 || owners[1].Manager != "helm" || owners[1].Operation != metav1.ManagedFieldsOperationApply || owners[1].FieldsV1.GetRawString() != `{"f:data":{"f:external":{}}}`) {
153
+ t.Fatal("migration changed external ownership")
154
+ }
155
+ }
156
+
157
+ func TestResourceEditMigrationFailsClosed(t *testing.T) {
158
+ for _, missing := range []string{"uid", "resourceVersion"} {
159
+ live := editFixture()
160
+ unstructured.RemoveNestedField(live.Object, "metadata", missing)
161
+ if patch, err := resourceEditMigrationPatch(live, testEditManager); err != nil || patch != nil {
162
+ t.Fatalf("migrated without %s: %s %v", missing, patch, err)
163
+ }
164
+ }
165
+ for _, manager := range []string{"helm", testEditManager + "-other", testEditManager + `" using v1`} {
166
+ if onlyResourceEditConflicts(editFixture(), editConflict(manager), testEditManager) {
167
+ t.Fatalf("misidentified foreign manager %q", manager)
168
+ }
169
+ }
170
+ for _, entry := range []metav1.ManagedFieldsEntry{
171
+ {Manager: testEditManager, Operation: metav1.ManagedFieldsOperationUpdate, APIVersion: "v1beta1"},
172
+ {Manager: testEditManager, Operation: metav1.ManagedFieldsOperationApply, APIVersion: "v1beta1"},
173
+ } {
174
+ live := editFixture()
175
+ live.SetManagedFields(append(live.GetManagedFields(), entry))
176
+ if patch, err := resourceEditMigrationPatch(live, testEditManager); err != nil || patch != nil {
177
+ t.Fatalf("discarded another version's ownership: %s %v", patch, err)
178
+ }
179
+ }
180
+ if onlyResourceEditConflicts(editFixture(), apierrors.NewApplyConflict(nil, "unknown conflict"), testEditManager) {
181
+ t.Fatal("accepted incomplete conflict evidence")
182
+ }
183
+ }
@@ -0,0 +1,243 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "math"
9
+ "sort"
10
+ "time"
11
+
12
+ cr "github.com/opensoha/soha-contracts/resource"
13
+ corev1 "k8s.io/api/core/v1"
14
+ "k8s.io/client-go/rest"
15
+ )
16
+
17
+ // ReadResourceMonitoringInventory returns bounded current aggregate facts, not Pod manifests.
18
+ func ReadResourceMonitoringInventory(ctx context.Context, client rest.Interface, clusterID string) (cr.ResourceMonitoringInventory, error) {
19
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
20
+ defer cancel()
21
+ now := time.Now().UTC().Format(time.RFC3339)
22
+ output := cr.ResourceMonitoringInventory{Nodes: []cr.ResourceMonitoringNode{}, Quality: "valid", ObservedAt: now}
23
+ var nodes corev1.NodeList
24
+ if err := readMonitoringList(ctx, client, "nodes", cr.ResourceMonitoringMaxNodes, 2<<20, &nodes); err != nil {
25
+ return output, err
26
+ }
27
+ var pods corev1.PodList
28
+ podErr := readMonitoringList(ctx, client, "pods", 5000, 16<<20, &pods)
29
+ complete := podErr == nil && pods.Continue == "" && len(pods.Items) <= 5000
30
+ if !complete {
31
+ output.Quality = "partial"
32
+ output.ReasonCode = "pod_snapshot_incomplete"
33
+ output.Truncated = pods.Continue != "" || len(pods.Items) > 5000
34
+ }
35
+ if nodes.Continue != "" || len(nodes.Items) > cr.ResourceMonitoringMaxNodes {
36
+ output.Quality = "partial"
37
+ output.ReasonCode = "node_budget"
38
+ output.Truncated = true
39
+ }
40
+ aggregates := monitoringPodAggregates(pods.Items)
41
+ if complete {
42
+ output.UnscheduledPendingPods = number(aggregates[""].pending)
43
+ }
44
+ for index, node := range nodes.Items {
45
+ if index >= cr.ResourceMonitoringMaxNodes {
46
+ break
47
+ }
48
+ fact := monitoringNodeFact(clusterID, node, aggregates[node.Name], complete, now)
49
+ output.Nodes = append(output.Nodes, fact)
50
+ }
51
+
52
+ sort.Slice(output.Nodes, func(i, j int) bool { return output.Nodes[i].NodeRef.Name < output.Nodes[j].NodeRef.Name })
53
+ return output, nil
54
+ }
55
+
56
+ func readMonitoringList(ctx context.Context, client rest.Interface, resource string, limit, maxBytes int, target any) error {
57
+ if client == nil {
58
+ return fmt.Errorf("kubernetes REST client unavailable")
59
+ }
60
+ stream, err := client.Get().AbsPath("/api/v1/"+resource).Param("limit", fmt.Sprint(limit)).SetHeader("Accept", "application/json").Stream(ctx)
61
+ if err != nil {
62
+ return err
63
+ }
64
+ defer func() { _ = stream.Close() }()
65
+ raw, err := io.ReadAll(io.LimitReader(stream, int64(maxBytes)+1))
66
+ if err != nil {
67
+ return err
68
+ }
69
+ if len(raw) > maxBytes {
70
+ return fmt.Errorf("monitoring inventory byte budget")
71
+ }
72
+ return json.Unmarshal(raw, target)
73
+ }
74
+
75
+ type monitoringPodAggregate struct {
76
+ cpuRequest, memoryRequest, cpuLimit, memoryLimit, count, pending float64
77
+ cpuComplete, memoryComplete bool
78
+ initialized bool
79
+ }
80
+
81
+ func monitoringPodAggregates(pods []corev1.Pod) map[string]monitoringPodAggregate {
82
+ result := map[string]monitoringPodAggregate{}
83
+ for _, pod := range pods {
84
+ if monitoringTerminalPod(pod) {
85
+ continue
86
+ }
87
+ aggregate := result[pod.Spec.NodeName]
88
+ if !aggregate.initialized {
89
+ aggregate.cpuComplete = true
90
+ aggregate.memoryComplete = true
91
+ aggregate.initialized = true
92
+ }
93
+ aggregate.count++
94
+ if pod.Status.Phase == corev1.PodPending {
95
+ aggregate.pending++
96
+ }
97
+ requests, limits, cpuComplete, memoryComplete := effectiveMonitoringPodResources(pod)
98
+ aggregate.cpuRequest += requests.cpu
99
+ aggregate.memoryRequest += requests.memory
100
+ aggregate.cpuLimit += limits.cpu
101
+ aggregate.memoryLimit += limits.memory
102
+ aggregate.cpuComplete = aggregate.cpuComplete && cpuComplete
103
+ aggregate.memoryComplete = aggregate.memoryComplete && memoryComplete
104
+ result[pod.Spec.NodeName] = aggregate
105
+ }
106
+ return result
107
+ }
108
+ func monitoringTerminalPod(pod corev1.Pod) bool {
109
+ return pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed
110
+ }
111
+
112
+ type monitoringResources struct{ cpu, memory float64 }
113
+
114
+ func monitoringResourcesFromList(values corev1.ResourceList) monitoringResources {
115
+ cpu := values[corev1.ResourceCPU]
116
+ memory := values[corev1.ResourceMemory]
117
+ return monitoringResources{cpu: float64(cpu.MilliValue()) / 1000, memory: float64(memory.Value())}
118
+ }
119
+ func (r monitoringResources) add(other monitoringResources) monitoringResources {
120
+ return monitoringResources{cpu: r.cpu + other.cpu, memory: r.memory + other.memory}
121
+ }
122
+ func (r monitoringResources) max(other monitoringResources) monitoringResources {
123
+ return monitoringResources{cpu: math.Max(r.cpu, other.cpu), memory: math.Max(r.memory, other.memory)}
124
+ }
125
+
126
+ func effectiveMonitoringPodResources(pod corev1.Pod) (monitoringResources, monitoringResources, bool, bool) {
127
+ var regularRequest, regularLimit, sidecarRequest, sidecarLimit, initRequest, initLimit monitoringResources
128
+ cpuComplete, memoryComplete := true, true
129
+ addCoverage := func(container corev1.Container) {
130
+ cpuValue, cpu := container.Resources.Limits[corev1.ResourceCPU]
131
+ memoryValue, memory := container.Resources.Limits[corev1.ResourceMemory]
132
+ cpu = cpu && !cpuValue.IsZero()
133
+ memory = memory && !memoryValue.IsZero()
134
+ cpuComplete = cpuComplete && cpu
135
+ memoryComplete = memoryComplete && memory
136
+ }
137
+ for _, container := range pod.Spec.Containers {
138
+ regularRequest = regularRequest.add(monitoringResourcesFromList(container.Resources.Requests))
139
+ regularLimit = regularLimit.add(monitoringResourcesFromList(container.Resources.Limits))
140
+ addCoverage(container)
141
+ }
142
+ for _, container := range pod.Spec.InitContainers {
143
+ requests := monitoringResourcesFromList(container.Resources.Requests)
144
+ limits := monitoringResourcesFromList(container.Resources.Limits)
145
+ addCoverage(container)
146
+ if container.RestartPolicy != nil && *container.RestartPolicy == corev1.ContainerRestartPolicyAlways {
147
+ sidecarRequest = sidecarRequest.add(requests)
148
+ sidecarLimit = sidecarLimit.add(limits)
149
+ requests = sidecarRequest
150
+ limits = sidecarLimit
151
+ } else {
152
+ requests = requests.add(sidecarRequest)
153
+ limits = limits.add(sidecarLimit)
154
+ }
155
+ initRequest = initRequest.max(requests)
156
+ initLimit = initLimit.max(limits)
157
+ }
158
+ requests := regularRequest.add(sidecarRequest).max(initRequest)
159
+ limits := regularLimit.add(sidecarLimit).max(initLimit)
160
+ if pod.Spec.Resources != nil {
161
+ requests = monitoringPodLevelOverride(requests, pod.Spec.Resources.Requests)
162
+ limits = monitoringPodLevelOverride(limits, pod.Spec.Resources.Limits)
163
+ if value, ok := pod.Spec.Resources.Limits[corev1.ResourceCPU]; ok {
164
+ cpuComplete = !value.IsZero()
165
+ }
166
+ if value, ok := pod.Spec.Resources.Limits[corev1.ResourceMemory]; ok {
167
+ memoryComplete = !value.IsZero()
168
+ }
169
+ }
170
+ overhead := monitoringResourcesFromList(pod.Spec.Overhead)
171
+ requests = requests.add(overhead)
172
+ if limits.cpu > 0 {
173
+ limits.cpu += overhead.cpu
174
+ }
175
+ if limits.memory > 0 {
176
+ limits.memory += overhead.memory
177
+ }
178
+ return requests, limits, cpuComplete, memoryComplete
179
+ }
180
+ func monitoringPodLevelOverride(current monitoringResources, values corev1.ResourceList) monitoringResources {
181
+ if value, ok := values[corev1.ResourceCPU]; ok {
182
+ current.cpu = float64(value.MilliValue()) / 1000
183
+ }
184
+ if value, ok := values[corev1.ResourceMemory]; ok {
185
+ current.memory = float64(value.Value())
186
+ }
187
+ return current
188
+ }
189
+ func monitoringQuantity(values corev1.ResourceList) cr.ResourceMonitoringQuantity {
190
+ result := cr.ResourceMonitoringQuantity{}
191
+ if cpu, ok := values[corev1.ResourceCPU]; ok {
192
+ result.CPUCores = number(float64(cpu.MilliValue()) / 1000)
193
+ }
194
+ if memory, ok := values[corev1.ResourceMemory]; ok {
195
+ result.MemoryBytes = number(float64(memory.Value()))
196
+ }
197
+ if pods, ok := values[corev1.ResourcePods]; ok {
198
+ result.PodSlots = number(float64(pods.Value()))
199
+ }
200
+ return result
201
+ }
202
+ func monitoringNodeFact(clusterID string, node corev1.Node, aggregate monitoringPodAggregate, complete bool, now string) cr.ResourceMonitoringNode {
203
+ fact := cr.ResourceMonitoringNode{NodeRef: cr.ResourceRef{ClusterID: clusterID, APIVersion: "v1", Kind: "Node", Name: node.Name, ScopeMode: cr.ResourceScopeModeCluster, UID: string(node.UID), ResourceVersion: node.ResourceVersion}, OS: node.Status.NodeInfo.OperatingSystem, CreatedAt: node.CreationTimestamp.UTC().Format(time.RFC3339), Ready: "Unknown", MemoryPressure: "Unknown", DiskPressure: "Unknown", PIDPressure: "Unknown", Cordoned: node.Spec.Unschedulable, Capacity: monitoringQuantity(node.Status.Capacity), Allocatable: monitoringQuantity(node.Status.Allocatable), Quality: "valid", ObservedAt: now}
204
+ for _, address := range node.Status.Addresses {
205
+ if address.Type == corev1.NodeInternalIP {
206
+ fact.InternalIP = address.Address
207
+ break
208
+ }
209
+ }
210
+ for _, condition := range node.Status.Conditions {
211
+ switch condition.Type {
212
+ case corev1.NodeReady:
213
+ fact.Ready = string(condition.Status)
214
+ case corev1.NodeMemoryPressure:
215
+ fact.MemoryPressure = string(condition.Status)
216
+ case corev1.NodeDiskPressure:
217
+ fact.DiskPressure = string(condition.Status)
218
+ case corev1.NodePIDPressure:
219
+ fact.PIDPressure = string(condition.Status)
220
+ }
221
+ }
222
+ if !complete {
223
+ fact.Quality = "partial"
224
+ return fact
225
+ }
226
+ fact.Requests = cr.ResourceMonitoringQuantity{CPUCores: number(aggregate.cpuRequest), MemoryBytes: number(aggregate.memoryRequest)}
227
+ fact.CPULimitsComplete = !aggregate.initialized || aggregate.cpuComplete
228
+ fact.MemoryLimitsComplete = !aggregate.initialized || aggregate.memoryComplete
229
+ if fact.CPULimitsComplete {
230
+ fact.Limits.CPUCores = number(aggregate.cpuLimit)
231
+ }
232
+ if fact.MemoryLimitsComplete {
233
+ fact.Limits.MemoryBytes = number(aggregate.memoryLimit)
234
+ }
235
+ fact.BoundPods = number(aggregate.count)
236
+ fact.PendingPods = number(aggregate.pending)
237
+ if fact.Allocatable.PodSlots != nil {
238
+ fact.RemainingPodSlots = number(math.Max(0, *fact.Allocatable.PodSlots-aggregate.count))
239
+ fact.OverflowPodSlots = number(math.Max(0, aggregate.count-*fact.Allocatable.PodSlots))
240
+ }
241
+ return fact
242
+ }
243
+ func number(value float64) *float64 { return &value }
@@ -0,0 +1,88 @@
1
+ package runtime
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "testing"
9
+
10
+ corev1 "k8s.io/api/core/v1"
11
+ "k8s.io/apimachinery/pkg/api/resource"
12
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13
+ "k8s.io/apimachinery/pkg/types"
14
+ "k8s.io/client-go/kubernetes"
15
+ "k8s.io/client-go/rest"
16
+ )
17
+
18
+ func monitoringResourceList(cpu, memory string) corev1.ResourceList {
19
+ return corev1.ResourceList{corev1.ResourceCPU: resource.MustParse(cpu), corev1.ResourceMemory: resource.MustParse(memory)}
20
+ }
21
+ func TestMonitoringFootprintHandlesSidecarsInitAndOverhead(t *testing.T) {
22
+ always := corev1.ContainerRestartPolicyAlways
23
+ container := func(name, cpu string) corev1.Container {
24
+ return corev1.Container{Name: name, Resources: corev1.ResourceRequirements{Requests: monitoringResourceList(cpu, "1Mi"), Limits: monitoringResourceList(cpu, "1Mi")}}
25
+ }
26
+ sidecar := container("sidecar", "1")
27
+ sidecar.RestartPolicy = &always
28
+ pod := corev1.Pod{Spec: corev1.PodSpec{Containers: []corev1.Container{container("app", "2")}, InitContainers: []corev1.Container{sidecar, container("init", "4")}, Overhead: monitoringResourceList("100m", "1Mi")}}
29
+ requests, limits, cpuComplete, memoryComplete := effectiveMonitoringPodResources(pod)
30
+ if requests.cpu != 5.1 || limits.cpu != 5.1 || !cpuComplete || !memoryComplete {
31
+ t.Fatalf("effective footprint requests=%+v limits=%+v complete=%v/%v", requests, limits, cpuComplete, memoryComplete)
32
+ }
33
+ pod.Spec.Containers[0].Resources.Limits = monitoringResourceList("0", "0")
34
+ _, _, cpuComplete, memoryComplete = effectiveMonitoringPodResources(pod)
35
+ if cpuComplete || memoryComplete {
36
+ t.Fatal("zero limits treated as an explicit finite limit")
37
+ }
38
+ pod.Spec.Resources = &corev1.ResourceRequirements{Limits: monitoringResourceList("0", "0")}
39
+ _, _, cpuComplete, memoryComplete = effectiveMonitoringPodResources(pod)
40
+ if cpuComplete || memoryComplete {
41
+ t.Fatal("Pod-level zero limits treated as complete finite limits")
42
+ }
43
+ }
44
+ func TestMonitoringInventoryCountsOnlyBoundNonterminalPods(t *testing.T) {
45
+ nodes := corev1.NodeList{Items: []corev1.Node{{ObjectMeta: metav1.ObjectMeta{Name: "node", UID: types.UID("node-uid")}, Status: corev1.NodeStatus{Capacity: monitoringResourceList("4", "8Gi"), Allocatable: corev1.ResourceList{corev1.ResourcePods: resource.MustParse("1")}}}}}
46
+ deleting := metav1.Now()
47
+ pods := corev1.PodList{Items: []corev1.Pod{
48
+ {ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &deleting}, Spec: corev1.PodSpec{NodeName: "node"}, Status: corev1.PodStatus{Phase: corev1.PodRunning}},
49
+ {Spec: corev1.PodSpec{NodeName: "node"}, Status: corev1.PodStatus{Phase: corev1.PodPending}},
50
+ {Spec: corev1.PodSpec{NodeName: "node"}, Status: corev1.PodStatus{Phase: corev1.PodSucceeded}},
51
+ {Spec: corev1.PodSpec{NodeName: "node"}, Status: corev1.PodStatus{Phase: corev1.PodFailed}},
52
+ {Status: corev1.PodStatus{Phase: corev1.PodPending}},
53
+ }}
54
+ forbidden := false
55
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56
+ switch r.URL.Path {
57
+ case "/api/v1/nodes":
58
+ _ = json.NewEncoder(w).Encode(nodes)
59
+ case "/api/v1/pods":
60
+ if forbidden {
61
+ http.Error(w, "Forbidden", http.StatusForbidden)
62
+ return
63
+ }
64
+ _ = json.NewEncoder(w).Encode(pods)
65
+ default:
66
+ t.Errorf("unexpected path %s", r.URL.Path)
67
+ http.NotFound(w, r)
68
+ }
69
+ }))
70
+ defer server.Close()
71
+ client, _ := kubernetes.NewForConfig(&rest.Config{Host: server.URL})
72
+ inventory, err := ReadResourceMonitoringInventory(context.Background(), client.CoreV1().RESTClient(), "c")
73
+ if err != nil {
74
+ t.Fatal(err)
75
+ }
76
+ fact := inventory.Nodes[0]
77
+ if fact.BoundPods == nil || *fact.BoundPods != 2 || *fact.PendingPods != 1 || *fact.RemainingPodSlots != 0 || *fact.OverflowPodSlots != 1 || *inventory.UnscheduledPendingPods != 1 {
78
+ t.Fatalf("wrong capacity: %+v", fact)
79
+ }
80
+ forbidden = true
81
+ inventory, err = ReadResourceMonitoringInventory(context.Background(), client.CoreV1().RESTClient(), "c")
82
+ if err != nil {
83
+ t.Fatal(err)
84
+ }
85
+ if inventory.Quality != "partial" || inventory.Nodes[0].BoundPods != nil || inventory.Nodes[0].Requests.CPUCores != nil {
86
+ t.Fatalf("partial Pod read invented zero capacity facts: %+v", inventory)
87
+ }
88
+ }