Skip to content

Commit 0f8c850

Browse files
authored
Merge pull request #44 from stacklok/feat/configurable-tmp-size
Add configurable /tmp tmpfs size for guest VMs
2 parents cf51779 + 27f9234 commit 0f8c850

13 files changed

Lines changed: 272 additions & 20 deletions

File tree

guest/boot/boot.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func Run(logger *slog.Logger, opts ...Option) (shutdown func(), err error) {
4545

4646
// 1. Essential mounts — /proc is needed before netlink can work.
4747
logger.Info("mounting essential filesystems")
48-
if err := mount.Essential(logger); err != nil {
48+
if err := mount.Essential(logger, cfg.tmpSizeMiB); err != nil {
4949
return nil, fmt.Errorf("essential mounts: %w", err)
5050
}
5151

guest/boot/options.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type config struct {
3333
lockdownRoot bool
3434
sshAgentForwarding bool
3535
seccomp bool
36+
tmpSizeMiB uint32
3637
}
3738

3839
func defaultConfig() *config {
@@ -116,6 +117,17 @@ func WithSSHAgentForwarding(enabled bool) Option {
116117
return optionFunc(func(c *config) { c.sshAgentForwarding = enabled })
117118
}
118119

120+
// WithTmpSize sets the size of the /tmp tmpfs in MiB. Defaults to 256 MiB when
121+
// 0 or not set. The value is read from /etc/propolis-vm.json when provided by
122+
// the host via [github.com/stacklok/propolis.WithTmpSize].
123+
func WithTmpSize(mib uint32) Option {
124+
return optionFunc(func(c *config) {
125+
if mib > 0 {
126+
c.tmpSizeMiB = mib
127+
}
128+
})
129+
}
130+
119131
// WithSeccomp controls whether a seccomp BPF blocklist filter is
120132
// applied as the last step of the boot sequence. When enabled, the
121133
// filter blocks dangerous syscalls (io_uring, ptrace, bpf, mount,

guest/mount/export_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build linux
5+
6+
package mount
7+
8+
// EssentialMountsForTest exposes essentialMounts for testing.
9+
var EssentialMountsForTest = essentialMounts

guest/mount/mount.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"time"
1515
)
1616

17+
const defaultTmpSizeMiB = 256
18+
1719
type mountEntry struct {
1820
source string
1921
target string
@@ -22,16 +24,26 @@ type mountEntry struct {
2224
data string
2325
}
2426

25-
// Essential mounts the core filesystems required for a minimal Linux userspace.
26-
func Essential(logger *slog.Logger) error {
27-
mounts := []mountEntry{
27+
// essentialMounts returns the mount table for Essential, applying the default
28+
// tmp size when tmpSizeMiB is zero.
29+
func essentialMounts(tmpSizeMiB uint32) []mountEntry {
30+
if tmpSizeMiB == 0 {
31+
tmpSizeMiB = defaultTmpSizeMiB
32+
}
33+
return []mountEntry{
2834
{"proc", "/proc", "proc", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""},
2935
{"sysfs", "/sys", "sysfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""},
3036
{"devtmpfs", "/dev", "devtmpfs", syscall.MS_NOSUID | syscall.MS_NOEXEC, ""},
3137
{"devpts", "/dev/pts", "devpts", syscall.MS_NOSUID | syscall.MS_NOEXEC, "newinstance,ptmxmode=0666,mode=0620,gid=5"},
32-
{"tmpfs", "/tmp", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV, "size=256m"},
38+
{"tmpfs", "/tmp", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV, fmt.Sprintf("size=%dm", tmpSizeMiB)},
3339
{"tmpfs", "/run", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, "size=64m"},
3440
}
41+
}
42+
43+
// Essential mounts the core filesystems required for a minimal Linux userspace.
44+
// tmpSizeMiB sets the size of the /tmp tmpfs in MiB; 0 uses the default (256 MiB).
45+
func Essential(logger *slog.Logger, tmpSizeMiB uint32) error {
46+
mounts := essentialMounts(tmpSizeMiB)
3547

3648
for _, m := range mounts {
3749
if err := os.MkdirAll(m.target, 0o755); err != nil {

guest/mount/mount_test.go

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func TestEssentialRequiresRoot(t *testing.T) {
1818
if os.Getuid() == 0 {
1919
t.Skip("test must run as non-root")
2020
}
21-
err := Essential(slog.Default())
21+
err := Essential(slog.Default(), 0)
2222
assert.Error(t, err)
2323
}
2424

@@ -31,20 +31,30 @@ func TestWorkspaceReturnsErrorForInvalidMount(t *testing.T) {
3131
assert.Error(t, err)
3232
}
3333

34-
func TestEssentialMountPoints(t *testing.T) {
34+
func TestEssentialMounts(t *testing.T) {
3535
t.Parallel()
3636

37-
// Verify the mount table contains the expected entries.
38-
expected := []string{"/proc", "/sys", "/dev", "/dev/pts", "/tmp", "/run"}
39-
mounts := []mountEntry{
40-
{"proc", "/proc", "proc", 0, ""},
41-
{"sysfs", "/sys", "sysfs", 0, ""},
42-
{"devtmpfs", "/dev", "devtmpfs", 0, ""},
43-
{"devpts", "/dev/pts", "devpts", 0, "newinstance,ptmxmode=0666,mode=0620,gid=5"},
44-
{"tmpfs", "/tmp", "tmpfs", 0, "size=256m"},
45-
{"tmpfs", "/run", "tmpfs", 0, "size=64m"},
46-
}
47-
for i, m := range mounts {
48-
assert.Equal(t, expected[i], m.target)
49-
}
37+
t.Run("default targets", func(t *testing.T) {
38+
t.Parallel()
39+
mounts := EssentialMountsForTest(0)
40+
expected := []string{"/proc", "/sys", "/dev", "/dev/pts", "/tmp", "/run"}
41+
targets := make([]string, len(mounts))
42+
for i, m := range mounts {
43+
targets[i] = m.target
44+
}
45+
assert.Equal(t, expected, targets)
46+
})
47+
48+
t.Run("zero uses default tmp size", func(t *testing.T) {
49+
t.Parallel()
50+
mounts := EssentialMountsForTest(0)
51+
// /tmp is the 5th entry.
52+
assert.Equal(t, "size=256m", mounts[4].data)
53+
})
54+
55+
t.Run("custom tmp size flows through", func(t *testing.T) {
56+
t.Parallel()
57+
mounts := EssentialMountsForTest(512)
58+
assert.Equal(t, "size=512m", mounts[4].data)
59+
})
5060
}

guest/vmconfig/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package vmconfig reads the VM configuration file written by the host into
5+
// the rootfs before boot. Guest init binaries use this to apply host-side
6+
// configuration (e.g. /tmp size) before the SSH server starts.
7+
package vmconfig

guest/vmconfig/export_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package vmconfig
5+
6+
// ReadFromForTest exposes readFrom for testing.
7+
var ReadFromForTest = readFrom

guest/vmconfig/vmconfig.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package vmconfig
5+
6+
import (
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"os"
11+
)
12+
13+
// GuestPath is the guest path where the host writes the VM config.
14+
const GuestPath = "/etc/propolis-vm.json"
15+
16+
// Config holds settings written by the host and read by the guest init.
17+
// Zero values mean "use the built-in default" for each field.
18+
type Config struct {
19+
// TmpSizeMiB is the size of the /tmp tmpfs in MiB. Zero means use the
20+
// mount package default (256 MiB).
21+
TmpSizeMiB uint32 `json:"tmp_size_mib,omitempty"`
22+
}
23+
24+
// Read loads the VM config from /etc/propolis-vm.json.
25+
// Returns a zero-value Config (all defaults) if the file does not exist,
26+
// ensuring backward compatibility with hosts that do not write the file.
27+
func Read() (Config, error) {
28+
return readFrom(GuestPath)
29+
}
30+
31+
// readFrom loads the VM config from the given path.
32+
func readFrom(path string) (Config, error) {
33+
data, err := os.ReadFile(path)
34+
if err != nil {
35+
if errors.Is(err, os.ErrNotExist) {
36+
return Config{}, nil
37+
}
38+
return Config{}, fmt.Errorf("reading vm config: %w", err)
39+
}
40+
var cfg Config
41+
if err := json.Unmarshal(data, &cfg); err != nil {
42+
return Config{}, fmt.Errorf("parsing vm config: %w", err)
43+
}
44+
return cfg, nil
45+
}

guest/vmconfig/vmconfig_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package vmconfig
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestReadFrom(t *testing.T) {
16+
t.Parallel()
17+
18+
tests := []struct {
19+
name string
20+
content *string // nil = file does not exist
21+
want Config
22+
wantErr bool
23+
}{
24+
{
25+
name: "file does not exist",
26+
content: nil,
27+
want: Config{},
28+
},
29+
{
30+
name: "valid JSON with TmpSizeMiB",
31+
content: strPtr(`{"tmp_size_mib":512}`),
32+
want: Config{TmpSizeMiB: 512},
33+
},
34+
{
35+
name: "empty JSON object",
36+
content: strPtr(`{}`),
37+
want: Config{},
38+
},
39+
{
40+
name: "malformed JSON",
41+
content: strPtr(`{not json`),
42+
wantErr: true,
43+
},
44+
}
45+
46+
for _, tt := range tests {
47+
t.Run(tt.name, func(t *testing.T) {
48+
t.Parallel()
49+
50+
dir := t.TempDir()
51+
path := filepath.Join(dir, "vm.json")
52+
53+
if tt.content != nil {
54+
require.NoError(t, os.WriteFile(path, []byte(*tt.content), 0o644))
55+
}
56+
57+
got, err := ReadFromForTest(path)
58+
if tt.wantErr {
59+
assert.Error(t, err)
60+
return
61+
}
62+
require.NoError(t, err)
63+
assert.Equal(t, tt.want, got)
64+
})
65+
}
66+
}
67+
68+
func strPtr(s string) *string { return &s }

hooks/hooks.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package hooks
55

66
import (
7+
"encoding/json"
78
"fmt"
89
"log/slog"
910
"os"
@@ -12,11 +13,25 @@ import (
1213
"sort"
1314
"strings"
1415

16+
"github.com/stacklok/propolis/guest/vmconfig"
1517
"github.com/stacklok/propolis/image"
1618
"github.com/stacklok/propolis/internal/pathutil"
1719
"github.com/stacklok/propolis/internal/xattr"
1820
)
1921

22+
// InjectVMConfig returns a RootFSHook that writes the given VM config as JSON
23+
// to /etc/propolis-vm.json inside the rootfs. The guest init reads this file
24+
// to configure mounts before the SSH server starts.
25+
func InjectVMConfig(cfg vmconfig.Config) func(string, *image.OCIConfig) error {
26+
return func(rootfsPath string, _ *image.OCIConfig) error {
27+
data, err := json.Marshal(cfg)
28+
if err != nil {
29+
return fmt.Errorf("marshal vm config: %w", err)
30+
}
31+
return InjectFile(vmconfig.GuestPath, data, 0o644)(rootfsPath, nil)
32+
}
33+
}
34+
2035
// validEnvKey matches POSIX-compliant environment variable names.
2136
var validEnvKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
2237

0 commit comments

Comments
 (0)