diff --git a/go.mod b/go.mod index 540a2f1..5af56a0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.22.0 toolchain go1.23.0 require ( + github.com/openshift/api v0.0.0-20240911192208-3e5de946111c + github.com/openshift/client-go v0.0.0-20240906181530-b2f7c4ab0984 github.com/rs/zerolog v1.33.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index 45fd338..e1f1d5a 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,10 @@ github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= +github.com/openshift/api v0.0.0-20240911192208-3e5de946111c h1:46hH/7XmmaPmeJWTyrzh8TRB6I7TCwzJdxxWeyK8blM= +github.com/openshift/api v0.0.0-20240911192208-3e5de946111c/go.mod h1:OOh6Qopf21pSzqNVCB5gomomBXb8o5sGKZxG2KNpaXM= +github.com/openshift/client-go v0.0.0-20240906181530-b2f7c4ab0984 h1:4OVV/fm6ea+51rZbA/52SFbHdjlzjCKK6OCE7Xtn834= +github.com/openshift/client-go v0.0.0-20240906181530-b2f7c4ab0984/go.mod h1:K+5rEJpGf5LpcwdNtkGsvV3u8wU7m3oHzcVZzuGTRZ4= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/internal/calc/calc.go b/internal/calc/calc.go index a90d5bb..95aeb17 100644 --- a/internal/calc/calc.go +++ b/internal/calc/calc.go @@ -4,13 +4,15 @@ package calc import ( "errors" "fmt" - + openshiftAppsV1 "github.com/openshift/api/apps/v1" + openshiftScheme "github.com/openshift/client-go/apps/clientset/versioned/scheme" "github.com/rs/zerolog/log" appsv1 "k8s.io/api/apps/v1" batchV1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/client-go/kubernetes/scheme" ) @@ -101,7 +103,13 @@ func ResourceQuotaFromYaml(yamlData []byte) (*ResourceUsage, error) { var kind string - object, gvk, err := scheme.Codecs.UniversalDeserializer().Decode(yamlData, nil, nil) + combinedScheme := runtime.NewScheme() + _ = scheme.AddToScheme(combinedScheme) + _ = openshiftScheme.AddToScheme(combinedScheme) + codecs := serializer.NewCodecFactory(combinedScheme) + decoder := codecs.UniversalDeserializer() + + object, gvk, err := decoder.Decode(yamlData, nil, nil) if err != nil { // when the kind is not found, I just warn and skip @@ -110,7 +118,7 @@ func ResourceQuotaFromYaml(yamlData []byte) (*ResourceUsage, error) { unknown := runtime.Unknown{Raw: yamlData} - if _, gvk1, err := scheme.Codecs.UniversalDeserializer().Decode(yamlData, nil, &unknown); err == nil { + if _, gvk1, err := decoder.Decode(yamlData, nil, &unknown); err == nil { kind = gvk1.Kind version = gvk1.Version } @@ -123,6 +131,16 @@ func ResourceQuotaFromYaml(yamlData []byte) (*ResourceUsage, error) { } switch obj := object.(type) { + case *openshiftAppsV1.DeploymentConfig: + usage, err := deploymentConfig(*obj) + if err != nil { + return nil, CalculationError{ + Version: gvk.Version, + Kind: gvk.Kind, + err: err, + } + } + return usage, nil case *appsv1.Deployment: usage, err := deployment(*obj) if err != nil { diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go index 094e7a2..e4a22df 100644 --- a/internal/calc/calc_test.go +++ b/internal/calc/calc_test.go @@ -20,6 +20,52 @@ spec: kind: Service name: coffee-svc ` +var normalDeploymentConfig = `--- +apiVersion: apps.openshift.io/v1 +kind: DeploymentConfig +metadata: + labels: + app: normal + name: normal +spec: + progressDeadlineSeconds: 600 + replicas: 10 + revisionHistoryLimit: 10 + selector: + app: normal + strategy: + rollingParams: + maxSurge: 25% + maxUnavailable: 25% + type: Rolling + template: + metadata: + creationTimestamp: null + labels: + app: normal + spec: + containers: + - image: myapp:v1.0.7 + command: + - sleep + - infinity + imagePullPolicy: IfNotPresent + name: normal + resources: + limits: + cpu: '500m' + memory: 4Gi + requests: + cpu: '250m' + memory: 2Gi + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30` + var normalDeployment = `--- apiVersion: apps/v1 kind: Deployment diff --git a/internal/calc/deploymentConfig.go b/internal/calc/deploymentConfig.go new file mode 100644 index 0000000..a9ffcf9 --- /dev/null +++ b/internal/calc/deploymentConfig.go @@ -0,0 +1,127 @@ +package calc + +import ( + "fmt" + openshiftAppsV1 "github.com/openshift/api/apps/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/intstr" + "math" +) + +// calculates the cpu/memory resources a single deployment needs. Replicas and the deployment +// strategy are taken into account. +func deploymentConfig(deploymentConfig openshiftAppsV1.DeploymentConfig) (*ResourceUsage, error) { + var ( + resourceOverhead float64 // max overhead compute resources (percent) + podOverhead int32 // max overhead pods during deploymentConfig + ) + + replicas := deploymentConfig.Spec.Replicas + strategy := deploymentConfig.Spec.Strategy + + if replicas == 0 { + return &ResourceUsage{ + CPUMin: new(resource.Quantity), + CPUMax: new(resource.Quantity), + MemoryMin: new(resource.Quantity), + MemoryMax: new(resource.Quantity), + Details: Details{ + Version: deploymentConfig.APIVersion, + Kind: deploymentConfig.Kind, + Name: deploymentConfig.Name, + Replicas: replicas, + MaxReplicas: replicas, + Strategy: string(strategy.Type), + }, + }, nil + } + // TODO lookup default values, these are copied from kubernetes Deployment + switch strategy.Type { + case openshiftAppsV1.DeploymentStrategyTypeRecreate: + // no overhead on recreate + resourceOverhead = 1 + podOverhead = 0 + case "": + // Rolling is the default and can be an empty string. If so, set the defaults + // (https://pkg.go.dev/k8s.io/api/apps/v1?tab=doc#RollingUpdateDeployment) and continue calculation. + defaults := intstr.FromString("25%") + strategy = openshiftAppsV1.DeploymentStrategy{ + Type: openshiftAppsV1.DeploymentStrategyTypeRolling, + RollingParams: &openshiftAppsV1.RollingDeploymentStrategyParams{ + MaxUnavailable: &defaults, + MaxSurge: &defaults, + }, + } + + fallthrough + case openshiftAppsV1.DeploymentStrategyTypeRolling: + // Documentation: https://pkg.go.dev/k8s.io/api/apps/v1?tab=doc#RollingUpdateDeployment + // all default values are set as stated in the docs + var ( + maxUnavailableValue intstr.IntOrString + maxSurgeValue intstr.IntOrString + ) + + // can be nil, if so apply default value + if strategy.RollingParams == nil { + maxUnavailableValue = intstr.FromString("25%") + maxSurgeValue = intstr.FromString("25%") + } else { + maxUnavailableValue = *strategy.RollingParams.MaxUnavailable + maxSurgeValue = *strategy.RollingParams.MaxSurge + } + + // docs say, that the absolute number is calculated by rounding down. + maxUnavailable, err := intstr.GetScaledValueFromIntOrPercent(&maxUnavailableValue, int(replicas), false) + if err != nil { + return nil, err + } + + // docs say, absolute number is calculated by rounding up. + maxSurge, err := intstr.GetScaledValueFromIntOrPercent(&maxSurgeValue, int(replicas), true) + if err != nil { + return nil, err + } + + // podOverhead is the number of pods which can run more during a deployment + podOverheadInt := maxSurge - maxUnavailable + if podOverheadInt > math.MaxInt32 || podOverheadInt < math.MinInt32 { + return nil, fmt.Errorf("deploymentConfig: %s maxSurge - maxUnavailable (%d-%d) was out of bounds for int32", deploymentConfig.Name, maxSurge, maxUnavailable) + } + podOverhead = int32(podOverheadInt) //nolint:gosec,wsl // gosec doesn't understand that the int conversion is already guarded, wsl wants to group the assignment with the next block + + resourceOverhead = (float64(podOverhead) / float64(replicas)) + 1 + default: + return nil, fmt.Errorf("deploymentConfig: %s deploymentConfig strategy %q is unknown", deploymentConfig.Name, strategy.Type) + } + + cpuMin, cpuMax, memoryMin, memoryMax := podResources(&deploymentConfig.Spec.Template.Spec) + strategyResources := &deploymentConfig.Spec.Strategy.Resources + + memMin := float64(memoryMin.Value())*float64(replicas)*resourceOverhead + float64(strategyResources.Requests.Memory().Value()) + memoryMin.Set(int64(math.Round(memMin))) + + memMax := float64(memoryMax.Value())*float64(replicas)*resourceOverhead + float64(strategyResources.Limits.Memory().Value()) + memoryMax.Set(int64(math.Round(memMax))) + + cpuMin.SetMilli(int64(math.Round(float64(cpuMin.MilliValue())*float64(replicas)*resourceOverhead)) + strategyResources.Requests.Cpu().MilliValue()) + + cpuMax.SetMilli(int64(math.Round(float64(cpuMax.MilliValue())*float64(replicas)*resourceOverhead)) + strategyResources.Limits.Cpu().MilliValue()) + + resourceUsage := ResourceUsage{ + CPUMin: cpuMin, + CPUMax: cpuMax, + MemoryMin: memoryMin, + MemoryMax: memoryMax, + Details: Details{ + Version: deploymentConfig.APIVersion, + Kind: deploymentConfig.Kind, + Name: deploymentConfig.Name, + Replicas: replicas, + Strategy: string(strategy.Type), + MaxReplicas: replicas + podOverhead, + }, + } + + return &resourceUsage, nil +} diff --git a/internal/calc/deploymentConfig_test.go b/internal/calc/deploymentConfig_test.go new file mode 100644 index 0000000..97797a6 --- /dev/null +++ b/internal/calc/deploymentConfig_test.go @@ -0,0 +1,54 @@ +package calc + +import ( + "testing" + + openshiftAppsV1 "github.com/openshift/api/apps/v1" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestDeploymentConfig(t *testing.T) { + var tests = []struct { + name string + deploymentConfig string + cpuMin resource.Quantity + cpuMax resource.Quantity + memoryMin resource.Quantity + memoryMax resource.Quantity + replicas int32 + maxReplicas int32 + strategy openshiftAppsV1.DeploymentStrategyType + }{ + { + name: "normal deploymentConfig", + deploymentConfig: normalDeploymentConfig, + cpuMin: resource.MustParse("2750m"), + cpuMax: resource.MustParse("5500m"), + memoryMin: resource.MustParse("22Gi"), + memoryMax: resource.MustParse("44Gi"), + replicas: 10, + maxReplicas: 11, + strategy: openshiftAppsV1.DeploymentStrategyTypeRolling, + }, + //TODO add more tests + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := require.New(t) + + usage, err := ResourceQuotaFromYaml([]byte(test.deploymentConfig)) + r.NoError(err) + r.NotEmpty(usage) + + AssertEqualQuantities(r, test.cpuMin, *usage.CPUMin, "cpu request value") + AssertEqualQuantities(r, test.cpuMax, *usage.CPUMax, "cpu limit value") + AssertEqualQuantities(r, test.memoryMin, *usage.MemoryMin, "memory request value") + AssertEqualQuantities(r, test.memoryMax, *usage.MemoryMax, "memory limit value") + r.Equal(test.replicas, usage.Details.Replicas, "replicas") + r.Equal(string(test.strategy), usage.Details.Strategy, "strategy") + r.Equal(test.maxReplicas, usage.Details.MaxReplicas, "maxReplicas") + }) + } +}