diff --git a/pkg/driver/microsandbox/cliclient.go b/pkg/driver/microsandbox/cliclient.go index 68fa00083..e1d2ef7ba 100644 --- a/pkg/driver/microsandbox/cliclient.go +++ b/pkg/driver/microsandbox/cliclient.go @@ -151,8 +151,10 @@ func (cliClient) ensureVolumes(ctx context.Context, mounts []volumeMount) error } const ( - msbCmdRun = "run" - msbFlagDetach = "--detach" + msbCmdRun = "run" + msbFlagDetach = "--detach" + flagRootDisk = "--root-disk" + defaultEphemeralRootDiskGB = 8 ) // runArgs builds a detached `msb run` invocation, matching microsandbox's own @@ -205,6 +207,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..5c5d47b04 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,74 @@ 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. +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..770d74f4c 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,20 +210,41 @@ 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{} 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) } @@ -232,14 +255,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 +277,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 +308,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 +322,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 +349,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 +360,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 +380,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 +403,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 +474,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 +482,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 +495,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 +511,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 +523,86 @@ 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) { + got := hostRequirementStorageGB(&config.HostRequirements{Storage: "1536mb"}) + if got != 2 { + t.Errorf("hostRequirementStorageGB(1536mb) = %d, want 2", got) + } +} + +func TestHostRequirementStorageGBRoundsUpSubGiB(t *testing.T) { + got := hostRequirementStorageGB(&config.HostRequirements{Storage: "512mb"}) + if got != 1 { + t.Errorf("hostRequirementStorageGB(512mb) = %d, want 1", got) + } +} + +func TestHostRequirementMemoryMiBRoundsUpFractionalMiB(t *testing.T) { + 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: