diff --git a/cli/verifier/cpu_count_valid.go b/cli/verifier/cpu_count_valid.go new file mode 100644 index 00000000000..8b9806727b9 --- /dev/null +++ b/cli/verifier/cpu_count_valid.go @@ -0,0 +1,37 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package verifier + +import ( + "errors" + "fmt" + "strings" + + "github.com/edgelesssys/contrast/internal/kuberesource" + applycorev1 "k8s.io/client-go/applyconfigurations/core/v1" +) + +// CPUCountValid verifies that all workloads have a valid vCPU count. +type CPUCountValid struct{} + +// Verify checks that pods do not require more than the currently supported 8 vCPUs. +func (v *CPUCountValid) Verify(toVerify any) error { + var findings error + + kuberesource.MapPodSpec(toVerify, func(spec *applycorev1.PodSpecApplyConfiguration) *applycorev1.PodSpecApplyConfiguration { + if spec == nil || spec.RuntimeClassName == nil || !strings.HasPrefix(*spec.RuntimeClassName, "contrast-cc") { + return spec + } + + cpuCount := kuberesource.GetPodCPUCount(spec) + if cpuCount < 1 || cpuCount > 8 { + // TODO(charludo): find way to add pod name to error message + findings = errors.Join(findings, fmt.Errorf("pod failed verification: currently only 0-7 additional vCPUs are supported (1-8 vCPUs in total), but %d were requested", cpuCount)) + } + + return spec + }) + + return findings +} diff --git a/cli/verifier/cpu_count_valid_test.go b/cli/verifier/cpu_count_valid_test.go new file mode 100644 index 00000000000..ea065aed67d --- /dev/null +++ b/cli/verifier/cpu_count_valid_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package verifier_test + +import ( + "testing" + + "github.com/edgelesssys/contrast/cli/verifier" + "github.com/edgelesssys/contrast/internal/kuberesource" + "github.com/stretchr/testify/require" +) + +const deploymentWithoutExplicitCPUCount = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 1 + template: + spec: + runtimeClassName: contrast-cc + containers: + - name: app + resources: + limits: + cpu: 2000m +` + +const deploymentWithValidCPUCount = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 1 + template: + spec: + runtimeClassName: contrast-cc + containers: + - name: app + resources: + limits: + cpu: 2000m +` + +const deploymentWithTooManyCPUs = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 1 + template: + metadata: + name: web-pod + namespace: test + spec: + runtimeClassName: contrast-cc + containers: + - name: app + resources: + limits: + cpu: "8" # invalid because of always-added CPU +` + +func TestCPUCountValid(t *testing.T) { + testCases := map[string]struct { + k8sYaml string + wantErr bool + }{ + "no explicit cpu count": { + k8sYaml: deploymentWithoutExplicitCPUCount, + }, + "valid cpu count": { + k8sYaml: deploymentWithValidCPUCount, + }, + "too many cpus": { + k8sYaml: deploymentWithTooManyCPUs, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + toVerifySlice, err := kuberesource.UnmarshalApplyConfigurations([]byte(tc.k8sYaml)) + require.NoError(err) + + verifier := verifier.CPUCountValid{} + + for _, toVerify := range toVerifySlice { + err := verifier.Verify(toVerify) + if tc.wantErr { + require.Error(err) + } else { + require.NoError(err) + } + } + }) + } +} diff --git a/cli/verifier/verifiers.go b/cli/verifier/verifiers.go index 51e1e5fcc54..41bda8e37f3 100644 --- a/cli/verifier/verifiers.go +++ b/cli/verifier/verifiers.go @@ -18,6 +18,7 @@ func AllVerifiersBeforeGenerate(cmd *cobra.Command) []Verifier { &ImageRefValid{ExcludeContrastImages: true}, &ServiceMeshEgressNotEmpty{}, &RuntimeClassesExist{Command: cmd}, + &CPUCountValid{}, } } diff --git a/internal/kuberesource/parts.go b/internal/kuberesource/parts.go index 0d61d7b270b..a91dc3ac428 100644 --- a/internal/kuberesource/parts.go +++ b/internal/kuberesource/parts.go @@ -523,3 +523,38 @@ func getPorts(podSpec *applycorev1.PodSpecApplyConfiguration) (ports []applycore } return ports } + +// GetCPUCount returns the CPU count in milliCPUs from resources. +func GetCPUCount(resources *applycorev1.ResourceRequirementsApplyConfiguration) int64 { + if resources != nil && resources.Limits != nil { + return resources.Limits.Cpu().MilliValue() + } + return 0 +} + +// GetPodCPUCount computes the total CPU count (in whole CPUs) required by a pod, adding hypervisor overhead. +func GetPodCPUCount(spec *applycorev1.PodSpecApplyConfiguration) uint64 { + var regularContainersCPU int64 + for _, container := range spec.Containers { + regularContainersCPU += GetCPUCount(container.Resources) + } + var maxInitContainerCPU int64 + for _, container := range spec.InitContainers { + cpuCount := GetCPUCount(container.Resources) + // Sidecar containers remain running alongside the actual application, consuming CPU resources + if container.RestartPolicy != nil && *container.RestartPolicy == corev1.ContainerRestartPolicyAlways { + regularContainersCPU += cpuCount + } else { + if cpuCount > maxInitContainerCPU { + maxInitContainerCPU = cpuCount + } + } + } + podLevelCPU := GetCPUCount(spec.Resources) + + // Convert milliCPUs to number of CPUs (rounding up), and add 1 for hypervisor overhead. + // Pod resources are added to the maximum of regular containers and init containers. + totalMilliCPUs := max(regularContainersCPU, maxInitContainerCPU) + podLevelCPU + totalCPUs := (totalMilliCPUs+999)/1000 + 1 + return uint64(totalCPUs) +}