image: verify compressed sources before unpacking
This commit is contained in:
parent
791d528a99
commit
b01613fe9e
@ -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 {
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user