From 910d01d22aa3f82a979e584fd865c774179d58d4 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 11 Jan 2026 02:35:24 -0300 Subject: [PATCH] feat: add download/checksum and burn execution stub --- cmd/metis/main.go | 8 ++--- pkg/image/download.go | 79 +++++++++++++++++++++++++++++++++++++++++++ pkg/plan/burn.go | 47 +++++++++++++++++++++++++ pkg/util/run.go | 24 +++++++++++++ 4 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 pkg/image/download.go create mode 100644 pkg/plan/burn.go create mode 100644 pkg/util/run.go diff --git a/cmd/metis/main.go b/cmd/metis/main.go index 9144ba4..cd4b26b 100644 --- a/cmd/metis/main.go +++ b/cmd/metis/main.go @@ -72,17 +72,17 @@ func burnCmd(args []string) { log.Fatalf("--node and --device are required") } inv := loadInventory(*invPath) - p, err := plan.Build(inv, *node, *device, *cache) + p, err := plan.Execute(inv, *node, *device, *cache, *confirm) if err != nil { - log.Fatalf("build plan: %v", err) + log.Fatalf("burn: %v", err) } fmt.Printf("Plan for %s to %s:\n", p.Node, p.Device) for _, a := range p.Actions { fmt.Printf("- [%s] %s\n", a.Type, a.Detail) } if !*confirm { - fmt.Printf("\nDry run. Re-run with --yes to execute (not yet implemented).\n") + fmt.Printf("\nDry run. Re-run with --yes to execute.\n") return } - log.Fatalf("burn execution not yet implemented; follow plan commands manually") + fmt.Println("\nBurn complete. Safely eject and insert the SD into the node.") } diff --git a/pkg/image/download.go b/pkg/image/download.go new file mode 100644 index 0000000..6cc3ab3 --- /dev/null +++ b/pkg/image/download.go @@ -0,0 +1,79 @@ +package image + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +// Download fetches url into dest if dest does not exist. +func Download(url, dest string) error { + if _, err := os.Stat(dest); err == nil { + return nil + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if strings.HasPrefix(url, "file://") { + src := strings.TrimPrefix(url, "file://") + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err + } + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download failed: %s", resp.Status) + } + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +// VerifyChecksum checks sha256 in the form "sha256:". +func VerifyChecksum(path, checksum string) error { + if checksum == "" { + return nil + } + parts := strings.SplitN(checksum, ":", 2) + if len(parts) != 2 || parts[0] != "sha256" { + return errors.New("unsupported checksum format; use sha256:") + } + expected := strings.ToLower(parts[1]) + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + sum := hex.EncodeToString(h.Sum(nil)) + if sum != expected { + return fmt.Errorf("checksum mismatch: expected %s got %s", expected, sum) + } + return nil +} diff --git a/pkg/plan/burn.go b/pkg/plan/burn.go new file mode 100644 index 0000000..6c75523 --- /dev/null +++ b/pkg/plan/burn.go @@ -0,0 +1,47 @@ +package plan + +import ( + "fmt" + "os/exec" + "path/filepath" + + "metis/pkg/image" + "metis/pkg/inventory" +) + +// Execute performs a burn if confirm is true. With confirm=false, it only downloads/verifies and returns the plan. +func Execute(inv *inventory.Inventory, nodeName, device, cacheDir string, confirm bool) (*Plan, error) { + p, err := Build(inv, nodeName, device, cacheDir) + if err != nil { + return nil, err + } + cacheImage := filepath.Join(cacheDir, filepath.Base(p.Image)) + if err := image.Download(p.Image, cacheImage); err != nil { + return p, fmt.Errorf("download image: %w", err) + } + if err := image.VerifyChecksum(cacheImage, checksumFromInventory(inv, nodeName)); err != nil { + return p, err + } + if !confirm { + return p, nil + } + if device == "" || device == "/dev/sdX" { + return p, fmt.Errorf("refusing to write to placeholder device") + } + ddCmd := []string{"dd", fmt.Sprintf("if=%s", cacheImage), fmt.Sprintf("of=%s", device), "bs=4M", "status=progress", "conv=fsync"} + cmd := exec.Command(ddCmd[0], ddCmd[1:]...) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return p, fmt.Errorf("dd failed: %w", err) + } + return p, nil +} + +func checksumFromInventory(inv *inventory.Inventory, node string) string { + _, cls, err := inv.FindNode(node) + if err != nil || cls == nil { + return "" + } + return cls.Checksum +} diff --git a/pkg/util/run.go b/pkg/util/run.go new file mode 100644 index 0000000..e824a76 --- /dev/null +++ b/pkg/util/run.go @@ -0,0 +1,24 @@ +package util + +import ( + "fmt" + "os/exec" +) + +// Run executes a command and returns combined output or error. +func Run(cmd string, args ...string) error { + c := exec.Command(cmd, args...) + c.Stdout = nil + c.Stderr = nil + return c.Run() +} + +// RunLogged returns stdout/stderr for logging while failing on error. +func RunLogged(cmd string, args ...string) (string, error) { + c := exec.Command(cmd, args...) + out, err := c.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("%s %v failed: %w: %s", cmd, args, err, string(out)) + } + return string(out), nil +}