cmd/tailscale/cli: add 'tailscale configure flash-appliance'
Adds a CLI subcommand that downloads a signed Tailscale appliance image (Gokrazy archive format, GAF) from pkgs.tailscale.com, constructs a fresh GPT-partitioned disk from it (mbr.img + a synthesized partition table + boot.img + root.img), formats /perm as ext4 in pure Go via go-diskfs, and ejects the disk so a user running on a regular workstation can flash an SD card or homelab VM disk in one command without installing e2fsprogs. On macOS the target disk is auto-discovered via diskutil, skipping the boot disk and anything bigger than 256 GB out of paranoia. On Linux the user passes --disk=/dev/sdX explicitly. Windows is not supported yet and the command returns an error. The GPT layout matches monogok's full-disk layout via the new public github.com/bradfitz/monogok/disklayout package; a drift- guard test inside monogok asserts the two implementations stay byte-identical so OTA updates against monogok-built images keep working. Behind a ts_omit_flashappliance build tag (on by default). Updates #1866 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: Ic1a8cd185e7039edccb7702ab4104544fcb58d29
This commit is contained in:
committed by
Brad Fitzpatrick
parent
64422f274d
commit
d0fcb668d5
+27
-49
@@ -10,20 +10,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"tailscale.com/gokrazy/mkfs"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,25 +30,22 @@ var (
|
||||
gaf = flag.Bool("gaf", false, "if true, build a gokrazy archive format file instead of a full disk image")
|
||||
)
|
||||
|
||||
func findMkfsExt4() (string, error) {
|
||||
tries := []string{
|
||||
"/opt/homebrew/opt/e2fsprogs/sbin/mkfs.ext4",
|
||||
"/sbin/mkfs.ext4",
|
||||
}
|
||||
for _, p := range tries {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
p, err := exec.LookPath("mkfs.ext4")
|
||||
if err == nil {
|
||||
return p, nil
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "", errors.New("no mkfs.ext4 found; run `brew install e2fsprogs`")
|
||||
}
|
||||
return "", errors.New("No mkfs.ext4 found on system")
|
||||
}
|
||||
// imageSizeBytes is the size of the disk image we ask monogok to
|
||||
// produce (and that the AWS AMI import expects). It has to be large
|
||||
// enough to fit gokrazy's standard partition layout (see
|
||||
// github.com/bradfitz/monogok/disklayout):
|
||||
//
|
||||
// 4 MiB gap before the first partition
|
||||
// 100 MiB boot (FAT)
|
||||
// 500 MiB root A (squashfs; the partition OTA updates write into)
|
||||
// 500 MiB root B (squashfs)
|
||||
// ~96 MiB /perm (ext4; rest of the disk minus the secondary GPT)
|
||||
//
|
||||
// Bump this to give /perm more room (and to make the produced .img
|
||||
// file larger). The same value is passed to monogok via
|
||||
// --target_storage_bytes and to mkfs.Perm so the GPT and the ext4
|
||||
// inside it agree on the disk's size.
|
||||
const imageSizeBytes = 1258299392
|
||||
|
||||
var conf gokrazyConfig
|
||||
|
||||
@@ -141,14 +135,13 @@ func buildImage() error {
|
||||
args = append(args,
|
||||
"overwrite",
|
||||
"--full", filepath.Join(dir, *app+".img"),
|
||||
"--target_storage_bytes=1258299392",
|
||||
fmt.Sprintf("--target_storage_bytes=%d", imageSizeBytes),
|
||||
)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Dir = filepath.Join(dir, *app)
|
||||
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return err
|
||||
@@ -157,31 +150,16 @@ func buildImage() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mkfs, err := findMkfsExt4()
|
||||
imgPath := filepath.Join(dir, *app+".img")
|
||||
f, err := os.OpenFile(imgPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("open %s: %w", imgPath, err)
|
||||
}
|
||||
|
||||
// monogok overwrite emits a line of text saying how to run mkfs.ext4
|
||||
// to create the ext4 /perm filesystem. Parse that and run it.
|
||||
// The regexp is tight to avoid matching if the command changes,
|
||||
// to force us to check it's still correct/safe. But it shouldn't
|
||||
// change on its own because we pin the monogok version in our go.mod.
|
||||
//
|
||||
// TODO(bradfitz): emit this in a machine-readable way from monogok.
|
||||
rx := regexp.MustCompile(`(?m)/mkfs.ext4 (-F) (-E) (offset=\d+) (\S+) (\d+)\s*?$`)
|
||||
m := rx.FindStringSubmatch(buf.String())
|
||||
if m == nil {
|
||||
return fmt.Errorf("found no ext4 instructions in output")
|
||||
defer f.Close()
|
||||
if err := mkfs.Perm(f, imageSizeBytes); err != nil {
|
||||
return fmt.Errorf("formatting /perm in %s: %v", imgPath, err)
|
||||
}
|
||||
|
||||
log.Printf("Running %s %q ...", mkfs, m[1:])
|
||||
out, err := exec.Command(mkfs, m[1:]...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running %v: %v, %s", mkfs, err, out)
|
||||
}
|
||||
log.Printf("Success.")
|
||||
|
||||
log.Printf("Wrote ext4 /perm filesystem to %s.", imgPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package mkfs creates the writable ext4 /perm filesystem inside a
|
||||
// gokrazy disk image or block device, at the offset and length
|
||||
// determined by the gokrazy partition layout.
|
||||
//
|
||||
// Used by gokrazy/build.go when producing a "--full" disk image and by
|
||||
// "tailscale configure flash-appliance" when flashing an image to an
|
||||
// SD card, so the appliance has a working /perm on first boot without
|
||||
// requiring users to install mkfs.ext4 (e.g. e2fsprogs on macOS).
|
||||
package mkfs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/bradfitz/monogok/disklayout"
|
||||
"github.com/diskfs/go-diskfs/backend"
|
||||
"github.com/diskfs/go-diskfs/filesystem/ext4"
|
||||
)
|
||||
|
||||
// gptSecondaryReservedSectors is the number of 512-byte sectors that
|
||||
// monogok's GPT writer reserves at the end of the disk for the
|
||||
// secondary GPT (1 header sector + 32 partition-entry sectors). The
|
||||
// perm partition entry written by disklayout.WriteGPT is this many
|
||||
// sectors shorter than [disklayout.PermSize], so the ext4 filesystem
|
||||
// we create must shrink by the same amount to fit within the partition
|
||||
// the kernel sees.
|
||||
const gptSecondaryReservedSectors = 34
|
||||
|
||||
const sectorSize = 512
|
||||
|
||||
// Perm creates an ext4 filesystem with volume label "PERM" inside the
|
||||
// gokrazy /perm partition of f. devsizeBytes is the total disk size
|
||||
// that the gokrazy GPT in f was written for; the partition layout is
|
||||
// derived from it via [disklayout].
|
||||
//
|
||||
// To avoid issuing ext4.Create's hundreds of small scattered writes
|
||||
// against slow storage one syscall at a time, the filesystem is first
|
||||
// built in an in-memory sparse buffer and then only the genuinely
|
||||
// non-zero metadata pages are flushed to f, coalesced into the
|
||||
// fewest possible contiguous writes. ext4's initial superblock,
|
||||
// group descriptors, bitmaps, root inode, etc. land at the same
|
||||
// per-group byte offsets whether the destination had old ext4
|
||||
// metadata or zeros there, so a fresh ext4 always overwrites stale
|
||||
// metadata in place; data-area bytes that were never written are
|
||||
// not read by the kernel until they're allocated.
|
||||
//
|
||||
// f must be open read/write, and on macOS should be the buffered
|
||||
// /dev/diskN device rather than the raw /dev/rdiskN alias.
|
||||
func Perm(f *os.File, devsizeBytes int64) error {
|
||||
permStart := int64(disklayout.PermStartLBA(disklayout.DefaultBootPartitionStartLBA)) * sectorSize
|
||||
permSize := int64(disklayout.PermSize(disklayout.DefaultBootPartitionStartLBA, uint64(devsizeBytes))-gptSecondaryReservedSectors) * sectorSize
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Formatting /perm as ext4 (PERM): %s filesystem\n", humanBytes(permSize))
|
||||
|
||||
mem := newMemBackend(permSize)
|
||||
if _, err := ext4.Create(mem, permSize, 0, sectorSize, &ext4.Params{
|
||||
VolumeName: "PERM",
|
||||
// Force 4 KiB blocks. go-diskfs v1.9.3 otherwise defaults to 1
|
||||
// KiB blocks regardless of filesystem size, which makes a 128
|
||||
// MiB journal need ~131k blocks — past the 65535-blocks-per-
|
||||
// extent limit. 4 KiB blocks keep a typical journal in a
|
||||
// single extent. (Fixed upstream after v1.9.3.)
|
||||
SectorsPerBlock: 8,
|
||||
// Disable resize_inode. go-diskfs v1.9.3 only implements it
|
||||
// for 1 KiB block filesystems; for our 4 KiB blocks +
|
||||
// ~96 MiB perm, initResizeInode fails with "no backup groups
|
||||
// available". Matches go-diskfs's own tests for non-1 KiB
|
||||
// block sizes.
|
||||
Features: []ext4.FeatureOpt{
|
||||
ext4.WithFeatureReservedGDTBlocksForExpansion(false),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("ext4.Create: %w", err)
|
||||
}
|
||||
return mem.flushTo(f, permStart)
|
||||
}
|
||||
|
||||
// memPageSize is the granularity of memBackend's sparse allocation.
|
||||
// 4 KiB matches the ext4 block size we use, so most of ext4.Create's
|
||||
// writes touch exactly one page.
|
||||
const memPageSize = 4096
|
||||
|
||||
// memBackend is a sparse in-memory implementation of go-diskfs's
|
||||
// [backend.Storage]. It only allocates a [memPageSize]-byte chunk for
|
||||
// each page that ext4.Create actually touches; unwritten regions cost
|
||||
// only a map entry's worth of overhead and read back as zeros. The
|
||||
// caller flushes the allocated pages to the destination in contiguous
|
||||
// runs via [memBackend.flushTo].
|
||||
type memBackend struct {
|
||||
size int64 // logical size of the virtual device
|
||||
pages map[int64][]byte // page index → memPageSize bytes
|
||||
off int64 // current offset for io.Reader / io.Seeker compatibility
|
||||
}
|
||||
|
||||
func newMemBackend(size int64) *memBackend {
|
||||
return &memBackend{
|
||||
size: size,
|
||||
pages: make(map[int64][]byte),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadAt implements [io.ReaderAt]. Bytes within pages that were never
|
||||
// written read as zero.
|
||||
func (m *memBackend) ReadAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 || off >= m.size {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if max := m.size - off; int64(len(p)) > max {
|
||||
p = p[:max]
|
||||
}
|
||||
// Default everything to zero; allocated pages overwrite below.
|
||||
clear(p)
|
||||
total := 0
|
||||
for total < len(p) {
|
||||
absOff := off + int64(total)
|
||||
page := absOff / memPageSize
|
||||
within := int(absOff % memPageSize)
|
||||
room := memPageSize - within
|
||||
if room > len(p)-total {
|
||||
room = len(p) - total
|
||||
}
|
||||
if chunk, ok := m.pages[page]; ok {
|
||||
copy(p[total:total+room], chunk[within:within+room])
|
||||
}
|
||||
total += room
|
||||
}
|
||||
if int64(total) < int64(len(p)) {
|
||||
return total, io.EOF
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// WriteAt implements [io.WriterAt]. Pages are allocated on first
|
||||
// touch, except that writes whose data is entirely zero do NOT
|
||||
// allocate (or modify) any page: the caller's destination is assumed
|
||||
// to already have zeros where we never write. ext4.Create writes
|
||||
// tens-to-hundreds of MiB of zeros to initialize the inode table and
|
||||
// journal; suppressing those allocations is what keeps memory and SD
|
||||
// card writes proportional to the *real* metadata rather than the
|
||||
// filesystem size.
|
||||
//
|
||||
// CAVEAT: if the destination has stale non-zero data in those regions
|
||||
// (e.g. an SD card previously formatted with a different filesystem),
|
||||
// that data is left in place. For a fresh card this is fine; for
|
||||
// re-flashed cards the perm region's old data could confuse ext4's
|
||||
// recovery on first mount. Callers that re-flash should discard the
|
||||
// perm region first; we don't do that here.
|
||||
func (m *memBackend) WriteAt(p []byte, off int64) (int, error) {
|
||||
if off < 0 || off+int64(len(p)) > m.size {
|
||||
return 0, fmt.Errorf("write past buffer end: off=%d len=%d size=%d", off, len(p), m.size)
|
||||
}
|
||||
total := 0
|
||||
for total < len(p) {
|
||||
absOff := off + int64(total)
|
||||
page := absOff / memPageSize
|
||||
within := int(absOff % memPageSize)
|
||||
room := memPageSize - within
|
||||
if room > len(p)-total {
|
||||
room = len(p) - total
|
||||
}
|
||||
chunk, ok := m.pages[page]
|
||||
if !ok && isAllZero(p[total:total+room]) {
|
||||
// Don't allocate a fresh zero page.
|
||||
total += room
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
chunk = make([]byte, memPageSize)
|
||||
m.pages[page] = chunk
|
||||
}
|
||||
copy(chunk[within:within+room], p[total:total+room])
|
||||
total += room
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// isAllZero reports whether p is entirely 0x00.
|
||||
func isAllZero(p []byte) bool {
|
||||
for _, b := range p {
|
||||
if b != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Read implements [io.Reader].
|
||||
func (m *memBackend) Read(p []byte) (int, error) {
|
||||
n, err := m.ReadAt(p, m.off)
|
||||
m.off += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Seek implements [io.Seeker].
|
||||
func (m *memBackend) Seek(off int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
m.off = off
|
||||
case io.SeekCurrent:
|
||||
m.off += off
|
||||
case io.SeekEnd:
|
||||
m.off = m.size + off
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid whence %d", whence)
|
||||
}
|
||||
return m.off, nil
|
||||
}
|
||||
|
||||
// Close implements [io.Closer].
|
||||
func (m *memBackend) Close() error { return nil }
|
||||
|
||||
// Stat implements [fs.File].
|
||||
func (m *memBackend) Stat() (fs.FileInfo, error) {
|
||||
return memFileInfo{size: m.size}, nil
|
||||
}
|
||||
|
||||
// Sys implements [backend.Storage]; it returns ErrNotSuitable so
|
||||
// ext4.Create's optional fsync (ext4.go:730) is gracefully skipped.
|
||||
func (m *memBackend) Sys() (*os.File, error) { return nil, backend.ErrNotSuitable }
|
||||
|
||||
// Writable implements [backend.Storage].
|
||||
func (m *memBackend) Writable() (backend.WritableFile, error) { return m, nil }
|
||||
|
||||
// Path implements [backend.Storage].
|
||||
func (m *memBackend) Path() string { return "" }
|
||||
|
||||
type memFileInfo struct{ size int64 }
|
||||
|
||||
func (fi memFileInfo) Name() string { return "mkfs-buffer" }
|
||||
func (fi memFileInfo) Size() int64 { return fi.size }
|
||||
func (fi memFileInfo) Mode() fs.FileMode { return 0o600 }
|
||||
func (fi memFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (fi memFileInfo) IsDir() bool { return false }
|
||||
func (fi memFileInfo) Sys() any { return nil }
|
||||
|
||||
// flushTo writes the allocated (non-zero) pages of m to f at
|
||||
// baseOffset+pageIndex*memPageSize, coalescing consecutive page
|
||||
// indices into a single WriteAt so the destination sees the fewest
|
||||
// possible writes. Pages that ext4.Create only ever wrote zeros into
|
||||
// were never allocated by WriteAt and are not written here either; the
|
||||
// destination is assumed to have zeros (or a previous ext4 install's
|
||||
// metadata in the same locations, which is functionally equivalent
|
||||
// since fresh ext4 metadata overwrites it in place).
|
||||
//
|
||||
// Progress is printed to os.Stderr roughly once per second.
|
||||
func (m *memBackend) flushTo(f io.WriterAt, baseOffset int64) error {
|
||||
if len(m.pages) == 0 {
|
||||
return errors.New("BUG: ext4.Create allocated no pages")
|
||||
}
|
||||
|
||||
keys := make([]int64, 0, len(m.pages))
|
||||
for k := range m.pages {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
|
||||
totalBytes := int64(len(m.pages)) * memPageSize
|
||||
var written atomic.Int64
|
||||
stop := startExt4FlushProgress(&written, totalBytes)
|
||||
defer stop()
|
||||
|
||||
for i := 0; i < len(keys); {
|
||||
runStart := keys[i]
|
||||
j := i
|
||||
for j < len(keys) && keys[j] == runStart+int64(j-i) {
|
||||
j++
|
||||
}
|
||||
runPages := keys[i:j]
|
||||
buf := make([]byte, len(runPages)*memPageSize)
|
||||
for k, page := range runPages {
|
||||
copy(buf[k*memPageSize:], m.pages[page])
|
||||
}
|
||||
if _, err := f.WriteAt(buf, baseOffset+runStart*memPageSize); err != nil {
|
||||
return fmt.Errorf("flushing perm metadata: %w", err)
|
||||
}
|
||||
written.Add(int64(len(buf)))
|
||||
i = j
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startExt4FlushProgress prints "ext4 perm: N MB / M MB (X%)" to
|
||||
// os.Stderr roughly once a second, plus a final tick on stop. Returns
|
||||
// a function the caller must invoke when the flush is done.
|
||||
func startExt4FlushProgress(done *atomic.Int64, total int64) func() {
|
||||
stopCh := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
defer close(finished)
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
report := func() {
|
||||
d := done.Load()
|
||||
pct := 0.0
|
||||
if total > 0 {
|
||||
pct = float64(d) * 100 / float64(total)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " ext4 perm: %s / %s (%.1f%%)\n",
|
||||
humanBytes(d), humanBytes(total), pct)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
report()
|
||||
return
|
||||
case <-t.C:
|
||||
report()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(stopCh)
|
||||
<-finished
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const (
|
||||
gb = 1 << 30
|
||||
mb = 1 << 20
|
||||
kb = 1 << 10
|
||||
)
|
||||
switch {
|
||||
case n >= gb:
|
||||
return fmt.Sprintf("%.1f GB", float64(n)/float64(gb))
|
||||
case n >= mb:
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/float64(mb))
|
||||
case n >= kb:
|
||||
return fmt.Sprintf("%.1f KB", float64(n)/float64(kb))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package mkfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/diskfs/go-diskfs/filesystem/ext4"
|
||||
)
|
||||
|
||||
// fakeWriterAt records every WriteAt to a single contiguous backing
|
||||
// buffer (so tests can inspect what flushTo produced) and counts the
|
||||
// calls so we can assert the chunked flush issues a predictable
|
||||
// handful of big sequential writes.
|
||||
type fakeWriterAt struct {
|
||||
buf []byte
|
||||
calls int
|
||||
sizes []int
|
||||
}
|
||||
|
||||
func (w *fakeWriterAt) WriteAt(p []byte, off int64) (int, error) {
|
||||
w.calls++
|
||||
w.sizes = append(w.sizes, len(p))
|
||||
if int(off)+len(p) > len(w.buf) {
|
||||
w.buf = append(w.buf, make([]byte, int(off)+len(p)-len(w.buf))...)
|
||||
}
|
||||
return copy(w.buf[off:], p), nil
|
||||
}
|
||||
|
||||
// TestMemBackendSparseAlloc exercises ext4.Create against an in-memory
|
||||
// memBackend sized like a typical /perm partition and confirms that
|
||||
// the page allocator stays small. ext4.Create issues writes for tens
|
||||
// to hundreds of MiB of zero-initialized inode table and journal; we
|
||||
// rely on memBackend.WriteAt suppressing those zero writes so that
|
||||
// the eventual flush to the (slow) SD card stays under a few MiB.
|
||||
//
|
||||
// The assertion is intentionally loose — we only catch regressions
|
||||
// that bloat by an order of magnitude, not bookkeeping changes.
|
||||
func TestMemBackendSparseAlloc(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sizeBytes int64
|
||||
maxPagesKiB int64
|
||||
}{
|
||||
// ~96 MiB matches our tsapp pi/vm builds with
|
||||
// target_storage_bytes=1258299392.
|
||||
{"96MiB", 96 * 1024 * 1024, 256},
|
||||
// 2 GiB is the size the user complained about in the
|
||||
// flash-appliance progress meter: ext4.Create wrote ~131
|
||||
// MiB before suppression.
|
||||
{"2GiB", 2 * 1024 * 1024 * 1024, 1024},
|
||||
// 32 GiB simulates a full-size SD card. ext4.Create would
|
||||
// write a few hundred MiB of zeros for the inode table; we
|
||||
// must still stay tiny.
|
||||
{"32GiB", 32 * 1024 * 1024 * 1024, 2048},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mem := newMemBackend(tc.sizeBytes)
|
||||
_, err := ext4.Create(mem, tc.sizeBytes, 0, sectorSize, &ext4.Params{
|
||||
VolumeName: "PERM",
|
||||
SectorsPerBlock: 8,
|
||||
Features: []ext4.FeatureOpt{
|
||||
ext4.WithFeatureReservedGDTBlocksForExpansion(false),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ext4.Create: %v", err)
|
||||
}
|
||||
pageBytes := int64(len(mem.pages)) * memPageSize
|
||||
t.Logf("%s filesystem: %d allocated pages (%d KiB)",
|
||||
tc.name, len(mem.pages), pageBytes/1024)
|
||||
if pageBytes/1024 > tc.maxPagesKiB {
|
||||
t.Errorf("allocated %d KiB; want < %d KiB", pageBytes/1024, tc.maxPagesKiB)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlushToDirtyOnly exercises memBackend.flushTo against a fake
|
||||
// io.WriterAt: it must issue only one WriteAt per maximal run of
|
||||
// allocated (non-zero) pages — never anything for the gaps in between
|
||||
// — and the bytes at each destination offset must match what was
|
||||
// originally written.
|
||||
func TestFlushToDirtyOnly(t *testing.T) {
|
||||
const size = 40 * 1024 * 1024
|
||||
m := newMemBackend(size)
|
||||
|
||||
// Two contiguous runs separated by a large all-zero gap. The
|
||||
// flush should issue exactly two WriteAt calls (one per run),
|
||||
// and never touch the gap between them.
|
||||
page := func(b byte) []byte {
|
||||
p := make([]byte, memPageSize)
|
||||
p[0] = b
|
||||
return p
|
||||
}
|
||||
// Run 1: 2 consecutive pages at offset 0.
|
||||
if _, err := m.WriteAt(page(0x11), 0); err != nil {
|
||||
t.Fatalf("WriteAt: %v", err)
|
||||
}
|
||||
if _, err := m.WriteAt(page(0x22), memPageSize); err != nil {
|
||||
t.Fatalf("WriteAt: %v", err)
|
||||
}
|
||||
// Run 2: 1 page at the end of the region.
|
||||
if _, err := m.WriteAt(page(0x33), size-memPageSize); err != nil {
|
||||
t.Fatalf("WriteAt: %v", err)
|
||||
}
|
||||
|
||||
const baseOffset int64 = 1 << 20
|
||||
fw := &fakeWriterAt{}
|
||||
if err := m.flushTo(fw, baseOffset); err != nil {
|
||||
t.Fatalf("flushTo: %v", err)
|
||||
}
|
||||
|
||||
if fw.calls != 2 {
|
||||
t.Errorf("WriteAt calls=%d; want 2 (one per dirty run), sizes=%v", fw.calls, fw.sizes)
|
||||
}
|
||||
if got, want := fw.sizes[0], 2*memPageSize; got != want {
|
||||
t.Errorf("first run size=%d; want %d (2 contiguous pages)", got, want)
|
||||
}
|
||||
if got, want := fw.sizes[1], memPageSize; got != want {
|
||||
t.Errorf("second run size=%d; want %d (1 page)", got, want)
|
||||
}
|
||||
|
||||
// Page contents at the right absolute offsets.
|
||||
if fw.buf[baseOffset+0] != 0x11 {
|
||||
t.Errorf("page 0 marker = %#x; want 0x11", fw.buf[baseOffset+0])
|
||||
}
|
||||
if fw.buf[baseOffset+memPageSize] != 0x22 {
|
||||
t.Errorf("page 1 marker = %#x; want 0x22", fw.buf[baseOffset+memPageSize])
|
||||
}
|
||||
if fw.buf[baseOffset+size-memPageSize] != 0x33 {
|
||||
t.Errorf("last page marker = %#x; want 0x33", fw.buf[baseOffset+size-memPageSize])
|
||||
}
|
||||
// The gap pages between run 1 and run 2 must not have been touched
|
||||
// at all in the fake's backing buffer (it lazily grows on WriteAt;
|
||||
// untouched bytes stay zero).
|
||||
for _, off := range []int64{2 * memPageSize, 8 * 1024 * 1024, 20 * 1024 * 1024} {
|
||||
if !bytes.Equal(fw.buf[baseOffset+off:baseOffset+off+memPageSize], make([]byte, memPageSize)) {
|
||||
t.Errorf("flushTo touched an unallocated gap at offset %d", off)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemBackendZeroSuppressed asserts that a write whose data is all
|
||||
// zero does not allocate a page when the destination page is absent —
|
||||
// the core invariant that makes TestMemBackendSparseAlloc pass — and
|
||||
// that writes touching multiple pages allocate per-page based on
|
||||
// whether each page's slice has any non-zero byte.
|
||||
func TestMemBackendZeroSuppressed(t *testing.T) {
|
||||
m := newMemBackend(1 << 20)
|
||||
|
||||
// All-zero write spanning 2 pages: nothing allocated.
|
||||
zero := make([]byte, 8192)
|
||||
if _, err := m.WriteAt(zero, 4096); err != nil {
|
||||
t.Fatalf("WriteAt zero: %v", err)
|
||||
}
|
||||
if got := len(m.pages); got != 0 {
|
||||
t.Errorf("after %d-byte zero write: %d pages, want 0", len(zero), got)
|
||||
}
|
||||
|
||||
// Non-zero byte in page 0 only: page 0 allocated; page 1 stays
|
||||
// zero-suppressed.
|
||||
mixed := make([]byte, 8192)
|
||||
mixed[100] = 1
|
||||
if _, err := m.WriteAt(mixed, 0); err != nil {
|
||||
t.Fatalf("WriteAt mixed: %v", err)
|
||||
}
|
||||
if got := len(m.pages); got != 1 {
|
||||
t.Errorf("after write with non-zero only in page 0: %d pages, want 1", got)
|
||||
}
|
||||
|
||||
// Non-zero bytes in both pages: both allocated.
|
||||
m = newMemBackend(1 << 20)
|
||||
mixed[5000] = 1 // also non-zero in page 1
|
||||
if _, err := m.WriteAt(mixed, 0); err != nil {
|
||||
t.Fatalf("WriteAt mixed-both: %v", err)
|
||||
}
|
||||
if got := len(m.pages); got != 2 {
|
||||
t.Errorf("after write with non-zero in pages 0 and 1: %d pages, want 2", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user