From 9b16b262754a8f3e0b8ea1c50e4c02a7b084cb69 Mon Sep 17 00:00:00 2001 From: Ananth Bhaskararaman Date: Sun, 16 Aug 2026 21:04:45 +0530 Subject: [PATCH] fix(pivot): give the new rootfs its own tmpfs, sized from real RAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rootfs was extracted straight into --work-dir, which defaults to /run/xmorph/rootfs. /run is itself a size-capped tmpfs — 10-20% of RAM on a systemd host — so the new rootfs silently inherited a cap that has nothing to do with how much memory is actually free. Reproduced on a 415 MiB Raspberry Pi 3A+, where /run is 83 MiB: $ xmorph build --image docker.io/library/debian:trixie Error: build rootfs: layer 0: extract layer 0: write /run/xmorph/demo/usr/share/info/find.info.gz: no space left on device $ df -h /run tmpfs 84M 84M 0 100% /run Two things wrong with that. The error names a random file rather than the cause, so it reads like a corrupt layer. And it fills /run to 100% on a host that is still running — /run is where systemd, dbus and friends keep their sockets, so a failed build degrades the OS we were trying not to break. The check that should have caught it was never called. internal/sysmem was written and unit-tested, but the only reference to it outside its own package was a comment in runPivot saying it would be wired up "when M5's RAM telemetry is surfaced". It also would not have helped: it compares the rootfs against MemAvailable (246 MiB on that host), not against the tmpfs cap (83 MiB), so it would have approved the build that then failed. So: * Mount a real tmpfs at --work-dir, sized explicitly. Default is MemAvailable minus a 10% reserve; --rootfs-size takes 512M/2G/50%, and --no-rootfs-tmpfs keeps the old behaviour for operators who have already arranged the storage. On the Pi above the default is ~154 MiB instead of 83, and the budget is stated in the log either way. * Actually call HeadroomCheck, against the size we are about to mount, and refuse while the old OS is still alive rather than OOMing mid-extract. Sizing from MemAvailable rather than MemTotal is deliberate: the old OS still holds its memory, and an OOM kill is worse than an ENOSPC because the kernel picks the victim — on a small host as likely sshd as xmorph. * Report the tmpfs budget in the build-rootfs error, since ENOSPC there is now a sizing decision the operator can act on. Two follow-on fixes this exposed: * os.RemoveAll(WorkDir) fails with EBUSY once WorkDir is a mount point. Replaced with rootfs.CleanTarget, which empties the directory instead of unlinking it — the operation the callers actually wanted, since they want an empty target and not a deleted one. A reused mount is now cleaned too, so an aborted run cannot layer two rootfses together. * Prepare's self-bind is skipped when the new root is already a mount. It exists only to satisfy pivot_root's "new_root must be a mount point" rule, and stacking a second mount to re-satisfy a rule that already holds just adds a mount to unwind. Verified on the same Pi: extraction into a correctly sized tmpfs completes, and /run stays at 4%. --- internal/cli/build.go | 7 +-- internal/cli/pivot.go | 96 +++++++++++++++++++++++++++++-- internal/config/config.go | 15 +++++ internal/config/flags.go | 2 + internal/config/size.go | 64 +++++++++++++++++++++ internal/config/size_test.go | 50 ++++++++++++++++ internal/pivot/prepare.go | 16 +++++- internal/pivot/tmpfs.go | 86 +++++++++++++++++++++++++++ internal/rootfs/clean.go | 37 ++++++++++++ internal/sysmem/meminfo.go | 23 ++++++++ internal/sysmem/recommend_test.go | 50 ++++++++++++++++ 11 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 internal/config/size.go create mode 100644 internal/config/size_test.go create mode 100644 internal/pivot/tmpfs.go create mode 100644 internal/rootfs/clean.go create mode 100644 internal/sysmem/recommend_test.go diff --git a/internal/cli/build.go b/internal/cli/build.go index 786fabc..91ac19a 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -51,12 +51,11 @@ func runBuild(ctx context.Context, cfg *config.Config) error { return nil } - if err := os.RemoveAll(cfg.WorkDir); err != nil && !os.IsNotExist(err) { + // CleanTarget, not RemoveAll: the work dir may be a mount point (the + // pivot path mounts a tmpfs there) and unlinking one fails with EBUSY. + if err := rootfs.CleanTarget(cfg.WorkDir); err != nil { return fmt.Errorf("clean work dir: %w", err) } - if err := os.MkdirAll(cfg.WorkDir, 0o755); err != nil { - return fmt.Errorf("create work dir: %w", err) - } slog.Info("building rootfs", "layers", len(cfg.Layers), "target", cfg.WorkDir) result, err := rootfs.Build(cfg.Layers, cfg.WorkDir) diff --git a/internal/cli/pivot.go b/internal/cli/pivot.go index 94df135..93ed913 100644 --- a/internal/cli/pivot.go +++ b/internal/cli/pivot.go @@ -20,6 +20,7 @@ import ( "github.com/ananthb/xmorph/internal/postpivot" "github.com/ananthb/xmorph/internal/process" "github.com/ananthb/xmorph/internal/rootfs" + "github.com/ananthb/xmorph/internal/sysmem" "github.com/ananthb/xmorph/internal/tsnetauth" "github.com/spf13/cobra" "golang.org/x/sys/unix" @@ -153,12 +154,96 @@ func runPivot(ctx context.Context, cfg *config.Config, stdout interface { return fmt.Errorf("create work dir: %w", err) } + // Give the new rootfs its own tmpfs, sized explicitly. + // + // Without this the rootfs is extracted into whatever filesystem WorkDir + // happens to land in — by default /run, which is itself a tmpfs capped + // at a fraction of RAM (83 MiB on a 415 MiB host). The extract then dies + // with ENOSPC well before RAM is exhausted, and the error points at a + // random file rather than at the real cause. Our own mount makes the + // budget explicit and independent of the host's /run sizing. + mem, err := sysmem.Read() + if err != nil { + return fmt.Errorf("read meminfo: %w", err) + } + var rootfsBudget uint64 + if cfg.NoRootfsTmpfs { + free, err := pivot.FreeBytes(cfg.WorkDir) + if err != nil { + return fmt.Errorf("statfs %s: %w", cfg.WorkDir, err) + } + rootfsBudget = free + slog.Info("using --work-dir as-is (no tmpfs)", "dir", cfg.WorkDir, "free_mib", free>>20) + } else { + mounted, err := pivot.IsMountPoint(cfg.WorkDir) + if err != nil { + return fmt.Errorf("check %s: %w", cfg.WorkDir, err) + } + if mounted { + // A leftover from an aborted run, or an operator-arranged mount. + // Reusing it silently would hide its size, so say so. + free, _ := pivot.FreeBytes(cfg.WorkDir) + rootfsBudget = free + slog.Warn("work dir is already a mount point; reusing it instead of mounting a tmpfs", + "dir", cfg.WorkDir, "free_mib", free>>20) + // A reused mount may hold a half-extracted rootfs from an + // aborted run. Layering a new rootfs over those leftovers is + // how you get a system that boots with two distros' worth of + // /etc, so empty it first. + if err := rootfs.CleanTarget(cfg.WorkDir); err != nil { + return fmt.Errorf("clean reused work dir: %w", err) + } + } else { + if cfg.RootfsSize != "" { + rootfsBudget, err = config.ParseSize(cfg.RootfsSize, mem.Total) + if err != nil { + return fmt.Errorf("--rootfs-size: %w", err) + } + } else { + rootfsBudget = mem.RecommendRootfsBytes() + } + if rootfsBudget == 0 { + return fmt.Errorf("no RAM available for a rootfs: %d MiB available, %d MiB total, %d MiB reserved", + mem.Available>>20, mem.Total>>20, mem.ReserveBytes()>>20) + } + // A tmpfs bigger than available RAM is not an error the kernel + // will report — it just gets swapped or OOMs mid-extract. Refuse + // while the old OS is still alive to say so. + if warn, err := mem.HeadroomCheck(rootfsBudget); err != nil { + return fmt.Errorf("%w (requested rootfs tmpfs of %d MiB; lower --rootfs-size or free memory first)", + err, rootfsBudget>>20) + } else if warn { + slog.Warn("rootfs tmpfs leaves little headroom; a large rootfs may OOM mid-extract", + "size_mib", rootfsBudget>>20, "available_mib", mem.Available>>20) + } + if err := pivot.MountTmpfs(cfg.WorkDir, rootfsBudget); err != nil { + return err + } + slog.Info("mounted rootfs tmpfs", "dir", cfg.WorkDir, "size_mib", rootfsBudget>>20, + "available_mib", mem.Available>>20, "total_mib", mem.Total>>20) + // Unmount on any abort before the pivot. unix.Exec never returns + // on success, so reaching a return at all means we are staying on + // the old OS — where leaving a RAM-backed copy of the rootfs + // pinned would be a slow leak on a host we just decided not to + // replace. No error check: "we returned" IS the abort signal. + defer func() { _ = pivot.UnmountTmpfs(cfg.WorkDir) }() + } + } + slog.Info("building rootfs", "layers", len(cfg.Layers), "target", cfg.WorkDir) result, err := rootfs.Build(cfg.Layers, cfg.WorkDir) if err != nil { - return fmt.Errorf("build rootfs: %w", err) + // ENOSPC here means the budget was too small, which is worth saying + // plainly — it is the single most common way this step fails. + return fmt.Errorf("build rootfs (tmpfs budget %d MiB — raise --rootfs-size if this was ENOSPC): %w", + rootfsBudget>>20, err) + } + if used, uerr := pivot.UsageBytes(cfg.WorkDir); uerr == nil { + slog.Info("rootfs built", "layers", result.LayerCount, "size_mib", used>>20, + "budget_mib", rootfsBudget>>20) + } else { + slog.Info("rootfs built", "layers", result.LayerCount) } - slog.Info("rootfs built", "layers", result.LayerCount) entrypoint, entryArgs, _ := resolveEntrypoint(cfg, result.Config) slog.Info("entrypoint resolved", "entrypoint", entrypoint, "args", len(entryArgs)) @@ -377,12 +462,11 @@ func runContain(cfg *config.Config) error { } slog.Info("building rootfs for --contain", "layers", len(cfg.Layers), "target", cfg.WorkDir) - if err := os.RemoveAll(cfg.WorkDir); err != nil && !os.IsNotExist(err) { + // CleanTarget, not RemoveAll: the work dir may be a mount point (the + // pivot path mounts a tmpfs there) and unlinking one fails with EBUSY. + if err := rootfs.CleanTarget(cfg.WorkDir); err != nil { return fmt.Errorf("clean work dir: %w", err) } - if err := os.MkdirAll(cfg.WorkDir, 0o755); err != nil { - return fmt.Errorf("create work dir: %w", err) - } result, err := rootfs.Build(cfg.Layers, cfg.WorkDir) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index e169837..69009bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,6 +86,21 @@ type Config struct { CacheDir string WorkDir string + // RootfsSize is the size of the tmpfs mounted at WorkDir to hold the new + // rootfs: a byte count ("512M", "2G") or a percentage of total RAM + // ("50%"). Empty means auto — see sysmem.RecommendRootfsBytes. + // + // This exists because WorkDir defaults to a path under /run, and /run is + // itself a size-capped tmpfs (commonly 10-20% of RAM). Without our own + // mount the rootfs silently inherits that cap. + RootfsSize string + + // NoRootfsTmpfs extracts straight into WorkDir instead of mounting a + // tmpfs over it. For the case where the operator has already arranged + // the storage — a pre-mounted tmpfs, or a disk that is not the one + // being overwritten. + NoRootfsTmpfs bool + // LogDir is an additional on-disk log sink: xmorph mirrors its slog // output to {LogDir}/xmorph.log (alongside stderr/journald and syslog) // and flushes the in-memory buffer there just before pivot_root, so the diff --git a/internal/config/flags.go b/internal/config/flags.go index 8ff11f8..18160f7 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -38,6 +38,8 @@ func bindCommon(fs *pflag.FlagSet, cfg *Config) { fs.BoolVarP(&cfg.Verbose, "verbose", "v", false, "verbose output") fs.BoolVar(&cfg.NoCache, "no-cache", false, "skip build cache, pull fresh") fs.StringVar(&cfg.WorkDir, "work-dir", DefaultWorkDir, "working directory for rootfs extraction") + fs.StringVar(&cfg.RootfsSize, "rootfs-size", "", "size of the tmpfs holding the new rootfs (e.g. 512M, 2G, 50%); default: available RAM minus a 10% reserve") + fs.BoolVar(&cfg.NoRootfsTmpfs, "no-rootfs-tmpfs", false, "extract into --work-dir as-is instead of mounting a tmpfs over it") } func bindPivotOnly(fs *pflag.FlagSet, cfg *Config) { diff --git a/internal/config/size.go b/internal/config/size.go new file mode 100644 index 0000000..7ea8313 --- /dev/null +++ b/internal/config/size.go @@ -0,0 +1,64 @@ +package config + +import ( + "fmt" + "strconv" + "strings" +) + +// ParseSize turns a human size into bytes. Accepts a plain byte count, an +// IEC suffix (K/M/G/T, optionally with an "i" and/or a trailing "B" — 512M, +// 512MiB and 512MB are all 512*1024*1024), or a percentage of total, which +// is why totalBytes is required. +// +// Percentages exist because the useful default here is relative: "half of +// RAM" is portable across a 415 MiB Pi and a 64 GiB server in a way that +// "2G" is not. +func ParseSize(s string, totalBytes uint64) (uint64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty size") + } + + if pct, ok := strings.CutSuffix(s, "%"); ok { + v, err := strconv.ParseFloat(strings.TrimSpace(pct), 64) + if err != nil { + return 0, fmt.Errorf("parse percentage %q: %w", s, err) + } + if v <= 0 || v > 100 { + return 0, fmt.Errorf("percentage %q out of range (0, 100]", s) + } + return uint64(float64(totalBytes) * v / 100), nil + } + + // Strip an optional trailing "B"/"iB" so 512MB, 512MiB and 512M agree. + u := strings.ToUpper(s) + u = strings.TrimSuffix(u, "B") + u = strings.TrimSuffix(u, "I") + + mult := uint64(1) + if len(u) > 0 { + switch u[len(u)-1] { + case 'K': + mult = 1 << 10 + case 'M': + mult = 1 << 20 + case 'G': + mult = 1 << 30 + case 'T': + mult = 1 << 40 + } + if mult > 1 { + u = u[:len(u)-1] + } + } + + n, err := strconv.ParseUint(strings.TrimSpace(u), 10, 64) + if err != nil { + return 0, fmt.Errorf("parse size %q: %w", s, err) + } + if n > (1<<64-1)/mult { + return 0, fmt.Errorf("size %q overflows", s) + } + return n * mult, nil +} diff --git a/internal/config/size_test.go b/internal/config/size_test.go new file mode 100644 index 0000000..5385062 --- /dev/null +++ b/internal/config/size_test.go @@ -0,0 +1,50 @@ +package config + +import "testing" + +func TestParseSize(t *testing.T) { + const total = 4 << 30 // 4 GiB + + cases := []struct { + in string + want uint64 + bad bool + }{ + {in: "512", want: 512}, + {in: "512K", want: 512 << 10}, + {in: "512M", want: 512 << 20}, + {in: "2G", want: 2 << 30}, + {in: "1T", want: 1 << 40}, + // The three spellings of the same size must agree; operators type + // all three and a silent 1000-vs-1024 difference is a bad surprise + // when it is the margin between fitting in RAM and an OOM. + {in: "512MB", want: 512 << 20}, + {in: "512MiB", want: 512 << 20}, + {in: "50%", want: 2 << 30}, + {in: "100%", want: total}, + {in: " 256M ", want: 256 << 20}, + {in: "", bad: true}, + {in: "0%", bad: true}, + {in: "101%", bad: true}, + {in: "-5%", bad: true}, + {in: "banana", bad: true}, + {in: "12X", bad: true}, + } + + for _, c := range cases { + got, err := ParseSize(c.in, total) + if c.bad { + if err == nil { + t.Errorf("ParseSize(%q) = %d, want error", c.in, got) + } + continue + } + if err != nil { + t.Errorf("ParseSize(%q) unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("ParseSize(%q) = %d, want %d", c.in, got, c.want) + } + } +} diff --git a/internal/pivot/prepare.go b/internal/pivot/prepare.go index 88c820c..b8b83a3 100644 --- a/internal/pivot/prepare.go +++ b/internal/pivot/prepare.go @@ -41,8 +41,20 @@ func Prepare(opts PrepareOptions) error { // (This is why pivot_root recipes always bind the new root onto itself // before populating it.) pivot_root also requires newRoot to be a mount // point, which this satisfies. - if err := EnsureMountPoint(opts.NewRoot); err != nil { - return fmt.Errorf("bind new root onto itself: %w", err) + // + // Skipped when newRoot is already a mount in its own right — the normal + // case now that the orchestrator mounts a tmpfs there. The self-bind + // exists only to satisfy pivot_root's "new_root must be a mount point" + // rule; stacking a second mount to re-satisfy a rule that already holds + // just adds a mount to unwind. + alreadyMounted, err := IsMountPoint(opts.NewRoot) + if err != nil { + return fmt.Errorf("check whether new root is a mount point: %w", err) + } + if !alreadyMounted { + if err := EnsureMountPoint(opts.NewRoot); err != nil { + return fmt.Errorf("bind new root onto itself: %w", err) + } } if err := MakePrivate(opts.NewRoot); err != nil { return fmt.Errorf("make new root private: %w", err) diff --git a/internal/pivot/tmpfs.go b/internal/pivot/tmpfs.go new file mode 100644 index 0000000..c69d5a3 --- /dev/null +++ b/internal/pivot/tmpfs.go @@ -0,0 +1,86 @@ +package pivot + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +// IsMountPoint reports whether path is itself a mount point, by comparing +// its device number with its parent's. This is the same test `mountpoint(1)` +// makes, and it is cheaper and more robust than scanning /proc/self/mounts +// for a string match. +func IsMountPoint(path string) (bool, error) { + var st, parent unix.Stat_t + if err := unix.Lstat(path, &st); err != nil { + return false, err + } + if err := unix.Lstat(filepath.Dir(path), &parent); err != nil { + return false, err + } + return st.Dev != parent.Dev, nil +} + +// MountTmpfs mounts a tmpfs of exactly sizeBytes at path, creating path if +// needed. +// +// This exists because the new rootfs must NOT inherit the size cap of +// whatever filesystem its path happens to sit in. The default work dir is +// under /run, which on a systemd host is a tmpfs sized to a fraction of RAM +// (10-20% is typical: 83 MiB on a 415 MiB Raspberry Pi). Extracting a rootfs +// into it fails with ENOSPC long before RAM is actually exhausted, and the +// failure surfaces as a confusing mid-extract write error rather than +// "your rootfs does not fit". +// +// Mounting our own tmpfs makes the budget explicit and independent of the +// host's /run sizing. +func MountTmpfs(path string, sizeBytes uint64) error { + if err := os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + opts := fmt.Sprintf("size=%d,mode=0755", sizeBytes) + if err := unix.Mount("tmpfs", path, "tmpfs", unix.MS_NOSUID|unix.MS_NODEV, opts); err != nil { + return fmt.Errorf("mount tmpfs (%s) at %s: %w", opts, path, err) + } + return nil +} + +// UnmountTmpfs removes a tmpfs previously mounted by MountTmpfs. Used on the +// abort paths so a failed pivot does not leave a RAM-backed filesystem (and +// its contents) pinned on a host that is staying on its old OS. +func UnmountTmpfs(path string) error { + return unix.Unmount(path, unix.MNT_DETACH) +} + +// FreeBytes returns the free space available at path. +func FreeBytes(path string) (uint64, error) { + var st unix.Statfs_t + if err := unix.Statfs(path, &st); err != nil { + return 0, err + } + return uint64(st.Bavail) * uint64(st.Bsize), nil +} + +// UsageBytes returns the bytes consumed by the tree rooted at path, +// counting allocated blocks rather than apparent size so it matches what +// the filesystem actually charges. +func UsageBytes(path string) (uint64, error) { + var total uint64 + err := filepath.WalkDir(path, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + var st unix.Stat_t + if err := unix.Lstat(p, &st); err != nil { + return nil // raced with a write; not worth failing the pivot over + } + total += uint64(st.Blocks) * 512 + return nil + }) + return total, err +} diff --git a/internal/rootfs/clean.go b/internal/rootfs/clean.go new file mode 100644 index 0000000..32e52fb --- /dev/null +++ b/internal/rootfs/clean.go @@ -0,0 +1,37 @@ +package rootfs + +import ( + "fmt" + "os" + "path/filepath" +) + +// CleanTarget empties dir without removing dir itself, creating it if it does +// not exist. +// +// The distinction matters: dir may be a mount point (the pivot path mounts a +// tmpfs at the work dir), and unlinking a mount point fails with EBUSY no +// matter how the caller feels about it. `os.RemoveAll(dir)` therefore breaks +// the moment the work dir stops being an ordinary directory, with an error — +// "device or resource busy" — that reads like a bug in the extractor rather +// than a lifecycle mistake. +// +// Removing the contents is also the more correct operation regardless: the +// caller wants an empty rootfs target, not a deleted one, and recreating the +// directory would drop whatever mode or mount the caller had arranged. +func CleanTarget(dir string) error { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return os.MkdirAll(dir, 0o755) + } + if err != nil { + return fmt.Errorf("read %s: %w", dir, err) + } + for _, e := range entries { + p := filepath.Join(dir, e.Name()) + if err := os.RemoveAll(p); err != nil { + return fmt.Errorf("remove %s: %w", p, err) + } + } + return nil +} diff --git a/internal/sysmem/meminfo.go b/internal/sysmem/meminfo.go index d5e6d91..d75cb75 100644 --- a/internal/sysmem/meminfo.go +++ b/internal/sysmem/meminfo.go @@ -76,6 +76,29 @@ func readFrom(path string) (*MemInfo, error) { return out, nil } +// ReserveBytes is the RAM deliberately left unused by the new rootfs: 10% of +// total. The same constant the headroom check enforces, expressed once so the +// tmpfs we size and the check we run afterwards cannot disagree. +func (m *MemInfo) ReserveBytes() uint64 { return m.Total / 10 } + +// RecommendRootfsBytes is the tmpfs size to give the new rootfs when the +// operator did not choose one: everything currently available, minus the +// reserve. +// +// Deliberately based on MemAvailable, not MemTotal. When the pivot runs the +// old OS is still holding its memory, so sizing off total would hand out RAM +// that is not actually free — turning an honest ENOSPC into an OOM kill +// partway through the extract. That is the worse failure: the OOM killer +// picks its own victim, and on a small host that is as likely to be sshd as +// it is to be xmorph. +func (m *MemInfo) RecommendRootfsBytes() uint64 { + reserve := m.ReserveBytes() + if m.Available <= reserve { + return 0 + } + return m.Available - reserve +} + // HeadroomCheck is the same rule as src/cmd/pivot.zig:412-452 and // src/util/memory.zig:25-52: returns nil if at least 10% of total RAM // would remain free after a rootfs of rootfsBytes is placed in tmpfs. diff --git a/internal/sysmem/recommend_test.go b/internal/sysmem/recommend_test.go new file mode 100644 index 0000000..7852c5f --- /dev/null +++ b/internal/sysmem/recommend_test.go @@ -0,0 +1,50 @@ +package sysmem + +import "testing" + +// The Raspberry Pi 3A+ that motivated the tmpfs work: 415 MiB total, ~246 MiB +// available. The old behaviour extracted into /run, capped at 83 MiB, while +// the headroom check (which was never called) would have looked at the 246 +// and happily approved. Both numbers matter, and they are not the same +// number — that is the whole bug. +func TestRecommendRootfsBytes_SmallHost(t *testing.T) { + m := &MemInfo{ + Total: 415 << 20, + Available: 246 << 20, + } + + got := m.RecommendRootfsBytes() + want := uint64(246<<20) - uint64(415<<20)/10 // available - 10% of total + if got != want { + t.Fatalf("RecommendRootfsBytes() = %d MiB, want %d MiB", got>>20, want>>20) + } + + // It must beat the /run cap that was silently in force before, or the + // fix buys nothing on exactly the host that needed it. + const runCap = 83 << 20 + if got <= runCap { + t.Errorf("recommended %d MiB is no better than the /run cap of %d MiB", got>>20, runCap>>20) + } + + // And whatever it recommends must survive its own headroom check, + // otherwise the default configuration refuses to run. + if _, err := m.HeadroomCheck(got); err != nil { + t.Errorf("recommended size fails HeadroomCheck: %v", err) + } +} + +func TestRecommendRootfsBytes_NoRoom(t *testing.T) { + // Available at or below the reserve leaves nothing to hand out. Zero is + // the signal for "refuse", not a size to mount. + m := &MemInfo{Total: 1 << 30, Available: 64 << 20} + if got := m.RecommendRootfsBytes(); got != 0 { + t.Errorf("RecommendRootfsBytes() = %d, want 0 when available <= reserve", got) + } +} + +func TestReserveIsTenPercent(t *testing.T) { + m := &MemInfo{Total: 1000, Available: 1000} + if got := m.ReserveBytes(); got != 100 { + t.Errorf("ReserveBytes() = %d, want 100", got) + } +}