From ff15b0c202a998a9fd840863d8cbc519db8c452d Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 16 Mar 2026 13:05:29 +0200 Subject: [PATCH 1/2] Add configurable /tmp tmpfs size for guest VMs Allow callers to set the /tmp tmpfs size via WithTmpSize(mib). The host writes the value to /etc/propolis-vm.json in the rootfs using InjectVMConfig, and the guest init reads it via the new guest/vmconfig package before mounting filesystems. When unset, the existing 256 MiB default is preserved. Co-Authored-By: Claude Opus 4.6 (1M context) --- guest/boot/boot.go | 2 +- guest/boot/options.go | 12 ++++++++++++ guest/mount/mount.go | 10 ++++++++-- guest/mount/mount_test.go | 16 +++++++++++++-- guest/vmconfig/doc.go | 7 +++++++ guest/vmconfig/vmconfig.go | 40 ++++++++++++++++++++++++++++++++++++++ hooks/hooks.go | 24 +++++++++++++++++++++++ options.go | 9 +++++++++ propolis.go | 11 +++++++++++ 9 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 guest/vmconfig/doc.go create mode 100644 guest/vmconfig/vmconfig.go diff --git a/guest/boot/boot.go b/guest/boot/boot.go index e645acd..afa480d 100644 --- a/guest/boot/boot.go +++ b/guest/boot/boot.go @@ -45,7 +45,7 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) { // 1. Essential mounts — /proc is needed before netlink can work. logger.Info("mounting essential filesystems") - if err := mount.Essential(logger); err != nil { + if err := mount.Essential(logger, cfg.tmpSizeMiB); err != nil { return nil, fmt.Errorf("essential mounts: %w", err) } diff --git a/guest/boot/options.go b/guest/boot/options.go index babe6c8..5c83df3 100644 --- a/guest/boot/options.go +++ b/guest/boot/options.go @@ -33,6 +33,7 @@ type config struct { lockdownRoot bool sshAgentForwarding bool seccomp bool + tmpSizeMiB uint32 } func defaultConfig() *config { @@ -116,6 +117,17 @@ func WithSSHAgentForwarding(enabled bool) Option { return optionFunc(func(c *config) { c.sshAgentForwarding = enabled }) } +// WithTmpSize sets the size of the /tmp tmpfs in MiB. Defaults to 256 MiB when +// 0 or not set. The value is read from /etc/propolis-vm.json when provided by +// the host via [github.com/stacklok/propolis.WithTmpSize]. +func WithTmpSize(mib uint32) Option { + return optionFunc(func(c *config) { + if mib > 0 { + c.tmpSizeMiB = mib + } + }) +} + // WithSeccomp controls whether a seccomp BPF blocklist filter is // applied as the last step of the boot sequence. When enabled, the // filter blocks dangerous syscalls (io_uring, ptrace, bpf, mount, diff --git a/guest/mount/mount.go b/guest/mount/mount.go index 1408d2c..45f5650 100644 --- a/guest/mount/mount.go +++ b/guest/mount/mount.go @@ -14,6 +14,8 @@ import ( "time" ) +const defaultTmpSizeMiB = 256 + type mountEntry struct { source string target string @@ -23,13 +25,17 @@ type mountEntry struct { } // Essential mounts the core filesystems required for a minimal Linux userspace. -func Essential(logger *slog.Logger) error { +// tmpSizeMiB sets the size of the /tmp tmpfs in MiB; 0 uses the default (256 MiB). +func Essential(logger *slog.Logger, tmpSizeMiB uint32) error { + if tmpSizeMiB == 0 { + tmpSizeMiB = defaultTmpSizeMiB + } mounts := []mountEntry{ {"proc", "/proc", "proc", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""}, {"sysfs", "/sys", "sysfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""}, {"devtmpfs", "/dev", "devtmpfs", syscall.MS_NOSUID | syscall.MS_NOEXEC, ""}, {"devpts", "/dev/pts", "devpts", syscall.MS_NOSUID | syscall.MS_NOEXEC, "newinstance,ptmxmode=0666,mode=0620,gid=5"}, - {"tmpfs", "/tmp", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV, "size=256m"}, + {"tmpfs", "/tmp", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV, fmt.Sprintf("size=%dm", tmpSizeMiB)}, {"tmpfs", "/run", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, "size=64m"}, } diff --git a/guest/mount/mount_test.go b/guest/mount/mount_test.go index c28d0a4..89f656e 100644 --- a/guest/mount/mount_test.go +++ b/guest/mount/mount_test.go @@ -6,6 +6,7 @@ package mount import ( + "fmt" "log/slog" "os" "testing" @@ -18,7 +19,7 @@ func TestEssentialRequiresRoot(t *testing.T) { if os.Getuid() == 0 { t.Skip("test must run as non-root") } - err := Essential(slog.Default()) + err := Essential(slog.Default(), 0) assert.Error(t, err) } @@ -41,10 +42,21 @@ func TestEssentialMountPoints(t *testing.T) { {"sysfs", "/sys", "sysfs", 0, ""}, {"devtmpfs", "/dev", "devtmpfs", 0, ""}, {"devpts", "/dev/pts", "devpts", 0, "newinstance,ptmxmode=0666,mode=0620,gid=5"}, - {"tmpfs", "/tmp", "tmpfs", 0, "size=256m"}, + {"tmpfs", "/tmp", "tmpfs", 0, fmt.Sprintf("size=%dm", defaultTmpSizeMiB)}, {"tmpfs", "/run", "tmpfs", 0, "size=64m"}, } for i, m := range mounts { assert.Equal(t, expected[i], m.target) } } + +func TestEssentialTmpSizeDefault(t *testing.T) { + t.Parallel() + if os.Getuid() == 0 { + t.Skip("test must run as non-root") + } + // Zero should be treated identically to the default size (no panic, same error path). + err0 := Essential(slog.Default(), 0) + errDef := Essential(slog.Default(), defaultTmpSizeMiB) + assert.Equal(t, err0 != nil, errDef != nil) +} diff --git a/guest/vmconfig/doc.go b/guest/vmconfig/doc.go new file mode 100644 index 0000000..69798e5 --- /dev/null +++ b/guest/vmconfig/doc.go @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package vmconfig reads the VM configuration file written by the host into +// the rootfs before boot. Guest init binaries use this to apply host-side +// configuration (e.g. /tmp size) before the SSH server starts. +package vmconfig diff --git a/guest/vmconfig/vmconfig.go b/guest/vmconfig/vmconfig.go new file mode 100644 index 0000000..c6f3d30 --- /dev/null +++ b/guest/vmconfig/vmconfig.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vmconfig + +import ( + "encoding/json" + "errors" + "fmt" + "os" +) + +// configPath is the guest path where the host writes the VM config. +const configPath = "/etc/propolis-vm.json" + +// Config holds settings written by the host and read by the guest init. +// Zero values mean "use the built-in default" for each field. +type Config struct { + // TmpSizeMiB is the size of the /tmp tmpfs in MiB. Zero means use the + // mount package default (256 MiB). + TmpSizeMiB uint32 `json:"tmp_size_mib,omitempty"` +} + +// Read loads the VM config from /etc/propolis-vm.json. +// Returns a zero-value Config (all defaults) if the file does not exist, +// ensuring backward compatibility with hosts that do not write the file. +func Read() (Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Config{}, nil + } + return Config{}, fmt.Errorf("reading vm config: %w", err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parsing vm config: %w", err) + } + return cfg, nil +} diff --git a/hooks/hooks.go b/hooks/hooks.go index b19bc65..6fbd1c4 100644 --- a/hooks/hooks.go +++ b/hooks/hooks.go @@ -4,6 +4,7 @@ package hooks import ( + "encoding/json" "fmt" "log/slog" "os" @@ -17,6 +18,29 @@ import ( "github.com/stacklok/propolis/internal/xattr" ) +// vmConfigGuestPath is the guest path for the VM config file written by InjectVMConfig. +const vmConfigGuestPath = "/etc/propolis-vm.json" + +// VMConfig holds settings written by the host into the rootfs and read by the +// guest init before mounting filesystems. Fields use omitempty so the file is +// only written when a non-default value is present. +type VMConfig struct { + TmpSizeMiB uint32 `json:"tmp_size_mib,omitempty"` +} + +// InjectVMConfig returns a RootFSHook that writes the given VM config as JSON +// to /etc/propolis-vm.json inside the rootfs. The guest init reads this file +// to configure mounts before the SSH server starts. +func InjectVMConfig(cfg VMConfig) func(string, *image.OCIConfig) error { + return func(rootfsPath string, _ *image.OCIConfig) error { + data, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal vm config: %w", err) + } + return InjectFile(vmConfigGuestPath, data, 0o644)(rootfsPath, nil) + } +} + // validEnvKey matches POSIX-compliant environment variable names. var validEnvKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) diff --git a/options.go b/options.go index 58fa403..5e7eb50 100644 --- a/options.go +++ b/options.go @@ -63,6 +63,7 @@ type config struct { name string cpus uint32 memory uint32 // MiB + tmpSizeMiB uint32 // /tmp tmpfs size in MiB; 0 = use guest default (256) ports []PortForward initOverride []string rootfsPath string // pre-built rootfs directory; skips OCI image pull when set @@ -297,3 +298,11 @@ func WithImageFetcher(f image.ImageFetcher) Option { func WithLogLevel(level uint32) Option { return optionFunc(func(c *config) { c.logLevel = level }) } + +// WithTmpSize sets the size of the /tmp tmpfs inside the guest VM in MiB. +// Defaults to 256 MiB when 0 or not set. +// The value is written to /etc/propolis-vm.json in the rootfs and read by +// the guest init before mounting filesystems. +func WithTmpSize(mib uint32) Option { + return optionFunc(func(c *config) { c.tmpSizeMiB = mib }) +} diff --git a/propolis.go b/propolis.go index 8b6339d..14a2aa2 100644 --- a/propolis.go +++ b/propolis.go @@ -27,6 +27,7 @@ import ( "syscall" "time" + "github.com/stacklok/propolis/hooks" "github.com/stacklok/propolis/hypervisor" "github.com/stacklok/propolis/hypervisor/libkrun" "github.com/stacklok/propolis/image" @@ -123,6 +124,16 @@ func Run(ctx context.Context, imageRef string, opts ...Option) (*VM, error) { } } + // 3b. Inject VM config for the guest init (e.g. /tmp size). + // Only written when a non-default value is configured, keeping the + // file absent for callers that rely on the built-in 256 MiB default. + if cfg.tmpSizeMiB > 0 { + vmCfgHook := hooks.InjectVMConfig(hooks.VMConfig{TmpSizeMiB: cfg.tmpSizeMiB}) + if err := vmCfgHook(rootfs.Path, rootfs.Config); err != nil { + return nil, fmt.Errorf("inject vm config: %w", err) + } + } + // 4. Prepare rootfs via backend. backend := cfg.backend if backend == nil { From 27f92349a7f39fc7ff168fdcf9a7f6091ccd482f Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Mon, 16 Mar 2026 13:25:36 +0200 Subject: [PATCH 2/2] Deduplicate VMConfig, strengthen tests for /tmp size Remove duplicate VMConfig struct and path constant from hooks package by importing guest/vmconfig directly. Extract essentialMounts() from Essential() so mount tests exercise real production code instead of hand-constructed literals. Add table-driven tests for vmconfig.Read() and hooks.InjectVMConfig. Improve WithTmpSize godoc. Co-Authored-By: Claude Opus 4.6 (1M context) --- guest/mount/export_test.go | 9 +++++ guest/mount/mount.go | 14 +++++-- guest/mount/mount_test.go | 50 ++++++++++++------------ guest/vmconfig/export_test.go | 7 ++++ guest/vmconfig/vmconfig.go | 11 ++++-- guest/vmconfig/vmconfig_test.go | 68 +++++++++++++++++++++++++++++++++ hooks/hooks.go | 15 ++------ hooks/hooks_test.go | 44 +++++++++++++++++++++ options.go | 4 +- propolis.go | 3 +- 10 files changed, 178 insertions(+), 47 deletions(-) create mode 100644 guest/mount/export_test.go create mode 100644 guest/vmconfig/export_test.go create mode 100644 guest/vmconfig/vmconfig_test.go diff --git a/guest/mount/export_test.go b/guest/mount/export_test.go new file mode 100644 index 0000000..c4f8756 --- /dev/null +++ b/guest/mount/export_test.go @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build linux + +package mount + +// EssentialMountsForTest exposes essentialMounts for testing. +var EssentialMountsForTest = essentialMounts diff --git a/guest/mount/mount.go b/guest/mount/mount.go index 45f5650..661d04c 100644 --- a/guest/mount/mount.go +++ b/guest/mount/mount.go @@ -24,13 +24,13 @@ type mountEntry struct { data string } -// Essential mounts the core filesystems required for a minimal Linux userspace. -// tmpSizeMiB sets the size of the /tmp tmpfs in MiB; 0 uses the default (256 MiB). -func Essential(logger *slog.Logger, tmpSizeMiB uint32) error { +// essentialMounts returns the mount table for Essential, applying the default +// tmp size when tmpSizeMiB is zero. +func essentialMounts(tmpSizeMiB uint32) []mountEntry { if tmpSizeMiB == 0 { tmpSizeMiB = defaultTmpSizeMiB } - mounts := []mountEntry{ + return []mountEntry{ {"proc", "/proc", "proc", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""}, {"sysfs", "/sys", "sysfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""}, {"devtmpfs", "/dev", "devtmpfs", syscall.MS_NOSUID | syscall.MS_NOEXEC, ""}, @@ -38,6 +38,12 @@ func Essential(logger *slog.Logger, tmpSizeMiB uint32) error { {"tmpfs", "/tmp", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV, fmt.Sprintf("size=%dm", tmpSizeMiB)}, {"tmpfs", "/run", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, "size=64m"}, } +} + +// Essential mounts the core filesystems required for a minimal Linux userspace. +// tmpSizeMiB sets the size of the /tmp tmpfs in MiB; 0 uses the default (256 MiB). +func Essential(logger *slog.Logger, tmpSizeMiB uint32) error { + mounts := essentialMounts(tmpSizeMiB) for _, m := range mounts { if err := os.MkdirAll(m.target, 0o755); err != nil { diff --git a/guest/mount/mount_test.go b/guest/mount/mount_test.go index 89f656e..a88e8e0 100644 --- a/guest/mount/mount_test.go +++ b/guest/mount/mount_test.go @@ -6,7 +6,6 @@ package mount import ( - "fmt" "log/slog" "os" "testing" @@ -32,31 +31,30 @@ func TestWorkspaceReturnsErrorForInvalidMount(t *testing.T) { assert.Error(t, err) } -func TestEssentialMountPoints(t *testing.T) { +func TestEssentialMounts(t *testing.T) { t.Parallel() - // Verify the mount table contains the expected entries. - expected := []string{"/proc", "/sys", "/dev", "/dev/pts", "/tmp", "/run"} - mounts := []mountEntry{ - {"proc", "/proc", "proc", 0, ""}, - {"sysfs", "/sys", "sysfs", 0, ""}, - {"devtmpfs", "/dev", "devtmpfs", 0, ""}, - {"devpts", "/dev/pts", "devpts", 0, "newinstance,ptmxmode=0666,mode=0620,gid=5"}, - {"tmpfs", "/tmp", "tmpfs", 0, fmt.Sprintf("size=%dm", defaultTmpSizeMiB)}, - {"tmpfs", "/run", "tmpfs", 0, "size=64m"}, - } - for i, m := range mounts { - assert.Equal(t, expected[i], m.target) - } -} - -func TestEssentialTmpSizeDefault(t *testing.T) { - t.Parallel() - if os.Getuid() == 0 { - t.Skip("test must run as non-root") - } - // Zero should be treated identically to the default size (no panic, same error path). - err0 := Essential(slog.Default(), 0) - errDef := Essential(slog.Default(), defaultTmpSizeMiB) - assert.Equal(t, err0 != nil, errDef != nil) + t.Run("default targets", func(t *testing.T) { + t.Parallel() + mounts := EssentialMountsForTest(0) + expected := []string{"/proc", "/sys", "/dev", "/dev/pts", "/tmp", "/run"} + targets := make([]string, len(mounts)) + for i, m := range mounts { + targets[i] = m.target + } + assert.Equal(t, expected, targets) + }) + + t.Run("zero uses default tmp size", func(t *testing.T) { + t.Parallel() + mounts := EssentialMountsForTest(0) + // /tmp is the 5th entry. + assert.Equal(t, "size=256m", mounts[4].data) + }) + + t.Run("custom tmp size flows through", func(t *testing.T) { + t.Parallel() + mounts := EssentialMountsForTest(512) + assert.Equal(t, "size=512m", mounts[4].data) + }) } diff --git a/guest/vmconfig/export_test.go b/guest/vmconfig/export_test.go new file mode 100644 index 0000000..ef448c6 --- /dev/null +++ b/guest/vmconfig/export_test.go @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vmconfig + +// ReadFromForTest exposes readFrom for testing. +var ReadFromForTest = readFrom diff --git a/guest/vmconfig/vmconfig.go b/guest/vmconfig/vmconfig.go index c6f3d30..436d452 100644 --- a/guest/vmconfig/vmconfig.go +++ b/guest/vmconfig/vmconfig.go @@ -10,8 +10,8 @@ import ( "os" ) -// configPath is the guest path where the host writes the VM config. -const configPath = "/etc/propolis-vm.json" +// GuestPath is the guest path where the host writes the VM config. +const GuestPath = "/etc/propolis-vm.json" // Config holds settings written by the host and read by the guest init. // Zero values mean "use the built-in default" for each field. @@ -25,7 +25,12 @@ type Config struct { // Returns a zero-value Config (all defaults) if the file does not exist, // ensuring backward compatibility with hosts that do not write the file. func Read() (Config, error) { - data, err := os.ReadFile(configPath) + return readFrom(GuestPath) +} + +// readFrom loads the VM config from the given path. +func readFrom(path string) (Config, error) { + data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return Config{}, nil diff --git a/guest/vmconfig/vmconfig_test.go b/guest/vmconfig/vmconfig_test.go new file mode 100644 index 0000000..d3a08f4 --- /dev/null +++ b/guest/vmconfig/vmconfig_test.go @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vmconfig + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadFrom(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content *string // nil = file does not exist + want Config + wantErr bool + }{ + { + name: "file does not exist", + content: nil, + want: Config{}, + }, + { + name: "valid JSON with TmpSizeMiB", + content: strPtr(`{"tmp_size_mib":512}`), + want: Config{TmpSizeMiB: 512}, + }, + { + name: "empty JSON object", + content: strPtr(`{}`), + want: Config{}, + }, + { + name: "malformed JSON", + content: strPtr(`{not json`), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "vm.json") + + if tt.content != nil { + require.NoError(t, os.WriteFile(path, []byte(*tt.content), 0o644)) + } + + got, err := ReadFromForTest(path) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func strPtr(s string) *string { return &s } diff --git a/hooks/hooks.go b/hooks/hooks.go index 6fbd1c4..c9ece59 100644 --- a/hooks/hooks.go +++ b/hooks/hooks.go @@ -13,31 +13,22 @@ import ( "sort" "strings" + "github.com/stacklok/propolis/guest/vmconfig" "github.com/stacklok/propolis/image" "github.com/stacklok/propolis/internal/pathutil" "github.com/stacklok/propolis/internal/xattr" ) -// vmConfigGuestPath is the guest path for the VM config file written by InjectVMConfig. -const vmConfigGuestPath = "/etc/propolis-vm.json" - -// VMConfig holds settings written by the host into the rootfs and read by the -// guest init before mounting filesystems. Fields use omitempty so the file is -// only written when a non-default value is present. -type VMConfig struct { - TmpSizeMiB uint32 `json:"tmp_size_mib,omitempty"` -} - // InjectVMConfig returns a RootFSHook that writes the given VM config as JSON // to /etc/propolis-vm.json inside the rootfs. The guest init reads this file // to configure mounts before the SSH server starts. -func InjectVMConfig(cfg VMConfig) func(string, *image.OCIConfig) error { +func InjectVMConfig(cfg vmconfig.Config) func(string, *image.OCIConfig) error { return func(rootfsPath string, _ *image.OCIConfig) error { data, err := json.Marshal(cfg) if err != nil { return fmt.Errorf("marshal vm config: %w", err) } - return InjectFile(vmConfigGuestPath, data, 0o644)(rootfsPath, nil) + return InjectFile(vmconfig.GuestPath, data, 0o644)(rootfsPath, nil) } } diff --git a/hooks/hooks_test.go b/hooks/hooks_test.go index b882eee..5bf2fac 100644 --- a/hooks/hooks_test.go +++ b/hooks/hooks_test.go @@ -4,6 +4,7 @@ package hooks import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -12,6 +13,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stacklok/propolis/guest/vmconfig" ) // chownCall records a single chown invocation. @@ -41,6 +44,47 @@ func recordingChown() (ChownFunc, func() []chownCall) { return fn, get } +func TestInjectVMConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg vmconfig.Config + }{ + { + name: "non-zero config", + cfg: vmconfig.Config{TmpSizeMiB: 512}, + }, + { + name: "zero-value config", + cfg: vmconfig.Config{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rootfs := t.TempDir() + hook := InjectVMConfig(tt.cfg) + + err := hook(rootfs, nil) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(rootfs, "etc", "propolis-vm.json")) + require.NoError(t, err) + + var got vmconfig.Config + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, tt.cfg, got) + + info, err := os.Stat(filepath.Join(rootfs, "etc", "propolis-vm.json")) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o644), info.Mode().Perm()) + }) + } +} + func TestInjectFile_WritesContent(t *testing.T) { t.Parallel() diff --git a/options.go b/options.go index 5e7eb50..f4c9c8f 100644 --- a/options.go +++ b/options.go @@ -300,7 +300,9 @@ func WithLogLevel(level uint32) Option { } // WithTmpSize sets the size of the /tmp tmpfs inside the guest VM in MiB. -// Defaults to 256 MiB when 0 or not set. +// Defaults to 256 MiB when 0 or not set. The kernel enforces available +// memory as the upper bound; unreasonable values will cause a mount failure +// inside the guest. // The value is written to /etc/propolis-vm.json in the rootfs and read by // the guest init before mounting filesystems. func WithTmpSize(mib uint32) Option { diff --git a/propolis.go b/propolis.go index 14a2aa2..a6e26e1 100644 --- a/propolis.go +++ b/propolis.go @@ -27,6 +27,7 @@ import ( "syscall" "time" + "github.com/stacklok/propolis/guest/vmconfig" "github.com/stacklok/propolis/hooks" "github.com/stacklok/propolis/hypervisor" "github.com/stacklok/propolis/hypervisor/libkrun" @@ -128,7 +129,7 @@ func Run(ctx context.Context, imageRef string, opts ...Option) (*VM, error) { // Only written when a non-default value is configured, keeping the // file absent for callers that rely on the built-in 256 MiB default. if cfg.tmpSizeMiB > 0 { - vmCfgHook := hooks.InjectVMConfig(hooks.VMConfig{TmpSizeMiB: cfg.tmpSizeMiB}) + vmCfgHook := hooks.InjectVMConfig(vmconfig.Config{TmpSizeMiB: cfg.tmpSizeMiB}) if err := vmCfgHook(rootfs.Path, rootfs.Config); err != nil { return nil, fmt.Errorf("inject vm config: %w", err) }