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
184 lines
6.2 KiB
Go
184 lines
6.2 KiB
Go
// 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)
|
|
}
|
|
}
|