runtime(metis): expose rootfs rewrite progress

This commit is contained in:
codex 2026-04-24 01:58:29 -03:00
parent af9fb25b7a
commit bf9c514fd0
6 changed files with 285 additions and 25 deletions

View File

@ -95,9 +95,14 @@ func remoteBuildCmd(args []string) {
image.RootFSProgressWritingFiles: {Stage: "build", ProgressPct: 50, Message: fmt.Sprintf("Injecting node-specific files into the root filesystem for %s", *node)}, image.RootFSProgressWritingFiles: {Stage: "build", ProgressPct: 50, Message: fmt.Sprintf("Injecting node-specific files into the root filesystem for %s", *node)},
image.RootFSProgressReplacing: {Stage: "build", ProgressPct: 56, Message: fmt.Sprintf("Replacing the root partition inside the replacement image for %s", *node)}, image.RootFSProgressReplacing: {Stage: "build", ProgressPct: 56, Message: fmt.Sprintf("Replacing the root partition inside the replacement image for %s", *node)},
} }
if err := image.InjectRootFSWithProgress(output, files, func(step string) { replaceEmitter := newProgressEmitter("build", 56, 58, fmt.Sprintf("Replacing the root partition inside the replacement image for %s", *node), true)
if update, ok := rootfsProgress[step]; ok { if err := image.InjectRootFSWithProgress(output, files, func(update image.RootFSProgressUpdate) {
emitProgress(update) if update.Step == image.RootFSProgressReplacing && update.TotalBytes > 0 {
replaceEmitter(update.WrittenBytes, update.TotalBytes)
return
}
if progressUpdate, ok := rootfsProgress[update.Step]; ok {
emitProgress(progressUpdate)
} }
}); err != nil { }); err != nil {
fatalf("inject rootfs: %v", err) fatalf("inject rootfs: %v", err)
@ -144,6 +149,7 @@ func remoteBuildCmd(args []string) {
if err := orasTag(taggedRef, "latest"); err != nil { if err := orasTag(taggedRef, "latest"); err != nil {
fatalf("oras tag latest: %v", err) fatalf("oras tag latest: %v", err)
} }
emitStageProgress("build", 80, fmt.Sprintf("Published the replacement image for %s to Harbor", *node))
summary := service.ArtifactSummary{ summary := service.ArtifactSummary{
Node: *node, Node: *node,

View File

@ -2,6 +2,7 @@ package main
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"os" "os"
"strings" "strings"
@ -33,6 +34,20 @@ func TestOrasPushInvocationUsesRelativeWorkspacePaths(t *testing.T) {
} }
} }
func TestWriteStructuredResultFatalOnMarshalError(t *testing.T) {
oldFatalf := fatalf
fatalf = func(format string, args ...any) {
panic(fmt.Sprintf(format, args...))
}
defer func() { fatalf = oldFatalf }()
defer func() {
if got := recover(); got == nil || !strings.Contains(fmt.Sprint(got), "encode result") {
t.Fatalf("unexpected panic: %v", got)
}
}()
writeStructuredResult(make(chan int))
}
func TestHumanHostPathMapsMountedTmpBackToHostTmp(t *testing.T) { func TestHumanHostPathMapsMountedTmpBackToHostTmp(t *testing.T) {
if got := humanHostPath("/host-tmp/metis-flash-test"); got != "/tmp/metis-flash-test" { if got := humanHostPath("/host-tmp/metis-flash-test"); got != "/tmp/metis-flash-test" {
t.Fatalf("expected /tmp/metis-flash-test, got %q", got) t.Fatalf("expected /tmp/metis-flash-test, got %q", got)

View File

@ -268,6 +268,30 @@ func TestPlanBurnAndRemoteBuildFatalStageEdges(t *testing.T) {
remoteBuildCmd([]string{"--inventory", invPath, "--node", "titan-15", "--artifact-ref", "registry.example/metis/titan-15", "--build-tag", "build-1", "--work-dir", filepath.Join(root, "login-fail"), "--cache", filepath.Join(root, "cache-login")}) remoteBuildCmd([]string{"--inventory", invPath, "--node", "titan-15", "--artifact-ref", "registry.example/metis/titan-15", "--build-tag", "build-1", "--work-dir", filepath.Join(root, "login-fail"), "--cache", filepath.Join(root, "cache-login")})
}) })
}) })
t.Run("remote build oras push failure", func(t *testing.T) {
tools := remoteBuildToolDir(t, baseImage, `cp "${@: -1}" "${@: -1}.xz"`, `case "${1:-}" in
login|pull) exit 0 ;;
push) printf 'push failed' >&2; exit 6 ;;
tag) exit 0 ;;
esac
exit 0`)
t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH"))
expectCommandFatal(t, "oras push", func() {
remoteBuildCmd([]string{"--inventory", invPath, "--node", "titan-15", "--artifact-ref", "registry.example/metis/titan-15", "--build-tag", "build-1", "--work-dir", filepath.Join(root, "push-fail"), "--cache", filepath.Join(root, "cache-push"), "--harbor-username", "admin", "--harbor-password", "pw"})
})
})
t.Run("remote build oras tag failure", func(t *testing.T) {
tools := remoteBuildToolDir(t, baseImage, `cp "${@: -1}" "${@: -1}.xz"`, `case "${1:-}" in
login|pull|push) exit 0 ;;
tag) printf 'tag failed' >&2; exit 7 ;;
esac
exit 0`)
t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH"))
expectCommandFatal(t, "oras tag latest", func() {
remoteBuildCmd([]string{"--inventory", invPath, "--node", "titan-15", "--artifact-ref", "registry.example/metis/titan-15", "--build-tag", "build-1", "--work-dir", filepath.Join(root, "tag-fail"), "--cache", filepath.Join(root, "cache-tag"), "--harbor-username", "admin", "--harbor-password", "pw"})
})
})
} }
func TestRemoteFlashFatalAndDeviceFlushEdges(t *testing.T) { func TestRemoteFlashFatalAndDeviceFlushEdges(t *testing.T) {

View File

@ -29,9 +29,16 @@ type partitionTablePart struct {
Type string `json:"type"` Type string `json:"type"`
} }
// RootFSProgressFunc receives coarse-grained step names while Metis rewrites a // RootFSProgressUpdate carries coarse-grained step changes and optional byte
// Linux root filesystem inside a raw image. // counters while Metis rewrites a Linux root filesystem inside a raw image.
type RootFSProgressFunc func(step string) type RootFSProgressUpdate struct {
Step string
WrittenBytes int64
TotalBytes int64
}
// RootFSProgressFunc receives RootFS progress updates during image rewriting.
type RootFSProgressFunc func(update RootFSProgressUpdate)
const ( const (
RootFSProgressFindingPartition = "finding-partition" RootFSProgressFindingPartition = "finding-partition"
@ -58,34 +65,40 @@ func InjectRootFSWithProgress(imagePath string, files []inject.FileSpec, progres
return nil return nil
} }
emitRootFSProgress(progress, RootFSProgressFindingPartition) emitRootFSProgress(progress, RootFSProgressUpdate{Step: RootFSProgressFindingPartition})
part, sectorSize, err := findLinuxPartition(imagePath) part, sectorSize, err := findLinuxPartition(imagePath)
if err != nil { if err != nil {
return err return err
} }
workDir, err := os.MkdirTemp("", "metis-rootfs-") workDir, err := mkdirTempNearPath(imagePath, "metis-rootfs-")
if err != nil { if err != nil {
return err return err
} }
defer os.RemoveAll(workDir) defer os.RemoveAll(workDir)
rootImage := filepath.Join(workDir, "root.ext4") rootImage := filepath.Join(workDir, "root.ext4")
emitRootFSProgress(progress, RootFSProgressExtracting) emitRootFSProgress(progress, RootFSProgressUpdate{Step: RootFSProgressExtracting})
if err := extractPartition(imagePath, rootImage, part, sectorSize); err != nil { if err := extractPartition(imagePath, rootImage, part, sectorSize); err != nil {
return err return err
} }
emitRootFSProgress(progress, RootFSProgressWritingFiles) emitRootFSProgress(progress, RootFSProgressUpdate{Step: RootFSProgressWritingFiles})
if err := writeExt4Files(rootImage, rootFiles); err != nil { if err := writeExt4Files(rootImage, rootFiles); err != nil {
return err return err
} }
emitRootFSProgress(progress, RootFSProgressReplacing) emitRootFSProgress(progress, RootFSProgressUpdate{Step: RootFSProgressReplacing})
return replacePartition(imagePath, rootImage, part, sectorSize) return replacePartition(imagePath, rootImage, part, sectorSize, func(written, total int64) {
emitRootFSProgress(progress, RootFSProgressUpdate{
Step: RootFSProgressReplacing,
WrittenBytes: written,
TotalBytes: total,
})
})
} }
func emitRootFSProgress(progress RootFSProgressFunc, step string) { func emitRootFSProgress(progress RootFSProgressFunc, update RootFSProgressUpdate) {
if progress != nil { if progress != nil {
progress(step) progress(update)
} }
} }
@ -144,7 +157,7 @@ func extractPartition(imagePath, outPath string, part partitionTablePart, sector
return out.Sync() return out.Sync()
} }
func replacePartition(imagePath, rootImage string, part partitionTablePart, sectorSize uint64) error { func replacePartition(imagePath, rootImage string, part partitionTablePart, sectorSize uint64, progress func(written, total int64)) error {
expectedSize := int64(part.Size * sectorSize) expectedSize := int64(part.Size * sectorSize)
info, err := os.Stat(rootImage) info, err := os.Stat(rootImage)
if err != nil { if err != nil {
@ -168,14 +181,14 @@ func replacePartition(imagePath, rootImage string, part partitionTablePart, sect
if _, err := out.Seek(int64(part.Start*sectorSize), io.SeekStart); err != nil { if _, err := out.Seek(int64(part.Start*sectorSize), io.SeekStart); err != nil {
return err return err
} }
if _, err := io.Copy(out, in); err != nil { if _, err := copyWithProgress(out, in, expectedSize, progress); err != nil {
return fmt.Errorf("write root partition: %w", err) return fmt.Errorf("write root partition: %w", err)
} }
return out.Sync() return out.Sync()
} }
func writeExt4Files(fsPath string, files []inject.FileSpec) error { func writeExt4Files(fsPath string, files []inject.FileSpec) error {
workDir, err := os.MkdirTemp("", "metis-ext4-") workDir, err := mkdirTempNearPath(fsPath, "metis-ext4-")
if err != nil { if err != nil {
return err return err
} }
@ -243,6 +256,47 @@ func writeExt4Files(fsPath string, files []inject.FileSpec) error {
return nil return nil
} }
func mkdirTempNearPath(targetPath, pattern string) (string, error) {
parent := strings.TrimSpace(os.Getenv("METIS_ROOTFS_TMP_DIR"))
if parent == "" && strings.TrimSpace(targetPath) != "" {
parent = filepath.Join(filepath.Dir(targetPath), ".metis-tmp")
}
if parent == "" {
return os.MkdirTemp("", pattern)
}
if err := os.MkdirAll(parent, 0o755); err != nil {
return "", err
}
return os.MkdirTemp(parent, pattern)
}
func copyWithProgress(dst io.Writer, src io.Reader, total int64, progress func(written, total int64)) (int64, error) {
buf := make([]byte, 2*1024*1024)
var written int64
for {
nr, er := src.Read(buf)
if nr > 0 {
nw, ew := dst.Write(buf[:nr])
written += int64(nw)
if progress != nil {
progress(written, total)
}
if ew != nil {
return written, ew
}
if nw != nr {
return written, io.ErrShortWrite
}
}
if er != nil {
if er == io.EOF {
return written, nil
}
return written, er
}
}
}
func verifyExt4File(fsPath string, file inject.FileSpec, workDir string) error { func verifyExt4File(fsPath string, file inject.FileSpec, workDir string) error {
destPath := "/" + strings.TrimPrefix(filepath.ToSlash(file.Path), "/") destPath := "/" + strings.TrimPrefix(filepath.ToSlash(file.Path), "/")
statOut, err := exec.Command("debugfs", "-R", "stat "+destPath, fsPath).CombinedOutput() statOut, err := exec.Command("debugfs", "-R", "stat "+destPath, fsPath).CombinedOutput()

View File

@ -0,0 +1,161 @@
package image
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"metis/pkg/inject"
)
type shortWriteOnly struct{}
func (shortWriteOnly) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
return len(p) - 1, nil
}
type failAfterFirstRead struct{ done bool }
func (r *failAfterFirstRead) Read(p []byte) (int, error) {
if r.done {
return 0, io.ErrUnexpectedEOF
}
r.done = true
copy(p, []byte("ab"))
return 2, nil
}
func TestInjectRootFSWithFakesReportsReplacingByteProgress(t *testing.T) {
scripts := fakeRootfsCommands(t, true)
t.Setenv("PATH", scripts+string(os.PathListSeparator)+os.Getenv("PATH"))
imagePath := filepath.Join(t.TempDir(), "image.img")
if err := os.WriteFile(imagePath, make([]byte, 4096), 0o644); err != nil {
t.Fatal(err)
}
files := []inject.FileSpec{{Path: "etc/metis/firstboot.env", Content: []byte("METIS_HOSTNAME='titan-13'\n"), Mode: 0o600, RootFS: true}}
var replacing []RootFSProgressUpdate
if err := InjectRootFSWithProgress(imagePath, files, func(update RootFSProgressUpdate) {
if update.Step == RootFSProgressReplacing && update.TotalBytes > 0 {
replacing = append(replacing, update)
}
}); err != nil {
t.Fatalf("InjectRootFSWithProgress: %v", err)
}
if len(replacing) == 0 {
t.Fatal("expected replacing progress updates")
}
last := replacing[len(replacing)-1]
if last.WrittenBytes != last.TotalBytes || last.TotalBytes != 1024 {
t.Fatalf("unexpected replacing progress: %+v", last)
}
}
func TestMkdirTempNearPathPrefersImageAdjacentScratch(t *testing.T) {
imagePath := filepath.Join(t.TempDir(), "build", "node.img")
if err := os.MkdirAll(filepath.Dir(imagePath), 0o755); err != nil {
t.Fatal(err)
}
workDir, err := mkdirTempNearPath(imagePath, "metis-rootfs-")
if err != nil {
t.Fatalf("mkdirTempNearPath: %v", err)
}
defer os.RemoveAll(workDir)
if got, want := filepath.Base(filepath.Dir(workDir)), ".metis-tmp"; got != want {
t.Fatalf("temp parent = %q want %q", got, want)
}
}
func TestCopyWithProgressReportsByteCounts(t *testing.T) {
var dst bytes.Buffer
var updates []RootFSProgressUpdate
src := bytes.NewReader([]byte("hello world"))
written, err := copyWithProgress(&dst, src, int64(src.Len()), func(written, total int64) {
updates = append(updates, RootFSProgressUpdate{Step: RootFSProgressReplacing, WrittenBytes: written, TotalBytes: total})
})
if err != nil {
t.Fatalf("copyWithProgress: %v", err)
}
if written != int64(len("hello world")) {
t.Fatalf("written = %d", written)
}
if dst.String() != "hello world" {
t.Fatalf("unexpected dst content: %q", dst.String())
}
if len(updates) == 0 || updates[len(updates)-1].WrittenBytes != updates[len(updates)-1].TotalBytes {
t.Fatalf("expected final byte-complete update, got %#v", updates)
}
}
func TestReplacePartitionWritesDataAndReportsProgress(t *testing.T) {
dir := t.TempDir()
imagePath := filepath.Join(dir, "image.img")
rootPath := filepath.Join(dir, "root.ext4")
imageBytes := make([]byte, 1024)
rootBytes := bytes.Repeat([]byte("r"), 512)
if err := os.WriteFile(imagePath, imageBytes, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(rootPath, rootBytes, 0o644); err != nil {
t.Fatal(err)
}
var updates []RootFSProgressUpdate
if err := replacePartition(imagePath, rootPath, partitionTablePart{Start: 1, Size: 1}, 512, func(written, total int64) {
updates = append(updates, RootFSProgressUpdate{Step: RootFSProgressReplacing, WrittenBytes: written, TotalBytes: total})
}); err != nil {
t.Fatalf("replacePartition: %v", err)
}
got, err := os.ReadFile(imagePath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got[512:], rootBytes) {
t.Fatalf("partition bytes were not replaced: %q", got[512:])
}
if len(updates) == 0 || updates[len(updates)-1].WrittenBytes != 512 || updates[len(updates)-1].TotalBytes != 512 {
t.Fatalf("expected final replace progress update, got %#v", updates)
}
}
func TestMkdirTempNearPathHonorsOverrideAndFallback(t *testing.T) {
t.Run("override", func(t *testing.T) {
override := filepath.Join(t.TempDir(), "override")
t.Setenv("METIS_ROOTFS_TMP_DIR", override)
workDir, err := mkdirTempNearPath(filepath.Join(t.TempDir(), "build", "node.img"), "metis-rootfs-")
if err != nil {
t.Fatalf("mkdirTempNearPath override: %v", err)
}
defer os.RemoveAll(workDir)
if got := filepath.Dir(workDir); got != override {
t.Fatalf("override parent = %q want %q", got, override)
}
})
t.Run("fallback tempdir", func(t *testing.T) {
workDir, err := mkdirTempNearPath("", "metis-rootfs-")
if err != nil {
t.Fatalf("mkdirTempNearPath fallback: %v", err)
}
defer os.RemoveAll(workDir)
if workDir == "" {
t.Fatal("expected fallback temp dir path")
}
})
}
func TestCopyWithProgressPropagatesReaderAndWriterErrors(t *testing.T) {
t.Run("short write", func(t *testing.T) {
if _, err := copyWithProgress(shortWriteOnly{}, bytes.NewReader([]byte("abc")), 3, nil); err != io.ErrShortWrite {
t.Fatalf("expected short write, got %v", err)
}
})
t.Run("reader error", func(t *testing.T) {
if _, err := copyWithProgress(&bytes.Buffer{}, &failAfterFirstRead{}, 4, nil); err != io.ErrUnexpectedEOF {
t.Fatalf("expected reader error, got %v", err)
}
})
}

View File

@ -96,7 +96,7 @@ func TestRootfsErrorBranches(t *testing.T) {
if err := os.WriteFile(dst, make([]byte, 512), 0o644); err != nil { if err := os.WriteFile(dst, make([]byte, 512), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := replacePartition(src, dst, part, 512); err == nil { if err := replacePartition(src, dst, part, 512, nil); err == nil {
t.Fatal("expected replacePartition size mismatch") t.Fatal("expected replacePartition size mismatch")
} }
} }
@ -113,8 +113,8 @@ func TestRootfsAdditionalTopLevelErrorBranches(t *testing.T) {
scripts := fakeImageCommandDir(t, map[string]string{"sfdisk": `exit 3`}) scripts := fakeImageCommandDir(t, map[string]string{"sfdisk": `exit 3`})
t.Setenv("PATH", scripts+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", scripts+string(os.PathListSeparator)+os.Getenv("PATH"))
var steps []string var steps []string
err := InjectRootFSWithProgress(imagePath, rootFiles, func(step string) { err := InjectRootFSWithProgress(imagePath, rootFiles, func(update RootFSProgressUpdate) {
steps = append(steps, step) steps = append(steps, update.Step)
}) })
if err == nil || len(steps) != 1 || steps[0] != RootFSProgressFindingPartition { if err == nil || len(steps) != 1 || steps[0] != RootFSProgressFindingPartition {
t.Fatalf("expected partition error after progress, steps=%v err=%v", steps, err) t.Fatalf("expected partition error after progress, steps=%v err=%v", steps, err)
@ -149,7 +149,7 @@ JSON`})
if err := os.WriteFile(tmpFile, []byte("not-a-dir"), 0o644); err != nil { if err := os.WriteFile(tmpFile, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Setenv("TMPDIR", tmpFile) t.Setenv("METIS_ROOTFS_TMP_DIR", tmpFile)
if err := InjectRootFSWithProgress(imagePath, rootFiles, nil); err == nil { if err := InjectRootFSWithProgress(imagePath, rootFiles, nil); err == nil {
t.Fatal("expected rootfs temp-dir failure") t.Fatal("expected rootfs temp-dir failure")
} }
@ -206,18 +206,18 @@ func TestRootfsDirectHelperErrorBranches(t *testing.T) {
t.Fatal("expected extract destination create failure") t.Fatal("expected extract destination create failure")
} }
if err := replacePartition(src, filepath.Join(dir, "missing-root.img"), partitionTablePart{Start: 0, Size: 1}, 512); err == nil { if err := replacePartition(src, filepath.Join(dir, "missing-root.img"), partitionTablePart{Start: 0, Size: 1}, 512, nil); err == nil {
t.Fatal("expected missing root image failure") t.Fatal("expected missing root image failure")
} }
root := filepath.Join(dir, "root.img") root := filepath.Join(dir, "root.img")
if err := os.WriteFile(root, make([]byte, 512), 0o644); err != nil { if err := os.WriteFile(root, make([]byte, 512), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := replacePartition(filepath.Join(dir, "missing-image.img"), root, partitionTablePart{Start: 0, Size: 1}, 512); err == nil { if err := replacePartition(filepath.Join(dir, "missing-image.img"), root, partitionTablePart{Start: 0, Size: 1}, 512, nil); err == nil {
t.Fatal("expected destination image open failure") t.Fatal("expected destination image open failure")
} }
if _, err := os.Stat("/dev/full"); err == nil { if _, err := os.Stat("/dev/full"); err == nil {
if err := replacePartition("/dev/full", root, partitionTablePart{Start: 0, Size: 1}, 512); err == nil { if err := replacePartition("/dev/full", root, partitionTablePart{Start: 0, Size: 1}, 512, nil); err == nil {
t.Fatal("expected /dev/full replace failure") t.Fatal("expected /dev/full replace failure")
} }
} }
@ -226,7 +226,7 @@ func TestRootfsDirectHelperErrorBranches(t *testing.T) {
if err := os.WriteFile(tmpFile, []byte("not-a-dir"), 0o644); err != nil { if err := os.WriteFile(tmpFile, []byte("not-a-dir"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
t.Setenv("TMPDIR", tmpFile) t.Setenv("METIS_ROOTFS_TMP_DIR", tmpFile)
if err := writeExt4Files(src, []inject.FileSpec{{Path: "etc/node.json", Content: []byte("{}"), Mode: 0o600, RootFS: true}}); err == nil { if err := writeExt4Files(src, []inject.FileSpec{{Path: "etc/node.json", Content: []byte("{}"), Mode: 0o600, RootFS: true}}); err == nil {
t.Fatal("expected writeExt4Files temp-dir failure") t.Fatal("expected writeExt4Files temp-dir failure")
} }