image: verify compressed sources before unpacking

This commit is contained in:
Brad Stein 2026-03-31 19:00:48 -03:00
parent 791d528a99
commit b01613fe9e
6 changed files with 108 additions and 37 deletions

View File

@ -15,21 +15,48 @@ import (
// Download fetches url into dest if dest does not exist. // Download fetches url into dest if dest does not exist.
func Download(url, dest string) error { func Download(url, dest string) error {
if _, err := os.Stat(dest); err == nil { _, err := DownloadAndVerify(url, dest, "")
return nil return err
} }
// DownloadAndVerify fetches the source image, verifies it when a checksum is provided,
// and returns the local raw image path ready for copying or injection.
func DownloadAndVerify(url, dest, checksum string) (string, error) {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err return "", err
} }
if strings.HasSuffix(url, ".xz") { if strings.HasSuffix(url, ".xz") {
tmp := dest + ".download.xz" archive := dest + ".xz"
if err := downloadRaw(url, tmp); err != nil { if _, err := os.Stat(archive); errors.Is(err, os.ErrNotExist) {
return err if err := downloadRaw(url, archive); err != nil {
return "", err
}
} else if err != nil {
return "", err
} }
defer os.Remove(tmp) if err := VerifyChecksum(archive, checksum); err != nil {
return decompressXZ(tmp, dest) return "", err
}
if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
if err := decompressXZ(archive, dest); err != nil {
return "", err
}
} else if err != nil {
return "", err
}
return dest, nil
} }
return downloadRaw(url, dest) if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
if err := downloadRaw(url, dest); err != nil {
return "", err
}
} else if err != nil {
return "", err
}
if err := VerifyChecksum(dest, checksum); err != nil {
return "", err
}
return dest, nil
} }
func downloadRaw(url, dest string) error { func downloadRaw(url, dest string) error {

View File

@ -27,8 +27,47 @@ func TestDownloadDecompressesXZFileURLs(t *testing.T) {
if err := Download("file://"+compressed, dest); err != nil { if err := Download("file://"+compressed, dest); err != nil {
t.Fatalf("Download: %v", err) t.Fatalf("Download: %v", err)
} }
sum := sha256.Sum256([]byte("metis-xz-test")) data, err := os.ReadFile(dest)
if err := VerifyChecksum(dest, "sha256:"+hex.EncodeToString(sum[:])); err != nil { if err != nil {
t.Fatalf("VerifyChecksum: %v", err) t.Fatalf("ReadFile: %v", err)
}
if string(data) != "metis-xz-test" {
t.Fatalf("unexpected decompressed content: %q", string(data))
}
}
func TestDownloadAndVerifyUsesArchiveChecksumForXZ(t *testing.T) {
if _, err := exec.LookPath("xz"); err != nil {
t.Skip("xz not available")
}
dir := t.TempDir()
raw := filepath.Join(dir, "base.img")
if err := os.WriteFile(raw, []byte("metis-xz-test"), 0o644); err != nil {
t.Fatal(err)
}
compressed := raw + ".xz"
cmd := exec.Command("xz", "-zk", raw)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("xz: %v: %s", err, string(out))
}
archiveBytes, err := os.ReadFile(compressed)
if err != nil {
t.Fatalf("ReadFile archive: %v", err)
}
archiveSum := sha256.Sum256(archiveBytes)
dest := filepath.Join(dir, "copy.img")
localPath, err := DownloadAndVerify("file://"+compressed, dest, "sha256:"+hex.EncodeToString(archiveSum[:]))
if err != nil {
t.Fatalf("DownloadAndVerify: %v", err)
}
if localPath != dest {
t.Fatalf("expected local path %s, got %s", dest, localPath)
}
data, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("ReadFile dest: %v", err)
}
if string(data) != "metis-xz-test" {
t.Fatalf("unexpected decompressed content: %q", string(data))
} }
} }

View File

@ -18,13 +18,11 @@ func Execute(inv *inventory.Inventory, nodeName, device, cacheDir string, confir
if err != nil { if err != nil {
return nil, err return nil, err
} }
cacheImage := filepath.Join(cacheDir, filepath.Base(p.Image)) cacheImage := filepath.Join(cacheDir, cacheName(p.Image))
if err := image.Download(p.Image, cacheImage); err != nil { cacheImage, err = image.DownloadAndVerify(p.Image, cacheImage, checksumFromInventory(inv, nodeName))
if err != nil {
return p, fmt.Errorf("download image: %w", err) return p, fmt.Errorf("download image: %w", err)
} }
if err := image.VerifyChecksum(cacheImage, checksumFromInventory(inv, nodeName)); err != nil {
return p, err
}
if !confirm { if !confirm {
return p, nil return p, nil
} }

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"metis/pkg/image" "metis/pkg/image"
"metis/pkg/inventory" "metis/pkg/inventory"
@ -21,13 +22,11 @@ func BuildImageFile(ctx context.Context, inv *inventory.Inventory, nodeName, cac
return fmt.Errorf("load node class: %w", err) return fmt.Errorf("load node class: %w", err)
} }
cacheImage := filepath.Join(cacheDir, filepath.Base(p.Image)) cacheImage := filepath.Join(cacheDir, cacheName(p.Image))
if err := image.Download(p.Image, cacheImage); err != nil { cacheImage, err = image.DownloadAndVerify(p.Image, cacheImage, class.Checksum)
if err != nil {
return fmt.Errorf("download image: %w", err) return fmt.Errorf("download image: %w", err)
} }
if err := image.VerifyChecksum(cacheImage, class.Checksum); err != nil {
return fmt.Errorf("verify checksum: %w", err)
}
if err := writer.WriteImage(ctx, cacheImage, output); err != nil { if err := writer.WriteImage(ctx, cacheImage, output); err != nil {
return fmt.Errorf("copy base image: %w", err) return fmt.Errorf("copy base image: %w", err)
} }
@ -41,3 +40,8 @@ func BuildImageFile(ctx context.Context, inv *inventory.Inventory, nodeName, cac
} }
return nil return nil
} }
func cacheName(source string) string {
base := filepath.Base(source)
return strings.TrimSuffix(base, ".xz")
}

View File

@ -377,7 +377,7 @@ func (a *App) runBuild(job *Job, flash bool) {
a.setJob(job.ID, func(j *Job) { a.setJob(job.ID, func(j *Job) {
j.Status = JobRunning j.Status = JobRunning
j.Stage = "download" j.Stage = "download"
j.Message = "Fetching base image" j.Message = "Fetching and verifying base image"
j.ProgressPct = 5 j.ProgressPct = 5
}) })
output := a.artifactPath(job.Node) output := a.artifactPath(job.Node)
@ -395,18 +395,9 @@ func (a *App) runBuild(job *Job, flash bool) {
a.metrics.RecordBuild(job.Node, "error") a.metrics.RecordBuild(job.Node, "error")
return return
} }
cacheImage := filepath.Join(cacheDir, filepath.Base(planData.Image)) cacheImage := filepath.Join(cacheDir, cachedImageName(planData.Image))
if err := image.Download(planData.Image, cacheImage); err != nil { cacheImage, err = image.DownloadAndVerify(planData.Image, cacheImage, class.Checksum)
a.failJob(job.ID, err) if err != nil {
a.metrics.RecordBuild(job.Node, "error")
return
}
a.setJob(job.ID, func(j *Job) {
j.Stage = "verify"
j.Message = "Verifying base image checksum"
j.ProgressPct = 18
})
if err := image.VerifyChecksum(cacheImage, class.Checksum); err != nil {
a.failJob(job.ID, err) a.failJob(job.ID, err)
a.metrics.RecordBuild(job.Node, "error") a.metrics.RecordBuild(job.Node, "error")
return return
@ -414,7 +405,7 @@ func (a *App) runBuild(job *Job, flash bool) {
a.setJob(job.ID, func(j *Job) { a.setJob(job.ID, func(j *Job) {
j.Stage = "copy" j.Stage = "copy"
j.Message = "Copying base image into artifact" j.Message = "Copying base image into artifact"
j.ProgressPct = 35 j.ProgressPct = 24
}) })
if err := writer.WriteImage(context.Background(), cacheImage, output); err != nil { if err := writer.WriteImage(context.Background(), cacheImage, output); err != nil {
a.failJob(job.ID, err) a.failJob(job.ID, err)
@ -649,6 +640,10 @@ func (a *App) artifactPath(node string) string {
return filepath.Join(a.settings.ArtifactDir, fmt.Sprintf("%s.img", node)) return filepath.Join(a.settings.ArtifactDir, fmt.Sprintf("%s.img", node))
} }
func cachedImageName(source string) string {
return strings.TrimSuffix(filepath.Base(source), ".xz")
}
func (a *App) flashHosts() []string { func (a *App) flashHosts() []string {
hosts := map[string]struct{}{} hosts := map[string]struct{}{}
for _, host := range a.settings.FlashHosts { for _, host := range a.settings.FlashHosts {

View File

@ -518,6 +518,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
</div> </div>
<div class="microcopy" id="host-note"></div> <div class="microcopy" id="host-note"></div>
<div class="microcopy" id="device-note"></div> <div class="microcopy" id="device-note"></div>
<div class="microcopy" id="artifact-note"></div>
<div class="actions"> <div class="actions">
<button class="secondary" id="refresh-devices">Refresh media</button> <button class="secondary" id="refresh-devices">Refresh media</button>
<button class="secondary" id="build-only">Build image only</button> <button class="secondary" id="build-only">Build image only</button>
@ -578,6 +579,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
const targetCountEl = document.getElementById('target-count'); const targetCountEl = document.getElementById('target-count');
const hostNoteEl = document.getElementById('host-note'); const hostNoteEl = document.getElementById('host-note');
const deviceNoteEl = document.getElementById('device-note'); const deviceNoteEl = document.getElementById('device-note');
const artifactNoteEl = document.getElementById('artifact-note');
const bannerEl = document.getElementById('status-banner'); const bannerEl = document.getElementById('status-banner');
const bannerTitleEl = document.getElementById('status-title'); const bannerTitleEl = document.getElementById('status-title');
const bannerTextEl = document.getElementById('status-text'); const bannerTextEl = document.getElementById('status-text');
@ -730,6 +732,11 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
deviceNoteEl.textContent = 'Insert an SD card or removable drive on the selected flash host, then refresh media.'; deviceNoteEl.textContent = 'Insert an SD card or removable drive on the selected flash host, then refresh media.';
} }
const artifact = (state.artifacts || {})[nodeSelect.value];
artifactNoteEl.textContent = artifact && artifact.path
? 'Latest built image: ' + artifact.path
: 'Successful build-only runs are stored on ' + state.local_host + ' under /var/lib/metis/artifacts/<node>.img.';
document.getElementById('build-only').disabled = busy || !nodeSelect.value; document.getElementById('build-only').disabled = busy || !nodeSelect.value;
document.getElementById('refresh-devices').disabled = busy; document.getElementById('refresh-devices').disabled = busy;
document.getElementById('replace-run').disabled = busy || !nodeSelect.value || !deviceSelect.value || !!state.device_error; document.getElementById('replace-run').disabled = busy || !nodeSelect.value || !deviceSelect.value || !!state.device_error;
@ -850,6 +857,7 @@ var metisPage = template.Must(template.New("metis").Parse(`<!doctype html>
} }
}); });
}); });
nodeSelect.addEventListener('change', render);
render(); render();
clearBanner(); clearBanner();