From dc80261a02a34998666e597633be45398b245871 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 18 Aug 2026 22:10:10 +0000 Subject: [PATCH 1/2] feat(microsandbox): size the microVM from hostRequirements Devcontainer.json's hostRequirements.storage was ignored by the microsandbox provider: the driver had no --root-disk concept at all, so workspaces that installed dependencies (cargo, npm) inside the guest's default-sized OCI root disk ran out of space with no way to configure it. Add a MICROSANDBOX_STORAGE provider option (GiB, same convention as MICROSANDBOX_MEMORY/CPUS) that maps to msb run's --root-disk flag. When unset, fall back to the devcontainer's hostRequirements.storage so a provider that provisions its own VM can size it to what the devcontainer actually asked for, rather than only using hostRequirements to validate against the host machine. The same fallback now applies to CPUs and memory: MICROSANDBOX_CPUS/MEMORY still win when configured, hostRequirements.cpus/memory fill the gap otherwise. hostRequirements reaches the driver via RunImageDevContainerParams. ParsedConfig, the same plumbing point checkGPURequirement already uses; RunDevContainer (unused in the real run path but kept for driver.RunOptionsDriver conformance) passes nil. Also wires up sandboxSpec.Ephemeral, previously a dead field that was read from config but never passed to the msb CLI. Ephemeral now boots a tmpfs root disk (--root-disk tmpfs:G), so the microVM's disk state is actually discarded on stop as MICROSANDBOX_EPHEMERAL's description always claimed. Sized the same way as the persistent case; a tmpfs disk needs an explicit size, so a documented 8GiB default (devsy's own choice, not a microsandbox runtime default) applies when neither MICROSANDBOX_STORAGE nor hostRequirements.storage is set. Addresses #1037. --- pkg/driver/microsandbox/cliclient.go | 16 ++ pkg/driver/microsandbox/cliclient_test.go | 31 ++++ pkg/driver/microsandbox/client.go | 1 + pkg/driver/microsandbox/microsandbox.go | 112 +++++++++++++- pkg/driver/microsandbox/microsandbox_test.go | 145 +++++++++++++++--- pkg/options/resolve.go | 4 + pkg/options/resolve_test.go | 3 + pkg/provider/provider.go | 3 + providers/microsandbox/provider.yaml | 6 +- .../docs/developing-providers/driver.mdx | 6 +- 10 files changed, 298 insertions(+), 29 deletions(-) diff --git a/pkg/driver/microsandbox/cliclient.go b/pkg/driver/microsandbox/cliclient.go index 68fa00083..75182241c 100644 --- a/pkg/driver/microsandbox/cliclient.go +++ b/pkg/driver/microsandbox/cliclient.go @@ -153,6 +153,13 @@ func (cliClient) ensureVolumes(ctx context.Context, mounts []volumeMount) error const ( msbCmdRun = "run" msbFlagDetach = "--detach" + flagRootDisk = "--root-disk" + + // defaultEphemeralRootDiskGB sizes an ephemeral sandbox's tmpfs root disk + // when no explicit MICROSANDBOX_STORAGE/hostRequirements.storage size is + // configured. tmpfs requires an explicit size; this is devsy's own chosen + // default, not a microsandbox runtime default. + defaultEphemeralRootDiskGB = 8 ) // runArgs builds a detached `msb run` invocation, matching microsandbox's own @@ -205,6 +212,15 @@ func resourceArgs(spec sandboxSpec) []string { if spec.MaxCPUs > 0 { args = append(args, "--max-cpus", strconv.Itoa(int(spec.MaxCPUs))) } + if spec.Ephemeral { + size := spec.RootDiskGB + if size == 0 { + size = defaultEphemeralRootDiskGB + } + args = append(args, flagRootDisk, fmt.Sprintf("tmpfs:%dG", size)) + } else if spec.RootDiskGB > 0 { + args = append(args, flagRootDisk, fmt.Sprintf("%dG", spec.RootDiskGB)) + } return args } diff --git a/pkg/driver/microsandbox/cliclient_test.go b/pkg/driver/microsandbox/cliclient_test.go index 8cf5cc624..373ff72eb 100644 --- a/pkg/driver/microsandbox/cliclient_test.go +++ b/pkg/driver/microsandbox/cliclient_test.go @@ -1,6 +1,7 @@ package microsandbox import ( + "fmt" "slices" "strings" "testing" @@ -130,6 +131,36 @@ func TestResourceArgsOmitsZero(t *testing.T) { } } +func TestResourceArgsRootDisk(t *testing.T) { + if got := resourceArgs(sandboxSpec{RootDiskGB: 32}); !slices.Equal( + got, + []string{flagRootDisk, "32G"}, + ) { + t.Errorf("resourceArgs = %v", got) + } + if got := resourceArgs(sandboxSpec{}); slices.Contains(got, flagRootDisk) { + t.Errorf("zero RootDiskGB should omit --root-disk, got %v", got) + } +} + +func TestResourceArgsEphemeralUsesTmpfsRootDisk(t *testing.T) { + if got := resourceArgs(sandboxSpec{Ephemeral: true, RootDiskGB: 32}); !slices.Equal( + got, + []string{flagRootDisk, "tmpfs:32G"}, + ) { + t.Errorf("resourceArgs = %v", got) + } +} + +func TestResourceArgsEphemeralWithoutSizeUsesDefault(t *testing.T) { + if got := resourceArgs(sandboxSpec{Ephemeral: true}); !slices.Equal( + got, + []string{flagRootDisk, fmt.Sprintf("tmpfs:%dG", defaultEphemeralRootDiskGB)}, + ) { + t.Errorf("resourceArgs = %v", got) + } +} + func TestRedactArgsMasksEnvValues(t *testing.T) { args := []string{ names.Create, diff --git a/pkg/driver/microsandbox/client.go b/pkg/driver/microsandbox/client.go index 8e23ecbe2..65f080c74 100644 --- a/pkg/driver/microsandbox/client.go +++ b/pkg/driver/microsandbox/client.go @@ -20,6 +20,7 @@ type sandboxSpec struct { MaxMemory uint32 MaxCPUs uint8 BlockEgress bool + RootDiskGB uint32 } type volumeMount struct { diff --git a/pkg/driver/microsandbox/microsandbox.go b/pkg/driver/microsandbox/microsandbox.go index 951b8a9e2..cd4337381 100644 --- a/pkg/driver/microsandbox/microsandbox.go +++ b/pkg/driver/microsandbox/microsandbox.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" "io" + "math" "runtime" "strconv" "strings" @@ -33,6 +34,7 @@ type specDefaults struct { maxMemory uint32 maxCPUs uint8 blockEgress bool + rootDiskGB uint32 } type microsandboxDriver struct { @@ -80,6 +82,7 @@ func NewMicrosandboxDriver( maxMemory: parseUint32(cfg.MaxMemory), maxCPUs: parseUint8(cfg.MaxCPUs), blockEgress: cfg.BlockEgress == pkgconfig.BoolTrue, + rootDiskGB: parseUint32(cfg.Storage), } log.Debugf( @@ -100,7 +103,7 @@ func (d *microsandboxDriver) RunDevContainer( workspaceID string, options *driver.RunOptions, ) error { - return d.runFromOptions(ctx, workspaceID, options) + return d.runFromOptions(ctx, workspaceID, options, nil) } func (d *microsandboxDriver) RunImageDevContainer( @@ -110,7 +113,11 @@ func (d *microsandboxDriver) RunImageDevContainer( if err := checkGPURequirement(params.ParsedConfig); err != nil { return err } - return d.runFromOptions(ctx, params.WorkspaceID, params.Options) + var hostReqs *config.HostRequirements + if params.ParsedConfig != nil { + hostReqs = params.ParsedConfig.HostRequirements + } + return d.runFromOptions(ctx, params.WorkspaceID, params.Options, hostReqs) } func checkGPURequirement(parsedConfig *config.DevContainerConfig) error { @@ -305,6 +312,7 @@ func (d *microsandboxDriver) runFromOptions( ctx context.Context, workspaceID string, options *driver.RunOptions, + hostReqs *config.HostRequirements, ) error { if options == nil { return fmt.Errorf( @@ -324,7 +332,7 @@ func (d *microsandboxDriver) runFromOptions( if err := d.client.Create( ctx, sandboxName(workspaceID), - d.buildSpec(workspaceID, options), + d.buildSpec(workspaceID, options, hostReqs), ); err != nil { return fmt.Errorf("create microsandbox VM: %w", err) } @@ -346,7 +354,12 @@ func (d *microsandboxDriver) dockerImageDriver() (driver.ImageDriver, error) { return dd, nil } -func (d *microsandboxDriver) buildSpec(workspaceID string, options *driver.RunOptions) sandboxSpec { +// buildSpec resolves sizing from, in priority order, the operator-configured +// MICROSANDBOX_* defaults, then the devcontainer's hostRequirements, falling +// back to the microsandbox runtime default (zero) when neither is set. +func (d *microsandboxDriver) buildSpec( + workspaceID string, options *driver.RunOptions, hostReqs *config.HostRequirements, +) sandboxSpec { labels := config.ListToObject(config.GetIDLabels(workspaceID, d.idLabels)) if labels == nil { labels = map[string]string{} @@ -354,12 +367,24 @@ func (d *microsandboxDriver) buildSpec(workspaceID string, options *driver.RunOp if options.User != "" { labels[userLabel] = options.User } + memory := d.defaults.memory + if memory == 0 { + memory = hostRequirementMemoryMiB(hostReqs) + } + cpus := d.defaults.cpus + if cpus == 0 { + cpus = hostRequirementCPUs(hostReqs) + } + rootDiskGB := d.defaults.rootDiskGB + if rootDiskGB == 0 { + rootDiskGB = hostRequirementStorageGB(hostReqs) + } return sandboxSpec{ Image: options.Image, Entrypoint: options.Entrypoint, Cmd: options.Cmd, - Memory: d.defaults.memory, - CPUs: d.defaults.cpus, + Memory: memory, + CPUs: cpus, Env: options.Env, Labels: labels, Ephemeral: d.defaults.ephemeral, @@ -368,6 +393,7 @@ func (d *microsandboxDriver) buildSpec(workspaceID string, options *driver.RunOp MaxMemory: d.defaults.maxMemory, MaxCPUs: d.defaults.maxCPUs, BlockEgress: d.defaults.blockEgress, + RootDiskGB: rootDiskGB, } } @@ -473,7 +499,7 @@ func parseUint32(s string) uint32 { } v, err := strconv.ParseUint(s, 10, 32) if err != nil { - log.Warnf("invalid microsandbox memory value %q, using runtime default", s) + log.Warnf("invalid microsandbox numeric value %q, using runtime default", s) return 0 } return uint32(v) @@ -499,8 +525,78 @@ func parseUint8(s string) uint8 { } v, err := strconv.ParseUint(s, 10, 8) if err != nil { - log.Warnf("invalid microsandbox cpus value %q, using runtime default", s) + log.Warnf("invalid microsandbox numeric value %q, using runtime default", s) return 0 } return uint8(v) } + +// hostRequirementCPUs converts devcontainer.json's hostRequirements.cpus into +// a vCPU count, used only as a fallback when no MICROSANDBOX_CPUS default is +// configured. +func hostRequirementCPUs(hostReqs *config.HostRequirements) uint8 { + if hostReqs == nil || hostReqs.CPUs <= 0 { + return 0 + } + return parseUint8(strconv.Itoa(hostReqs.CPUs)) +} + +// hostRequirementMemoryMiB converts devcontainer.json's hostRequirements.memory +// (e.g. "8gb") into MiB, used only as a fallback when no MICROSANDBOX_MEMORY +// default is configured. +func hostRequirementMemoryMiB(hostReqs *config.HostRequirements) uint32 { + if hostReqs == nil || hostReqs.Memory == "" { + return 0 + } + bytes, err := config.ParseSizeToBytes(hostReqs.Memory) + if err != nil { + log.Warnf( + "invalid hostRequirements.memory %q, ignoring for microsandbox sizing: %v", + hostReqs.Memory, err, + ) + return 0 + } + return ceilBytesToUint32(bytes, 1024*1024) +} + +// hostRequirementStorageGB converts devcontainer.json's hostRequirements.storage +// (e.g. "32gb") into GiB for --root-disk, used only as a fallback when no +// MICROSANDBOX_STORAGE default is configured. +func hostRequirementStorageGB(hostReqs *config.HostRequirements) uint32 { + if hostReqs == nil || hostReqs.Storage == "" { + return 0 + } + bytes, err := config.ParseSizeToBytes(hostReqs.Storage) + if err != nil { + log.Warnf( + "invalid hostRequirements.storage %q, ignoring for microsandbox sizing: %v", + hostReqs.Storage, err, + ) + return 0 + } + return ceilBytesToUint32(bytes, 1024*1024*1024) +} + +// clampUint64ToUint32 saturates rather than wraps, so an outsized +// hostRequirements value degrades to the largest representable size instead +// of silently overflowing to a small or negative one. +func clampUint64ToUint32(v uint64) uint32 { + if v > math.MaxUint32 { + return math.MaxUint32 + } + return uint32(v) +} + +// ceilBytesToUint32 rounds a byte count up to the next whole unit before +// clamping. hostRequirements express a minimum, so any fractional or +// sub-unit remainder must round up rather than truncate away — otherwise a +// requirement like "1536mb" (1.5GiB) would provision less than requested, +// and a sub-unit requirement like "512mb" would truncate to zero and be +// silently dropped. +func ceilBytesToUint32(bytes, unit uint64) uint32 { + value := bytes / unit + if bytes%unit != 0 { + value++ + } + return clampUint64ToUint32(value) +} diff --git a/pkg/driver/microsandbox/microsandbox_test.go b/pkg/driver/microsandbox/microsandbox_test.go index 087ff81a5..3b02cc0df 100644 --- a/pkg/driver/microsandbox/microsandbox_test.go +++ b/pkg/driver/microsandbox/microsandbox_test.go @@ -15,6 +15,7 @@ import ( ) const ( + wsID = "ws1" wsName = "devsy-ws1" testImage = "example:latest" testUser = "vscode" @@ -25,6 +26,7 @@ const ( callRemove = "remove:" + wsName testBindSrc = "/host/proj" testBindDst = "/workspaces/proj" + size32GB = "32gb" ) // fakeClient is an in-memory sandboxClient that records calls, so the driver's @@ -116,7 +118,7 @@ func TestRunDevContainerBuildsSpec(t *testing.T) { f := newFakeClient() d := newDriver(f, nil, specDefaults{memory: 2048, cpus: 4, ephemeral: true}) - err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{ + err := d.RunDevContainer(context.Background(), wsID, &driver.RunOptions{ Image: testImage, User: testUser, Env: map[string]string{"FOO": "bar"}, @@ -157,7 +159,7 @@ func TestRunDevContainerReplacesStaleSandbox(t *testing.T) { f.info[wsName] = &sandboxInfo{Name: wsName, Running: true} d := newDriver(f, nil, specDefaults{}) - err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{Image: imgX}) + err := d.RunDevContainer(context.Background(), wsID, &driver.RunOptions{Image: imgX}) if err != nil { t.Fatalf("RunDevContainer: %v", err) } @@ -181,7 +183,7 @@ func TestRunDevContainerContinuesWhenPrePullFails(t *testing.T) { // Pre-pull failure must not abort the run; create should still be attempted. if err := d.RunDevContainer( context.Background(), - "ws1", + wsID, &driver.RunOptions{Image: imgX}, ); err != nil { t.Fatalf("RunDevContainer should proceed despite pull failure: %v", err) @@ -196,7 +198,7 @@ func TestRunImageDevContainerRunsFromParams(t *testing.T) { d := newDriver(f, nil, specDefaults{}) err := d.RunImageDevContainer(context.Background(), &driver.RunImageDevContainerParams{ - WorkspaceID: "ws1", + WorkspaceID: wsID, Options: &driver.RunOptions{Image: "built-local:latest"}, }) if err != nil { @@ -208,6 +210,30 @@ func TestRunImageDevContainerRunsFromParams(t *testing.T) { } } +func TestRunImageDevContainerAppliesHostRequirementsSizing(t *testing.T) { + f := newFakeClient() + d := newDriver(f, nil, specDefaults{}) + + err := d.RunImageDevContainer(context.Background(), &driver.RunImageDevContainerParams{ + WorkspaceID: wsID, + Options: &driver.RunOptions{Image: "built-local:latest"}, + ParsedConfig: &config.DevContainerConfig{ + DevContainerConfigBase: config.DevContainerConfigBase{ + HostRequirements: &config.HostRequirements{ + CPUs: 4, Memory: "8gb", Storage: size32GB, + }, + }, + }, + }) + if err != nil { + t.Fatalf("RunImageDevContainer: %v", err) + } + spec := f.created[wsName] + if spec.CPUs != 4 || spec.Memory != 8192 || spec.RootDiskGB != 32 { + t.Errorf("hostRequirements sizing not applied, got %+v", spec) + } +} + func TestCheckGPURequirement(t *testing.T) { // required GPU -> error req := &config.DevContainerConfig{} @@ -232,14 +258,14 @@ func TestCheckGPURequirement(t *testing.T) { func TestUpdateContainerUserUIDIsNoop(t *testing.T) { d := newDriver(newFakeClient(), nil, specDefaults{}) - if err := d.UpdateContainerUserUID(context.Background(), "ws1", nil, nil); err != nil { + if err := d.UpdateContainerUserUID(context.Background(), wsID, nil, nil); err != nil { t.Errorf("UpdateContainerUserUID should be a no-op, got %v", err) } } func TestRunDevContainerRequiresImage(t *testing.T) { d := newDriver(newFakeClient(), nil, specDefaults{}) - if err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{}); err == nil { + if err := d.RunDevContainer(context.Background(), wsID, &driver.RunOptions{}); err == nil { t.Fatal("expected an error when image is empty") } } @@ -254,7 +280,7 @@ func TestFindDevContainerMapsState(t *testing.T) { } d := newDriver(f, nil, specDefaults{}) - details, err := d.FindDevContainer(context.Background(), "ws1") + details, err := d.FindDevContainer(context.Background(), wsID) if err != nil { t.Fatalf("FindDevContainer: %v", err) } @@ -285,7 +311,7 @@ func TestDeleteStopsRunningThenRemoves(t *testing.T) { f.info[wsName] = &sandboxInfo{Name: wsName, Running: true} d := newDriver(f, nil, specDefaults{}) - if err := d.DeleteDevContainer(context.Background(), "ws1"); err != nil { + if err := d.DeleteDevContainer(context.Background(), wsID); err != nil { t.Fatalf("DeleteDevContainer: %v", err) } want := []string{callFind, "stop:devsy-ws1", callRemove} @@ -299,7 +325,7 @@ func TestDeleteStoppedSkipsStop(t *testing.T) { f.info[wsName] = &sandboxInfo{Name: wsName, Running: false} d := newDriver(f, nil, specDefaults{}) - if err := d.DeleteDevContainer(context.Background(), "ws1"); err != nil { + if err := d.DeleteDevContainer(context.Background(), wsID); err != nil { t.Fatalf("DeleteDevContainer: %v", err) } want := []string{callFind, callRemove} @@ -326,7 +352,7 @@ func TestDeletePropagatesStopError(t *testing.T) { f.failStop = errors.New("boom") d := newDriver(f, nil, specDefaults{}) - if err := d.DeleteDevContainer(context.Background(), "ws1"); err == nil { + if err := d.DeleteDevContainer(context.Background(), wsID); err == nil { t.Fatal("expected the stop error to propagate") } } @@ -337,7 +363,7 @@ func TestCommandDevContainerForwardsRequest(t *testing.T) { var out bytes.Buffer err := d.CommandDevContainer(context.Background(), &driver.CommandParams{ - WorkspaceID: "ws1", + WorkspaceID: wsID, User: testUser, Command: "echo hi", Stdout: &out, @@ -357,7 +383,7 @@ func TestRunDevContainerSetsEntrypoint(t *testing.T) { f := newFakeClient() d := newDriver(f, nil, specDefaults{}) - err := d.RunDevContainer(context.Background(), "ws1", &driver.RunOptions{ + err := d.RunDevContainer(context.Background(), wsID, &driver.RunOptions{ Image: testImage, Entrypoint: shPath, Cmd: []string{"-c", "start", "-"}, @@ -380,7 +406,7 @@ func TestCommandContainerArgvForwardsArgv(t *testing.T) { d := newDriver(f, nil, specDefaults{}) argv := []string{"sh", "-c", "cat > /usr/local/bin/devsy"} - err := d.CommandContainerArgv(context.Background(), "ws1", argv, driver.Streams{}) + err := d.CommandContainerArgv(context.Background(), wsID, argv, driver.Streams{}) if err != nil { t.Fatalf("CommandContainerArgv: %v", err) } @@ -451,7 +477,7 @@ func TestParseDuration(t *testing.T) { func TestBuildSpecMapsAllMountTypes(t *testing.T) { d := newDriver(newFakeClient(), nil, specDefaults{}) - spec := d.buildSpec("ws1", &driver.RunOptions{ + spec := d.buildSpec(wsID, &driver.RunOptions{ Image: imgX, Mounts: []*config.Mount{ {Type: driver.MountTypeVolume, Source: "vol1", Target: "/data"}, @@ -459,7 +485,7 @@ func TestBuildSpecMapsAllMountTypes(t *testing.T) { {Type: driver.MountTypeBind, Source: testBindSrc, Target: "/mnt"}, nil, }, - }) + }, nil) want := []volumeMount{ {Target: "/data", Volume: "vol1"}, {Target: "/scratch", Tmpfs: true}, @@ -472,14 +498,14 @@ func TestBuildSpecMapsAllMountTypes(t *testing.T) { func TestBuildSpecMapsWorkspaceMount(t *testing.T) { d := newDriver(newFakeClient(), nil, specDefaults{}) - spec := d.buildSpec("ws1", &driver.RunOptions{ + spec := d.buildSpec(wsID, &driver.RunOptions{ Image: imgX, WorkspaceMount: &config.Mount{ Type: driver.MountTypeBind, Source: testBindSrc, Target: testBindDst, }, - }) + }, nil) want := []volumeMount{{Target: testBindDst, Source: testBindSrc}} if !slices.Equal(spec.Mounts, want) { t.Errorf("mounts = %+v, want %+v", spec.Mounts, want) @@ -488,7 +514,7 @@ func TestBuildSpecMapsWorkspaceMount(t *testing.T) { func TestBuildSpecCarriesIdleTimeout(t *testing.T) { d := newDriver(newFakeClient(), nil, specDefaults{idleTimeout: 90 * time.Second}) - spec := d.buildSpec("ws1", &driver.RunOptions{Image: imgX}) + spec := d.buildSpec(wsID, &driver.RunOptions{Image: imgX}, nil) if spec.IdleTimeout != 90*time.Second { t.Errorf("idle timeout = %s, want 90s", spec.IdleTimeout) } @@ -500,12 +526,93 @@ func TestBuildSpecCarriesCeilingsAndEgress(t *testing.T) { nil, specDefaults{maxMemory: 4096, maxCPUs: 4, blockEgress: true}, ) - spec := d.buildSpec("ws1", &driver.RunOptions{Image: imgX}) + spec := d.buildSpec(wsID, &driver.RunOptions{Image: imgX}, nil) if spec.MaxMemory != 4096 || spec.MaxCPUs != 4 || !spec.BlockEgress { t.Errorf("unexpected spec ceilings/egress: %+v", spec) } } +func TestBuildSpecUsesHostRequirementsWhenDefaultsUnset(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{}) + hostReqs := &config.HostRequirements{CPUs: 4, Memory: "8gb", Storage: size32GB} + spec := d.buildSpec(wsID, &driver.RunOptions{Image: imgX}, hostReqs) + if spec.CPUs != 4 { + t.Errorf("CPUs = %d, want 4", spec.CPUs) + } + if spec.Memory != 8192 { + t.Errorf("Memory = %d MiB, want 8192", spec.Memory) + } + if spec.RootDiskGB != 32 { + t.Errorf("RootDiskGB = %d, want 32", spec.RootDiskGB) + } +} + +func TestBuildSpecPrefersConfiguredDefaultsOverHostRequirements(t *testing.T) { + d := newDriver( + newFakeClient(), + nil, + specDefaults{memory: 2048, cpus: 2, rootDiskGB: 16}, + ) + hostReqs := &config.HostRequirements{CPUs: 8, Memory: size32GB, Storage: "64gb"} + spec := d.buildSpec(wsID, &driver.RunOptions{Image: imgX}, hostReqs) + if spec.CPUs != 2 || spec.Memory != 2048 || spec.RootDiskGB != 16 { + t.Errorf("configured defaults should win, got %+v", spec) + } +} + +func TestBuildSpecIgnoresNilHostRequirements(t *testing.T) { + d := newDriver(newFakeClient(), nil, specDefaults{}) + spec := d.buildSpec(wsID, &driver.RunOptions{Image: imgX}, nil) + if spec.CPUs != 0 || spec.Memory != 0 || spec.RootDiskGB != 0 { + t.Errorf("nil hostRequirements with no defaults should leave sizing zero, got %+v", spec) + } +} + +func TestHostRequirementStorageGBRoundsUpFractionalGiB(t *testing.T) { + // 1536mb = 1.5GiB; a hostRequirements minimum must round up, not down, + // or the provisioned disk would be smaller than what was requested. + got := hostRequirementStorageGB(&config.HostRequirements{Storage: "1536mb"}) + if got != 2 { + t.Errorf("hostRequirementStorageGB(1536mb) = %d, want 2", got) + } +} + +func TestHostRequirementStorageGBRoundsUpSubGiB(t *testing.T) { + // A sub-GiB requirement must not truncate to zero, which would silently + // drop the requirement (and, on the ephemeral path, fall through to an + // unrelated default size). + got := hostRequirementStorageGB(&config.HostRequirements{Storage: "512mb"}) + if got != 1 { + t.Errorf("hostRequirementStorageGB(512mb) = %d, want 1", got) + } +} + +func TestHostRequirementMemoryMiBRoundsUpFractionalMiB(t *testing.T) { + // 1500kb = 1500*1024 bytes = 1.46484375MiB; must round up to 2, not + // truncate to 1. + got := hostRequirementMemoryMiB(&config.HostRequirements{Memory: "1500kb"}) + if got != 2 { + t.Errorf("hostRequirementMemoryMiB(1500kb) = %d, want 2", got) + } +} + +func TestCeilBytesToUint32(t *testing.T) { + cases := []struct { + bytes, unit uint64 + want uint32 + }{ + {0, 1024, 0}, + {1024, 1024, 1}, + {1025, 1024, 2}, + {1023, 1024, 1}, + } + for _, c := range cases { + if got := ceilBytesToUint32(c.bytes, c.unit); got != c.want { + t.Errorf("ceilBytesToUint32(%d, %d) = %d, want %d", c.bytes, c.unit, got, c.want) + } + } +} + func TestParseUint8(t *testing.T) { cases := []struct { in string diff --git a/pkg/options/resolve.go b/pkg/options/resolve.go index 0ada85bc4..98fafbbe8 100644 --- a/pkg/options/resolve.go +++ b/pkg/options/resolve.go @@ -392,6 +392,10 @@ func resolveAgentMicrosandboxConfig( agentConfig.Microsandbox.BlockEgress = types.StrBool( resolver.ResolveDefaultValue(string(agentConfig.Microsandbox.BlockEgress), options), ) + agentConfig.Microsandbox.Storage = resolver.ResolveDefaultValue( + agentConfig.Microsandbox.Storage, + options, + ) } func resolveAgentPathAndURL( diff --git a/pkg/options/resolve_test.go b/pkg/options/resolve_test.go index 50cb4265d..e9bb5d0ff 100644 --- a/pkg/options/resolve_test.go +++ b/pkg/options/resolve_test.go @@ -806,6 +806,7 @@ func TestResolveAgentMicrosandboxConfig(t *testing.T) { agentConfig.Microsandbox.MaxMemory = "${MICROSANDBOX_MAX_MEMORY}" agentConfig.Microsandbox.BlockEgress = types.StrBool("${MICROSANDBOX_BLOCK_EGRESS}") + agentConfig.Microsandbox.Storage = "${MICROSANDBOX_STORAGE}" options := map[string]string{ "MICROSANDBOX_MEMORY": "2048", @@ -813,6 +814,7 @@ func TestResolveAgentMicrosandboxConfig(t *testing.T) { "MICROSANDBOX_EPHEMERAL": "true", "MICROSANDBOX_MAX_MEMORY": "8192", "MICROSANDBOX_BLOCK_EGRESS": "true", + "MICROSANDBOX_STORAGE": "32", } resolveAgentMicrosandboxConfig(agentConfig, options) @@ -822,6 +824,7 @@ func TestResolveAgentMicrosandboxConfig(t *testing.T) { assert.Equal(t, types.StrBool("true"), agentConfig.Microsandbox.Ephemeral) assert.Equal(t, "8192", agentConfig.Microsandbox.MaxMemory) assert.Equal(t, types.StrBool("true"), agentConfig.Microsandbox.BlockEgress) + assert.Equal(t, "32", agentConfig.Microsandbox.Storage) } func TestResolveAgentDownloadURL(t *testing.T) { diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index d86e92fb5..41fe948ed 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -195,6 +195,9 @@ type ProviderMicrosandboxDriverConfig struct { // Ephemeral removes the sandbox's disk state when it stops. Ephemeral types.StrBool `json:"ephemeral,omitempty"` + + // Storage is the OCI root disk size in GiB. Empty uses the runtime default. + Storage string `json:"storage,omitempty"` } type ProviderCustomDriverConfig struct { diff --git a/providers/microsandbox/provider.yaml b/providers/microsandbox/provider.yaml index 5ee9da15f..8d72ab0ee 100644 --- a/providers/microsandbox/provider.yaml +++ b/providers/microsandbox/provider.yaml @@ -10,6 +10,7 @@ optionGroups: - MICROSANDBOX_CPUS - MICROSANDBOX_MAX_MEMORY - MICROSANDBOX_MAX_CPUS + - MICROSANDBOX_STORAGE - MICROSANDBOX_BLOCK_EGRESS - MICROSANDBOX_EPHEMERAL - INACTIVITY_TIMEOUT @@ -24,12 +25,14 @@ options: description: "Hotplug memory ceiling in MiB. Empty uses the runtime default." MICROSANDBOX_MAX_CPUS: description: "Hotplug CPU ceiling. Empty uses the runtime default." + MICROSANDBOX_STORAGE: + description: "OCI root disk size in GiB. Empty uses the runtime default. Falls back to devcontainer.json's hostRequirements.storage when set." MICROSANDBOX_BLOCK_EGRESS: description: "If true, deny the microVM outbound public network (sandbox hardening)." default: "false" type: boolean MICROSANDBOX_EPHEMERAL: - description: "If true, the microVM's disk state is discarded when it stops." + description: "If true, boot from a tmpfs root disk so the microVM's disk state is discarded when it stops. Sized by MICROSANDBOX_STORAGE/hostRequirements.storage, or 8GiB if neither is set." default: "false" type: boolean INACTIVITY_TIMEOUT: @@ -43,6 +46,7 @@ agent: cpus: ${MICROSANDBOX_CPUS} maxMemory: ${MICROSANDBOX_MAX_MEMORY} maxCpus: ${MICROSANDBOX_MAX_CPUS} + storage: ${MICROSANDBOX_STORAGE} blockEgress: ${MICROSANDBOX_BLOCK_EGRESS} ephemeral: ${MICROSANDBOX_EPHEMERAL} exec: diff --git a/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx b/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx index 0bc530c78..c2411b49a 100644 --- a/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx @@ -140,8 +140,12 @@ Available options: - **cpus**: number of virtual CPUs. Empty uses the runtime default. - **maxMemory**: hotplug memory ceiling in MiB. Empty uses the runtime default. - **maxCpus**: hotplug CPU ceiling. Empty uses the runtime default. +- **storage**: OCI root disk size in GiB. Empty uses the runtime default, falling + back to the devcontainer's `hostRequirements.storage` when set. - **blockEgress**: if true, deny the microVM outbound public network access (sandbox hardening). -- **ephemeral**: if true, the microVM's disk state is discarded when it stops. +- **ephemeral**: if true, boot from a tmpfs root disk so the microVM's disk + state is discarded when it stops. Sized by `storage`/`hostRequirements.storage`, + or 8GiB if neither is set. ```yaml agent: From ca947a66dd3b9afbf8b2316dad6d5bf1f1b0e1ff Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 18 Aug 2026 19:24:25 -0500 Subject: [PATCH 2/2] style: clean comments --- pkg/driver/microsandbox/cliclient.go | 11 +++-------- pkg/driver/microsandbox/microsandbox.go | 6 +----- pkg/driver/microsandbox/microsandbox_test.go | 10 ---------- 3 files changed, 4 insertions(+), 23 deletions(-) diff --git a/pkg/driver/microsandbox/cliclient.go b/pkg/driver/microsandbox/cliclient.go index 75182241c..e1d2ef7ba 100644 --- a/pkg/driver/microsandbox/cliclient.go +++ b/pkg/driver/microsandbox/cliclient.go @@ -151,14 +151,9 @@ func (cliClient) ensureVolumes(ctx context.Context, mounts []volumeMount) error } const ( - msbCmdRun = "run" - msbFlagDetach = "--detach" - flagRootDisk = "--root-disk" - - // defaultEphemeralRootDiskGB sizes an ephemeral sandbox's tmpfs root disk - // when no explicit MICROSANDBOX_STORAGE/hostRequirements.storage size is - // configured. tmpfs requires an explicit size; this is devsy's own chosen - // default, not a microsandbox runtime default. + msbCmdRun = "run" + msbFlagDetach = "--detach" + flagRootDisk = "--root-disk" defaultEphemeralRootDiskGB = 8 ) diff --git a/pkg/driver/microsandbox/microsandbox.go b/pkg/driver/microsandbox/microsandbox.go index cd4337381..5c5d47b04 100644 --- a/pkg/driver/microsandbox/microsandbox.go +++ b/pkg/driver/microsandbox/microsandbox.go @@ -588,11 +588,7 @@ func clampUint64ToUint32(v uint64) uint32 { } // ceilBytesToUint32 rounds a byte count up to the next whole unit before -// clamping. hostRequirements express a minimum, so any fractional or -// sub-unit remainder must round up rather than truncate away — otherwise a -// requirement like "1536mb" (1.5GiB) would provision less than requested, -// and a sub-unit requirement like "512mb" would truncate to zero and be -// silently dropped. +// clamping. func ceilBytesToUint32(bytes, unit uint64) uint32 { value := bytes / unit if bytes%unit != 0 { diff --git a/pkg/driver/microsandbox/microsandbox_test.go b/pkg/driver/microsandbox/microsandbox_test.go index 3b02cc0df..770d74f4c 100644 --- a/pkg/driver/microsandbox/microsandbox_test.go +++ b/pkg/driver/microsandbox/microsandbox_test.go @@ -235,19 +235,16 @@ func TestRunImageDevContainerAppliesHostRequirementsSizing(t *testing.T) { } func TestCheckGPURequirement(t *testing.T) { - // required GPU -> error req := &config.DevContainerConfig{} req.HostRequirements = &config.HostRequirements{GPU: &config.GPURequirement{Value: "true"}} if err := checkGPURequirement(req); err == nil { t.Error("expected an error when a GPU is required") } - // optional GPU -> no error opt := &config.DevContainerConfig{} opt.HostRequirements = &config.HostRequirements{GPU: &config.GPURequirement{Value: "optional"}} if err := checkGPURequirement(opt); err != nil { t.Errorf("optional GPU should not error, got %v", err) } - // no GPU / nil config -> no error if err := checkGPURequirement(nil); err != nil { t.Errorf("nil config should not error, got %v", err) } @@ -569,8 +566,6 @@ func TestBuildSpecIgnoresNilHostRequirements(t *testing.T) { } func TestHostRequirementStorageGBRoundsUpFractionalGiB(t *testing.T) { - // 1536mb = 1.5GiB; a hostRequirements minimum must round up, not down, - // or the provisioned disk would be smaller than what was requested. got := hostRequirementStorageGB(&config.HostRequirements{Storage: "1536mb"}) if got != 2 { t.Errorf("hostRequirementStorageGB(1536mb) = %d, want 2", got) @@ -578,9 +573,6 @@ func TestHostRequirementStorageGBRoundsUpFractionalGiB(t *testing.T) { } func TestHostRequirementStorageGBRoundsUpSubGiB(t *testing.T) { - // A sub-GiB requirement must not truncate to zero, which would silently - // drop the requirement (and, on the ephemeral path, fall through to an - // unrelated default size). got := hostRequirementStorageGB(&config.HostRequirements{Storage: "512mb"}) if got != 1 { t.Errorf("hostRequirementStorageGB(512mb) = %d, want 1", got) @@ -588,8 +580,6 @@ func TestHostRequirementStorageGBRoundsUpSubGiB(t *testing.T) { } func TestHostRequirementMemoryMiBRoundsUpFractionalMiB(t *testing.T) { - // 1500kb = 1500*1024 bytes = 1.46484375MiB; must round up to 2, not - // truncate to 1. got := hostRequirementMemoryMiB(&config.HostRequirements{Memory: "1500kb"}) if got != 2 { t.Errorf("hostRequirementMemoryMiB(1500kb) = %d, want 2", got)