Avoid the Alpine boot stall caused by kernel ip= autoconfig running before virtio_net is available. Split runtime and image-build boot args so managed VMs boot without kernel network autoconfig, inject a static guest network config plus bootstrap script into the runtime overlay, and keep image builds on the old path for compatibility with existing base images. Preserve executable bits when patching guest files into ext4 images and add coverage for the new boot-arg split and guest network config generation. Validated with go test ./..., a rebuilt Alpine image, and a fresh alp-fast create/ssh check that brought vm.start down to about 2.7s.
510 lines
14 KiB
Go
510 lines
14 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type systemCall struct {
|
|
sudo bool
|
|
name string
|
|
args []string
|
|
}
|
|
|
|
type systemStep struct {
|
|
call systemCall
|
|
out []byte
|
|
err error
|
|
}
|
|
|
|
type scriptedRunner struct {
|
|
t *testing.T
|
|
steps []systemStep
|
|
calls []systemCall
|
|
}
|
|
|
|
func (r *scriptedRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
return r.next(systemCall{name: name, args: append([]string(nil), args...)})
|
|
}
|
|
|
|
func (r *scriptedRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
|
|
return r.next(systemCall{sudo: true, args: append([]string(nil), args...)})
|
|
}
|
|
|
|
func (r *scriptedRunner) next(call systemCall) ([]byte, error) {
|
|
r.t.Helper()
|
|
r.calls = append(r.calls, call)
|
|
if len(r.steps) == 0 {
|
|
r.t.Fatalf("unexpected call: %+v", call)
|
|
}
|
|
step := r.steps[0]
|
|
r.steps = r.steps[1:]
|
|
if step.call.sudo != call.sudo || step.call.name != call.name || !reflect.DeepEqual(step.call.args, call.args) {
|
|
r.t.Fatalf("call mismatch:\n got: %+v\n want: %+v", call, step.call)
|
|
}
|
|
return step.out, step.err
|
|
}
|
|
|
|
func (r *scriptedRunner) assertExhausted() {
|
|
r.t.Helper()
|
|
if len(r.steps) != 0 {
|
|
r.t.Fatalf("unconsumed steps: %+v", r.steps)
|
|
}
|
|
}
|
|
|
|
type funcRunner struct {
|
|
run func(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
runSudo func(ctx context.Context, args ...string) ([]byte, error)
|
|
}
|
|
|
|
func (r funcRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
if r.run == nil {
|
|
return nil, errors.New("unexpected Run call")
|
|
}
|
|
return r.run(ctx, name, args...)
|
|
}
|
|
|
|
func (r funcRunner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
|
|
if r.runSudo == nil {
|
|
return nil, errors.New("unexpected RunSudo call")
|
|
}
|
|
return r.runSudo(ctx, args...)
|
|
}
|
|
|
|
func TestResizeExt4ImageStopsAtFirstFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T) string
|
|
steps func(path string) []systemStep
|
|
wantErr string
|
|
wantCalls int
|
|
}{
|
|
{
|
|
name: "truncate failure",
|
|
setup: func(t *testing.T) string {
|
|
return t.TempDir()
|
|
},
|
|
wantErr: "",
|
|
wantCalls: 0,
|
|
},
|
|
{
|
|
name: "e2fsck failure",
|
|
steps: func(path string) []systemStep {
|
|
return []systemStep{
|
|
{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", path}}, err: errors.New("e2fsck failed")},
|
|
}
|
|
},
|
|
wantErr: "e2fsck failed",
|
|
wantCalls: 1,
|
|
},
|
|
{
|
|
name: "resize2fs failure",
|
|
steps: func(path string) []systemStep {
|
|
return []systemStep{
|
|
{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", path}}},
|
|
{call: systemCall{name: "resize2fs", args: []string{path}}, err: errors.New("resize2fs failed")},
|
|
}
|
|
},
|
|
wantErr: "resize2fs failed",
|
|
wantCalls: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
path := "/tmp/root.ext4"
|
|
if tt.setup != nil {
|
|
path = tt.setup(t)
|
|
} else {
|
|
path = filepath.Join(t.TempDir(), "root.ext4")
|
|
if err := os.WriteFile(path, []byte("seed"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile(%s): %v", path, err)
|
|
}
|
|
}
|
|
var steps []systemStep
|
|
if tt.steps != nil {
|
|
steps = tt.steps(path)
|
|
}
|
|
runner := &scriptedRunner{t: t, steps: steps}
|
|
err := ResizeExt4Image(context.Background(), runner, path, 4096)
|
|
if err == nil {
|
|
t.Fatal("ResizeExt4Image() succeeded, want error")
|
|
}
|
|
if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("ResizeExt4Image() error = %v, want %q", err, tt.wantErr)
|
|
}
|
|
if len(runner.calls) != tt.wantCalls {
|
|
t.Fatalf("call count = %d, want %d", len(runner.calls), tt.wantCalls)
|
|
}
|
|
runner.assertExhausted()
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadNormalizedLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "packages.apt")
|
|
if err := os.WriteFile(path, []byte("\n# comment\n git \nless # trailing\n\r\ntmux\r\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
got, err := ReadNormalizedLines(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadNormalizedLines: %v", err)
|
|
}
|
|
want := []string{"git", "less", "tmux"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("lines = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildBootArgsOmitsKernelIPAutoconfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := BuildBootArgs("devbox")
|
|
want := "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rootfstype=ext4 rw hostname=devbox systemd.mask=home.mount systemd.mask=var.mount"
|
|
if got != want {
|
|
t.Fatalf("BuildBootArgs() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildBootArgsWithKernelIPIncludesHostnameInIPField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := BuildBootArgsWithKernelIP("devbox", "172.16.0.2", "172.16.0.1", "1.1.1.1")
|
|
want := "console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rootfstype=ext4 rw ip=172.16.0.2::172.16.0.1:255.255.255.0:devbox:eth0:off:1.1.1.1 hostname=devbox systemd.mask=home.mount systemd.mask=var.mount"
|
|
if got != want {
|
|
t.Fatalf("BuildBootArgsWithKernelIP() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestWriteExt4FileRemovesTempFileAndReturnsCopyError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
copyErr := errors.New("e2cp failed")
|
|
var tempPath string
|
|
runner := funcRunner{
|
|
runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
|
|
switch args[0] {
|
|
case "e2rm":
|
|
return nil, errors.New("ignore remove")
|
|
case "e2cp":
|
|
tempPath = args[1]
|
|
if _, err := os.Stat(tempPath); err != nil {
|
|
t.Fatalf("temp file missing during e2cp: %v", err)
|
|
}
|
|
return nil, copyErr
|
|
default:
|
|
t.Fatalf("unexpected sudo call: %v", args)
|
|
return nil, nil
|
|
}
|
|
},
|
|
}
|
|
|
|
err := WriteExt4File(context.Background(), runner, "/tmp/root.ext4", "/etc/hostname", []byte("devbox\n"))
|
|
if !errors.Is(err, copyErr) {
|
|
t.Fatalf("WriteExt4File() error = %v, want %v", err, copyErr)
|
|
}
|
|
if tempPath == "" {
|
|
t.Fatal("expected e2cp temp path to be recorded")
|
|
}
|
|
if _, err := os.Stat(tempPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("temp file still exists after WriteExt4File: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWriteExt4FileModeUsesRequestedPermissions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
debugfsCalled := false
|
|
runner := funcRunner{
|
|
runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
|
|
switch args[0] {
|
|
case "e2rm":
|
|
return nil, nil
|
|
case "e2cp":
|
|
info, err := os.Stat(args[1])
|
|
if err != nil {
|
|
t.Fatalf("Stat(temp file): %v", err)
|
|
}
|
|
if got := info.Mode().Perm(); got != 0o755 {
|
|
t.Fatalf("temp file mode = %o, want 755", got)
|
|
}
|
|
return nil, nil
|
|
case "debugfs":
|
|
debugfsCalled = true
|
|
want := []string{"debugfs", "-w", "-R", "sif /usr/local/libexec/banger-network-bootstrap mode 0100755", "/tmp/root.ext4"}
|
|
if !reflect.DeepEqual(args, want) {
|
|
t.Fatalf("debugfs args = %v, want %v", args, want)
|
|
}
|
|
return nil, nil
|
|
default:
|
|
t.Fatalf("unexpected sudo call: %v", args)
|
|
return nil, nil
|
|
}
|
|
},
|
|
}
|
|
|
|
if err := WriteExt4FileMode(context.Background(), runner, "/tmp/root.ext4", "/usr/local/libexec/banger-network-bootstrap", 0o755, []byte("#!/bin/sh\n")); err != nil {
|
|
t.Fatalf("WriteExt4FileMode() error = %v", err)
|
|
}
|
|
if !debugfsCalled {
|
|
t.Fatal("expected debugfs mode fixup to run")
|
|
}
|
|
}
|
|
|
|
func TestMountTempDirUsesLoopForRegularFilesAndCleanupUsesBackgroundContext(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
source := filepath.Join(t.TempDir(), "root.ext4")
|
|
if err := os.WriteFile(source, []byte("rootfs"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var mountDir string
|
|
calls := 0
|
|
runner := funcRunner{
|
|
runSudo: func(callCtx context.Context, args ...string) ([]byte, error) {
|
|
calls++
|
|
switch calls {
|
|
case 1:
|
|
mountDir = args[len(args)-1]
|
|
want := []string{"mount", "-o", "ro,loop", source, mountDir}
|
|
if !reflect.DeepEqual(args, want) {
|
|
t.Fatalf("mount args = %v, want %v", args, want)
|
|
}
|
|
case 2:
|
|
if callCtx.Err() != nil {
|
|
t.Fatalf("cleanup context should not be canceled: %v", callCtx.Err())
|
|
}
|
|
want := []string{"umount", mountDir}
|
|
if !reflect.DeepEqual(args, want) {
|
|
t.Fatalf("cleanup args = %v, want %v", args, want)
|
|
}
|
|
default:
|
|
t.Fatalf("unexpected RunSudo call %d: %v", calls, args)
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
gotMountDir, cleanup, err := MountTempDir(ctx, runner, source, true)
|
|
if err != nil {
|
|
t.Fatalf("MountTempDir: %v", err)
|
|
}
|
|
if gotMountDir != mountDir {
|
|
t.Fatalf("mount dir = %q, want %q", gotMountDir, mountDir)
|
|
}
|
|
cancel()
|
|
if err := cleanup(); err != nil {
|
|
t.Fatalf("cleanup: %v", err)
|
|
}
|
|
if _, err := os.Stat(mountDir); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("mount dir still exists after cleanup: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseProcHelpers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stat, err := parseProcStat("1234 (firecracker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22")
|
|
if err != nil {
|
|
t.Fatalf("parseProcStat: %v", err)
|
|
}
|
|
if stat.userTicks != 11 || stat.systemTicks != 12 || stat.startTicks != 19 {
|
|
t.Fatalf("proc stat = %+v", stat)
|
|
}
|
|
|
|
statm, err := parseProcStatm("200 50 0 0 0 0 0")
|
|
if err != nil {
|
|
t.Fatalf("parseProcStatm: %v", err)
|
|
}
|
|
if statm.sizePages != 200 || statm.residentPages != 50 {
|
|
t.Fatalf("proc statm = %+v", statm)
|
|
}
|
|
|
|
uptime, err := parseProcUptime("321.50 42.10")
|
|
if err != nil {
|
|
t.Fatalf("parseProcUptime: %v", err)
|
|
}
|
|
if uptime != 321.50 {
|
|
t.Fatalf("uptime = %v, want 321.50", uptime)
|
|
}
|
|
}
|
|
|
|
func TestParseMemTotal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
total, err := parseMemTotal("MemTotal: 131900020 kB\nMemFree: 1024 kB\n")
|
|
if err != nil {
|
|
t.Fatalf("parseMemTotal: %v", err)
|
|
}
|
|
if total != 131900020*1024 {
|
|
t.Fatalf("total = %d, want %d", total, int64(131900020*1024))
|
|
}
|
|
}
|
|
|
|
func TestParseMemTotalErrorsWhenMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if _, err := parseMemTotal("MemFree: 123 kB\n"); err == nil {
|
|
t.Fatal("parseMemTotal() error = nil, want missing MemTotal")
|
|
}
|
|
}
|
|
|
|
func TestReadFilesystemUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
usage, err := ReadFilesystemUsage(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("ReadFilesystemUsage: %v", err)
|
|
}
|
|
if usage.TotalBytes <= 0 {
|
|
t.Fatalf("usage.TotalBytes = %d, want positive", usage.TotalBytes)
|
|
}
|
|
if usage.FreeBytes < 0 {
|
|
t.Fatalf("usage.FreeBytes = %d, want non-negative", usage.FreeBytes)
|
|
}
|
|
}
|
|
|
|
func TestMountTempDirRemovesTempDirWhenMountFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
source := t.TempDir()
|
|
var mountDir string
|
|
runner := funcRunner{
|
|
runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
|
|
mountDir = args[len(args)-1]
|
|
return nil, errors.New("mount failed")
|
|
},
|
|
}
|
|
|
|
_, _, err := MountTempDir(context.Background(), runner, source, false)
|
|
if err == nil || !strings.Contains(err.Error(), "mount failed") {
|
|
t.Fatalf("MountTempDir() error = %v, want mount failure", err)
|
|
}
|
|
if mountDir == "" {
|
|
t.Fatal("expected mount path to be recorded")
|
|
}
|
|
if _, statErr := os.Stat(mountDir); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("mount dir still exists after failed mount: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestParseMetricsFileHandlesWholeAndLineDelimitedJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
full := filepath.Join(t.TempDir(), "metrics-full.json")
|
|
if err := os.WriteFile(full, []byte(`{"uptime":1,"signals":{"sigterm":0}}`), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
got := ParseMetricsFile(full)
|
|
if got["uptime"] != float64(1) {
|
|
t.Fatalf("ParseMetricsFile(full) = %v", got)
|
|
}
|
|
|
|
lines := filepath.Join(t.TempDir(), "metrics-lines.json")
|
|
if err := os.WriteFile(lines, []byte("junk\n{\"uptime\":2}\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
got = ParseMetricsFile(lines)
|
|
if got["uptime"] != float64(2) {
|
|
t.Fatalf("ParseMetricsFile(lines) = %v", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateFSTabStripsLegacyMountsAndAddsDefaultsOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := strings.Join([]string{
|
|
"/dev/vda / ext4 defaults 0 1",
|
|
"/dev/vdb /home ext4 defaults 0 2",
|
|
"/dev/vdc /var ext4 defaults 0 2",
|
|
"/dev/vdb /root ext4 defaults 0 2",
|
|
"tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0",
|
|
"",
|
|
}, "\n")
|
|
|
|
got := UpdateFSTab(input)
|
|
if strings.Contains(got, "/home") || strings.Contains(got, "/var") {
|
|
t.Fatalf("UpdateFSTab() kept legacy mounts: %q", got)
|
|
}
|
|
if strings.Count(got, "/dev/vdb /root ext4 defaults 0 2") != 1 {
|
|
t.Fatalf("UpdateFSTab() duplicated /root mount: %q", got)
|
|
}
|
|
if strings.Count(got, "tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0") != 1 {
|
|
t.Fatalf("UpdateFSTab() duplicated /run mount: %q", got)
|
|
}
|
|
if !strings.Contains(got, "tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0") {
|
|
t.Fatalf("UpdateFSTab() missing /tmp mount: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestUseLoopMount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
file := filepath.Join(t.TempDir(), "root.ext4")
|
|
if err := os.WriteFile(file, []byte("rootfs"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
|
|
if !useLoopMount(file) {
|
|
t.Fatalf("useLoopMount(%s) = false, want true", file)
|
|
}
|
|
if useLoopMount(dir) {
|
|
t.Fatalf("useLoopMount(%s) = true, want false", dir)
|
|
}
|
|
if useLoopMount(filepath.Join(dir, "missing")) {
|
|
t.Fatalf("useLoopMount(missing) = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestEstimateWorkSeedSizeFallsBackToSudoDuWhenUnreadable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootHome := filepath.Join(t.TempDir(), "root")
|
|
if err := os.Mkdir(rootHome, 0o700); err != nil {
|
|
t.Fatalf("Mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(rootHome, "visible.txt"), []byte("seed"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
if err := os.Chmod(rootHome, 0o000); err != nil {
|
|
t.Fatalf("Chmod: %v", err)
|
|
}
|
|
defer os.Chmod(rootHome, 0o700)
|
|
|
|
var sudoCalled bool
|
|
runner := funcRunner{
|
|
runSudo: func(ctx context.Context, args ...string) ([]byte, error) {
|
|
sudoCalled = true
|
|
want := []string{"du", "-sb", rootHome}
|
|
if !reflect.DeepEqual(args, want) {
|
|
t.Fatalf("RunSudo args = %v, want %v", args, want)
|
|
}
|
|
return []byte("4096\t" + rootHome + "\n"), nil
|
|
},
|
|
}
|
|
|
|
sizeBytes, err := estimateWorkSeedSize(context.Background(), runner, rootHome)
|
|
if err != nil {
|
|
t.Fatalf("estimateWorkSeedSize: %v", err)
|
|
}
|
|
if !sudoCalled {
|
|
t.Fatal("estimateWorkSeedSize did not fall back to sudo du")
|
|
}
|
|
if sizeBytes != minWorkSeedBytes {
|
|
t.Fatalf("sizeBytes = %d, want %d", sizeBytes, minWorkSeedBytes)
|
|
}
|
|
}
|