metis/cmd/metis/remote_cmd.go

382 lines
11 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"metis/pkg/plan"
"metis/pkg/service"
"metis/pkg/writer"
)
func remoteDevicesCmd(args []string) {
fs := flag.NewFlagSet("remote-devices", flag.ExitOnError)
maxBytes := fs.Int64("max-device-bytes", 300000000000, "max real removable device size")
hostTmpDir := fs.String("host-tmp-dir", "/tmp/metis-flash-test", "host tmp dir for test writes")
fs.Parse(args)
devices, err := localFlashDevices(*maxBytes, *hostTmpDir)
if err != nil {
log.Fatalf("remote devices: %v", err)
}
sort.Slice(devices, func(i, j int) bool {
left := localDeviceScore(devices[i])
right := localDeviceScore(devices[j])
if left != right {
return left > right
}
if devices[i].SizeBytes != devices[j].SizeBytes {
return devices[i].SizeBytes < devices[j].SizeBytes
}
return devices[i].Path < devices[j].Path
})
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{"devices": devices})
}
func remoteBuildCmd(args []string) {
fs := flag.NewFlagSet("remote-build", flag.ExitOnError)
invPath := fs.String("inventory", "inventory.yaml", "inventory file")
node := fs.String("node", "", "target node")
cacheDir := fs.String("cache", filepath.Join(os.TempDir(), "metis-cache"), "image cache dir")
workDir := fs.String("work-dir", filepath.Join(os.TempDir(), "metis-work"), "working directory")
artifactRef := fs.String("artifact-ref", "", "harbor artifact ref without tag")
buildTag := fs.String("build-tag", "", "artifact build tag")
harborRegistry := fs.String("harbor-registry", getenvOr("METIS_HARBOR_REGISTRY", "registry.bstein.dev"), "harbor registry host")
harborUsername := fs.String("harbor-username", getenvOr("METIS_HARBOR_USERNAME", ""), "harbor username")
harborPassword := fs.String("harbor-password", getenvOr("METIS_HARBOR_PASSWORD", ""), "harbor password")
fs.Parse(args)
if *node == "" || *artifactRef == "" || *buildTag == "" {
log.Fatalf("--node, --artifact-ref, and --build-tag are required")
}
if err := os.MkdirAll(*workDir, 0o755); err != nil {
log.Fatalf("mkdir workdir: %v", err)
}
output := filepath.Join(*workDir, fmt.Sprintf("%s.img", *node))
inv := loadInventory(*invPath)
if err := plan.BuildImageFile(context.Background(), inv, *node, *cacheDir, output); err != nil {
log.Fatalf("build image: %v", err)
}
if err := exec.Command("xz", "-T0", "-z", "-f", output).Run(); err != nil {
log.Fatalf("xz compress: %v", err)
}
compressedPath := output + ".xz"
info, err := os.Stat(compressedPath)
if err != nil {
log.Fatalf("stat compressed image: %v", err)
}
metadataPath := filepath.Join(*workDir, "metadata.json")
builtAt := time.Now().UTC()
meta := map[string]any{
"node": *node,
"artifact_ref": *artifactRef,
"build_tag": *buildTag,
"built_at": builtAt.Format(time.RFC3339),
"size_bytes": info.Size(),
"compressed": true,
}
metaBytes, err := json.MarshalIndent(meta, "", " ")
if err != nil {
log.Fatalf("encode metadata: %v", err)
}
if err := os.WriteFile(metadataPath, metaBytes, 0o644); err != nil {
log.Fatalf("write metadata: %v", err)
}
if err := orasLogin(*harborRegistry, *harborUsername, *harborPassword); err != nil {
log.Fatalf("oras login: %v", err)
}
taggedRef := fmt.Sprintf("%s:%s", *artifactRef, *buildTag)
if err := orasPush(taggedRef, compressedPath, metadataPath); err != nil {
log.Fatalf("oras push: %v", err)
}
if err := orasTag(taggedRef, "latest"); err != nil {
log.Fatalf("oras tag latest: %v", err)
}
summary := service.ArtifactSummary{
Node: *node,
Ref: fmt.Sprintf("%s:latest", *artifactRef),
BuildTag: *buildTag,
LocalPath: compressedPath,
Compressed: true,
UpdatedAt: builtAt,
SizeBytes: info.Size(),
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(summary)
}
func remoteFlashCmd(args []string) {
fs := flag.NewFlagSet("remote-flash", flag.ExitOnError)
node := fs.String("node", "", "target node")
device := fs.String("device", "", "target device path or test sink")
artifactRef := fs.String("artifact-ref", "", "harbor artifact ref without tag")
workDir := fs.String("work-dir", filepath.Join(os.TempDir(), "metis-flash"), "working directory")
harborRegistry := fs.String("harbor-registry", getenvOr("METIS_HARBOR_REGISTRY", "registry.bstein.dev"), "harbor registry host")
harborUsername := fs.String("harbor-username", getenvOr("METIS_HARBOR_USERNAME", ""), "harbor username")
harborPassword := fs.String("harbor-password", getenvOr("METIS_HARBOR_PASSWORD", ""), "harbor password")
hostTmpDir := fs.String("host-tmp-dir", "/host-tmp/metis-flash-test", "mounted host tmp dir for test writes")
fs.Parse(args)
if *node == "" || *device == "" || *artifactRef == "" {
log.Fatalf("--node, --device, and --artifact-ref are required")
}
if err := os.MkdirAll(*workDir, 0o755); err != nil {
log.Fatalf("mkdir workdir: %v", err)
}
if err := orasLogin(*harborRegistry, *harborUsername, *harborPassword); err != nil {
log.Fatalf("oras login: %v", err)
}
if err := orasPull(fmt.Sprintf("%s:latest", *artifactRef), *workDir); err != nil {
log.Fatalf("oras pull: %v", err)
}
imagePath, compressed, err := resolvePulledArtifact(*workDir)
if err != nil {
log.Fatalf("resolve artifact: %v", err)
}
rawImage := imagePath
if compressed {
rawImage = filepath.Join(*workDir, fmt.Sprintf("%s.img", *node))
cmd := exec.Command("sh", "-lc", fmt.Sprintf("xz -dc '%s' > '%s'", imagePath, rawImage))
if out, err := cmd.CombinedOutput(); err != nil {
log.Fatalf("xz stream decompress: %v: %s", err, strings.TrimSpace(string(out)))
}
}
destPath := *device
if strings.HasPrefix(destPath, "hosttmp://") {
if err := os.MkdirAll(*hostTmpDir, 0o755); err != nil {
log.Fatalf("mkdir host tmp dir: %v", err)
}
destPath = filepath.Join(*hostTmpDir, fmt.Sprintf("%s.img", *node))
}
if err := writer.WriteImage(context.Background(), rawImage, destPath); err != nil {
log.Fatalf("write image: %v", err)
}
_ = exec.Command("sync").Run()
if strings.HasPrefix(destPath, "/dev/") {
_ = exec.Command("blockdev", "--flushbufs", destPath).Run()
}
info, err := os.Stat(destPath)
if err != nil {
log.Fatalf("stat destination: %v", err)
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(map[string]any{
"node": *node,
"device": *device,
"dest_path": destPath,
"size_bytes": info.Size(),
})
}
func localFlashDevices(maxBytes int64, hostTmpDir string) ([]service.Device, error) {
cmd := exec.Command("lsblk", "-J", "-b", "-o", "NAME,PATH,RM,HOTPLUG,SIZE,MODEL,TRAN,TYPE")
out, err := cmd.Output()
if err != nil {
return nil, err
}
var payload struct {
Blockdevices []struct {
Name string `json:"name"`
Path string `json:"path"`
RM bool `json:"rm"`
Hotplug bool `json:"hotplug"`
Size any `json:"size"`
Model string `json:"model"`
Tran string `json:"tran"`
Type string `json:"type"`
Mountpoint string `json:"mountpoint"`
Children []struct {
Mountpoint string `json:"mountpoint"`
} `json:"children"`
} `json:"blockdevices"`
}
if err := json.Unmarshal(out, &payload); err != nil {
return nil, err
}
devices := make([]service.Device, 0, len(payload.Blockdevices)+1)
for _, dev := range payload.Blockdevices {
if dev.Type != "disk" {
continue
}
size := int64(0)
switch value := dev.Size.(type) {
case string:
size, _ = strconv.ParseInt(value, 10, 64)
case float64:
size = int64(value)
}
if size <= 0 || size > maxBytes {
continue
}
if dev.Tran != "usb" && !dev.RM {
continue
}
if strings.TrimSpace(dev.Mountpoint) != "" {
continue
}
if hasMountedChildren(dev.Children) {
continue
}
devices = append(devices, service.Device{
Name: dev.Name,
Path: dev.Path,
Model: strings.TrimSpace(dev.Model),
Transport: dev.Tran,
Type: dev.Type,
Removable: dev.RM,
Hotplug: dev.Hotplug,
SizeBytes: size,
})
}
devices = append(devices, service.Device{
Name: "host-tmp",
Path: "hosttmp:///tmp",
Model: "Host /tmp",
Transport: "test",
Type: "file",
Note: fmt.Sprintf("Test-only host write target under %s", humanHostPath(hostTmpDir)),
Removable: false,
Hotplug: false,
SizeBytes: 1,
})
return devices, nil
}
func localDeviceScore(device service.Device) int {
score := 0
if strings.HasPrefix(device.Path, "hosttmp://") {
return -100
}
if device.Transport == "usb" {
score += 50
}
if device.Removable {
score += 30
}
if device.Hotplug {
score += 20
}
if strings.Contains(strings.ToLower(device.Model), "sd") {
score += 10
}
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 {
cmd := exec.Command("oras", "push", ref,
fmt.Sprintf("%s:application/x-raw-disk-image", imagePath),
fmt.Sprintf("%s:application/json", metadataPath),
)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return 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 {
for _, child := range children {
if strings.TrimSpace(child.Mountpoint) != "" {
return true
}
}
return false
}
func humanHostPath(path string) string {
path = strings.TrimSpace(path)
if strings.HasPrefix(path, "/host-tmp/") {
return "/" + strings.TrimPrefix(path, "/host-tmp/")
}
if path == "/host-tmp" {
return "/tmp"
}
return path
}
func getenvOr(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}