Skip to content

Commit 27f9234

Browse files
JAORMXclaude
andcommitted
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) <noreply@anthropic.com>
1 parent ff15b0c commit 27f9234

10 files changed

Lines changed: 178 additions & 47 deletions

File tree

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: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,26 @@ type mountEntry struct {
2424
data string
2525
}
2626

27-
// Essential mounts the core filesystems required for a minimal Linux userspace.
28-
// tmpSizeMiB sets the size of the /tmp tmpfs in MiB; 0 uses the default (256 MiB).
29-
func Essential(logger *slog.Logger, tmpSizeMiB uint32) error {
27+
// essentialMounts returns the mount table for Essential, applying the default
28+
// tmp size when tmpSizeMiB is zero.
29+
func essentialMounts(tmpSizeMiB uint32) []mountEntry {
3030
if tmpSizeMiB == 0 {
3131
tmpSizeMiB = defaultTmpSizeMiB
3232
}
33-
mounts := []mountEntry{
33+
return []mountEntry{
3434
{"proc", "/proc", "proc", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""},
3535
{"sysfs", "/sys", "sysfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, ""},
3636
{"devtmpfs", "/dev", "devtmpfs", syscall.MS_NOSUID | syscall.MS_NOEXEC, ""},
3737
{"devpts", "/dev/pts", "devpts", syscall.MS_NOSUID | syscall.MS_NOEXEC, "newinstance,ptmxmode=0666,mode=0620,gid=5"},
3838
{"tmpfs", "/tmp", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV, fmt.Sprintf("size=%dm", tmpSizeMiB)},
3939
{"tmpfs", "/run", "tmpfs", syscall.MS_NOSUID | syscall.MS_NODEV | syscall.MS_NOEXEC, "size=64m"},
4040
}
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)
4147

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

guest/mount/mount_test.go

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
package mount
77

88
import (
9-
"fmt"
109
"log/slog"
1110
"os"
1211
"testing"
@@ -32,31 +31,30 @@ func TestWorkspaceReturnsErrorForInvalidMount(t *testing.T) {
3231
assert.Error(t, err)
3332
}
3433

35-
func TestEssentialMountPoints(t *testing.T) {
34+
func TestEssentialMounts(t *testing.T) {
3635
t.Parallel()
3736

38-
// Verify the mount table contains the expected entries.
39-
expected := []string{"/proc", "/sys", "/dev", "/dev/pts", "/tmp", "/run"}
40-
mounts := []mountEntry{
41-
{"proc", "/proc", "proc", 0, ""},
42-
{"sysfs", "/sys", "sysfs", 0, ""},
43-
{"devtmpfs", "/dev", "devtmpfs", 0, ""},
44-
{"devpts", "/dev/pts", "devpts", 0, "newinstance,ptmxmode=0666,mode=0620,gid=5"},
45-
{"tmpfs", "/tmp", "tmpfs", 0, fmt.Sprintf("size=%dm", defaultTmpSizeMiB)},
46-
{"tmpfs", "/run", "tmpfs", 0, "size=64m"},
47-
}
48-
for i, m := range mounts {
49-
assert.Equal(t, expected[i], m.target)
50-
}
51-
}
52-
53-
func TestEssentialTmpSizeDefault(t *testing.T) {
54-
t.Parallel()
55-
if os.Getuid() == 0 {
56-
t.Skip("test must run as non-root")
57-
}
58-
// Zero should be treated identically to the default size (no panic, same error path).
59-
err0 := Essential(slog.Default(), 0)
60-
errDef := Essential(slog.Default(), defaultTmpSizeMiB)
61-
assert.Equal(t, err0 != nil, errDef != nil)
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+
})
6260
}

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: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
"os"
1111
)
1212

13-
// configPath is the guest path where the host writes the VM config.
14-
const configPath = "/etc/propolis-vm.json"
13+
// GuestPath is the guest path where the host writes the VM config.
14+
const GuestPath = "/etc/propolis-vm.json"
1515

1616
// Config holds settings written by the host and read by the guest init.
1717
// Zero values mean "use the built-in default" for each field.
@@ -25,7 +25,12 @@ type Config struct {
2525
// Returns a zero-value Config (all defaults) if the file does not exist,
2626
// ensuring backward compatibility with hosts that do not write the file.
2727
func Read() (Config, error) {
28-
data, err := os.ReadFile(configPath)
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)
2934
if err != nil {
3035
if errors.Is(err, os.ErrNotExist) {
3136
return Config{}, nil

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: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,22 @@ import (
1313
"sort"
1414
"strings"
1515

16+
"github.com/stacklok/propolis/guest/vmconfig"
1617
"github.com/stacklok/propolis/image"
1718
"github.com/stacklok/propolis/internal/pathutil"
1819
"github.com/stacklok/propolis/internal/xattr"
1920
)
2021

21-
// vmConfigGuestPath is the guest path for the VM config file written by InjectVMConfig.
22-
const vmConfigGuestPath = "/etc/propolis-vm.json"
23-
24-
// VMConfig holds settings written by the host into the rootfs and read by the
25-
// guest init before mounting filesystems. Fields use omitempty so the file is
26-
// only written when a non-default value is present.
27-
type VMConfig struct {
28-
TmpSizeMiB uint32 `json:"tmp_size_mib,omitempty"`
29-
}
30-
3122
// InjectVMConfig returns a RootFSHook that writes the given VM config as JSON
3223
// to /etc/propolis-vm.json inside the rootfs. The guest init reads this file
3324
// to configure mounts before the SSH server starts.
34-
func InjectVMConfig(cfg VMConfig) func(string, *image.OCIConfig) error {
25+
func InjectVMConfig(cfg vmconfig.Config) func(string, *image.OCIConfig) error {
3526
return func(rootfsPath string, _ *image.OCIConfig) error {
3627
data, err := json.Marshal(cfg)
3728
if err != nil {
3829
return fmt.Errorf("marshal vm config: %w", err)
3930
}
40-
return InjectFile(vmConfigGuestPath, data, 0o644)(rootfsPath, nil)
31+
return InjectFile(vmconfig.GuestPath, data, 0o644)(rootfsPath, nil)
4132
}
4233
}
4334

hooks/hooks_test.go

Lines changed: 44 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
"os"
910
"path/filepath"
@@ -12,6 +13,8 @@ import (
1213

1314
"github.com/stretchr/testify/assert"
1415
"github.com/stretchr/testify/require"
16+
17+
"github.com/stacklok/propolis/guest/vmconfig"
1518
)
1619

1720
// chownCall records a single chown invocation.
@@ -41,6 +44,47 @@ func recordingChown() (ChownFunc, func() []chownCall) {
4144
return fn, get
4245
}
4346

47+
func TestInjectVMConfig(t *testing.T) {
48+
t.Parallel()
49+
50+
tests := []struct {
51+
name string
52+
cfg vmconfig.Config
53+
}{
54+
{
55+
name: "non-zero config",
56+
cfg: vmconfig.Config{TmpSizeMiB: 512},
57+
},
58+
{
59+
name: "zero-value config",
60+
cfg: vmconfig.Config{},
61+
},
62+
}
63+
64+
for _, tt := range tests {
65+
t.Run(tt.name, func(t *testing.T) {
66+
t.Parallel()
67+
68+
rootfs := t.TempDir()
69+
hook := InjectVMConfig(tt.cfg)
70+
71+
err := hook(rootfs, nil)
72+
require.NoError(t, err)
73+
74+
data, err := os.ReadFile(filepath.Join(rootfs, "etc", "propolis-vm.json"))
75+
require.NoError(t, err)
76+
77+
var got vmconfig.Config
78+
require.NoError(t, json.Unmarshal(data, &got))
79+
assert.Equal(t, tt.cfg, got)
80+
81+
info, err := os.Stat(filepath.Join(rootfs, "etc", "propolis-vm.json"))
82+
require.NoError(t, err)
83+
assert.Equal(t, os.FileMode(0o644), info.Mode().Perm())
84+
})
85+
}
86+
}
87+
4488
func TestInjectFile_WritesContent(t *testing.T) {
4589
t.Parallel()
4690

options.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,9 @@ func WithLogLevel(level uint32) Option {
300300
}
301301

302302
// WithTmpSize sets the size of the /tmp tmpfs inside the guest VM in MiB.
303-
// Defaults to 256 MiB when 0 or not set.
303+
// Defaults to 256 MiB when 0 or not set. The kernel enforces available
304+
// memory as the upper bound; unreasonable values will cause a mount failure
305+
// inside the guest.
304306
// The value is written to /etc/propolis-vm.json in the rootfs and read by
305307
// the guest init before mounting filesystems.
306308
func WithTmpSize(mib uint32) Option {

propolis.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"syscall"
2828
"time"
2929

30+
"github.com/stacklok/propolis/guest/vmconfig"
3031
"github.com/stacklok/propolis/hooks"
3132
"github.com/stacklok/propolis/hypervisor"
3233
"github.com/stacklok/propolis/hypervisor/libkrun"
@@ -128,7 +129,7 @@ func Run(ctx context.Context, imageRef string, opts ...Option) (*VM, error) {
128129
// Only written when a non-default value is configured, keeping the
129130
// file absent for callers that rely on the built-in 256 MiB default.
130131
if cfg.tmpSizeMiB > 0 {
131-
vmCfgHook := hooks.InjectVMConfig(hooks.VMConfig{TmpSizeMiB: cfg.tmpSizeMiB})
132+
vmCfgHook := hooks.InjectVMConfig(vmconfig.Config{TmpSizeMiB: cfg.tmpSizeMiB})
132133
if err := vmCfgHook(rootfs.Path, rootfs.Config); err != nil {
133134
return nil, fmt.Errorf("inject vm config: %w", err)
134135
}

0 commit comments

Comments
 (0)