diff --git a/cmd/metis/remote_artifacts.go b/cmd/metis/remote_artifacts.go new file mode 100644 index 0000000..4a6ed76 --- /dev/null +++ b/cmd/metis/remote_artifacts.go @@ -0,0 +1,94 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func orasLogin(registry, username, password string) error { + if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { + return fmt.Errorf("harbor credentials missing") + } + cmd := exec.Command("oras", "login", registry, "-u", username, "-p", password) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func orasPush(ref, imagePath, metadataPath string) error { + dir, args, err := orasPushInvocation(ref, imagePath, metadataPath) + if err != nil { + return err + } + cmd := exec.Command("oras", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func orasPushInvocation(ref, imagePath, metadataPath string) (string, []string, error) { + imageDir := filepath.Dir(imagePath) + metadataDir := filepath.Dir(metadataPath) + if imageDir != metadataDir { + return "", nil, fmt.Errorf("oras push requires artifacts in one directory: %s vs %s", imageDir, metadataDir) + } + return imageDir, []string{ + "push", + ref, + fmt.Sprintf("%s:application/x-raw-disk-image", filepath.Base(imagePath)), + fmt.Sprintf("%s:application/json", filepath.Base(metadataPath)), + }, nil +} + +func orasTag(ref string, tags ...string) error { + args := append([]string{"tag", ref}, tags...) + cmd := exec.Command("oras", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func orasPull(ref, outDir string) error { + cmd := exec.Command("oras", "pull", ref, "-o", outDir) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func resolvePulledArtifact(dir string) (string, bool, error) { + var rawPath string + var compressedPath string + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + switch { + case strings.HasSuffix(path, ".img.xz"): + compressedPath = path + case strings.HasSuffix(path, ".img"): + rawPath = path + } + return nil + }) + if err != nil { + return "", false, err + } + if compressedPath != "" { + return compressedPath, true, nil + } + if rawPath != "" { + return rawPath, false, nil + } + return "", false, fmt.Errorf("no .img or .img.xz artifact found in %s", dir) +} diff --git a/cmd/metis/remote_cmd.go b/cmd/metis/remote_cmd.go index 11af42e..b297d3d 100644 --- a/cmd/metis/remote_cmd.go +++ b/cmd/metis/remote_cmd.go @@ -383,91 +383,6 @@ func localDeviceScore(device service.Device) int { return score } -func orasLogin(registry, username, password string) error { - if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" { - return fmt.Errorf("harbor credentials missing") - } - cmd := exec.Command("oras", "login", registry, "-u", username, "-p", password) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) - } - return nil -} - -func orasPush(ref, imagePath, metadataPath string) error { - dir, args, err := orasPushInvocation(ref, imagePath, metadataPath) - if err != nil { - return err - } - cmd := exec.Command("oras", args...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) - } - return nil -} - -func orasPushInvocation(ref, imagePath, metadataPath string) (string, []string, error) { - imageDir := filepath.Dir(imagePath) - metadataDir := filepath.Dir(metadataPath) - if imageDir != metadataDir { - return "", nil, fmt.Errorf("oras push requires artifacts in one directory: %s vs %s", imageDir, metadataDir) - } - return imageDir, []string{ - "push", - ref, - fmt.Sprintf("%s:application/x-raw-disk-image", filepath.Base(imagePath)), - fmt.Sprintf("%s:application/json", filepath.Base(metadataPath)), - }, nil -} - -func orasTag(ref string, tags ...string) error { - args := append([]string{"tag", ref}, tags...) - cmd := exec.Command("oras", args...) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) - } - return nil -} - -func orasPull(ref, outDir string) error { - cmd := exec.Command("oras", "pull", ref, "-o", outDir) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) - } - return nil -} - -func resolvePulledArtifact(dir string) (string, bool, error) { - var rawPath string - var compressedPath string - err := filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if d.IsDir() { - return nil - } - switch { - case strings.HasSuffix(path, ".img.xz"): - compressedPath = path - case strings.HasSuffix(path, ".img"): - rawPath = path - } - return nil - }) - if err != nil { - return "", false, err - } - if compressedPath != "" { - return compressedPath, true, nil - } - if rawPath != "" { - return rawPath, false, nil - } - return "", false, fmt.Errorf("no .img or .img.xz artifact found in %s", dir) -} - func hasMountedChildren(children []struct { Mountpoint string `json:"mountpoint"` }) bool { diff --git a/pkg/image/rootfs.go b/pkg/image/rootfs.go index 6102a30..f9efc99 100644 --- a/pkg/image/rootfs.go +++ b/pkg/image/rootfs.go @@ -29,6 +29,8 @@ type partitionTablePart struct { Type string `json:"type"` } +// RootFSProgressFunc receives coarse-grained step names while Metis rewrites a +// Linux root filesystem inside a raw image. type RootFSProgressFunc func(step string) const ( diff --git a/pkg/service/app_devices.go b/pkg/service/app_devices.go new file mode 100644 index 0000000..07ccdbd --- /dev/null +++ b/pkg/service/app_devices.go @@ -0,0 +1,86 @@ +package service + +import ( + "errors" + "strings" + "time" +) + +func (a *App) cachedDevices(host string) ([]Device, error) { + host = strings.TrimSpace(host) + if host == "" { + host = a.settings.DefaultFlashHost + } + a.mu.RLock() + snapshot, ok := a.deviceStore[host] + a.mu.RUnlock() + if !ok { + return nil, nil + } + if strings.TrimSpace(snapshot.Err) != "" { + return cloneDevices(snapshot.Devices), errors.New(snapshot.Err) + } + return cloneDevices(snapshot.Devices), nil +} + +func (a *App) recordDevices(host string, devices []Device, err error) { + host = strings.TrimSpace(host) + if host == "" { + host = a.settings.DefaultFlashHost + } + snapshot := deviceSnapshot{ + Devices: cloneDevices(devices), + CheckedAt: time.Now().UTC(), + } + if err != nil { + snapshot.Err = err.Error() + } + a.mu.Lock() + if existing, ok := a.deviceStore[host]; ok && len(snapshot.Devices) == 0 { + snapshot.Devices = cloneDevices(existing.Devices) + } + a.deviceStore[host] = snapshot + a.mu.Unlock() +} + +func deviceScore(device Device) int { + score := 0 + model := strings.ToLower(strings.TrimSpace(device.Model)) + switch { + case strings.Contains(model, "microsd"), strings.Contains(model, "micro sd"): + score += 60 + case strings.Contains(model, "sdxc"), strings.Contains(model, "sdhc"), strings.Contains(model, "sd "): + score += 50 + case strings.Contains(model, "card"), strings.Contains(model, "reader"): + score += 40 + } + if device.Removable { + score += 20 + } + if device.Hotplug { + score += 10 + } + if device.Transport == "usb" { + score += 5 + } + if strings.HasPrefix(device.Name, "mmcblk") { + score += 25 + } + return score +} + +func moveToFront(values []string, preferred string) []string { + if preferred == "" || len(values) < 2 { + return values + } + out := append([]string{}, values...) + for idx, value := range out { + if value != preferred { + continue + } + copy(out[1:idx+1], out[:idx]) + out[0] = preferred + return out + } + return out +} diff --git a/pkg/service/app_helpers.go b/pkg/service/app_helpers.go index 613e2a1..be6f8c3 100644 --- a/pkg/service/app_helpers.go +++ b/pkg/service/app_helpers.go @@ -40,6 +40,7 @@ type activeNodeJobError struct { JobID string } +// Error reports that a replacement-capable job is already active for the node. func (e *activeNodeJobError) Error() string { if e == nil { return "node already has an active metis job" @@ -403,85 +404,6 @@ func cloneDevices(devices []Device) []Device { return out } -func (a *App) cachedDevices(host string) ([]Device, error) { - host = strings.TrimSpace(host) - if host == "" { - host = a.settings.DefaultFlashHost - } - a.mu.RLock() - snapshot, ok := a.deviceStore[host] - a.mu.RUnlock() - if !ok { - return nil, nil - } - if strings.TrimSpace(snapshot.Err) != "" { - return cloneDevices(snapshot.Devices), errors.New(snapshot.Err) - } - return cloneDevices(snapshot.Devices), nil -} - -func (a *App) recordDevices(host string, devices []Device, err error) { - host = strings.TrimSpace(host) - if host == "" { - host = a.settings.DefaultFlashHost - } - snapshot := deviceSnapshot{ - Devices: cloneDevices(devices), - CheckedAt: time.Now().UTC(), - } - if err != nil { - snapshot.Err = err.Error() - } - a.mu.Lock() - if existing, ok := a.deviceStore[host]; ok && len(snapshot.Devices) == 0 { - snapshot.Devices = cloneDevices(existing.Devices) - } - a.deviceStore[host] = snapshot - a.mu.Unlock() -} - -func deviceScore(device Device) int { - score := 0 - model := strings.ToLower(strings.TrimSpace(device.Model)) - switch { - case strings.Contains(model, "microsd"), strings.Contains(model, "micro sd"): - score += 60 - case strings.Contains(model, "sdxc"), strings.Contains(model, "sdhc"), strings.Contains(model, "sd "): - score += 50 - case strings.Contains(model, "card"), strings.Contains(model, "reader"): - score += 40 - } - if device.Removable { - score += 20 - } - if device.Hotplug { - score += 10 - } - if device.Transport == "usb" { - score += 5 - } - if strings.HasPrefix(device.Name, "mmcblk") { - score += 25 - } - return score -} - -func moveToFront(values []string, preferred string) []string { - if preferred == "" || len(values) < 2 { - return values - } - out := append([]string{}, values...) - for idx, value := range out { - if value != preferred { - continue - } - copy(out[1:idx+1], out[:idx]) - out[0] = preferred - return out - } - return out -} - func deleteNodeObject(node string) error { if err := deleteNodeObjectInCluster(node); err == nil { return nil