diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply.go b/cmd/atecontroller/internal/controllers/workerpool_apply.go index 8a52a93b9..b78e1d466 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply.go @@ -16,11 +16,13 @@ package controllers import ( corev1 "k8s.io/api/core/v1" + apiresource "k8s.io/apimachinery/pkg/api/resource" appsv1ac "k8s.io/client-go/applyconfigurations/apps/v1" corev1ac "k8s.io/client-go/applyconfigurations/core/v1" metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" + "github.com/agent-substrate/substrate/internal/sizing" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -163,6 +165,12 @@ func buildDeploymentApplyConfig(wp *atev1alpha1.WorkerPool, otel ateomOTelSettin func ateomContainerEnv(otel ateomOTelSettings) []*corev1ac.EnvVarApplyConfiguration { envs := []*corev1ac.EnvVarApplyConfiguration{ fieldRefEnv("POD_UID", "metadata.uid"), + // Right-sizing: ateom reads these to size the sandbox (gVisor cgroup leaf / + // micro-VM VmConfig) to the worker pod's own limits; see internal/sizing. + // resourceFieldRef falls back to node allocatable when no limit is set, so + // WorkerPool templates should set CPU/memory limits. + resourceFieldRefEnv(sizing.EnvCPUMilli, "ateom", "limits.cpu", "1m"), + resourceFieldRefEnv(sizing.EnvMemoryBytes, "ateom", "limits.memory", "1"), } if otel.Endpoint == "" { return envs @@ -194,6 +202,19 @@ func fieldRefEnv(name, fieldPath string) *corev1ac.EnvVarApplyConfiguration { WithFieldPath(fieldPath))) } +// resourceFieldRefEnv builds a downward-API env var sourced from a container's +// resource field (e.g. limits.cpu) with the given divisor, letting Kubernetes do +// the unit conversion and round-up. +func resourceFieldRefEnv(name, container, resource, divisor string) *corev1ac.EnvVarApplyConfiguration { + return corev1ac.EnvVar(). + WithName(name). + WithValueFrom(corev1ac.EnvVarSource(). + WithResourceFieldRef(corev1ac.ResourceFieldSelector(). + WithContainerName(container). + WithResource(resource). + WithDivisor(apiresource.MustParse(divisor)))) +} + // ateomGvisorCapabilities is the capability set an unprivileged gVisor worker // needs. runsc's gofer maps a full-range identity in a user namespace // (SETUID/SETGID/SETPCAP/SETFCAP), the sandbox pivots root and traces the diff --git a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go index 45dc9ca49..29c86e1b3 100644 --- a/cmd/atecontroller/internal/controllers/workerpool_apply_test.go +++ b/cmd/atecontroller/internal/controllers/workerpool_apply_test.go @@ -26,6 +26,7 @@ import ( metav1ac "k8s.io/client-go/applyconfigurations/meta/v1" "github.com/agent-substrate/substrate/internal/ateompath" + "github.com/agent-substrate/substrate/internal/sizing" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" ) @@ -545,11 +546,27 @@ func expectedDeploymentApplyConfig(mutatePodSpec func(*corev1ac.PodSpecApplyConf WithAdd(ateomGvisorCapabilities...)). WithAppArmorProfile(corev1ac.AppArmorProfile(). WithType(corev1.AppArmorProfileTypeUnconfined))). - WithEnv(corev1ac.EnvVar(). - WithName("POD_UID"). - WithValueFrom(corev1ac.EnvVarSource(). - WithFieldRef(corev1ac.ObjectFieldSelector(). - WithFieldPath("metadata.uid")))). + WithEnv( + corev1ac.EnvVar(). + WithName("POD_UID"). + WithValueFrom(corev1ac.EnvVarSource(). + WithFieldRef(corev1ac.ObjectFieldSelector(). + WithFieldPath("metadata.uid"))), + corev1ac.EnvVar(). + WithName(sizing.EnvCPUMilli). + WithValueFrom(corev1ac.EnvVarSource(). + WithResourceFieldRef(corev1ac.ResourceFieldSelector(). + WithContainerName("ateom"). + WithResource("limits.cpu"). + WithDivisor(resource.MustParse("1m")))), + corev1ac.EnvVar(). + WithName(sizing.EnvMemoryBytes). + WithValueFrom(corev1ac.EnvVarSource(). + WithResourceFieldRef(corev1ac.ResourceFieldSelector(). + WithContainerName("ateom"). + WithResource("limits.memory"). + WithDivisor(resource.MustParse("1")))), + ). WithVolumeMounts( corev1ac.VolumeMount(). WithName("run-ateom"). diff --git a/cmd/ateom-gvisor/runsc.go b/cmd/ateom-gvisor/runsc.go index 003bd861a..80fc2b2b5 100644 --- a/cmd/ateom-gvisor/runsc.go +++ b/cmd/ateom-gvisor/runsc.go @@ -29,6 +29,7 @@ import ( specs "github.com/opencontainers/runtime-spec/specs-go" "github.com/agent-substrate/substrate/internal/ateompath" + "github.com/agent-substrate/substrate/internal/sizing" ) type runsc struct { @@ -57,10 +58,13 @@ func (r *runsc) ensureContainerCgroupsPath(containerName string) error { if spec.Linux == nil { spec.Linux = &specs.Linux{} } - if spec.Linux.CgroupsPath != "" { - return nil + if spec.Linux.CgroupsPath == "" { + spec.Linux.CgroupsPath = "/" + containerName } - spec.Linux.CgroupsPath = "/" + containerName + // Right-size the per-container cgroup leaf to the worker pod's limits; runsc + // applies spec.Linux.Resources when it creates the leaf. Shared with the + // micro-VM runtime via internal/sizing. + sizing.Discover().ApplyToOCISpec(&spec) out, err := json.MarshalIndent(&spec, "", " ") if err != nil { return fmt.Errorf("marshaling %q: %w", specPath, err) @@ -90,6 +94,10 @@ func (r *runsc) cmdCreate(ctx context.Context, out io.Writer, containerName stri // "-log-packets", // "-strace", "-root", ateompath.RunSCStateDir(r.actorUID), + // Provision the sentry's vCPU count from the cgroup CPU quota written by + // sizing.ApplyToOCISpec, so the sandbox is sized to the pod's limit (runsc + // otherwise sizes to all host CPUs). Global flag: before the subcommand. + "--cpu-num-from-quota", "create", "-bundle", ateompath.OCIBundlePath(r.actorUID, containerName), "-pid-file", ateompath.PIDFilePath(r.actorUID, containerName), @@ -237,6 +245,8 @@ func (r *runsc) cmdRestore(ctx context.Context, out io.Writer, containerName, ch // "-log-packets", // "-strace", "-root", ateompath.RunSCStateDir(r.actorUID), + // Match cmdCreate: size the restored sentry from the cgroup CPU quota. + "--cpu-num-from-quota", "restore", "-bundle", ateompath.OCIBundlePath(r.actorUID, containerName), "-image-path", checkpointPath, diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 24c340794..d08b7efce 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -53,11 +53,12 @@ import ( ) var ( - podUID = flag.String("pod-uid", "", "The UID of the current pod") - chBinary = flag.String("cloud-hypervisor-binary", "cloud-hypervisor", "Path to the cloud-hypervisor binary (used to relaunch on restore).") - kataConfig = flag.String("kata-config", "", "Path to a kata configuration.toml (passed to the shim as KATA_CONF_FILE). Empty uses kata's default. atelet generates one pointing at runtime-fetched assets.") - kataDebug = flag.Bool("kata-debug", false, "Verbose kata-agent debugging: raise the guest agent log level and forward the guest console (incl. agent logs) into the pod logs.") - showVersion = flag.Bool("version", false, "Print version and exit.") + podUID = flag.String("pod-uid", "", "The UID of the current pod") + chBinary = flag.String("cloud-hypervisor-binary", "cloud-hypervisor", "Path to the cloud-hypervisor binary (used to relaunch on restore).") + kataConfig = flag.String("kata-config", "", "Path to a kata configuration.toml (passed to the shim as KATA_CONF_FILE). Empty uses kata's default. atelet generates one pointing at runtime-fetched assets.") + kataDebug = flag.Bool("kata-debug", false, "Verbose kata-agent debugging: raise the guest agent log level and forward the guest console (incl. agent logs) into the pod logs.") + vmmMemReserve = flag.Int("vmm-mem-reserve-mib", vmmMemReserveMiB, "Guest RAM (MiB) held back from the pod's memory limit for the cloud-hypervisor VMM + virtiofsd, which run as host processes in the pod cgroup alongside the guest RAM. Prevents the pod OOMing when the VM is sized to the pod's memory limit.") + showVersion = flag.Bool("version", false, "Print version and exit.") atunnelListenAddress = flag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS") atunnelCredentialBundle = flag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "PEM credential bundle for actor ingress HTTPS") @@ -208,7 +209,7 @@ func do(ctx context.Context) error { grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.UnaryInterceptor(ateinterceptors.InternalServerUnaryInterceptor), ) - ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, interiorNetNS, actorLogger, atunnelServer, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelEgressTrustBundle)) + ateompb.RegisterAteomServer(svr, NewService(*podUID, *chBinary, *kataConfig, *kataDebug, *vmmMemReserve, interiorNetNS, actorLogger, atunnelServer, atunnelEgress, atunnelEgressPort, *atunnelCredentialBundle, *atunnelEgressTrustBundle)) reflection.Register(svr) slog.InfoContext(ctx, "ateom-microvm serving", slog.String("socket", sockPath)) @@ -259,6 +260,11 @@ type AteomService struct { kataConfig string kataDebug bool + // memReserveMiB is guest RAM (MiB) held back from the pod's memory limit for + // the cloud-hypervisor VMM + virtiofsd (host processes sharing the pod cgroup + // with the guest RAM). Set from --vmm-mem-reserve-mib. + memReserveMiB int + // interiorNetNS hosts the per-activation actor veth peer (see net.go); // kata is pointed at it. interiorNetNS netns.NsHandle @@ -284,12 +290,13 @@ type AteomService struct { var _ ateompb.AteomServer = (*AteomService)(nil) // NewService creates a new AteomService. -func NewService(podUID, chBinary, kataConfig string, kataDebug bool, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelServer *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, egressTrustBundle string) *AteomService { +func NewService(podUID, chBinary, kataConfig string, kataDebug bool, memReserveMiB int, interiorNetNS netns.NsHandle, actorLogger *actorlog.ActorLogger, atunnelServer *atunnel.Server, atunnelEgress *atunnel.Egress, atunnelEgressPort uint16, credentialBundle, egressTrustBundle string) *AteomService { return &AteomService{ podUID: podUID, chBinary: chBinary, kataConfig: kataConfig, kataDebug: kataDebug, + memReserveMiB: memReserveMiB, interiorNetNS: interiorNetNS, actorLogger: actorLogger, atunnel: atunnelServer, diff --git a/cmd/ateom-microvm/run.go b/cmd/ateom-microvm/run.go index ae691a2ca..180885484 100644 --- a/cmd/ateom-microvm/run.go +++ b/cmd/ateom-microvm/run.go @@ -38,6 +38,7 @@ import ( "github.com/agent-substrate/substrate/internal/proto/ateompb" "github.com/agent-substrate/substrate/internal/readyz" "github.com/agent-substrate/substrate/internal/resources" + "github.com/agent-substrate/substrate/internal/sizing" specs "github.com/opencontainers/runtime-spec/specs-go" "golang.org/x/sys/unix" "google.golang.org/grpc/codes" @@ -105,6 +106,12 @@ const ( assetVirtiofsd = "virtiofsd" ) +// vmmMemReserveMiB is the DEFAULT guest RAM held back from the pod's memory limit +// for the cloud-hypervisor VMM + virtiofsd, which run as host processes in the same +// pod cgroup as the guest RAM; without a margin the pod OOMs. Overridable per +// deployment via --vmm-mem-reserve-mib (see AteomService.memReserveMiB). +const vmmMemReserveMiB = 256 + // maxActorContainers is a sanity cap on containers per actor (all share the one // micro-VM + virtiofsd). 25 is far above any real pod. const maxActorContainers = 25 @@ -335,6 +342,22 @@ func (s *AteomService) coldBootActor(ctx context.Context, p actorBootParams) (re return err } + // Right-size the VM to the worker pod's limits (see internal/sizing), keeping + // the kata-config values above as the fallback when a limit is unset. vCPUs + // round up; VM RAM reserves a fixed margin for the VMM + virtiofsd, which share + // the pod cgroup with the guest RAM. NB: a FULL-scope snapshot restore reuses + // the size baked into the snapshot (restoreFullScope), so resizing an existing + // actor takes effect on its next cold boot. + sz := sizing.Discover() + if v := sz.VCPUs(); v > 0 { + vcpus = v + } + if sz.MemoryBytes > 0 { + if m := int(sz.MemoryBytes/(1024*1024)) - s.memReserveMiB; m > 0 { + memMiB = m + } + } + // Clean stale per-sandbox state + create the runtime dir for the sockets. kata.CleanupSandboxState(ctx, actorUID) if err := os.MkdirAll(kata.VMDir(actorUID), 0o700); err != nil { diff --git a/cmd/ateom-microvm/spec.go b/cmd/ateom-microvm/spec.go index 7962bc5aa..cfe3ca76d 100644 --- a/cmd/ateom-microvm/spec.go +++ b/cmd/ateom-microvm/spec.go @@ -24,6 +24,8 @@ import ( "strings" specs "github.com/opencontainers/runtime-spec/specs-go" + + "github.com/agent-substrate/substrate/internal/sizing" ) // ensureKataCompatibleSpec augments the bundle's config.json with the fields @@ -51,6 +53,11 @@ func ensureKataCompatibleSpec(bundle, id, netnsPath string) (*specs.Spec, error) if spec.Linux.CgroupsPath == "" { spec.Linux.CgroupsPath = "/ateomchv/" + id } + // Right-size the guest container cgroup to the worker pod's limits; the + // kata-agent applies spec.Linux.Resources inside the VM. Shared with the gVisor + // runtime via internal/sizing; overlays the device allowlist + CPU shares set + // by defaultKataResources. + sizing.Discover().ApplyToOCISpec(&spec) // atelet's spec carries gVisor pause-model CRI annotations // (container-type=container, sandbox-id=pause). kata reads those and waits diff --git a/demos/counter/counter-microvm.yaml.tmpl b/demos/counter/counter-microvm.yaml.tmpl index ed3cc4e31..1bcb05218 100644 --- a/demos/counter/counter-microvm.yaml.tmpl +++ b/demos/counter/counter-microvm.yaml.tmpl @@ -100,6 +100,17 @@ spec: sandboxClass: microvm sandboxConfigName: counter-microvm ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-microvm + template: + # Per-worker resources; the limits size the micro-VM (vCPUs + RAM, less a + # VMM reserve) via the downward API (see internal/sizing). Keep memory well + # above the VMM reserve so the guest gets usable RAM. + resources: + requests: + cpu: "1" + memory: 1Gi + limits: + cpu: "2" + memory: 2Gi --- diff --git a/demos/counter/counter.yaml.tmpl b/demos/counter/counter.yaml.tmpl index fa8801842..04d9268d5 100644 --- a/demos/counter/counter.yaml.tmpl +++ b/demos/counter/counter.yaml.tmpl @@ -29,6 +29,15 @@ metadata: spec: replicas: 5 ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + template: + # Per-worker resources; the limits size each sandbox (see internal/sizing). + resources: + requests: + cpu: "250m" + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi --- diff --git a/demos/sandbox/sandbox.yaml.tmpl b/demos/sandbox/sandbox.yaml.tmpl index 0265eea62..d552dbce9 100644 --- a/demos/sandbox/sandbox.yaml.tmpl +++ b/demos/sandbox/sandbox.yaml.tmpl @@ -27,6 +27,17 @@ metadata: spec: replicas: 2 ateomImage: ko://github.com/agent-substrate/substrate/cmd/ateom-gvisor + template: + # Per-worker resources. The limits size each sandbox: ateom reads them via + # the downward API and applies them to the gVisor cgroup leaf / micro-VM + # (see internal/sizing). Set limits so sizing does not fall back to the node. + resources: + requests: + cpu: "500m" + memory: 512Mi + limits: + cpu: "2" + memory: 2Gi --- apiVersion: ate.dev/v1alpha1 kind: ActorTemplate diff --git a/docs/api-guide.md b/docs/api-guide.md index 2469dd9b9..4c319f462 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -26,6 +26,19 @@ The `WorkerPool` defines the pool of physical "warm" compute capacity. It manage | `nodeAffinity` | `NodeAffinity` | `spec.affinity.nodeAffinity` | | `resources` | `ResourceRequirements` | `spec.containers[].resources` | +#### Sandbox Right-Sizing (`spec.template.resources`) + +Setting `resources.limits` (CPU and Memory) on a `WorkerPool` directly sizes the sandboxes hosted on those worker pods (rather than sizing to the whole host): + +- **gVisor (`ateom-gvisor`)**: + - `limits.cpu`: Configures the cgroup v2 CPU quota (`cpu.max`) and provisions the Sentry vCPU count (`--cpu-num-from-quota`), ensuring the sandboxed applications see matching CPU cores and do not oversubscribe threads. + - `limits.memory`: Sets the cgroup v2 memory limit (`memory.max`) and bounds the virtual total memory seen by the sandbox (preventing applications like JVM/Go from over-allocating based on host RAM). +- **Micro-VM (`ateom-microvm`)**: + - `limits.cpu`: Determines the Cloud Hypervisor `BootVcpus` / `MaxVcpus` (rounded up to whole vCPUs). + - `limits.memory`: Sets the guest RAM for the VM, reserving a small configurable margin (default 256 MiB, tunable via `--vmm-mem-reserve-mib`) for the VMM and virtiofsd host processes so the pod cgroup does not OOM. +- **Default Fallback**: + - If `limits` are omitted, the Kubernetes Downward API falls back to the host node's allocatable capacity. Setting explicit `limits` on `WorkerPool.spec.template.resources` is recommended so sandboxes are sized to the pod rather than the whole node. + ### Example ```yaml diff --git a/internal/sizing/sizing.go b/internal/sizing/sizing.go new file mode 100644 index 000000000..966251fb7 --- /dev/null +++ b/internal/sizing/sizing.go @@ -0,0 +1,129 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package sizing right-sizes a sandbox to its worker pod's resource allocation. +// The bulk of right-sizing is writing the correct cgroup values, which is +// identical for the gVisor and micro-VM runtimes, so both ateom binaries share +// this package: Discover reads the pod's limits and ApplyToOCISpec writes them +// into the container OCI spec. runsc then applies them to the host cgroup leaf +// (gVisor) and the kata-agent applies them to the guest cgroup (micro-VM). The +// micro-VM additionally sizes the VM itself from the same PodSize (see VCPUs). +package sizing + +import ( + "os" + "strconv" + + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +// Downward-API environment variables carrying the worker pod's resource limits. +// The ateom controller injects these on the ateom container via resourceFieldRef +// (see cmd/atecontroller buildDeploymentApplyConfig). Both runtimes read the same +// names so the sizing logic stays common. +const ( + // EnvCPUMilli is limits.cpu with divisor 1m, i.e. millicores. + EnvCPUMilli = "ATEOM_SIZE_CPU_MILLI" + // EnvMemoryBytes is limits.memory with divisor 1, i.e. bytes. + EnvMemoryBytes = "ATEOM_SIZE_MEMORY_BYTES" + + // cpuQuotaPeriodMicros is the cgroup v2 cpu.max period (100ms, the kernel + // default) against which the CPU quota is expressed. + cpuQuotaPeriodMicros = 100000 +) + +// PodSize is the sandbox's target size derived from the worker pod's resource +// limits. A zero field means "unset": the caller keeps its own default (the kata +// config for the micro-VM, unlimited for gVisor). +type PodSize struct { + // MilliCPU is the CPU limit in millicores (1000 = one core), or 0 if unset. + MilliCPU int64 + // MemoryBytes is the memory limit in bytes, or 0 if unset. + MemoryBytes int64 +} + +// Discover reads the worker pod's resource limits from the downward-API +// environment. It is runtime-agnostic; both ateom-gvisor and ateom-microvm call +// it. A missing or malformed variable yields a zero field. +// +// NOTE: resourceFieldRef falls back to the node's allocatable capacity when the +// container sets no limit, so a WorkerPool template SHOULD set CPU/memory limits; +// otherwise the sandbox is sized to the whole node. +func Discover() PodSize { + return PodSize{ + MilliCPU: envInt64(EnvCPUMilli), + MemoryBytes: envInt64(EnvMemoryBytes), + } +} + +func envInt64(name string) int64 { + v := os.Getenv(name) + if v == "" { + return 0 + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + return 0 + } + return n +} + +// VCPUs converts the CPU limit to a whole vCPU count for the micro-VM, rounding +// up so a fractional limit still yields a usable core (minimum 1 when a limit is +// set). Returns 0 when the CPU limit is unset, letting the caller keep its +// default. +func (s PodSize) VCPUs() int { + if s.MilliCPU <= 0 { + return 0 + } + v := (s.MilliCPU + 999) / 1000 + if v < 1 { + v = 1 + } + return int(v) +} + +// ApplyToOCISpec writes the pod's CPU/memory limits into the container OCI spec's +// linux.resources so the sandbox cgroup is created with the right values. This is +// the piece shared by both runtimes: runsc applies it to the host cgroup leaf +// (gVisor) and the kata-agent applies it to the guest cgroup (micro-VM). Fields +// that are unset in PodSize are left untouched, preserving any existing values +// (e.g. the micro-VM's device allowlist and CPU shares). +func (s PodSize) ApplyToOCISpec(spec *specs.Spec) { + if s.MilliCPU <= 0 && s.MemoryBytes <= 0 { + return + } + if spec.Linux == nil { + spec.Linux = &specs.Linux{} + } + if spec.Linux.Resources == nil { + spec.Linux.Resources = &specs.LinuxResources{} + } + if s.MilliCPU > 0 { + if spec.Linux.Resources.CPU == nil { + spec.Linux.Resources.CPU = &specs.LinuxCPU{} + } + period := uint64(cpuQuotaPeriodMicros) + quota := s.MilliCPU * cpuQuotaPeriodMicros / 1000 + spec.Linux.Resources.CPU.Period = &period + spec.Linux.Resources.CPU.Quota = "a + } + if s.MemoryBytes > 0 { + if spec.Linux.Resources.Memory == nil { + spec.Linux.Resources.Memory = &specs.LinuxMemory{} + } + limit := s.MemoryBytes + spec.Linux.Resources.Memory.Limit = &limit + } +} diff --git a/internal/sizing/sizing_test.go b/internal/sizing/sizing_test.go new file mode 100644 index 000000000..759e355c6 --- /dev/null +++ b/internal/sizing/sizing_test.go @@ -0,0 +1,107 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sizing + +import ( + "testing" + + specs "github.com/opencontainers/runtime-spec/specs-go" +) + +func TestDiscover(t *testing.T) { + t.Setenv(EnvCPUMilli, "1500") + t.Setenv(EnvMemoryBytes, "2147483648") + got := Discover() + if got.MilliCPU != 1500 || got.MemoryBytes != 2147483648 { + t.Fatalf("Discover() = %+v", got) + } +} + +func TestDiscoverUnsetAndMalformed(t *testing.T) { + t.Setenv(EnvCPUMilli, "") + t.Setenv(EnvMemoryBytes, "not-a-number") + got := Discover() + if got.MilliCPU != 0 || got.MemoryBytes != 0 { + t.Fatalf("Discover() = %+v, want zero", got) + } +} + +func TestVCPUs(t *testing.T) { + cases := []struct { + milli int64 + want int + }{ + {0, 0}, + {1, 1}, + {999, 1}, + {1000, 1}, + {1001, 2}, + {2500, 3}, + {4000, 4}, + } + for _, c := range cases { + if got := (PodSize{MilliCPU: c.milli}).VCPUs(); got != c.want { + t.Errorf("VCPUs(%d) = %d, want %d", c.milli, got, c.want) + } + } +} + +func TestApplyToOCISpec(t *testing.T) { + spec := &specs.Spec{} + (PodSize{MilliCPU: 2000, MemoryBytes: 1073741824}).ApplyToOCISpec(spec) + + if spec.Linux == nil || spec.Linux.Resources == nil { + t.Fatal("resources not set") + } + cpu := spec.Linux.Resources.CPU + if cpu == nil || cpu.Quota == nil || cpu.Period == nil { + t.Fatal("cpu not set") + } + if *cpu.Period != cpuQuotaPeriodMicros || *cpu.Quota != 2*cpuQuotaPeriodMicros { + t.Errorf("cpu = quota %d period %d", *cpu.Quota, *cpu.Period) + } + mem := spec.Linux.Resources.Memory + if mem == nil || mem.Limit == nil || *mem.Limit != 1073741824 { + t.Errorf("memory limit not set correctly: %+v", mem) + } +} + +func TestApplyToOCISpecPreservesExistingAndSkipsUnset(t *testing.T) { + shares := uint64(1024) + spec := &specs.Spec{Linux: &specs.Linux{Resources: &specs.LinuxResources{ + CPU: &specs.LinuxCPU{Shares: &shares}, + }}} + // Only memory set; CPU limit unset must not clobber existing shares and must + // not add a quota. + (PodSize{MemoryBytes: 512}).ApplyToOCISpec(spec) + + if spec.Linux.Resources.CPU.Shares == nil || *spec.Linux.Resources.CPU.Shares != 1024 { + t.Error("existing cpu shares clobbered") + } + if spec.Linux.Resources.CPU.Quota != nil { + t.Error("cpu quota set despite unset MilliCPU") + } + if spec.Linux.Resources.Memory == nil || *spec.Linux.Resources.Memory.Limit != 512 { + t.Error("memory limit not applied") + } +} + +func TestApplyToOCISpecNoopWhenEmpty(t *testing.T) { + spec := &specs.Spec{} + (PodSize{}).ApplyToOCISpec(spec) + if spec.Linux != nil { + t.Error("empty PodSize mutated spec") + } +}