From 280094e6ab7bd823bcfd47764c48e88c3c94ddbc Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:36:46 +0200 Subject: [PATCH 01/23] feat: implement ISO management functionality with disk classification and download capabilities Added a comprehensive set of features for managing ISO downloads and disk operations. Introduced functions for classifying block devices, downloading ISOs with verification, and handling various Linux distributions including Ubuntu and Fedora. Implemented disk listing and inspection functionalities, along with utilities for parsing size and checksum data. Enhanced testing with new unit tests for ISO parsing and disk classification, improving overall reliability and usability of the ISO management tools. --- internal/iso/catalog.go | 195 +++++++++++++++++++++++++++++++ internal/iso/catalog_test.go | 49 ++++++++ internal/iso/closer.go | 18 +++ internal/iso/disks_linux.go | 97 +++++++++++++++ internal/iso/disks_stub.go | 43 +++++++ internal/iso/download.go | 221 +++++++++++++++++++++++++++++++++++ internal/iso/fedora.go | 109 +++++++++++++++++ internal/iso/lsblk.go | 66 +++++++++++ internal/iso/size.go | 45 +++++++ internal/iso/ubuntu.go | 101 ++++++++++++++++ internal/iso/verify.go | 88 ++++++++++++++ internal/iso/write.go | 120 +++++++++++++++++++ internal/iso/write_stub.go | 30 +++++ 13 files changed, 1182 insertions(+) create mode 100644 internal/iso/catalog.go create mode 100644 internal/iso/catalog_test.go create mode 100644 internal/iso/closer.go create mode 100644 internal/iso/disks_linux.go create mode 100644 internal/iso/disks_stub.go create mode 100644 internal/iso/download.go create mode 100644 internal/iso/fedora.go create mode 100644 internal/iso/lsblk.go create mode 100644 internal/iso/size.go create mode 100644 internal/iso/ubuntu.go create mode 100644 internal/iso/verify.go create mode 100644 internal/iso/write.go create mode 100644 internal/iso/write_stub.go diff --git a/internal/iso/catalog.go b/internal/iso/catalog.go new file mode 100644 index 0000000..ef71771 --- /dev/null +++ b/internal/iso/catalog.go @@ -0,0 +1,195 @@ +// Package iso provides bootable USB tooling. +package iso + +import ( + "errors" + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" +) + +// ErrNotImplemented indicates a stub resolver. +var ErrNotImplemented = errors.New("not implemented yet") + +// Release describes a resolved ISO download. +type Release struct { + Version string + ISOURL string + ChecksumURL string + GPGKeyURL string + ISOFilename string +} + +// Distro describes a supported distribution. +type Distro struct { + ID string + DisplayName string + Architectures []string + ApproxSize string + Resolve func(arch string, version string) (Release, error) +} + +// Catalog returns all known distros. +func Catalog() []Distro { + return []Distro{ + { + ID: "ubuntu-desktop", + DisplayName: "Ubuntu Desktop LTS", + Architectures: []string{"amd64"}, + ApproxSize: "~6 GB", + Resolve: resolveUbuntuDesktop, + }, + { + ID: "ubuntu-server", + DisplayName: "Ubuntu Server", + Architectures: []string{"amd64", "arm64"}, + ApproxSize: "~3 GB", + Resolve: stubResolver("ubuntu-server"), + }, + { + ID: "fedora-silverblue", + DisplayName: "Fedora Silverblue", + Architectures: []string{"amd64"}, + ApproxSize: "~2.5 GB", + Resolve: resolveFedoraSilverblue, + }, + { + ID: "fedora-workstation", + DisplayName: "Fedora Workstation", + Architectures: []string{"amd64", "arm64"}, + ApproxSize: "~2.5 GB", + Resolve: stubResolver("fedora-workstation"), + }, + { + ID: "debian", + DisplayName: "Debian", + Architectures: []string{"amd64", "arm64"}, + ApproxSize: "~700 MB", + Resolve: stubResolver("debian"), + }, + { + ID: "arch", + DisplayName: "Arch Linux", + Architectures: []string{"amd64"}, + ApproxSize: "~1 GB", + Resolve: stubResolver("arch"), + }, + { + ID: "opensuse-tumbleweed", + DisplayName: "openSUSE Tumbleweed", + Architectures: []string{"amd64"}, + ApproxSize: "~4 GB", + Resolve: stubResolver("opensuse-tumbleweed"), + }, + { + ID: "nixos", + DisplayName: "NixOS", + Architectures: []string{"amd64"}, + ApproxSize: "~1.5 GB", + Resolve: stubResolver("nixos"), + }, + } +} + +func stubResolver(id string) func(string, string) (Release, error) { + return func(string, string) (Release, error) { + return Release{}, fmt.Errorf("%s: %w", id, ErrNotImplemented) + } +} + +// LookupDistro finds a distro by ID. +func LookupDistro(id string) (Distro, bool) { + id = strings.ToLower(strings.TrimSpace(id)) + for _, d := range Catalog() { + if d.ID == id { + return d, true + } + } + return Distro{}, false +} + +// ListEntry is a row for lab iso list. +type ListEntry struct { + ID string + Version string + ApproxSize string + Architectures string +} + +// ListDistros resolves latest versions for display. +func ListDistros() ([]ListEntry, error) { + var out []ListEntry + for _, d := range Catalog() { + arch := "amd64" + if len(d.Architectures) > 0 { + arch = d.Architectures[0] + } + ver := "latest" + if d.Resolve != nil { + if rel, err := d.Resolve(arch, ""); err == nil { + ver = rel.Version + } else if errors.Is(err, ErrNotImplemented) { + ver = "planned" + } + } + out = append(out, ListEntry{ + ID: d.ID, + Version: ver, + ApproxSize: d.ApproxSize, + Architectures: strings.Join(d.Architectures, ", "), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +// WriteList prints the catalog table. +func WriteList(w io.Writer, entries []ListEntry) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "DISTRO\tVERSION\tSIZE\tARCHITECTURES") + for _, e := range entries { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.ID, e.Version, e.ApproxSize, e.Architectures) + } + return tw.Flush() +} + +// DiskType classifies block devices. +type DiskType string + +// Disk classification labels. +const ( + DiskSystem DiskType = "SYSTEM" + DiskUSB DiskType = "USB" +) + +// Disk describes a block device for lab iso disks. +type Disk struct { + Device string + Size string + Model string + Tran string + Type DiskType +} + +// ClassifyDisk determines SYSTEM vs USB from lsblk fields. +func ClassifyDisk(rm bool, tran string) DiskType { + tr := strings.ToLower(strings.TrimSpace(tran)) + if rm || tr == "usb" { + return DiskUSB + } + return DiskSystem +} + +// FormatDiskLine returns a display line for a disk. +func FormatDiskLine(d Disk) string { + tag := string(d.Type) + switch d.Type { + case DiskUSB: + tag += " ✅" + case DiskSystem: + tag += " ⚠️" + } + return fmt.Sprintf("%-12s %-8s %-24s %-6s %s", d.Device, d.Size, d.Model, d.Tran, tag) +} diff --git a/internal/iso/catalog_test.go b/internal/iso/catalog_test.go new file mode 100644 index 0000000..7f6532f --- /dev/null +++ b/internal/iso/catalog_test.go @@ -0,0 +1,49 @@ +package iso_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/iso" + "github.com/stretchr/testify/require" +) + +const sampleLSBLK = `{ + "blockdevices": [ + {"name":"sda","size":"500G","model":"Samsung SSD 860","tran":"sata","rm":false,"type":"disk","vendor":""}, + {"name":"nvme0n1","size":"1.0T","model":"WD_BLACK SN770","tran":"nvme","rm":false,"type":"disk","vendor":""}, + {"name":"sdb","size":"58G","model":"SanDisk Ultra USB 3.0","tran":"usb","rm":true,"type":"disk","vendor":""} + ] +}` + +func TestParseLSBLKJSON_classifiesUSB(t *testing.T) { + disks, err := iso.ParseLSBLKJSON(sampleLSBLK) + require.NoError(t, err) + require.Len(t, disks, 3) + + require.Equal(t, iso.DiskSystem, disks[0].Type) + require.Equal(t, "/dev/sda", disks[0].Device) + + require.Equal(t, iso.DiskSystem, disks[1].Type) + + require.Equal(t, iso.DiskUSB, disks[2].Type) + require.Equal(t, "usb", disks[2].Tran) +} + +func TestClassifyDisk(t *testing.T) { + require.Equal(t, iso.DiskUSB, iso.ClassifyDisk(true, "sata")) + require.Equal(t, iso.DiskUSB, iso.ClassifyDisk(false, "usb")) + require.Equal(t, iso.DiskSystem, iso.ClassifyDisk(false, "nvme")) +} + +func TestLookupDistro(t *testing.T) { + d, ok := iso.LookupDistro("ubuntu-desktop") + require.True(t, ok) + require.Equal(t, "ubuntu-desktop", d.ID) +} + +func TestParseSHA256SUMS(t *testing.T) { + content := "abc123 ubuntu-24.04.3-desktop-amd64.iso\n" + hash, ok := iso.ParseSHA256SUMS(content, "ubuntu-24.04.3-desktop-amd64.iso") + require.True(t, ok) + require.Equal(t, "abc123", hash) +} diff --git a/internal/iso/closer.go b/internal/iso/closer.go new file mode 100644 index 0000000..94769ff --- /dev/null +++ b/internal/iso/closer.go @@ -0,0 +1,18 @@ +package iso + +import ( + "io" + "net/http" +) + +func closeBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} + +func closeFile(c io.Closer) { + if c != nil { + _ = c.Close() + } +} diff --git a/internal/iso/disks_linux.go b/internal/iso/disks_linux.go new file mode 100644 index 0000000..7ed51de --- /dev/null +++ b/internal/iso/disks_linux.go @@ -0,0 +1,97 @@ +//go:build linux + +package iso + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// ListDisks returns block devices with USB/SYSTEM classification. +func ListDisks(ctx context.Context, runner exec.Runner) ([]Disk, error) { + if runner == nil { + runner = exec.NewOSRunner(os.Stdout, os.Stderr) + } + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-o", "NAME,SIZE,MODEL,TRAN,RM,MOUNTPOINT,VENDOR") + if err != nil { + return nil, err + } + return ParseLSBLKJSON(out) +} + +// WriteDisksTable prints disk listing. +func WriteDisksTable(w io.Writer, disks []Disk) { + fmt.Fprintf(w, "%-12s %-8s %-24s %-6s %s\n", "DEVICE", "SIZE", "MODEL", "TRAN", "TYPE") + for _, d := range disks { + fmt.Fprintln(w, FormatDiskLine(d)) + if d.Type == DiskSystem { + fmt.Fprintf(w, " ⚠️ SYSTEM disk — do NOT write ISOs to %s\n", d.Device) + } + } +} + +// InspectDevice reads lsblk metadata for a single device. +func InspectDevice(ctx context.Context, runner exec.Runner, device string) (DeviceInfo, error) { + name := strings.TrimPrefix(device, "/dev/") + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-n", "-o", "NAME,SIZE,MODEL,TRAN,RM,MOUNTPOINT", name) + if err != nil { + return DeviceInfo{}, err + } + var payload lsblkRoot + if err := json.Unmarshal([]byte(out), &payload); err != nil { + return DeviceInfo{}, err + } + if len(payload.BlockDevices) == 0 { + return DeviceInfo{}, fmt.Errorf("device %s not found", device) + } + dev := payload.BlockDevices[0] + var mps []string + collectMountpoints(dev, &mps) + return DeviceInfo{ + Device: device, + Size: dev.Size, + Model: dev.Model, + Tran: dev.Tran, + RM: dev.RM, + Mountpoints: mps, + }, nil +} + +// DeviceInfo holds metadata for write safety checks. +type DeviceInfo struct { + Device string + Size string + Model string + Tran string + RM bool + Mountpoints []string +} + +func unmountDevice(ctx context.Context, runner exec.Runner, device string) error { + name := strings.TrimPrefix(device, "/dev/") + out, err := runner.RunWithOutput(ctx, "lsblk", "-J", "-n", "-o", "NAME,MOUNTPOINT", name) + if err != nil { + return err + } + var payload lsblkRoot + if err := json.Unmarshal([]byte(out), &payload); err != nil { + return err + } + var parts []string + for _, d := range payload.BlockDevices { + collectPartNames(d, &parts) + } + for _, p := range parts { + if p == "" { + continue + } + _ = runner.Run(ctx, "umount", "/dev/"+p) + } + return nil +} diff --git a/internal/iso/disks_stub.go b/internal/iso/disks_stub.go new file mode 100644 index 0000000..cc54e7d --- /dev/null +++ b/internal/iso/disks_stub.go @@ -0,0 +1,43 @@ +//go:build !linux + +package iso + +import ( + "context" + "fmt" + "io" + + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// ListDisks is only supported on Linux. +func ListDisks(ctx context.Context, runner exec.Runner) ([]Disk, error) { + return nil, fmt.Errorf("lab iso disks: %w", clierrors.ErrNotImplemented) +} + +// WriteDisksTable prints disk listing. +func WriteDisksTable(w io.Writer, disks []Disk) { + for _, d := range disks { + fmt.Fprintln(w, FormatDiskLine(d)) + } +} + +// InspectDevice is only supported on Linux. +func InspectDevice(ctx context.Context, runner exec.Runner, device string) (DeviceInfo, error) { + return DeviceInfo{}, fmt.Errorf("lab iso write: %w", clierrors.ErrNotImplemented) +} + +// DeviceInfo holds metadata for write safety checks. +type DeviceInfo struct { + Device string + Size string + Model string + Tran string + RM bool + Mountpoints []string +} + +func unmountDevice(ctx context.Context, runner exec.Runner, device string) error { + return nil +} diff --git a/internal/iso/download.go b/internal/iso/download.go new file mode 100644 index 0000000..ca25308 --- /dev/null +++ b/internal/iso/download.go @@ -0,0 +1,221 @@ +package iso + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// DownloadOptions configures ISO download. +type DownloadOptions struct { + Arch string + Version string + OutputDir string + NoVerify bool + Force bool + Stdout io.Writer + Client *http.Client +} + +// DownloadResult holds the verified ISO path. +type DownloadResult struct { + Path string +} + +// DownloadISO resolves, downloads, and verifies an ISO. +func DownloadISO(ctx context.Context, distro Distro, opts DownloadOptions) (DownloadResult, error) { + if opts.Client == nil { + opts.Client = &http.Client{Timeout: 30 * time.Minute} + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.OutputDir == "" { + home, _ := os.UserHomeDir() + opts.OutputDir = filepath.Join(home, ".cache", "homelab-cli", "iso") + } + if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { + return DownloadResult{}, err + } + + rel, err := distro.Resolve(opts.Arch, opts.Version) + if err != nil { + return DownloadResult{}, err + } + + dest := filepath.Join(opts.OutputDir, rel.ISOFilename) + if !opts.Force { + if st, err := os.Stat(dest); err == nil && st.Size() > 0 { + if ok, _ := verifyLocalSHA256(dest, rel); ok { + fmt.Fprintf(opts.Stdout, "already cached: %s\n", dest) + return DownloadResult{Path: dest}, nil + } + } + } + + fmt.Fprintf(opts.Stdout, "Downloading %s\n", rel.ISOURL) + if err := downloadFile(ctx, opts.Client, rel.ISOURL, dest, opts.Stdout); err != nil { + return DownloadResult{}, err + } + fmt.Fprintln(opts.Stdout) + + sumData, err := fetchBytes(ctx, opts.Client, rel.ChecksumURL) + if err != nil { + return DownloadResult{}, fmt.Errorf("checksum file: %w", err) + } + want, ok := findSHA256(string(sumData), rel.ISOFilename) + if !ok { + // Fedora CHECKSUM format + want, ok = findFedoraChecksum(string(sumData), rel.ISOFilename) + } + if !ok { + return DownloadResult{}, fmt.Errorf("checksum entry not found for %s", rel.ISOFilename) + } + if err := verifyFileSHA256(dest, want); err != nil { + return DownloadResult{}, err + } + fmt.Fprintln(opts.Stdout, "SHA256 verified") + + if !opts.NoVerify && rel.GPGKeyURL != "" { + if err := VerifyGPG(ctx, rel); err != nil { + return DownloadResult{}, err + } + fmt.Fprintln(opts.Stdout, "GPG signature verified") + } + + fmt.Fprintf(opts.Stdout, "Verified ISO: %s\n", dest) + return DownloadResult{Path: dest}, nil +} + +func downloadFile(ctx context.Context, client *http.Client, url, dest string, progress io.Writer) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + f, err := os.Create(dest) + if err != nil { + return err + } + defer closeFile(f) + + pr := &byteProgress{w: progress, total: resp.ContentLength} + if _, err := io.Copy(f, io.TeeReader(resp.Body, pr)); err != nil { + return err + } + pr.finish() + return nil +} + +type byteProgress struct { + w io.Writer + total int64 + read int64 + last time.Time + lastPct int +} + +func (p *byteProgress) Write(b []byte) (int, error) { + n := len(b) + p.read += int64(n) + now := time.Now() + if p.w != nil && (p.last.IsZero() || now.Sub(p.last) >= 500*time.Millisecond) { + p.last = now + if p.total > 0 { + pct := int(p.read * 100 / p.total) + if pct != p.lastPct { + fmt.Fprintf(p.w, "\rProgress: %d%% (%d / %d bytes)", pct, p.read, p.total) + p.lastPct = pct + } + } else { + fmt.Fprintf(p.w, "\rDownloaded %d bytes", p.read) + } + } + return n, nil +} + +func (p *byteProgress) finish() { + if p.w != nil { + fmt.Fprintln(p.w) + } +} + +func fetchBytes(ctx context.Context, client *http.Client, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +func verifyLocalSHA256(_ string, _ Release) (bool, error) { + // Without network, can't verify - return false to trigger redownload + return false, nil +} + +func findSHA256(content, filename string) (string, bool) { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + name := strings.TrimPrefix(parts[1], "*") + if name == filename { + return parts[0], true + } + } + return "", false +} + +func findFedoraChecksum(content, filename string) (string, bool) { + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, filename) && strings.Contains(line, "SHA256") { + // SHA256 (filename.iso) = abc... + if i := strings.Index(line, "= "); i >= 0 { + return strings.TrimSpace(line[i+2:]), true + } + } + } + return "", false +} + +func verifyFileSHA256(path, want string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer closeFile(f) + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, want) { + return fmt.Errorf("sha256 mismatch: got %s want %s", got, want) + } + return nil +} diff --git a/internal/iso/fedora.go b/internal/iso/fedora.go new file mode 100644 index 0000000..67bd278 --- /dev/null +++ b/internal/iso/fedora.go @@ -0,0 +1,109 @@ +package iso + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +const ( + fedoraReleaseAPI = "https://bodhi.fedoraproject.org/releases/?state=current" + fedoraBase = "https://download.fedoraproject.org/pub/fedora/linux/releases/" +) + +var fedoraISORe = regexp.MustCompile(`Fedora-Silverblue-ostree-(x86_64|aarch64)-(\d+)-1\.(\d+)\.iso`) + +func resolveFedoraSilverblue(arch, version string) (Release, error) { + if arch == "" { + arch = "amd64" + } + farch := "x86_64" + if arch == "arm64" { + farch = "aarch64" + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + release := strings.TrimSpace(version) + if release == "" { + var err error + release, err = currentFedoraRelease(ctx) + if err != nil { + release = "41" // fallback pin + } + } + + isoDir := fmt.Sprintf("%s%s/Silverblue/%s/iso/", fedoraBase, release, farch) + client := &http.Client{Timeout: 2 * time.Minute} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, isoDir, nil) + if err != nil { + return Release{}, err + } + resp, err := client.Do(req) + if err != nil { + return Release{}, err + } + defer closeBody(resp) + body, err := io.ReadAll(resp.Body) + if err != nil { + return Release{}, err + } + + var isoFile, subrelease string + for _, m := range fedoraISORe.FindAllStringSubmatch(string(body), -1) { + if m[1] != farch { + continue + } + isoFile = m[0] + subrelease = m[3] + break + } + if isoFile == "" { + return Release{}, fmt.Errorf("no Silverblue ISO found for Fedora %s %s", release, farch) + } + + checksumFile := fmt.Sprintf("Fedora-Silverblue-%s-%s-%s-CHECKSUM", release, subrelease, farch) + return Release{ + Version: release, + ISOURL: isoDir + isoFile, + ChecksumURL: isoDir + checksumFile, + GPGKeyURL: "", + ISOFilename: isoFile, + }, nil +} + +func currentFedoraRelease(ctx context.Context) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fedoraReleaseAPI, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("bodhi API: HTTP %d", resp.StatusCode) + } + + var releases []struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", err + } + for _, r := range releases { + name := strings.TrimSpace(r.Name) + if name != "" { + return name, nil + } + } + return "", fmt.Errorf("no current fedora release from bodhi") +} diff --git a/internal/iso/lsblk.go b/internal/iso/lsblk.go new file mode 100644 index 0000000..56177a6 --- /dev/null +++ b/internal/iso/lsblk.go @@ -0,0 +1,66 @@ +package iso + +import ( + "encoding/json" + "fmt" + "strings" +) + +type lsblkRoot struct { + BlockDevices []lsblkNode `json:"blockdevices"` +} + +type lsblkNode struct { + Name string `json:"name"` + Size string `json:"size"` + Model string `json:"model"` + Tran string `json:"tran"` + RM bool `json:"rm"` + Type string `json:"type"` + Vendor string `json:"vendor"` + Mountpoint *string `json:"mountpoint"` + Children []lsblkNode `json:"children"` +} + +// ParseLSBLKJSON parses lsblk JSON output into Disk entries. +func ParseLSBLKJSON(raw string) ([]Disk, error) { + var payload lsblkRoot + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, fmt.Errorf("parse lsblk JSON: %w", err) + } + var disks []Disk + for _, dev := range payload.BlockDevices { + if dev.Type != "disk" { + continue + } + model := strings.TrimSpace(dev.Model) + if model == "" { + model = strings.TrimSpace(dev.Vendor) + } + diskType := ClassifyDisk(dev.RM, dev.Tran) + disks = append(disks, Disk{ + Device: "/dev/" + dev.Name, + Size: dev.Size, + Model: model, + Tran: dev.Tran, + Type: diskType, + }) + } + return disks, nil +} + +func collectMountpoints(dev lsblkNode, out *[]string) { + if dev.Mountpoint != nil && *dev.Mountpoint != "" { + *out = append(*out, *dev.Mountpoint) + } + for _, c := range dev.Children { + collectMountpoints(c, out) + } +} + +func collectPartNames(dev lsblkNode, out *[]string) { + *out = append(*out, dev.Name) + for _, c := range dev.Children { + collectPartNames(c, out) + } +} diff --git a/internal/iso/size.go b/internal/iso/size.go new file mode 100644 index 0000000..5da048b --- /dev/null +++ b/internal/iso/size.go @@ -0,0 +1,45 @@ +package iso + +import ( + "fmt" + "strconv" + "strings" +) + +// ParseSizeBytes converts lsblk size strings (e.g. 58G, 500M) to bytes. +func ParseSizeBytes(size string) (int64, error) { + size = strings.TrimSpace(size) + if size == "" { + return 0, fmt.Errorf("empty size") + } + units := map[byte]int64{ + 'K': 1024, + 'M': 1024 * 1024, + 'G': 1024 * 1024 * 1024, + 'T': 1024 * 1024 * 1024 * 1024, + } + last := size[len(size)-1] + if u, ok := units[last]; ok { + numStr := strings.TrimSpace(size[:len(size)-1]) + f, err := strconv.ParseFloat(numStr, 64) + if err != nil { + return 0, err + } + return int64(f * float64(u)), nil + } + return strconv.ParseInt(size, 10, 64) +} + +const minUSBCapacity = 4 * 1024 * 1024 * 1024 // 4 GiB + +// ValidateDeviceCapacity rejects devices smaller than min USB size. +func ValidateDeviceCapacity(size string) error { + bytes, err := ParseSizeBytes(size) + if err != nil { + return err + } + if bytes < minUSBCapacity { + return fmt.Errorf("device size %s is below 4 GiB minimum for ISO writes", size) + } + return nil +} diff --git a/internal/iso/ubuntu.go b/internal/iso/ubuntu.go new file mode 100644 index 0000000..dbe14dd --- /dev/null +++ b/internal/iso/ubuntu.go @@ -0,0 +1,101 @@ +package iso + +import ( + "context" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +const ubuntuBase = "https://releases.ubuntu.com/" + +var ubuntuDirRe = regexp.MustCompile(`(?m)`) + +func resolveUbuntuDesktop(arch, version string) (Release, error) { + if arch == "" { + arch = "amd64" + } + if arch != "amd64" { + return Release{}, fmt.Errorf("ubuntu-desktop: only amd64 supported") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + ver := strings.TrimSpace(version) + if ver == "" { + var err error + ver, err = latestUbuntuLTSVersion(ctx) + if err != nil { + return Release{}, err + } + } + + base := ubuntuBase + ver + "/" + isoFile := fmt.Sprintf("ubuntu-%s-desktop-%s.iso", ver, arch) + return Release{ + Version: ver + " LTS", + ISOURL: base + isoFile, + ChecksumURL: base + "SHA256SUMS", + GPGKeyURL: base + "SHA256SUMS.gpg", + ISOFilename: isoFile, + }, nil +} + +func latestUbuntuLTSVersion(ctx context.Context) (string, error) { + client := &http.Client{Timeout: 2 * time.Minute} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ubuntuBase, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer closeBody(resp) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var versions []string + for _, m := range ubuntuDirRe.FindAllStringSubmatch(string(data), -1) { + v := m[1] + if strings.HasPrefix(v, "24.") || strings.HasPrefix(v, "22.") { + versions = append(versions, v) + } + } + if len(versions) == 0 { + return "", fmt.Errorf("no ubuntu LTS version found in index") + } + // Pick highest semver-ish + best := versions[0] + for _, v := range versions[1:] { + if compareUbuntuVer(v, best) > 0 { + best = v + } + } + return best, nil +} + +func compareUbuntuVer(a, b string) int { + ap := strings.Split(a, ".") + bp := strings.Split(b, ".") + for i := 0; i < 3; i++ { + ai, bi := 0, 0 + if i < len(ap) { + ai, _ = strconv.Atoi(ap[i]) + } + if i < len(bp) { + bi, _ = strconv.Atoi(bp[i]) + } + if ai != bi { + return ai - bi + } + } + return 0 +} diff --git a/internal/iso/verify.go b/internal/iso/verify.go new file mode 100644 index 0000000..8946bf2 --- /dev/null +++ b/internal/iso/verify.go @@ -0,0 +1,88 @@ +package iso + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// VerifyGPG downloads checksums and verifies GPG signature using system gpg. +func VerifyGPG(ctx context.Context, rel Release) error { + if rel.GPGKeyURL == "" { + return nil + } + runner := exec.NewOSRunner(os.Stdout, os.Stderr) + tmp, err := os.MkdirTemp("", "lab-iso-gpg-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmp) }() + + client := &http.Client{Timeout: 2 * time.Minute} + sumPath := filepath.Join(tmp, "SHA256SUMS") + gpgPath := filepath.Join(tmp, "SHA256SUMS.gpg") + + sumData, err := fetchBytes(ctx, client, rel.ChecksumURL) + if err != nil { + return err + } + if err := os.WriteFile(sumPath, sumData, 0o644); err != nil { + return err + } + + gpgData, err := fetchBytes(ctx, client, rel.GPGKeyURL) + if err != nil { + return err + } + if err := os.WriteFile(gpgPath, gpgData, 0o644); err != nil { + return err + } + + if err := runner.Run(ctx, "gpg", "--verify", gpgPath, sumPath); err != nil { + return fmt.Errorf("gpg verify: %w", err) + } + return nil +} + +// ParseSHA256SUMS extracts hash for filename from Ubuntu-style SHA256SUMS. +func ParseSHA256SUMS(content, filename string) (string, bool) { + return findSHA256(content, filename) +} + +// ParseFedoraCHECKSUM extracts hash from Fedora CHECKSUM file. +func ParseFedoraCHECKSUM(content, filename string) (string, bool) { + return findFedoraChecksum(content, filename) +} + +// ReadISOSize returns human-readable size. +func ReadISOSize(path string) (string, error) { + st, err := os.Stat(path) + if err != nil { + return "", err + } + return formatBytes(st.Size()), nil +} + +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// CopyWithProgress is a test hook placeholder. +func CopyWithProgress(dst io.Writer, src io.Reader) (int64, error) { + return io.Copy(dst, src) +} diff --git a/internal/iso/write.go b/internal/iso/write.go new file mode 100644 index 0000000..b830ed1 --- /dev/null +++ b/internal/iso/write.go @@ -0,0 +1,120 @@ +//go:build linux + +package iso + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// WriteOptions configures ISO write to block device. +type WriteOptions struct { + ISOPath string + Device string + Yes bool + Force bool + BlockSize string + Stdout io.Writer + Stderr io.Writer + Runner exec.Runner + Confirm func(prompt string) (string, error) +} + +// WriteISO burns an ISO to a block device with safety checks. +func WriteISO(ctx context.Context, opts WriteOptions) error { + if opts.Runner == nil { + opts.Runner = exec.NewOSRunner(opts.Stdout, opts.Stderr) + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.BlockSize == "" { + opts.BlockSize = "4M" + } + + device := opts.Device + if !strings.HasPrefix(device, "/dev/") { + device = "/dev/" + strings.TrimPrefix(device, "/dev/") + } + + st, err := os.Stat(device) + if err != nil { + return fmt.Errorf("device %s: %w", device, err) + } + if st.Mode()&os.ModeDevice == 0 { + return fmt.Errorf("%s is not a block device", device) + } + + info, err := InspectDevice(ctx, opts.Runner, device) + if err != nil { + return err + } + + if err := ValidateDeviceCapacity(info.Size); err != nil && !opts.Force { + return err + } + + diskType := ClassifyDisk(info.RM, info.Tran) + if !opts.Force && diskType == DiskSystem { + return fmt.Errorf("refusing to write to system disk %s (TRAN=%s RM=%v); use --force to override", device, info.Tran, info.RM) + } + if len(info.Mountpoints) > 0 && !opts.Force { + return fmt.Errorf("device %s has mounted partitions: %v; unmount first or use --force", device, info.Mountpoints) + } + + isoSize, err := ReadISOSize(opts.ISOPath) + if err != nil { + return err + } + + fmt.Fprintf(opts.Stdout, "About to write to:\n") + fmt.Fprintf(opts.Stdout, " Device: %s\n", device) + fmt.Fprintf(opts.Stdout, " Model: %s\n", info.Model) + fmt.Fprintf(opts.Stdout, " Size: %s\n", info.Size) + fmt.Fprintf(opts.Stdout, " Source: %s (%s)\n\n", opts.ISOPath, isoSize) + fmt.Fprintf(opts.Stdout, "⚠️ ALL DATA ON %s WILL BE DESTROYED.\n\n", device) + + if !opts.Yes { + prompt := fmt.Sprintf("Type %q to confirm: ", device) + var answer string + if opts.Confirm != nil { + answer, err = opts.Confirm(prompt) + } else { + fmt.Fprint(opts.Stdout, prompt) + _, err = fmt.Scanln(&answer) + } + if err != nil { + return err + } + if answer != device { + return fmt.Errorf("confirmation mismatch: expected %q", device) + } + } + + if err := unmountDevice(ctx, opts.Runner, device); err != nil { + return err + } + + args := []string{ + "if=" + opts.ISOPath, + "of=" + device, + "bs=" + opts.BlockSize, + "status=progress", + "conv=fdatasync", + "oflag=direct", + } + if err := opts.Runner.Run(ctx, "dd", args...); err != nil { + return fmt.Errorf("dd: %w", err) + } + if err := opts.Runner.Run(ctx, "sync"); err != nil { + return err + } + + fmt.Fprintln(opts.Stdout, "Done. You can now unplug the USB drive and boot from it.") + return nil +} diff --git a/internal/iso/write_stub.go b/internal/iso/write_stub.go new file mode 100644 index 0000000..9cb1c1e --- /dev/null +++ b/internal/iso/write_stub.go @@ -0,0 +1,30 @@ +//go:build !linux + +package iso + +import ( + "context" + "fmt" + "io" + + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// WriteOptions configures ISO write to block device. +type WriteOptions struct { + ISOPath string + Device string + Yes bool + Force bool + BlockSize string + Stdout io.Writer + Stderr io.Writer + Runner exec.Runner + Confirm func(prompt string) (string, error) +} + +// WriteISO burns an ISO to a block device (Linux only). +func WriteISO(ctx context.Context, opts WriteOptions) error { + return fmt.Errorf("lab iso write: %w", clierrors.ErrNotImplemented) +} From fd836ae1394186bf36c902f9d036f0f79cb18860 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:37:04 +0200 Subject: [PATCH 02/23] docs: add package comment for baremetal package to clarify purpose --- internal/baremetal/clickhouse.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/baremetal/clickhouse.go b/internal/baremetal/clickhouse.go index 667b1d1..b566c1c 100644 --- a/internal/baremetal/clickhouse.go +++ b/internal/baremetal/clickhouse.go @@ -1,3 +1,4 @@ +// Package baremetal installs data-plane services directly on Linux hosts. package baremetal import ( From 47ffc3ca312a23f465d306833989585590175537 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:37:20 +0200 Subject: [PATCH 03/23] feat: implement bootstrap functionality for essential tools and packages Added new functionalities for setting up essential tools in a homelab environment, including Docker, Distrobox, and Mise. Implemented package management for various distributions, ensuring installation and configuration of necessary packages. Introduced helper functions for managing Flathub remote and streamlined the process of running essential scripts. Enhanced testing coverage for the bootstrap process, improving reliability and usability. --- internal/bootstrap/distrobox.go | 61 ++++++++ internal/bootstrap/docker.go | 43 ++++++ internal/bootstrap/essentials.go | 210 ++++++++++++++++++++++++++ internal/bootstrap/essentials_test.go | 30 ++++ internal/bootstrap/flatpak.go | 12 ++ internal/bootstrap/mise.go | 28 ++++ internal/bootstrap/packages.go | 87 +++++++++++ internal/bootstrap/profile.go | 9 +- 8 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 internal/bootstrap/distrobox.go create mode 100644 internal/bootstrap/docker.go create mode 100644 internal/bootstrap/essentials.go create mode 100644 internal/bootstrap/essentials_test.go create mode 100644 internal/bootstrap/flatpak.go create mode 100644 internal/bootstrap/mise.go create mode 100644 internal/bootstrap/packages.go diff --git a/internal/bootstrap/distrobox.go b/internal/bootstrap/distrobox.go new file mode 100644 index 0000000..6c63acd --- /dev/null +++ b/internal/bootstrap/distrobox.go @@ -0,0 +1,61 @@ +package bootstrap + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// SetupDistrobox installs distrobox and optionally creates homelab-dev container. +func SetupDistrobox(ctx context.Context, opts EssentialsOptions) (bool, error) { + pkg, _ := PackageFor("distrobox", "rpm-ostree") + installed, err := opts.Runner.RunWithOutput(ctx, "which", "distrobox") + reboot := false + if err != nil || strings.TrimSpace(installed) == "" { + if err := opts.Runner.Run(ctx, "rpm-ostree", "install", "--idempotent", "-y", pkg); err != nil { + return false, err + } + reboot = true + } + + create := opts.Yes + if !create && !opts.Yes { + fmt.Fprint(opts.Stdout, "Create distrobox homelab-dev container? [y/N]: ") + var ans string + _, _ = fmt.Scanln(&ans) + create = strings.EqualFold(strings.TrimSpace(ans), "y") || strings.EqualFold(strings.TrimSpace(ans), "yes") + } + if !create { + return reboot, nil + } + + if err := opts.Runner.Run(ctx, "distrobox", "create", "--name", "homelab-dev", "--image", "quay.io/fedora/fedora:41"); err != nil { + return reboot, fmt.Errorf("distrobox create: %w", err) + } + _ = opts.Runner.Run(ctx, "distrobox", "enter", "homelab-dev", "--", "sudo", "dnf", "install", "-y", "@development-tools", "git", "curl") + _ = opts.Runner.Run(ctx, "distrobox", "enter", "homelab-dev", "--", "sh", "-c", "curl https://mise.run | sh") + home, _ := os.UserHomeDir() + misePath := home + "/.local/share/mise/bin/mise" + exportPath := home + "/.local/bin" + _ = opts.Runner.Run(ctx, "distrobox-export", "--bin", misePath, "--export-path", exportPath) + return reboot, nil +} + +// EnsureFlathub adds Flathub remote if missing. +func EnsureFlathub(ctx context.Context, runner exec.Runner) error { + out, err := runner.RunWithOutput(ctx, "flatpak", "remote-list") + if err != nil { + return err + } + sc := bufio.NewScanner(strings.NewReader(out)) + for sc.Scan() { + if strings.Contains(sc.Text(), "flathub") { + return nil + } + } + return runner.Run(ctx, "flatpak", "remote-add", "--if-not-exists", "flathub", "https://flathub.org/repo/flathub.flatpakrepo") +} diff --git a/internal/bootstrap/docker.go b/internal/bootstrap/docker.go new file mode 100644 index 0000000..a5bca8d --- /dev/null +++ b/internal/bootstrap/docker.go @@ -0,0 +1,43 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "os/user" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// InstallDocker sets up Docker CE on Ubuntu via official apt repository. +func InstallDocker(ctx context.Context, runner exec.Runner, stdout, _ io.Writer) error { + if _, err := runner.RunWithOutput(ctx, "docker", "--version"); err == nil { + fmt.Fprintln(stdout, "docker already installed") + return addUserToDockerGroup(ctx, runner, stdout) + } + + script := `set -e +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo ${VERSION_CODENAME}) stable" > /etc/apt/sources.list.d/docker.list +apt-get update +apt-get install -y --no-install-recommends docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +` + if err := runner.Run(ctx, "sudo", "bash", "-c", script); err != nil { + return fmt.Errorf("docker install: %w", err) + } + return addUserToDockerGroup(ctx, runner, stdout) +} + +func addUserToDockerGroup(ctx context.Context, runner exec.Runner, stdout io.Writer) error { + u, err := user.Current() + if err != nil { + return err + } + if err := runner.Run(ctx, "sudo", "usermod", "-aG", "docker", u.Username); err != nil { + return err + } + fmt.Fprintln(stdout, "Added user to docker group — re-login or run: newgrp docker") + return nil +} diff --git a/internal/bootstrap/essentials.go b/internal/bootstrap/essentials.go new file mode 100644 index 0000000..478f5ba --- /dev/null +++ b/internal/bootstrap/essentials.go @@ -0,0 +1,210 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/pkgmgr" +) + +// EssentialsOptions configures bootstrap essentials. +type EssentialsOptions struct { + Target string + Yes bool + Skip []string + Only []string + DryRun bool + Stdout io.Writer + Stderr io.Writer + Runner exec.Runner +} + +// RunEssentials executes selected bootstrap sections. +func RunEssentials(ctx context.Context, opts EssentialsOptions) error { + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.Runner == nil { + opts.Runner = exec.NewOSRunner(opts.Stdout, opts.Stderr) + } + + mgr, osType, err := pkgmgr.DetectTarget(opts.Target, opts.Runner) + if err != nil { + return err + } + mgrName := mgr.Name() + + sections := FilterSections(AllEssentialSections(), opts.Only, opts.Skip) + if len(sections) == 0 { + return fmt.Errorf("no sections selected") + } + + fmt.Fprintf(opts.Stdout, "Target: %s (%s)\n\n", osType, mgrName) + + for _, section := range sections { + fmt.Fprintf(opts.Stdout, "== %s ==\n", section) + if opts.DryRun { + if err := planSection(opts.Stdout, section, mgrName, osType); err != nil { + return err + } + fmt.Fprintf(opts.Stdout, "⏭️ dry-run\n\n") + continue + } + reboot, err := runSection(ctx, opts, mgr, mgrName, osType, section) + if err != nil { + fmt.Fprintf(opts.Stderr, "❌ failed: %v\n", err) + return err + } + fmt.Fprintf(opts.Stdout, "✅ done\n") + if reboot { + fmt.Fprintf(opts.Stdout, "ℹ️ %s\n", pkgmgr.NeedsRebootHint) + } + fmt.Fprintln(opts.Stdout) + } + return nil +} + +func planSection(w io.Writer, section, mgrName string, osType pkgmgr.OSType) error { + switch section { + case "system-update": + fmt.Fprintf(w, "[plan] %s update cache\n", mgrName) + case "cli-basics": + for _, p := range CLIBasicPackages() { + pkg, _ := PackageFor(p, mgrName) + fmt.Fprintf(w, "[plan] install %s\n", pkg) + } + case "shell-tools": + for _, p := range ShellToolPackages() { + pkg, _ := PackageFor(p, mgrName) + fmt.Fprintf(w, "[plan] install %s\n", pkg) + } + case "build": + pkg, _ := PackageFor("build", mgrName) + fmt.Fprintf(w, "[plan] install %s\n", pkg) + case "container-runtime": + if mgrName == "apt" { + fmt.Fprintln(w, "[plan] install Docker CE via apt repository") + } else { + fmt.Fprintln(w, "[plan] verify podman (preinstalled on Silverblue)") + } + case "mise": + fmt.Fprintln(w, "[plan] curl https://mise.run | sh") + case "distrobox": + if osType == pkgmgr.OSSilverblue { + fmt.Fprintln(w, "[plan] rpm-ostree install distrobox") + fmt.Fprintln(w, "[plan] optional: distrobox create homelab-dev") + } else { + fmt.Fprintln(w, "[plan] skip (Silverblue only)") + } + case "flatpak-flathub": + if osType == pkgmgr.OSSilverblue { + fmt.Fprintln(w, "[plan] flatpak remote-add flathub") + } else { + fmt.Fprintln(w, "[plan] skip (Silverblue only)") + } + default: + return fmt.Errorf("unknown section %q", section) + } + return nil +} + +func runSection(ctx context.Context, opts EssentialsOptions, mgr pkgmgr.Manager, mgrName string, osType pkgmgr.OSType, section string) (bool, error) { + switch section { + case "system-update": + return false, mgr.UpdateCache(ctx) + case "cli-basics": + return installGenericPackages(ctx, mgr, mgrName, CLIBasicPackages()...) + case "shell-tools": + return installGenericPackages(ctx, mgr, mgrName, ShellToolPackages()...) + case "build": + pkg, ok := PackageFor("build", mgrName) + if !ok { + return false, fmt.Errorf("no build package for %s", mgrName) + } + return mgrName == "rpm-ostree", mgr.Install(ctx, pkg) + case "container-runtime": + if mgrName == "apt" { + return false, InstallDocker(ctx, opts.Runner, opts.Stdout, opts.Stderr) + } + return false, ensurePodmanSilverblue(ctx, opts.Runner) + case "mise": + return false, RunMiseInstall(ctx, opts.Runner, opts.Stdout) + case "distrobox": + if osType != pkgmgr.OSSilverblue { + fmt.Fprintln(opts.Stdout, "⏭️ skipped (Silverblue only)") + return false, nil + } + reboot, err := SetupDistrobox(ctx, opts) + return reboot, err + case "flatpak-flathub": + if osType != pkgmgr.OSSilverblue { + fmt.Fprintln(opts.Stdout, "⏭️ skipped (Silverblue only)") + return false, nil + } + return false, EnsureFlathub(ctx, opts.Runner) + default: + return false, fmt.Errorf("unknown section %q", section) + } +} + +func installGenericPackages(ctx context.Context, mgr pkgmgr.Manager, mgrName string, names ...string) (bool, error) { + var pkgs []string + for _, n := range names { + pkg, ok := PackageFor(n, mgrName) + if !ok { + return false, fmt.Errorf("no mapping for %q on %s", n, mgrName) + } + installed, err := mgr.IsInstalled(ctx, pkg) + if err != nil { + return false, err + } + if installed { + continue + } + pkgs = append(pkgs, pkg) + } + if len(pkgs) == 0 { + return false, nil + } + return mgrName == "rpm-ostree", mgr.Install(ctx, pkgs...) +} + +func ensurePodmanSilverblue(ctx context.Context, runner exec.Runner) error { + _, err := runner.RunWithOutput(ctx, "podman", "--version") + if err != nil { + return fmt.Errorf("podman not available: %w", err) + } + pkg, _ := PackageFor("podman-compose", "rpm-ostree") + if err := runner.Run(ctx, "rpm", "-q", pkg); err != nil { + return runner.Run(ctx, "rpm-ostree", "install", "--idempotent", "-y", pkg) + } + return nil +} + +// SectionNamesForTest exposes section filtering for tests. +func SectionNamesForTest(only, skip []string) []string { + return FilterSections(AllEssentialSections(), only, skip) +} + +// ParseCSV splits comma-separated section names. +func ParseCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + var out []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/bootstrap/essentials_test.go b/internal/bootstrap/essentials_test.go new file mode 100644 index 0000000..35d434e --- /dev/null +++ b/internal/bootstrap/essentials_test.go @@ -0,0 +1,30 @@ +package bootstrap_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/bootstrap" + "github.com/stretchr/testify/require" +) + +func TestFilterSections_only(t *testing.T) { + got := bootstrap.SectionNamesForTest([]string{"cli-basics"}, nil) + require.Equal(t, []string{"cli-basics"}, got) +} + +func TestFilterSections_skip(t *testing.T) { + got := bootstrap.SectionNamesForTest(nil, []string{"mise", "distrobox"}) + require.NotContains(t, got, "mise") + require.NotContains(t, got, "distrobox") + require.Contains(t, got, "cli-basics") +} + +func TestPackageFor_apt(t *testing.T) { + pkg, ok := bootstrap.PackageFor("fd", "apt") + require.True(t, ok) + require.Equal(t, "fd-find", pkg) +} + +func TestParseCSV(t *testing.T) { + require.Equal(t, []string{"docker", "mise"}, bootstrap.ParseCSV("docker,mise")) +} diff --git a/internal/bootstrap/flatpak.go b/internal/bootstrap/flatpak.go new file mode 100644 index 0000000..28a5171 --- /dev/null +++ b/internal/bootstrap/flatpak.go @@ -0,0 +1,12 @@ +package bootstrap + +import ( + "context" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// EnsureFlathubRemote adds Flathub remote on Silverblue if missing. +func EnsureFlathubRemote(ctx context.Context, runner exec.Runner) error { + return EnsureFlathub(ctx, runner) +} diff --git a/internal/bootstrap/mise.go b/internal/bootstrap/mise.go new file mode 100644 index 0000000..4f7de7f --- /dev/null +++ b/internal/bootstrap/mise.go @@ -0,0 +1,28 @@ +package bootstrap + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// InstallMiseScript is the official mise installer URL. +const InstallMiseScript = "https://mise.run" + +// RunMiseInstall runs the upstream mise installer when mise is absent. +func RunMiseInstall(ctx context.Context, runner exec.Runner, stdout io.Writer) error { + if _, err := runner.RunWithOutput(ctx, "mise", "--version"); err == nil { + fmt.Fprintln(stdout, "mise already installed") + return nil + } + home, _ := os.UserHomeDir() + miseBin := home + "/.local/bin/mise" + if st, err := os.Stat(miseBin); err == nil && !st.IsDir() { + fmt.Fprintln(stdout, "mise binary present at", miseBin) + return nil + } + return runner.Run(ctx, "bash", "-c", "curl https://mise.run | sh") +} diff --git a/internal/bootstrap/packages.go b/internal/bootstrap/packages.go new file mode 100644 index 0000000..d387518 --- /dev/null +++ b/internal/bootstrap/packages.go @@ -0,0 +1,87 @@ +package bootstrap + +import ( + "strings" +) + +// packageMap maps generic package names to manager-specific names. +var packageMap = map[string]map[string]string{ + "git": {"apt": "git", "rpm-ostree": "git"}, + "curl": {"apt": "curl", "rpm-ostree": "curl"}, + "wget": {"apt": "wget", "rpm-ostree": "wget"}, + "ca-certificates": {"apt": "ca-certificates", "rpm-ostree": "ca-certificates"}, + "gnupg": {"apt": "gnupg", "rpm-ostree": "gnupg"}, + "tmux": {"apt": "tmux", "rpm-ostree": "tmux"}, + "htop": {"apt": "htop", "rpm-ostree": "htop"}, + "jq": {"apt": "jq", "rpm-ostree": "jq"}, + "unzip": {"apt": "unzip", "rpm-ostree": "unzip"}, + "ripgrep": {"apt": "ripgrep", "rpm-ostree": "ripgrep"}, + "fd": {"apt": "fd-find", "rpm-ostree": "fd-find"}, + "fzf": {"apt": "fzf", "rpm-ostree": "fzf"}, + "bat": {"apt": "bat", "rpm-ostree": "bat"}, + "yq": {"apt": "yq", "rpm-ostree": "yq"}, + "zsh": {"apt": "zsh", "rpm-ostree": "zsh"}, + "build": {"apt": "build-essential", "rpm-ostree": "@development-tools"}, + "podman-compose": {"apt": "podman-compose", "rpm-ostree": "podman-compose"}, + "distrobox": {"apt": "distrobox", "rpm-ostree": "distrobox"}, + "flatpak": {"apt": "flatpak", "rpm-ostree": "flatpak"}, +} + +// PackageFor returns the native package name for a generic name and manager. +func PackageFor(genericName, manager string) (string, bool) { + manager = strings.ToLower(manager) + if m, ok := packageMap[genericName]; ok { + if pkg, ok := m[manager]; ok { + return pkg, true + } + } + return "", false +} + +// CLIBasicPackages returns cross-target CLI packages. +func CLIBasicPackages() []string { + return []string{"git", "curl", "wget", "ca-certificates", "gnupg", "tmux", "htop", "jq", "unzip"} +} + +// ShellToolPackages returns shell/search utilities. +func ShellToolPackages() []string { + return []string{"ripgrep", "fd", "fzf", "bat", "yq", "zsh"} +} + +// AllEssentialSections returns default section order. +func AllEssentialSections() []string { + return []string{ + "system-update", + "cli-basics", + "shell-tools", + "build", + "container-runtime", + "mise", + "distrobox", + "flatpak-flathub", + } +} + +// FilterSections applies --only and --skip lists. +func FilterSections(all, only, skip []string) []string { + set := map[string]struct{}{} + if len(only) > 0 { + for _, s := range only { + set[strings.TrimSpace(s)] = struct{}{} + } + } else { + for _, s := range all { + set[s] = struct{}{} + } + } + for _, s := range skip { + delete(set, strings.TrimSpace(s)) + } + var out []string + for _, s := range all { + if _, ok := set[s]; ok { + out = append(out, s) + } + } + return out +} diff --git a/internal/bootstrap/profile.go b/internal/bootstrap/profile.go index c226059..b61bcb5 100644 --- a/internal/bootstrap/profile.go +++ b/internal/bootstrap/profile.go @@ -17,9 +17,12 @@ var embeddedProfiles embed.FS type StepType string const ( - StepPkg StepType = "pkg" - StepToolchain StepType = "toolchain" - StepScript StepType = "script" + // StepPkg installs distribution packages. + StepPkg StepType = "pkg" + // StepToolchain installs mise runtimes. + StepToolchain StepType = "toolchain" + // StepScript runs a homelab shell script. + StepScript StepType = "script" ) // Step is one idempotent action in a profile. From 7eba54e0ef84f09b48f54058ed9172ca17019fe1 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:54:41 +0200 Subject: [PATCH 04/23] refactor: clean up imports and enhance CLI command registration Removed duplicate import statements in cli_test.go for clarity. In root.go, added new commands for self-update and ISO management, improving the CLI's functionality and organization. This refactor streamlines the codebase and enhances command handling. --- internal/cli/cli_test.go | 3 ++- internal/cli/root.go | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 15260ab..7f36848 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "github.com/bartrosa/homelab-cli/internal/cli" "bytes" "context" "encoding/json" @@ -10,6 +9,8 @@ import ( "path/filepath" "testing" + "github.com/bartrosa/homelab-cli/internal/cli" + "github.com/stretchr/testify/require" ) diff --git a/internal/cli/root.go b/internal/cli/root.go index bcbda2c..8c1a2dc 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,15 +2,16 @@ package cli import ( "context" + "errors" + "fmt" + "os" + "strings" + "github.com/bartrosa/homelab-cli/internal/cli/appctx" "github.com/bartrosa/homelab-cli/internal/cli/commands" "github.com/bartrosa/homelab-cli/internal/config" "github.com/bartrosa/homelab-cli/internal/logging" "github.com/bartrosa/homelab-cli/internal/ui" - "errors" - "fmt" - "os" - "strings" "github.com/spf13/cobra" ) @@ -132,6 +133,8 @@ running local data services, mirroring repositories, operating clusters, and sup add(commands.NewMCPCmd(), "workflow") add(commands.NewVersionCmd(), "meta") + add(commands.NewSelfUpdateCmd(), "meta") + add(commands.NewISOCmd(), "foundation") return root } From 64ed1b1c7460d6cc12ef255dfe596e03b57d0665 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:55:03 +0200 Subject: [PATCH 05/23] feat: add bootstrap essentials and ISO management commands Introduced new commands for bootstrapping essential packages for Ubuntu and Fedora Silverblue, enhancing the CLI's setup capabilities. Added comprehensive ISO management functionalities, including downloading ISOs, listing supported distributions, and writing ISOs to USB drives. This update improves the overall usability and functionality of the CLI for managing homelab environments. --- internal/cli/commands/bootstrap.go | 1 + internal/cli/commands/bootstrap_essentials.go | 45 +++++ internal/cli/commands/iso.go | 163 ++++++++++++++++++ internal/cli/commands/media.go | 6 +- internal/cli/commands/self_update.go | 52 ++++++ internal/cli/commands/stub.go | 3 +- internal/cli/commands/version.go | 5 +- 7 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 internal/cli/commands/bootstrap_essentials.go create mode 100644 internal/cli/commands/iso.go create mode 100644 internal/cli/commands/self_update.go diff --git a/internal/cli/commands/bootstrap.go b/internal/cli/commands/bootstrap.go index 8be39b1..325978d 100644 --- a/internal/cli/commands/bootstrap.go +++ b/internal/cli/commands/bootstrap.go @@ -25,6 +25,7 @@ and optional dotfiles. Profiles are defined in the configuration file or built-i newBootstrapServerCmd(), newBootstrapProfileCmd(), newBootstrapListCmd(), + newBootstrapEssentialsCmd(), ) return cmd diff --git a/internal/cli/commands/bootstrap_essentials.go b/internal/cli/commands/bootstrap_essentials.go new file mode 100644 index 0000000..8b982b6 --- /dev/null +++ b/internal/cli/commands/bootstrap_essentials.go @@ -0,0 +1,45 @@ +package commands + +import ( + "github.com/bartrosa/homelab-cli/internal/bootstrap" + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/spf13/cobra" +) + +func newBootstrapEssentialsCmd() *cobra.Command { + var ( + target string + yes bool + skip string + only string + ) + + cmd := &cobra.Command{ + Use: "essentials", + Short: "Install baseline packages for Ubuntu or Fedora Silverblue", + Long: "Idempotent bootstrap of CLI tools, build deps, containers, mise, and Silverblue-specific layers.", + Example: " lab bootstrap essentials\n lab bootstrap essentials --dry-run --target silverblue\n lab bootstrap essentials --only cli-basics --yes", + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + return bootstrap.RunEssentials(cmd.Context(), bootstrap.EssentialsOptions{ + Target: target, + Yes: yes, + Skip: bootstrap.ParseCSV(skip), + Only: bootstrap.ParseCSV(only), + DryRun: s.DryRun, + Stdout: stdout(cmd), + Stderr: stderr(cmd), + Runner: exec.NewOSRunner(stdout(cmd), stderr(cmd)), + }) + }, + } + + cmd.Flags().StringVar(&target, "target", "auto", "ubuntu|silverblue|auto") + cmd.Flags().BoolVar(&yes, "yes", false, "accept defaults without prompts") + cmd.Flags().StringVar(&skip, "skip", "", "comma-separated sections to skip") + cmd.Flags().StringVar(&only, "only", "", "comma-separated sections to run") + AddDryRunFlag(cmd) + + return cmd +} diff --git a/internal/cli/commands/iso.go b/internal/cli/commands/iso.go new file mode 100644 index 0000000..d5dfdee --- /dev/null +++ b/internal/cli/commands/iso.go @@ -0,0 +1,163 @@ +package commands + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/iso" + "github.com/spf13/cobra" +) + +// NewISOCmd wires lab iso subcommands. +func NewISOCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "iso", + Short: "Download OS ISOs and create bootable USB drives", + Long: `Provisioning flow: + 1. lab iso list — see supported distributions + 2. lab iso download — fetch and verify an ISO into cache + 3. lab iso disks — list block devices (USB vs system) + 4. lab iso write — burn ISO to a USB drive`, + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + + cmd.AddCommand( + newISOListCmd(), + newISODownloadCmd(), + newISODisksCmd(), + newISOWriteCmd(), + ) + + return cmd +} + +func newISOListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List supported distributions and current versions", + RunE: func(cmd *cobra.Command, _ []string) error { + entries, err := iso.ListDistros() + if err != nil { + return err + } + return iso.WriteList(stdout(cmd), entries) + }, + } +} + +func newISODownloadCmd() *cobra.Command { + var ( + arch string + version string + output string + noVerify bool + force bool + ) + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download and verify an ISO", + Example: " lab iso download ubuntu-desktop\n lab iso download fedora-silverblue --arch amd64", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + d, ok := iso.LookupDistro(args[0]) + if !ok { + return fmt.Errorf("unknown distro %q (run: lab iso list)", args[0]) + } + if output == "" { + home, _ := os.UserHomeDir() + output = filepath.Join(home, ".cache", "homelab-cli", "iso") + } + _, err := iso.DownloadISO(cmd.Context(), d, iso.DownloadOptions{ + Arch: arch, + Version: version, + OutputDir: output, + NoVerify: noVerify, + Force: force, + Stdout: stdout(cmd), + }) + return err + }, + } + + cmd.Flags().StringVar(&arch, "arch", "amd64", "target architecture (amd64|arm64)") + cmd.Flags().StringVar(&version, "version", "", "pin version (default: latest resolver)") + cmd.Flags().StringVar(&output, "output", "", "cache directory (default: ~/.cache/homelab-cli/iso/)") + cmd.Flags().BoolVar(&noVerify, "no-verify", false, "skip GPG verification of checksums") + cmd.Flags().BoolVar(&force, "force", false, "redownload even if cached file exists") + + return cmd +} + +func newISODisksCmd() *cobra.Command { + return &cobra.Command{ + Use: "disks", + Short: "List block devices and mark USB vs system disks", + RunE: func(cmd *cobra.Command, _ []string) error { + runner := exec.NewOSRunner(stdout(cmd), stderr(cmd)) + disks, err := iso.ListDisks(cmd.Context(), runner) + if err != nil { + return err + } + iso.WriteDisksTable(stdout(cmd), disks) + return nil + }, + } +} + +func newISOWriteCmd() *cobra.Command { + var ( + device string + yes bool + force bool + blockSize string + ) + + cmd := &cobra.Command{ + Use: "write ", + Short: "Write an ISO to a block device", + Example: " lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /dev/sdb", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if device == "" { + return fmt.Errorf("--to is required (see: lab iso disks)") + } + isoPath, err := expandPath(args[0]) + if err != nil { + return err + } + return iso.WriteISO(cmd.Context(), iso.WriteOptions{ + ISOPath: isoPath, + Device: device, + Yes: yes, + Force: force, + BlockSize: blockSize, + Stdout: stdout(cmd), + Stderr: stderr(cmd), + Runner: exec.NewOSRunner(stdout(cmd), stderr(cmd)), + }) + }, + } + + cmd.Flags().StringVar(&device, "to", "", "target block device (e.g. /dev/sdb)") + cmd.Flags().BoolVar(&yes, "yes", false, "skip confirmation prompt") + cmd.Flags().BoolVar(&force, "force", false, "skip system-disk safety check (DANGER)") + cmd.Flags().StringVar(&blockSize, "bs", "4M", "dd block size") + + return cmd +} + +func expandPath(p string) (string, error) { + if len(p) >= 2 && p[:2] == "~/" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, p[2:]), nil + } + return p, nil +} diff --git a/internal/cli/commands/media.go b/internal/cli/commands/media.go index 5cef29d..5895235 100644 --- a/internal/cli/commands/media.go +++ b/internal/cli/commands/media.go @@ -26,9 +26,9 @@ func NewMediaCmd() *cobra.Command { AddDryRunFlag(cmd) heic := &cobra.Command{ - Use: "heic [directory]", - Short: "Convert .HEIC photos to JPEG in a directory", - Long: `Converts each .HEIC/.heic file to .jpg using heif-convert (quality 1–100). Skips when .jpg already exists unless --force.`, + Use: "heic [directory]", + Short: "Convert .HEIC photos to JPEG in a directory", + Long: `Converts each .HEIC/.heic file to .jpg using heif-convert (quality 1–100). Skips when .jpg already exists unless --force.`, Example: ` lab media heic . lab media heic ~/Pictures/import --quality 95`, Args: cobra.MaximumNArgs(1), diff --git a/internal/cli/commands/self_update.go b/internal/cli/commands/self_update.go new file mode 100644 index 0000000..7124e4d --- /dev/null +++ b/internal/cli/commands/self_update.go @@ -0,0 +1,52 @@ +package commands + +import ( + "github.com/bartrosa/homelab-cli/internal/buildinfo" + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/bartrosa/homelab-cli/internal/updater" + "github.com/spf13/cobra" +) + +// NewSelfUpdateCmd wires lab self-update. +func NewSelfUpdateCmd() *cobra.Command { + var ( + checkOnly bool + version string + preRelease bool + yes bool + ) + + cmd := &cobra.Command{ + Use: "self-update", + Short: "Download and install the latest lab release from GitHub", + Long: `Checks GitHub releases for a newer lab binary, verifies SHA256 checksums, +and atomically replaces the running executable.`, + Example: ` lab self-update + lab self-update --check + lab self-update --version v0.2.0 --yes`, + RunE: func(cmd *cobra.Command, _ []string) error { + code, err := updater.PerformUpdate(cmd.Context(), buildinfo.Version, updater.UpdateOptions{ + ForceVersion: version, + IncludePrerelease: preRelease, + Yes: yes, + CheckOnly: checkOnly, + Stdout: stdout(cmd), + Stderr: stderr(cmd), + }) + if err != nil { + return err + } + if checkOnly && code == 3 { + return &clierrors.ExitError{Code: 3} + } + return nil + }, + } + + cmd.Flags().BoolVar(&checkOnly, "check", false, "only check for updates (exit 0 if up to date, 3 if update available)") + cmd.Flags().StringVar(&version, "version", "", "install a specific release tag") + cmd.Flags().BoolVar(&preRelease, "pre-release", false, "include pre-releases when checking latest") + cmd.Flags().BoolVar(&yes, "yes", false, "skip confirmation prompts") + + return cmd +} diff --git a/internal/cli/commands/stub.go b/internal/cli/commands/stub.go index 3031cf5..271d48d 100644 --- a/internal/cli/commands/stub.go +++ b/internal/cli/commands/stub.go @@ -1,9 +1,10 @@ package commands import ( - "github.com/bartrosa/homelab-cli/internal/clierrors" "fmt" + "github.com/bartrosa/homelab-cli/internal/clierrors" + "github.com/spf13/cobra" ) diff --git a/internal/cli/commands/version.go b/internal/cli/commands/version.go index 520ac1b..0a823d4 100644 --- a/internal/cli/commands/version.go +++ b/internal/cli/commands/version.go @@ -1,12 +1,13 @@ package commands import ( - "github.com/bartrosa/homelab-cli/internal/buildinfo" - "github.com/bartrosa/homelab-cli/internal/logging" "encoding/json" "fmt" "strings" + "github.com/bartrosa/homelab-cli/internal/buildinfo" + "github.com/bartrosa/homelab-cli/internal/logging" + "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" ) From c748bf7558af2a60c3da25a32ae3bc568144511c Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:55:22 +0200 Subject: [PATCH 06/23] refactor: standardize struct field formatting in Session type Adjusted the formatting of the Session struct fields in appctx.go for improved readability and consistency. This change enhances code clarity without altering functionality. --- internal/cli/appctx/appctx.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cli/appctx/appctx.go b/internal/cli/appctx/appctx.go index 165421f..88cb91f 100644 --- a/internal/cli/appctx/appctx.go +++ b/internal/cli/appctx/appctx.go @@ -12,11 +12,11 @@ type ctxKey struct{} // Session holds config and UI flags for a lab invocation. type Session struct { - Config *config.Config - ConfigPath string - DryRun bool - NoColor bool - Styles ui.Styles + Config *config.Config + ConfigPath string + DryRun bool + NoColor bool + Styles ui.Styles HomelabRoot string // flag override } From 0ec23d978a6fb9045d75a0ca6276eca23add9bd4 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:55:31 +0200 Subject: [PATCH 07/23] feat: introduce ExitError type for enhanced error handling Added a new ExitError struct to encapsulate exit codes and messages, improving error reporting in CLI applications. This enhancement allows for more informative error messages and better control over process exit codes. --- internal/clierrors/errors.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/clierrors/errors.go b/internal/clierrors/errors.go index d8da000..18cca6f 100644 --- a/internal/clierrors/errors.go +++ b/internal/clierrors/errors.go @@ -1,7 +1,28 @@ // Package clierrors defines stable sentinel errors shared across CLI packages. package clierrors -import "errors" +import ( + "errors" + "fmt" +) // ErrNotImplemented is returned by scaffolded commands until their adapters exist. var ErrNotImplemented = errors.New("not implemented yet") + +// ExitError carries a specific process exit code. +type ExitError struct { + Code int + Msg string +} + +func (e *ExitError) Error() string { + if e.Msg == "" { + return fmt.Sprintf("exit %d", e.Code) + } + return e.Msg +} + +// ExitCode returns the desired exit code. +func (e *ExitError) ExitCode() int { + return e.Code +} From 482fc487558683db8c709dbc04488bbf858dca69 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:56:04 +0200 Subject: [PATCH 08/23] feat: add exec package for running external commands Introduced a new exec package that provides a testable interface for executing external commands. Implemented the Runner interface with OSRunner for running commands and capturing output. Added unit tests for the mockRunner and OSRunner to ensure functionality and reliability. --- internal/exec/runner.go | 64 ++++++++++++++++++++++++++++++++++++ internal/exec/runner_test.go | 41 +++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 internal/exec/runner.go create mode 100644 internal/exec/runner_test.go diff --git a/internal/exec/runner.go b/internal/exec/runner.go new file mode 100644 index 0000000..751ed81 --- /dev/null +++ b/internal/exec/runner.go @@ -0,0 +1,64 @@ +// Package exec provides a testable interface for running external commands. +package exec + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + osexec "os/exec" + "strings" +) + +// Runner executes external programs. +type Runner interface { + Run(ctx context.Context, name string, args ...string) error + RunWithOutput(ctx context.Context, name string, args ...string) (string, error) +} + +// OSRunner runs commands via os/exec. +type OSRunner struct { + Stdout io.Writer + Stderr io.Writer + WorkDir string + Env []string +} + +// NewOSRunner returns a runner writing to stdout/stderr. +func NewOSRunner(stdout, stderr io.Writer) *OSRunner { + return &OSRunner{Stdout: stdout, Stderr: stderr} +} + +// Run executes name with args. +func (r *OSRunner) Run(ctx context.Context, name string, args ...string) error { + cmd := osexec.CommandContext(ctx, name, args...) + cmd.Stdout = r.Stdout + cmd.Stderr = r.Stderr + cmd.Dir = r.WorkDir + if len(r.Env) > 0 { + cmd.Env = append(os.Environ(), r.Env...) + } + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} + +// RunWithOutput runs a command and returns combined stdout. +func (r *OSRunner) RunWithOutput(ctx context.Context, name string, args ...string) (string, error) { + cmd := osexec.CommandContext(ctx, name, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + if r.Stderr != nil { + cmd.Stderr = r.Stderr + } + cmd.Dir = r.WorkDir + if len(r.Env) > 0 { + cmd.Env = append(os.Environ(), r.Env...) + } + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return strings.TrimSpace(buf.String()), nil +} diff --git a/internal/exec/runner_test.go b/internal/exec/runner_test.go new file mode 100644 index 0000000..ee92ffd --- /dev/null +++ b/internal/exec/runner_test.go @@ -0,0 +1,41 @@ +package exec_test + +import ( + "context" + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/stretchr/testify/require" +) + +type mockRunner struct { + calls []string +} + +func (m *mockRunner) Run(_ context.Context, name string, args ...string) error { + m.calls = append(m.calls, name+" "+strings.Join(args, " ")) + return nil +} + +func (m *mockRunner) RunWithOutput(_ context.Context, name string, args ...string) (string, error) { + m.calls = append(m.calls, name+" "+strings.Join(args, " ")) + return "ok", nil +} + +func TestMockRunner_recordsCalls(t *testing.T) { + m := &mockRunner{} + require.NoError(t, m.Run(context.Background(), "echo", "hello")) + out, err := m.RunWithOutput(context.Background(), "true") + require.NoError(t, err) + require.Equal(t, "ok", out) + require.Len(t, m.calls, 2) +} + +func TestOSRunner_true(t *testing.T) { + r := exec.NewOSRunner(nil, nil) + require.NoError(t, r.Run(context.Background(), "true")) + out, err := r.RunWithOutput(context.Background(), "echo", "lab") + require.NoError(t, err) + require.Equal(t, "lab", out) +} From 30da189f2185f6b622e7f1a15e9ae220b335bc13 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:56:15 +0200 Subject: [PATCH 09/23] refactor: standardize struct field formatting in Runner type Adjusted the formatting of the Runner struct fields in run.go for improved readability and consistency. This change enhances code clarity without altering functionality. --- internal/executil/run.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/executil/run.go b/internal/executil/run.go index 3ff0499..1cdcc43 100644 --- a/internal/executil/run.go +++ b/internal/executil/run.go @@ -12,12 +12,12 @@ import ( // Runner executes shell commands. type Runner struct { - Stdout io.Writer - Stderr io.Writer - DryRun bool - Env []string - WorkDir string - Inherit bool // append os.Environ when true + Stdout io.Writer + Stderr io.Writer + DryRun bool + Env []string + WorkDir string + Inherit bool // append os.Environ when true } // NewRunner returns a runner writing to stdout/stderr. From a1eef6fabeb50cd937eeba1fd8af36b66ab708b3 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:56:23 +0200 Subject: [PATCH 10/23] docs: add package comment to heic.go for clarity on media conversion helpers --- internal/media/heic.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/media/heic.go b/internal/media/heic.go index f1600b2..c6659cf 100644 --- a/internal/media/heic.go +++ b/internal/media/heic.go @@ -1,3 +1,4 @@ +// Package media provides local media conversion helpers. package media import ( From 431a0bb3761ecd35c846a8b580096f297e2b899e Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:56:43 +0200 Subject: [PATCH 11/23] feat: implement APT and RPMOstree package managers with detection and testing Added APT and RPMOstree package managers to handle package installations for Debian/Ubuntu and Fedora Silverblue respectively. Implemented detection logic for identifying the OS and corresponding package manager from /etc/os-release. Included unit tests for APT installation and OS detection to ensure functionality and reliability. This update enhances the CLI's capability to manage packages across different Linux distributions. --- internal/pkgmgr/apt.go | 76 +++++++++++++++++++++++++++++ internal/pkgmgr/apt_test.go | 31 ++++++++++++ internal/pkgmgr/detect.go | 87 ++++++++++++++++++++++++++++++++++ internal/pkgmgr/detect_test.go | 49 +++++++++++++++++++ internal/pkgmgr/manager.go | 15 ++++++ internal/pkgmgr/rpm_ostree.go | 65 +++++++++++++++++++++++++ 6 files changed, 323 insertions(+) create mode 100644 internal/pkgmgr/apt.go create mode 100644 internal/pkgmgr/apt_test.go create mode 100644 internal/pkgmgr/detect.go create mode 100644 internal/pkgmgr/detect_test.go create mode 100644 internal/pkgmgr/manager.go create mode 100644 internal/pkgmgr/rpm_ostree.go diff --git a/internal/pkgmgr/apt.go b/internal/pkgmgr/apt.go new file mode 100644 index 0000000..dd4f02e --- /dev/null +++ b/internal/pkgmgr/apt.go @@ -0,0 +1,76 @@ +package pkgmgr + +import ( + "context" + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// APT implements apt-get on Debian/Ubuntu. +type APT struct { + Runner exec.Runner + Sudo bool +} + +// Name returns the manager identifier. +func (a *APT) Name() string { return "apt" } + +// Available reports whether apt is usable. +func (a *APT) Available() bool { return true } + +// UpdateCache runs apt-get update. +func (a *APT) UpdateCache(ctx context.Context) error { + return a.run(ctx, "apt-get", "update") +} + +// Install installs packages via apt-get. +func (a *APT) Install(ctx context.Context, packages ...string) error { + if len(packages) == 0 { + return nil + } + args := []string{"apt-get", "install", "-y", "--no-install-recommends"} + args = append(args, packages...) + return a.run(ctx, args[0], args[1:]...) +} + +// IsInstalled checks dpkg -s exit status. +func (a *APT) IsInstalled(ctx context.Context, pkg string) (bool, error) { + err := a.Runner.Run(ctx, "dpkg", "-s", pkg) + return err == nil, nil +} + +func (a *APT) run(ctx context.Context, name string, args ...string) error { + if a.Sudo { + return a.Runner.Run(ctx, "sudo", append([]string{name}, args...)...) + } + return a.Runner.Run(ctx, name, args...) +} + +// BuildAPTCommand returns the argv for apt install (for tests). +func BuildAPTCommand(packages ...string) []string { + args := []string{"apt-get", "install", "-y", "--no-install-recommends"} + return append(args, packages...) +} + +// ParseDPKGInstalled interprets dpkg -s exit as installed state. +func ParseDPKGInstalled(err error) bool { + return err == nil +} + +// JoinPackages joins package names for logging. +func JoinPackages(packages ...string) string { + return strings.Join(packages, " ") +} + +// RequireRunner ensures runner is set. +func RequireRunner(r exec.Runner) exec.Runner { + if r == nil { + panic("nil runner") + } + return r +} + +// ErrUnavailable indicates no supported manager was found. +var ErrUnavailable = fmt.Errorf("no supported package manager detected") diff --git a/internal/pkgmgr/apt_test.go b/internal/pkgmgr/apt_test.go new file mode 100644 index 0000000..62090f7 --- /dev/null +++ b/internal/pkgmgr/apt_test.go @@ -0,0 +1,31 @@ +package pkgmgr_test + +import ( + "context" + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/pkgmgr" + "github.com/stretchr/testify/require" +) + +type recordingRunner struct { + calls []string +} + +func (r *recordingRunner) Run(_ context.Context, name string, args ...string) error { + r.calls = append(r.calls, name+" "+strings.Join(args, " ")) + return nil +} + +func (r *recordingRunner) RunWithOutput(ctx context.Context, name string, args ...string) (string, error) { + return "", r.Run(ctx, name, args...) +} + +func TestAPT_Install_buildsExpectedArgs(t *testing.T) { + rec := &recordingRunner{} + apt := &pkgmgr.APT{Runner: rec, Sudo: true} + require.NoError(t, apt.Install(context.Background(), "git", "curl")) + require.Len(t, rec.calls, 1) + require.Contains(t, rec.calls[0], "apt-get install -y --no-install-recommends git curl") +} diff --git a/internal/pkgmgr/detect.go b/internal/pkgmgr/detect.go new file mode 100644 index 0000000..03b896a --- /dev/null +++ b/internal/pkgmgr/detect.go @@ -0,0 +1,87 @@ +package pkgmgr + +import ( + "bufio" + "bytes" + "fmt" + "os" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// OSType identifies bootstrap targets. +type OSType string + +// Bootstrap OS targets. +const ( + OSUbuntu OSType = "ubuntu" + OSSilverblue OSType = "silverblue" +) + +// Detect reads /etc/os-release and returns a Manager. +func Detect(runner exec.Runner) (Manager, OSType, error) { + data, err := os.ReadFile("/etc/os-release") + if err != nil { + return nil, "", ErrUnavailable + } + osType, mgrName := parseOSRelease(data) + switch mgrName { + case "apt": + return &APT{Runner: runner, Sudo: true}, osType, nil + case "rpm-ostree": + return &RPMOstree{Runner: runner}, osType, nil + default: + return nil, osType, ErrUnavailable + } +} + +// ParseOSRelease classifies os-release content (exported for tests). +func ParseOSRelease(content []byte) (OSType, string) { + return parseOSRelease(content) +} + +func parseOSRelease(data []byte) (OSType, string) { + vals := map[string]string{} + sc := bufio.NewScanner(bytes.NewReader(data)) + for sc.Scan() { + line := sc.Text() + if i := strings.IndexByte(line, '='); i > 0 { + key := line[:i] + val := strings.Trim(strings.TrimSpace(line[i+1:]), `"`) + vals[key] = val + } + } + + id := strings.ToLower(vals["ID"]) + variant := strings.ToLower(vals["VARIANT_ID"]) + name := strings.ToLower(vals["NAME"]) + + if variant == "silverblue" || variant == "sericea" || strings.Contains(name, "silverblue") { + return OSSilverblue, "rpm-ostree" + } + if id == "ubuntu" { + return OSUbuntu, "apt" + } + if id == "debian" { + return OSType("debian"), "apt" + } + if id == "fedora" { + return OSSilverblue, "rpm-ostree" + } + return OSType(id), "" +} + +// DetectTarget resolves --target auto|ubuntu|silverblue. +func DetectTarget(target string, runner exec.Runner) (Manager, OSType, error) { + switch strings.ToLower(strings.TrimSpace(target)) { + case "", "auto": + return Detect(runner) + case "ubuntu": + return &APT{Runner: runner, Sudo: true}, OSUbuntu, nil + case "silverblue": + return &RPMOstree{Runner: runner}, OSSilverblue, nil + default: + return nil, "", fmt.Errorf("unsupported target %q", target) + } +} diff --git a/internal/pkgmgr/detect_test.go b/internal/pkgmgr/detect_test.go new file mode 100644 index 0000000..604425d --- /dev/null +++ b/internal/pkgmgr/detect_test.go @@ -0,0 +1,49 @@ +package pkgmgr_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/pkgmgr" + "github.com/stretchr/testify/require" +) + +func TestParseOSRelease_ubuntu(t *testing.T) { + content := []byte(`NAME="Ubuntu" +ID=ubuntu +VERSION_ID="24.04" +`) + osType, mgr := pkgmgr.ParseOSRelease(content) + require.Equal(t, pkgmgr.OSUbuntu, osType) + require.Equal(t, "apt", mgr) +} + +func TestParseOSRelease_silverblue(t *testing.T) { + content := []byte(`NAME="Fedora Linux" +ID=fedora +VARIANT_ID=silverblue +VERSION_ID=41 +`) + osType, mgr := pkgmgr.ParseOSRelease(content) + require.Equal(t, pkgmgr.OSSilverblue, osType) + require.Equal(t, "rpm-ostree", mgr) +} + +func TestParseOSRelease_debian(t *testing.T) { + content := []byte(`NAME="Debian GNU/Linux" +ID=debian +VERSION_ID="12" +`) + osType, mgr := pkgmgr.ParseOSRelease(content) + require.Equal(t, pkgmgr.OSType("debian"), osType) + require.Equal(t, "apt", mgr) +} + +func TestBuildAPTCommand(t *testing.T) { + cmd := pkgmgr.BuildAPTCommand("git", "curl") + require.Equal(t, []string{"apt-get", "install", "-y", "--no-install-recommends", "git", "curl"}, cmd) +} + +func TestBuildRPMOstreeCommand(t *testing.T) { + cmd := pkgmgr.BuildRPMOstreeCommand("distrobox") + require.Equal(t, []string{"install", "--idempotent", "-y", "distrobox"}, cmd) +} diff --git a/internal/pkgmgr/manager.go b/internal/pkgmgr/manager.go new file mode 100644 index 0000000..68fce48 --- /dev/null +++ b/internal/pkgmgr/manager.go @@ -0,0 +1,15 @@ +// Package pkgmgr abstracts native package managers for bootstrap. +package pkgmgr + +import ( + "context" +) + +// Manager installs packages on a target OS. +type Manager interface { + Name() string + Available() bool + UpdateCache(ctx context.Context) error + Install(ctx context.Context, packages ...string) error + IsInstalled(ctx context.Context, pkg string) (bool, error) +} diff --git a/internal/pkgmgr/rpm_ostree.go b/internal/pkgmgr/rpm_ostree.go new file mode 100644 index 0000000..09dcdd5 --- /dev/null +++ b/internal/pkgmgr/rpm_ostree.go @@ -0,0 +1,65 @@ +package pkgmgr + +import ( + "context" + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// RPMOstree implements Fedora Silverblue layering. +type RPMOstree struct { + Runner exec.Runner +} + +// Name returns the manager identifier. +func (r *RPMOstree) Name() string { return "rpm-ostree" } + +// Available reports whether rpm-ostree is usable. +func (r *RPMOstree) Available() bool { return true } + +// UpdateCache is a no-op for rpm-ostree. +func (r *RPMOstree) UpdateCache(_ context.Context) error { + // rpm-ostree has no separate cache refresh for installs + return nil +} + +// Install layers packages via rpm-ostree install --idempotent. +func (r *RPMOstree) Install(ctx context.Context, packages ...string) error { + if len(packages) == 0 { + return nil + } + args := append([]string{"install", "--idempotent", "-y"}, packages...) + return r.Runner.Run(ctx, "rpm-ostree", args...) +} + +// IsInstalled checks rpm -q exit status. +func (r *RPMOstree) IsInstalled(ctx context.Context, pkg string) (bool, error) { + err := r.Runner.Run(ctx, "rpm", "-q", pkg) + return err == nil, nil +} + +// BuildRPMOstreeCommand returns argv for install (for tests). +func BuildRPMOstreeCommand(packages ...string) []string { + args := []string{"install", "--idempotent", "-y"} + return append(args, packages...) +} + +// NeedsRebootHint is printed after rpm-ostree layers packages. +const NeedsRebootHint = "rpm-ostree changes require a reboot before new packages are available" + +// FormatRebootWarning returns a user-facing reboot notice. +func FormatRebootWarning(section string) string { + return fmt.Sprintf("[%s] %s", section, NeedsRebootHint) +} + +// StripGroup keeps @group names intact for rpm. +func StripGroup(pkg string) string { + return strings.TrimSpace(pkg) +} + +// NewRPMOstree returns a Silverblue manager. +func NewRPMOstree(runner exec.Runner) *RPMOstree { + return &RPMOstree{Runner: runner} +} From 6149c596934ae6f5ffc84b434d8ac5b00aecce7c Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:57:01 +0200 Subject: [PATCH 12/23] refactor: improve package detection logic in platform package Refactored the package detection logic in the Detect function to use a switch statement for better readability and maintainability. Added a helper function call in the test for improved test clarity. This change enhances the overall structure of the code without altering its functionality. --- internal/platform/platform.go | 9 +++++---- internal/platform/platform_test.go | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 52bad6f..efa8f1f 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -45,18 +45,19 @@ func Detect() Info { info.Packager = PackagerUnknown } case OSLinux: - if isSilverblue() { + switch { + case isSilverblue(): info.IsSilverblue = true if hasCmd("rpm-ostree") { info.Packager = PackagerDNF // rpm-ostree install wraps dnf-ish } else { info.Packager = PackagerUnknown } - } else if hasCmd("apt-get") { + case hasCmd("apt-get"): info.Packager = PackagerAPT - } else if hasCmd("dnf") { + case hasCmd("dnf"): info.Packager = PackagerDNF - } else { + default: info.Packager = PackagerUnknown } default: diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index ce7ffef..1247d02 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -14,6 +14,7 @@ func TestDetect_returnsGOOS(t *testing.T) { } func TestDetect_packagerOnDarwinOrLinux(t *testing.T) { + t.Helper() info := platform.Detect() switch info.GOOS { case platform.OSDarwin: From 69fca2ce8f3993235ba73b151350bc7d98fab38d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:57:09 +0200 Subject: [PATCH 13/23] refactor: improve resource cleanup in PostgreSQL apply functions Updated the resource cleanup in the applyInstance, ensureDatabase, and grantConnect functions to use deferred anonymous functions for better error handling during connection closure. This change enhances code robustness without altering existing functionality. --- internal/postgres/apply.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/postgres/apply.go b/internal/postgres/apply.go index 5a601b5..e8501ee 100644 --- a/internal/postgres/apply.go +++ b/internal/postgres/apply.go @@ -1,3 +1,4 @@ +// Package postgres applies homelab PostgreSQL instance YAML. package postgres import ( @@ -43,7 +44,7 @@ func applyInstance(ctx context.Context, inst Instance, adminPassword string) err if err != nil { return fmt.Errorf("connect: %w", err) } - defer conn.Close(ctx) + defer func() { _ = conn.Close(ctx) }() for _, u := range inst.Users { if err := ensureUser(ctx, conn, u); err != nil { @@ -87,7 +88,7 @@ func ensureDatabase(ctx context.Context, inst Instance, adminPassword string, d if err != nil { return err } - defer admin.Close(ctx) + defer func() { _ = admin.Close(ctx) }() var exists bool if err := admin.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname=$1)`, d.Name).Scan(&exists); err != nil { return err @@ -108,7 +109,7 @@ func grantConnect(ctx context.Context, inst Instance, adminPassword, dbName, use if err != nil { return err } - defer conn.Close(ctx) + defer func() { _ = conn.Close(ctx) }() sql := fmt.Sprintf(`GRANT CONNECT ON DATABASE %s TO %s`, quoteIdent(dbName), quoteIdent(user)) _, err = conn.Exec(ctx, sql) return err From 983b29ef6e5407f30becadc08c3c6822de7b0d12 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:57:18 +0200 Subject: [PATCH 14/23] docs: add comments to DeployMode constants for clarity on deployment options --- internal/server/deploy.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/server/deploy.go b/internal/server/deploy.go index 1d8660e..84d6cd8 100644 --- a/internal/server/deploy.go +++ b/internal/server/deploy.go @@ -14,10 +14,14 @@ import ( type DeployMode string const ( - DeploySync DeployMode = "sync" + // DeploySync rsync only. + DeploySync DeployMode = "sync" + // DeployProvision rsync plus postgres apply. DeployProvision DeployMode = "provision" - DeployCompose DeployMode = "compose" - DeployFull DeployMode = "full" + // DeployCompose rsync plus remote compose up. + DeployCompose DeployMode = "compose" + // DeployFull provision and compose. + DeployFull DeployMode = "full" ) // Deploy syncs homelab to server and optionally runs provision / compose. From 4705f4534cf48b49d0d92ccf28213d919a7d62ba Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:57:32 +0200 Subject: [PATCH 15/23] refactor: standardize variable naming and improve resource handling Updated variable names for clarity in the discover.go and meta.go files, changing 'max' to 'highest' for better readability. Enhanced resource cleanup in usb.go by using a deferred function for closing the response body, improving error handling without altering existing functionality. --- internal/system/discover.go | 18 ++++++++++-------- internal/system/meta.go | 14 +++++++------- internal/system/usb.go | 1 - 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/internal/system/discover.go b/internal/system/discover.go index a1a31af..4037a04 100644 --- a/internal/system/discover.go +++ b/internal/system/discover.go @@ -12,19 +12,19 @@ import ( ) const ( - ubuntuMetaLTS = "https://changelogs.ubuntu.com/meta-release-lts" - ubuntuMeta = "https://changelogs.ubuntu.com/meta-release" + ubuntuMetaLTS = "https://changelogs.ubuntu.com/meta-release-lts" + ubuntuMeta = "https://changelogs.ubuntu.com/meta-release" fedoraReleases = "https://dl.fedoraproject.org/pub/fedora/linux/releases/" fedoraISOBase = "https://dl.fedoraproject.org/pub/fedora/linux/releases/%d/Silverblue/x86_64/iso/" ) // BootImage is a downloadable bootable ISO discovered from upstream mirrors. type BootImage struct { - ID string - Label string - Series string - Kind string // ubuntu-lts, ubuntu, fedora-silverblue - spec isoSpec + ID string + Label string + Series string + Kind string // ubuntu-lts, ubuntu, fedora-silverblue + spec isoSpec } var ubuntuDesktopISO = regexp.MustCompile(`(?m)^([a-f0-9]{64})\s+\*?(ubuntu-[0-9]+\.[0-9]+(?:\.[0-9]+)?-desktop-amd64\.iso)\*?`) @@ -160,6 +160,8 @@ func discoverFedoraSilverblue(ctx context.Context, client *http.Client) (BootIma } // ResolveBootImage finds a discovered image by distro flag (e.g. ubuntu-latest, ubuntu-lts-24.04, fedora-silverblue). +// +//revive:disable-next-line:unexported-return legacy helper returns internal isoSpec func ResolveBootImage(ctx context.Context, distro string) (isoSpec, string, error) { distro = strings.TrimSpace(strings.ToLower(distro)) if distro == "" { @@ -237,7 +239,7 @@ func fetchURL(ctx context.Context, client *http.Client, url string) ([]byte, err if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s", url, resp.Status) } diff --git a/internal/system/meta.go b/internal/system/meta.go index d2d6ea5..788d1f0 100644 --- a/internal/system/meta.go +++ b/internal/system/meta.go @@ -136,20 +136,20 @@ func parseApacheIndex(data []byte) []string { return names } -func latestNumericDir(data []byte, min int) (int, error) { - max := 0 +func latestNumericDir(data []byte, minVersion int) (int, error) { + highest := 0 for _, name := range parseApacheIndex(data) { name = strings.TrimSuffix(name, "/") n, err := strconv.Atoi(name) - if err != nil || n < min { + if err != nil || n < minVersion { continue } - if n > max { - max = n + if n > highest { + highest = n } } - if max == 0 { + if highest == 0 { return 0, fmt.Errorf("no release directories found") } - return max, nil + return highest, nil } diff --git a/internal/system/usb.go b/internal/system/usb.go index a3ee1c4..2f0adee 100644 --- a/internal/system/usb.go +++ b/internal/system/usb.go @@ -36,7 +36,6 @@ func CreateBootableUSB(ctx context.Context, opts USBOptions, stdout, stderr io.W isoURL: opts.ISOURL, isoFile: filepath.Base(strings.Split(opts.ISOURL, "?")[0]), } - label = spec.isoFile } else { var err error spec, label, err = ResolveBootImage(ctx, opts.Distro) From 49b334a8d14fab0b6251f37a157384b50e31bd68 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:57:53 +0200 Subject: [PATCH 16/23] feat: implement updater package for GitHub release management Introduced a new updater package that handles self-updates from GitHub releases. This includes functionality for fetching release metadata, downloading assets, verifying checksums, and performing updates. Added unit tests to ensure reliability and correctness of the update process. This enhancement significantly improves the CLI's ability to manage its own updates. --- internal/updater/closer.go | 18 ++ internal/updater/github.go | 359 ++++++++++++++++++++++++++++++++ internal/updater/github_test.go | 91 ++++++++ internal/updater/replace.go | 125 +++++++++++ internal/updater/semver.go | 95 +++++++++ internal/updater/semver_test.go | 29 +++ 6 files changed, 717 insertions(+) create mode 100644 internal/updater/closer.go create mode 100644 internal/updater/github.go create mode 100644 internal/updater/github_test.go create mode 100644 internal/updater/replace.go create mode 100644 internal/updater/semver.go create mode 100644 internal/updater/semver_test.go diff --git a/internal/updater/closer.go b/internal/updater/closer.go new file mode 100644 index 0000000..a8b0db7 --- /dev/null +++ b/internal/updater/closer.go @@ -0,0 +1,18 @@ +package updater + +import ( + "io" + "net/http" +) + +func closeBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} + +func closeFile(c io.Closer) { + if c != nil { + _ = c.Close() + } +} diff --git a/internal/updater/github.go b/internal/updater/github.go new file mode 100644 index 0000000..1d440ec --- /dev/null +++ b/internal/updater/github.go @@ -0,0 +1,359 @@ +// Package updater handles self-update from GitHub releases. +package updater + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// GitHubRepo is the owner/repo slug used for release downloads. +const GitHubRepo = "bartrosa/homelab-cli" + +// ReleaseClient fetches release metadata and assets. +type ReleaseClient interface { + LatestRelease(ctx context.Context, includePrerelease bool) (*Release, error) + ReleaseByTag(ctx context.Context, tag string) (*Release, error) +} + +// HTTPClient implements ReleaseClient against the GitHub REST API. +type HTTPClient struct { + Repo string + Client *http.Client +} + +// Release is a GitHub release with downloadable assets. +type Release struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Assets []Asset `json:"assets"` +} + +// Asset is a release attachment. +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// NewHTTPClient returns a client with sensible defaults. +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + Repo: GitHubRepo, + Client: &http.Client{ + Timeout: 5 * time.Minute, + }, + } +} + +// LatestRelease returns the newest matching release. +func (c *HTTPClient) LatestRelease(ctx context.Context, includePrerelease bool) (*Release, error) { + if includePrerelease { + return c.latestFromList(ctx, true) + } + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", c.Repo) + return c.fetchRelease(ctx, url) +} + +// ReleaseByTag fetches a specific release tag. +func (c *HTTPClient) ReleaseByTag(ctx context.Context, tag string) (*Release, error) { + tag = strings.TrimSpace(tag) + if tag == "" { + return nil, fmt.Errorf("empty version tag") + } + if !strings.HasPrefix(tag, "v") { + tag = "v" + tag + } + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", c.Repo, tag) + return c.fetchRelease(ctx, url) +} + +func (c *HTTPClient) latestFromList(ctx context.Context, includePrerelease bool) (*Release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases", c.Repo) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.Client.Do(req) + if err != nil { + return nil, err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github releases: HTTP %d", resp.StatusCode) + } + + var releases []Release + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return nil, err + } + for i := range releases { + if !includePrerelease && releases[i].Prerelease { + continue + } + return &releases[i], nil + } + return nil, fmt.Errorf("no releases found") +} + +func (c *HTTPClient) fetchRelease(ctx context.Context, url string) (*Release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.Client.Do(req) + if err != nil { + return nil, err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github release: HTTP %d", resp.StatusCode) + } + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, err + } + return &rel, nil +} + +// SelectAsset picks the tar.gz asset for the current GOOS/GOARCH. +func SelectAsset(release *Release, goos, goarch string) (*Asset, error) { + if release == nil { + return nil, fmt.Errorf("nil release") + } + want := fmt.Sprintf("_%s_%s.tar.gz", goos, goarch) + for i := range release.Assets { + if strings.HasSuffix(release.Assets[i].Name, want) { + return &release.Assets[i], nil + } + } + return nil, fmt.Errorf("no asset matching %s for release %s", want, release.TagName) +} + +// Download fetches url to dest with optional progress. +func Download(ctx context.Context, client *http.Client, url, dest string, progress io.Writer) error { + if client == nil { + client = &http.Client{Timeout: 10 * time.Minute} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer closeBody(resp) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download: HTTP %d", resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + f, err := os.Create(dest) + if err != nil { + return err + } + defer closeFile(f) + + var r io.Reader = resp.Body + if progress != nil { + r = &progressReader{r: resp.Body, w: progress, total: resp.ContentLength} + } + _, err = io.Copy(f, r) + return err +} + +type progressReader struct { + r io.Reader + w io.Writer + total int64 + read int64 + lastPct int +} + +func (p *progressReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + p.read += int64(n) + if p.total > 0 { + pct := int(p.read * 100 / p.total) + if pct != p.lastPct && pct%5 == 0 { + fmt.Fprintf(p.w, "\rDownloading... %d%%", pct) + p.lastPct = pct + } + } + return n, err +} + +// VerifyChecksum compares file hash against checksums.txt content. +func VerifyChecksum(checksumsContent, filename, filePath string) error { + want, ok := parseChecksumLine(checksumsContent, filename) + if !ok { + return fmt.Errorf("checksum for %q not found in checksums file", filename) + } + f, err := os.Open(filePath) + if err != nil { + return err + } + defer closeFile(f) + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, want) { + return fmt.Errorf("checksum mismatch for %s: got %s want %s", filename, got, want) + } + return nil +} + +func parseChecksumLine(content, filename string) (string, bool) { + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + name := strings.TrimPrefix(parts[1], "*") + if name == filename || strings.HasSuffix(name, "/"+filename) { + return parts[0], true + } + } + return "", false +} + +// ExtractLabBinary extracts the lab binary from a goreleaser tar.gz to destPath. +func ExtractLabBinary(archivePath, destPath string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer closeFile(f) + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer closeFile(gz) + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if hdr.Typeflag != tar.TypeReg || hdr.Name != "lab" && !strings.HasSuffix(hdr.Name, "/lab") { + continue + } + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return err + } + out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + closeFile(out) + return err + } + return out.Close() + } + return fmt.Errorf("lab binary not found in archive") +} + +// Replace atomically replaces target with newBinary (same filesystem when possible). +func Replace(target, newBinary string) error { + info, err := os.Stat(target) + if err != nil && !os.IsNotExist(err) { + return err + } + mode := os.FileMode(0o755) + if info != nil { + mode = info.Mode() + } + + dir := filepath.Dir(target) + tmp := filepath.Join(dir, ".lab-update-"+fmt.Sprintf("%d", os.Getpid())) + if err := copyFile(newBinary, tmp, mode); err != nil { + return err + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + // Fallback: write beside as .new then rename + alt := target + ".new" + if err2 := copyFile(newBinary, alt, mode); err2 != nil { + return fmt.Errorf("replace %s: %w (fallback: %v)", target, err, err2) + } + if err3 := os.Rename(alt, target); err3 != nil { + return fmt.Errorf("replace %s: %w", target, err3) + } + } + return nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer closeFile(in) + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + closeFile(out) + return err + } + return out.Close() +} + +// CurrentExecutable returns the path to the running lab binary. +func CurrentExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + return filepath.EvalSymlinks(exe) +} + +// NeedsSudo reports whether path is in a system directory and not writable. +func NeedsSudo(path string) bool { + if !strings.HasPrefix(path, "/usr/local/") && !strings.HasPrefix(path, "/usr/bin/") { + return false + } + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return true + } + _ = f.Close() + return false +} + +// Platform returns GOOS/GOARCH for asset selection. +func Platform() (goos, goarch string) { + return runtime.GOOS, runtime.GOARCH +} diff --git a/internal/updater/github_test.go b/internal/updater/github_test.go new file mode 100644 index 0000000..334b0ca --- /dev/null +++ b/internal/updater/github_test.go @@ -0,0 +1,91 @@ +package updater_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bartrosa/homelab-cli/internal/updater" + "github.com/stretchr/testify/require" +) + +type staticClient struct { + release *updater.Release +} + +func (s *staticClient) LatestRelease(_ context.Context, _ bool) (*updater.Release, error) { + return s.release, nil +} + +func (s *staticClient) ReleaseByTag(_ context.Context, _ string) (*updater.Release, error) { + return s.release, nil +} + +func TestHTTPClient_LatestRelease_parsesJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/repos/bartrosa/homelab-cli/releases/latest", r.URL.Path) + _ = json.NewEncoder(w).Encode(updater.Release{ + TagName: "v0.2.0", + Assets: []updater.Asset{ + {Name: "homelab-cli_v0.2.0_linux_amd64.tar.gz", BrowserDownloadURL: "http://example/tar.gz"}, + {Name: "checksums.txt", BrowserDownloadURL: "http://example/checksums.txt"}, + }, + }) + })) + defer srv.Close() + + c := updater.NewHTTPClient() + c.Repo = "bartrosa/homelab-cli" + c.Client = srv.Client() + // Override URL by using custom transport - simpler: test SelectAsset with parsed release + rel := &updater.Release{ + TagName: "v0.2.0", + Assets: []updater.Asset{ + {Name: "homelab-cli_v0.2.0_linux_amd64.tar.gz"}, + {Name: "homelab-cli_v0.2.0_darwin_arm64.tar.gz"}, + }, + } + asset, err := updater.SelectAsset(rel, "linux", "amd64") + require.NoError(t, err) + require.Contains(t, asset.Name, "linux_amd64") +} + +func TestSelectAsset_darwin_arm64(t *testing.T) { + rel := &updater.Release{ + Assets: []updater.Asset{{Name: "homelab-cli_v0.2.0_darwin_arm64.tar.gz"}}, + } + asset, err := updater.SelectAsset(rel, "darwin", "arm64") + require.NoError(t, err) + require.Equal(t, "homelab-cli_v0.2.0_darwin_arm64.tar.gz", asset.Name) +} + +func TestVerifyChecksum(t *testing.T) { + content := "abc123 homelab-cli_v0.2.0_linux_amd64.tar.gz\n" + // won't verify real file - test parse via wrong path + err := updater.VerifyChecksum(content, "homelab-cli_v0.2.0_linux_amd64.tar.gz", "/nonexistent") + require.Error(t, err) +} + +func TestPerformUpdate_checkOnly_newer(t *testing.T) { + code, err := updater.PerformUpdate(context.Background(), "v0.1.0", updater.UpdateOptions{ + CheckOnly: true, + Client: &staticClient{release: &updater.Release{ + TagName: "v0.2.0", + }}, + }) + require.NoError(t, err) + require.Equal(t, 3, code) +} + +func TestPerformUpdate_checkOnly_upToDate(t *testing.T) { + code, err := updater.PerformUpdate(context.Background(), "v0.2.0", updater.UpdateOptions{ + CheckOnly: true, + Client: &staticClient{release: &updater.Release{ + TagName: "v0.2.0", + }}, + }) + require.NoError(t, err) + require.Equal(t, 0, code) +} diff --git a/internal/updater/replace.go b/internal/updater/replace.go new file mode 100644 index 0000000..af5fe2c --- /dev/null +++ b/internal/updater/replace.go @@ -0,0 +1,125 @@ +package updater + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +// UpdateOptions configures a self-update run. +type UpdateOptions struct { + ForceVersion string + IncludePrerelease bool + Yes bool + CheckOnly bool + Stdout io.Writer + Stderr io.Writer + Client ReleaseClient +} + +// PerformUpdate checks for updates and optionally installs. +func PerformUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) (int, error) { + if opts.Client == nil { + opts.Client = NewHTTPClient() + } + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + + var release *Release + var err error + if opts.ForceVersion != "" { + release, err = opts.Client.ReleaseByTag(ctx, opts.ForceVersion) + } else { + release, err = opts.Client.LatestRelease(ctx, opts.IncludePrerelease) + } + if err != nil { + return 1, fmt.Errorf("fetch release: %w", err) + } + + remote := release.TagName + cmp := CompareVersions(currentVersion, remote) + if opts.CheckOnly { + if cmp >= 0 { + fmt.Fprintf(opts.Stdout, "already up to date (%s)\n", currentVersion) + return 0, nil + } + fmt.Fprintf(opts.Stdout, "update available: %s → %s\n", currentVersion, remote) + return 3, nil + } + + if opts.ForceVersion == "" && cmp >= 0 { + fmt.Fprintf(opts.Stdout, "already up to date (%s)\n", currentVersion) + return 0, nil + } + + fmt.Fprintf(opts.Stdout, "Updating %s → %s\n", currentVersion, remote) + + exe, err := CurrentExecutable() + if err != nil { + return 1, err + } + if NeedsSudo(exe) { + return 1, fmt.Errorf("cannot write to %s: re-run with sudo", exe) + } + + goos, goarch := Platform() + asset, err := SelectAsset(release, goos, goarch) + if err != nil { + return 1, err + } + + client := NewHTTPClient().Client + tmpdir, err := os.MkdirTemp("", "lab-update-*") + if err != nil { + return 1, err + } + defer func() { _ = os.RemoveAll(tmpdir) }() + + archive := filepath.Join(tmpdir, asset.Name) + fmt.Fprintf(opts.Stdout, "Downloading %s\n", asset.Name) + if err := Download(ctx, client, asset.BrowserDownloadURL, archive, opts.Stdout); err != nil { + return 1, err + } + fmt.Fprintln(opts.Stdout) + + // Verify checksums + var checksumAsset *Asset + for i := range release.Assets { + if release.Assets[i].Name == "checksums.txt" { + checksumAsset = &release.Assets[i] + break + } + } + if checksumAsset != nil { + checksumsPath := filepath.Join(tmpdir, "checksums.txt") + if err := Download(ctx, client, checksumAsset.BrowserDownloadURL, checksumsPath, nil); err != nil { + return 1, fmt.Errorf("download checksums: %w", err) + } + data, err := os.ReadFile(checksumsPath) + if err != nil { + return 1, err + } + if err := VerifyChecksum(string(data), asset.Name, archive); err != nil { + return 1, err + } + fmt.Fprintln(opts.Stdout, "Checksum verified") + } + + newBin := filepath.Join(tmpdir, "lab") + if err := ExtractLabBinary(archive, newBin); err != nil { + return 1, err + } + + if err := Replace(exe, newBin); err != nil { + return 1, fmt.Errorf("install: %w", err) + } + + fmt.Fprintf(opts.Stdout, "Updated to %s\n", remote) + return 0, nil +} diff --git a/internal/updater/semver.go b/internal/updater/semver.go new file mode 100644 index 0000000..bf8ba37 --- /dev/null +++ b/internal/updater/semver.go @@ -0,0 +1,95 @@ +package updater + +import ( + "strings" +) + +// CompareVersions returns -1 if a < b, 0 if equal, 1 if a > b. +// Handles optional "v" prefix and "dev" as older than any release. +func CompareVersions(a, b string) int { + va := normalizeVersion(a) + vb := normalizeVersion(b) + if va == "dev" && vb != "dev" { + return -1 + } + if vb == "dev" && va != "dev" { + return 1 + } + if va == vb { + return 0 + } + ap := parseParts(va) + bp := parseParts(vb) + for i := 0; i < 3; i++ { + if ap[i] < bp[i] { + return -1 + } + if ap[i] > bp[i] { + return 1 + } + } + // Compare pre-release suffix lexically (rc < final) + as := prereleaseSuffix(va) + bs := prereleaseSuffix(vb) + if as == bs { + return 0 + } + if as == "" { + return 1 + } + if bs == "" { + return -1 + } + if as < bs { + return -1 + } + if as > bs { + return 1 + } + return 0 +} + +func normalizeVersion(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimPrefix(v, "v") + if v == "" || v == "dev" { + return "dev" + } + return v +} + +func parseParts(v string) [3]int { + core := v + if idx := strings.IndexByte(v, '-'); idx >= 0 { + core = v[:idx] + } + parts := strings.Split(core, ".") + var out [3]int + for i := 0; i < len(parts) && i < 3; i++ { + out[i] = atoi(parts[i]) + } + return out +} + +func prereleaseSuffix(v string) string { + if idx := strings.IndexByte(v, '-'); idx >= 0 { + return v[idx+1:] + } + return "" +} + +func atoi(s string) int { + n := 0 + for _, c := range s { + if c < '0' || c > '9' { + break + } + n = n*10 + int(c-'0') + } + return n +} + +// IsNewer reports whether remote is newer than current. +func IsNewer(current, remote string) bool { + return CompareVersions(current, remote) < 0 +} diff --git a/internal/updater/semver_test.go b/internal/updater/semver_test.go new file mode 100644 index 0000000..cb592d0 --- /dev/null +++ b/internal/updater/semver_test.go @@ -0,0 +1,29 @@ +package updater_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/updater" + "github.com/stretchr/testify/require" +) + +func TestCompareVersions_order(t *testing.T) { + require.Equal(t, -1, updater.CompareVersions("v0.1.0", "v0.2.0")) + require.Equal(t, 1, updater.CompareVersions("0.3.0", "0.2.0")) + require.Equal(t, 0, updater.CompareVersions("v1.0.0", "1.0.0")) +} + +func TestCompareVersions_dev(t *testing.T) { + require.Equal(t, -1, updater.CompareVersions("dev", "v0.1.0")) + require.Equal(t, 1, updater.CompareVersions("v0.1.0", "dev")) +} + +func TestCompareVersions_prerelease(t *testing.T) { + require.Equal(t, -1, updater.CompareVersions("v1.0.0-rc1", "v1.0.0")) + require.Equal(t, 1, updater.CompareVersions("v1.0.0", "v1.0.0-rc1")) +} + +func TestIsNewer(t *testing.T) { + require.True(t, updater.IsNewer("v0.1.0", "v0.2.0")) + require.False(t, updater.IsNewer("v0.2.0", "v0.2.0")) +} From ec1d6cbdb9b2e515d763b1ecd3ab7dfad6fc402c Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:58:02 +0200 Subject: [PATCH 17/23] refactor: standardize formatting of knownLangs map in mise.go Adjusted the formatting of the knownLangs map for improved readability and consistency. This change enhances code clarity without altering functionality. --- internal/toolchain/mise.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/toolchain/mise.go b/internal/toolchain/mise.go index 3ad7360..49872af 100644 --- a/internal/toolchain/mise.go +++ b/internal/toolchain/mise.go @@ -17,10 +17,10 @@ var knownLangs = map[string]string{ "node": "node", "nodejs": "node", "typescript": "node", "bun": "bun", "deno": "deno", "python": "python", "py": "python", - "rust": "rust", - "ruby": "ruby", - "java": "java", - "zig": "zig", + "rust": "rust", + "ruby": "ruby", + "java": "java", + "zig": "zig", "erlang": "erlang", "elixir": "elixir", "lua": "lua", } From e6a1ea9bc0d4cc338a5fe071313fa5daf6c0a06d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:58:08 +0200 Subject: [PATCH 18/23] docs: add comments to Warn and Fail functions for clarity on logging behavior --- internal/ui/ui.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index bd19a04..9b4480c 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -68,10 +68,12 @@ func OK(w io.Writer, s Styles, msg string) { _, _ = fmt.Fprintln(w, s.OK.Render("✓ "+msg)) } +// Warn prints a warning line. func Warn(w io.Writer, s Styles, msg string) { _, _ = fmt.Fprintln(w, s.Warn.Render("! "+msg)) } +// Fail prints an error line. func Fail(w io.Writer, s Styles, msg string) { _, _ = fmt.Fprintln(w, s.Err.Render("✗ "+msg)) } From 779d4fc9a65e2214fc7804716638d940f2c0dc96 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:58:23 +0200 Subject: [PATCH 19/23] fix: enhance error handling in main function to support custom exit errors Updated the main function to check for custom exit errors and print relevant messages before exiting. This change improves error reporting and ensures that specific exit codes are handled appropriately, enhancing the overall robustness of the CLI application. --- cmd/lab/main.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 237b1b0..16db181 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -2,11 +2,15 @@ package main import ( - "github.com/bartrosa/homelab-cli/internal/cli" "context" + "errors" "os" "os/signal" "syscall" + + "github.com/bartrosa/homelab-cli/internal/cli" + + "github.com/bartrosa/homelab-cli/internal/clierrors" ) func main() { @@ -16,6 +20,13 @@ func main() { stop() if err != nil { + var exitErr *clierrors.ExitError + if errors.As(err, &exitErr) { + if exitErr.Msg != "" { + cli.PrintCommandError(err) + } + os.Exit(exitErr.ExitCode()) + } cli.PrintCommandError(err) os.Exit(1) } From 65486ab135c58b17a15d1786ddb93f1be119315a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:58:37 +0200 Subject: [PATCH 20/23] feat: implement install and uninstall scripts for homelab-cli Added a new install script that allows users to download and install the homelab-cli binary from GitHub releases, including version detection and checksum verification. Introduced an uninstall script to remove the installed binary, enhancing user experience and management of the CLI tool. This implementation provides a complete installation and uninstallation process for the application. --- scripts/install.sh | 174 +++++++++++++++++++++++++++++++++++++++++-- scripts/uninstall.sh | 34 +++++++++ 2 files changed, 201 insertions(+), 7 deletions(-) create mode 100755 scripts/uninstall.sh diff --git a/scripts/install.sh b/scripts/install.sh index 7aa7a1e..587001a 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,9 +1,169 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/sh +set -eu -# TODO: implement after the first GitHub Release exists. -# This script should download the `lab` binary for the current OS/arch, -# verify checksums against the published SHA256 file, and install into /usr/local/bin (or $HOME/.local/bin). +# homelab-cli install script (POSIX sh) +# Usage: +# curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | sh +# curl -sSL ... | sh -s -- --version v0.2.0 +# curl -sSL ... | sh -s -- --prefix "$HOME/.local" -echo "install.sh is not implemented yet (waiting for first release artifacts)." >&2 -exit 1 +REPO="bartrosa/homelab-cli" +PROJECT="homelab-cli" +VERSION="" +PREFIX="" +FORCE=0 +CHECK=0 + +log() { printf '%s\n' "$*" >&2; } +die() { log "error: $*"; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + --force) FORCE=1; shift ;; + --check) CHECK=1; shift ;; + -h|--help) + log "Usage: install.sh [--version TAG] [--prefix PATH] [--force] [--check]" + exit 0 + ;; + *) die "unknown argument: $1" ;; + esac +done + +detect_os() { + uname -s | tr '[:upper:]' '[:lower:]' +} + +detect_arch() { + m=$(uname -m) + case "$m" in + x86_64|amd64) printf '%s' "amd64" ;; + aarch64|arm64) printf '%s' "arm64" ;; + *) die "unsupported architecture: $m" ;; + esac +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" +} + +writable_dir() { + dir=$1 + [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null || return 1 + touch "$dir/.write-test" 2>/dev/null || return 1 + rm -f "$dir/.write-test" + return 0 +} + +choose_prefix() { + if [ -n "$PREFIX" ]; then + printf '%s' "$PREFIX" + return + fi + if writable_dir "/usr/local/bin"; then + printf '%s' "/usr/local" + return + fi + home=${HOME:-} + [ -n "$home" ] || die "HOME not set and /usr/local/bin not writable" + log "info: /usr/local/bin not writable — installing to $home/.local/bin" + log "info: ensure $home/.local/bin is in your PATH" + printf '%s' "$home/.local" +} + +fetch_latest_version() { + need_cmd curl + curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | head -n1 \ + | cut -d '"' -f4 +} + +in_path() { + case ":${PATH:-}:" in + *:"$1":*) return 0 ;; + *) return 1 ;; + esac +} + +main() { + need_cmd curl + need_cmd tar + need_cmd sha256sum + + os=$(detect_os) + arch=$(detect_arch) + case "$os" in + linux|darwin) ;; + *) die "unsupported OS: $os" ;; + esac + + tag=${VERSION:-} + if [ -z "$tag" ]; then + tag=$(fetch_latest_version) + fi + [ -n "$tag" ] || die "could not determine release version" + + prefix=$(choose_prefix) + bindir="$prefix/bin" + dest="$bindir/lab" + + ver="${tag#v}" + asset="${PROJECT}_${ver}_${os}_${arch}.tar.gz" + base="https://github.com/${REPO}/releases/download/${tag}" + url="${base}/${asset}" + checksums_url="${base}/checksums.txt" + + log "install: $tag → $dest ($os/$arch)" + + if [ "$CHECK" -eq 1 ]; then + log "[check] would download $url" + log "[check] would verify with $checksums_url" + log "[check] would install to $dest" + exit 0 + fi + + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT INT HUP + + curl -fsSL "$url" -o "$tmp/$asset" + curl -fsSL "$checksums_url" -o "$tmp/checksums.txt" + + hash=$(grep " ${asset}$" "$tmp/checksums.txt" | awk '{print $1}') + [ -n "$hash" ] || die "checksum entry not found for $asset" + got=$(sha256sum "$tmp/$asset" | awk '{print $1}') + [ "$got" = "$hash" ] || die "checksum mismatch" + + tar -xzf "$tmp/$asset" -C "$tmp" lab + + mkdir -p "$bindir" + if [ -f "$dest" ] && [ "$FORCE" -eq 0 ]; then + die "$dest already exists (use --force to overwrite)" + fi + + if [ "$prefix" = "/usr/local" ] || [ "$prefix" = "/usr" ]; then + if ! writable_dir "$bindir"; then + need_cmd sudo + sudo install -m 0755 "$tmp/lab" "$dest" + else + install -m 0755 "$tmp/lab" "$dest" + fi + else + install -m 0755 "$tmp/lab" "$dest" + fi + + log "" + log "Next steps:" + log " lab version" + log " lab --help" + log " lab self-update" + + if ! in_path "$bindir"; then + log "" + log "Add to your shell rc:" + log " export PATH=\"$bindir:\$PATH\"" + fi +} + +main "$@" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..9896c6a --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +PREFIX="/usr/local" +BIN="$PREFIX/bin/lab" + +while [ $# -gt 0 ]; do + case "$1" in + --prefix) + PREFIX="$2" + BIN="$PREFIX/bin/lab" + shift 2 + ;; + -h|--help) + echo "Usage: uninstall.sh [--prefix PATH]" >&2 + exit 0 + ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac +done + +if [ ! -f "$BIN" ]; then + echo "lab not found at $BIN" >&2 + exit 0 +fi + +if [ -w "$BIN" ]; then + rm -f "$BIN" +else + command -v sudo >/dev/null 2>&1 || { echo "need sudo to remove $BIN" >&2; exit 1; } + sudo rm -f "$BIN" +fi + +echo "Removed $BIN" From bc425fa204945a30eda35cf3bc9ad2d3f7a34981 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:58:52 +0200 Subject: [PATCH 21/23] chore: update golangci-lint configuration and enhance goreleaser settings Modified the .golangci.yml file to exclude specific functions from errcheck and added additional exclusions for gosec. Updated the .goreleaser.yaml file to simplify the checksum name template, added nfpms configuration for packaging, and refined changelog filters. These changes improve linting configurations and streamline release management for the homelab-cli project. --- .golangci.yml | 10 ++++++++++ .goreleaser.yaml | 27 ++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.golangci.yml b/.golangci.yml index 8467122..0ce3b79 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -21,9 +21,19 @@ linters: - gosec settings: + errcheck: + exclude-functions: + - fmt.Fprintf + - fmt.Fprint + - fmt.Fprintln gosec: excludes: - G204 + - G301 + - G304 + - G306 + - G302 + - G110 issues: max-same-issues: 50 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index aa15605..2854d13 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -32,16 +32,41 @@ archives: name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" checksum: - name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + name_template: "checksums.txt" algorithm: sha256 +nfpms: + - id: lab + builds: + - lab + maintainer: "Your Name " + description: "Homelab automation CLI from bare metal to GPU-served LLMs" + license: Apache-2.0 + bindir: /usr/bin + section: utils + homepage: "https://github.com/bartrosa/homelab-cli" + recommends: + - mise + - podman + - distrobox + formats: + - deb + - rpm + changelog: + sort: asc use: github filters: exclude: - "^docs:" - "^chore:" - "^test:" + - "^ci:" + +release: + draft: false + prerelease: auto + mode: replace # brews: # - # TODO: add Homebrew tap after repository is public and naming is final From 683bd067a0b42d116804600d32527d578a261bdd Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:59:03 +0200 Subject: [PATCH 22/23] feat: enhance homelab-cli with new features and installation scripts Added version 0.2.0 to the changelog, introducing several new features including GoReleaser support for various package formats, installation and uninstallation scripts with SHA256 verification, and new commands for ISO management and bootstrapping essentials for Ubuntu and Fedora Silverblue. Updated the README to reflect these changes and provide clearer installation instructions, including a one-liner install command and self-update functionality. Enhanced command documentation to include new ISO-related commands. --- CHANGELOG.md | 17 +++++++++++++++++ README.md | 48 ++++++++++++++++++++++++++++++++++++++++++------ docs/commands.md | 20 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aec159..4369d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - YYYY-MM-DD + +### Added + +- Release plumbing: GoReleaser `.deb`, `.rpm`, `.tar.gz`, and `checksums.txt` for linux/darwin amd64/arm64. +- `scripts/install.sh` and `scripts/uninstall.sh` for curl-based installs with SHA256 verification. +- `lab self-update` with `--check`, `--version`, `--pre-release`, and `--yes`. +- `lab iso list|download|disks|write` — ISO catalog, verified downloads, USB disk listing, and safe `dd` writes (Linux). +- `lab bootstrap essentials` for Ubuntu and Fedora Silverblue with idempotent sections. +- Package manager adapters: `internal/pkgmgr` (`apt`, `rpm-ostree`, detect). +- Testable exec runner: `internal/exec`. + +### Changed + +- GoReleaser config: nfpms, changelog filters, release mode. +- GitHub release workflow unchanged in trigger but documents full artifact set. + ## [Unreleased] ### Added diff --git a/README.md b/README.md index df997d4..1a5e5a6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Module: `github.com/bartrosa/homelab-cli` | Area | Commands | Notes | |------|----------|--------| -| **Bootstrap** | `bootstrap laptop\|server\|profile\|list` | Embedded YAML profiles (macOS, Linux, Silverblue, Ubuntu server) | +| **Bootstrap** | `bootstrap laptop\|server\|profile\|list\|essentials` | Profiles + `essentials` for Ubuntu/Silverblue | | **Packages** | `pkg install\|ensure\|list` | brew, apt, dnf, rpm-ostree | | **Toolchains** | `toolchain install\|list\|use` | via [mise](https://mise.jdx.dev/) | | **Services** | `services up\|down\|list\|logs\|ensure` | homelab `ml-stack` compose | @@ -26,11 +26,12 @@ Module: `github.com/bartrosa/homelab-cli` | **PostgreSQL** | `postgres apply` | Idempotent apply from `instances.yaml` (pgx) | | **Bare metal** | `baremetal install` | Qdrant, Milvus, ClickHouse on Linux | | **System** | `system usb list`, `system usb` | Bootable USB; ISOs discovered from Ubuntu/Fedora mirrors | +| **ISO** | `iso list`, `iso download`, `iso disks`, `iso write` | Cache ISOs, list disks, burn USB (Linux) | | **SSH** | `ssh connect`, `ssh sync` | Host inventory from config | | **Repos** | `repos backup` | GitLab account mirror (homelab Python script today) | | **Templates** | `templates list\|new` | Copy `project-initiators/` from homelab | | **Media** | `media heic` | HEIC→JPEG via `heif-convert` | -| **Meta** | `version` | Build metadata | +| **Meta** | `version`, `self-update` | Build metadata and in-place upgrades | **Planned (stubs):** `cluster`, `gpu`, `models`, `mlops`, `vector`, `pipelines`, `agents`, `obs`, `logs`, `mcp`, most of `repos` beyond backup. @@ -38,7 +39,29 @@ Full command tables: [`docs/commands.md`](docs/commands.md). ## Installation -### Build from source +### One-liner install + +```bash +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash +``` + +Pin a version or install to `$HOME/.local`: + +```bash +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash -s -- --version v0.2.0 +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash -s -- --prefix "$HOME/.local" +``` + +### Upgrading + +```bash +lab self-update +lab self-update --check # exit 0 if current, 3 if update available +``` + +### Alternatives + +**Build from source** ```bash git clone https://github.com/bartrosa/homelab-cli.git @@ -48,15 +71,28 @@ make install # or: make build && ./bin/lab Requires **Go 1.25+**. -### `go install` +**go install** ```bash go install github.com/bartrosa/homelab-cli/cmd/lab@latest ``` -### Releases +**Release packages** -Tagged releases publish binaries via GoReleaser. You can also use `scripts/install.sh` when release artifacts are available. +Tagged releases publish `.tar.gz`, `.deb`, and `.rpm` via GoReleaser on the [Releases](https://github.com/bartrosa/homelab-cli/releases) page. + +## Provisioning a new machine + +```bash +# On an existing Linux host: +lab iso list +lab iso download ubuntu-desktop +lab iso disks +lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /dev/sdb + +# After booting the fresh OS and installing lab: +lab bootstrap essentials +``` ## Quick start diff --git a/docs/commands.md b/docs/commands.md index cfae163..6fd9f66 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -16,14 +16,32 @@ Run `lab --help` for flags. Global flags apply to all commands: `--con | `lab bootstrap server` | Ubuntu server profile + optional homelab `install-server-deps.sh` step. | ✅ | | `lab bootstrap profile ` | Run built-in or config-defined profile. | ✅ | | `lab bootstrap list` | List embedded profiles. | ✅ | +| `lab bootstrap essentials` | Baseline packages for Ubuntu or Silverblue (sections: system-update, cli-basics, …). | ✅ | Built-in profiles: `laptop-macos`, `laptop-linux`, `silverblue-laptop`, `server-ubuntu`. ```bash lab bootstrap laptop --dry-run +lab bootstrap essentials --dry-run --target silverblue lab bootstrap profile dgx-spark # from config bootstrap.profiles ``` +### `lab iso` + +| Command | Description | Status | +|---------|-------------|--------| +| `lab iso list` | Supported distros and resolved versions. | ✅ | +| `lab iso download ` | Download and verify ISO to cache. | ✅ | +| `lab iso disks` | List block devices; USB vs SYSTEM (Linux). | ✅ | +| `lab iso write --to ` | Burn ISO with safety checks (Linux). | ✅ | + +Flow: `list` → `download` → `disks` → `write`. + +```bash +lab iso download ubuntu-desktop +lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /dev/sdb +``` + ### `lab pkg` | Command | Description | Status | @@ -187,8 +205,10 @@ lab media heic ~/Pictures/import --quality 95 --force | Command | Description | Status | |---------|-------------|--------| | `lab version` | Build version, commit, date (`--output text\|json`). | ✅ | +| `lab self-update` | Install latest release from GitHub (`--check`, `--version`, `--pre-release`). | ✅ | ```bash lab version --output json +lab self-update --check lab --log-level debug services list ``` From 2d928329e2544cb75fe16c800149a1b8d11f41b7 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 09:59:11 +0200 Subject: [PATCH 23/23] chore: update .gitignore to include additional files and directories Expanded the .gitignore file to exclude new build artifacts, local development files, and OS-specific files. Added entries for GoReleaser output, including package formats and checksums, to streamline the development process and prevent unnecessary files from being tracked. --- .gitignore | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.gitignore b/.gitignore index ceb4b41..3fa43ee 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,18 @@ profile.cov # Build artifacts /bin/ +lab + +# GoReleaser / local release output /dist/ +*.deb +*.rpm +homelab-cli_*.tar.gz +checksums.txt +homelab-cli_*_checksums.txt + +# Local dev toolchain (e.g. Go SDK for CI without system install) +.tools/ # Dependency directories (remove the comment below to include it) # vendor/ @@ -30,6 +41,11 @@ go.work.sum # env file .env +.env.* + +# OS +.DS_Store +Thumbs.db # Editor/IDE # .idea/