The TUI should show VM capacity pressure at a glance instead of making users read raw numbers or drill into per-VM details. Add a compact colored status row under the header that renders CPU, RAM, and disk usage as progress bars. CPU and RAM reflect reserved resources for running VMs, while disk reflects actual allocated overlay and work-disk bytes across all VMs against the filesystem backing banger state. Add host resource and filesystem helpers in the system package and cover the new aggregation and rendering behavior with TUI and system tests. Verified with GOCACHE=/tmp/banger-gocache go test ./... and GOCACHE=/tmp/banger-gocache make build.
411 lines
11 KiB
Go
411 lines
11 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 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 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")
|
|
}
|
|
}
|