From 88028286c4c7a8ddc7e339797ce4133cc24b9d56 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:14:40 +0200 Subject: [PATCH 01/39] feat: implement shell rc management with block generation and testing Added functionality to manage shell rc files for Bash, Zsh, and Fish, including generating, updating, reading, and removing managed blocks. Introduced unit tests for block generation to ensure idempotency and syntax correctness across different shell types. This enhances the usability of the homelab-cli by automating shell configuration management. --- internal/stack/shellrc/block.go | 209 +++++++++++++++++++++++++++ internal/stack/shellrc/block_test.go | 43 ++++++ 2 files changed, 252 insertions(+) create mode 100644 internal/stack/shellrc/block.go create mode 100644 internal/stack/shellrc/block_test.go diff --git a/internal/stack/shellrc/block.go b/internal/stack/shellrc/block.go new file mode 100644 index 0000000..d0f6ba7 --- /dev/null +++ b/internal/stack/shellrc/block.go @@ -0,0 +1,209 @@ +// Package shellrc manages homelab-cli PATH blocks in shell rc files. +package shellrc + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strings" +) + +// Entry is a shell rc snippet. +type Entry struct { + Shell string + Content string + Marker string +} + +// Shell identifies a shell rc file type. +type Shell string + +// Supported shell rc targets. +const ( + Bash Shell = "bash" + Zsh Shell = "zsh" + Fish Shell = "fish" +) + +const ( + beginMarker = "# BEGIN homelab-cli managed block (do not edit β€” regenerated by `lab stack path refresh`)" + endMarker = "# END homelab-cli managed block" +) + +// Detect returns shells to update based on $SHELL. +func Detect() ([]Shell, error) { + shell := os.Getenv("SHELL") + if shell == "" { + if u, err := user.Current(); err == nil { + shell = u.HomeDir + } + } + shell = strings.ToLower(filepath.Base(shell)) + switch { + case strings.Contains(shell, "zsh"): + return []Shell{Zsh}, nil + case strings.Contains(shell, "fish"): + return []Shell{Fish}, nil + default: + return []Shell{Bash}, nil + } +} + +// RCPath returns the rc file path for a shell. +func RCPath(sh Shell) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + switch sh { + case Bash: + return filepath.Join(home, ".bashrc"), nil + case Zsh: + return filepath.Join(home, ".zshrc"), nil + case Fish: + return filepath.Join(home, ".config", "fish", "config.fish"), nil + default: + return "", fmt.Errorf("unsupported shell %q", sh) + } +} + +// UpdateBlock writes or replaces the managed block in the shell rc. +func UpdateBlock(sh Shell, entries []Entry) error { + path, err := RCPath(sh) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + existing, _ := os.ReadFile(path) + if len(existing) > 0 && !strings.Contains(string(existing), beginMarker) { + backup := path + ".homelab-backup-" + fmt.Sprintf("%d", os.Getpid()) + _ = os.WriteFile(backup, existing, 0o600) + } + block := GenerateBlock(sh, entries) + updated := replaceBlock(string(existing), block) + return os.WriteFile(path, []byte(updated), 0o600) +} + +// ReadBlock returns the managed block content. +func ReadBlock(sh Shell) (string, error) { + path, err := RCPath(sh) + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + start := strings.Index(string(data), beginMarker) + if start < 0 { + return "", nil + } + end := strings.Index(string(data[start:]), endMarker) + if end < 0 { + return "", nil + } + return string(data[start : start+end+len(endMarker)]), nil +} + +// RemoveBlock deletes the managed block from rc. +func RemoveBlock(sh Shell) error { + path, err := RCPath(sh) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + updated := removeBlock(string(data)) + return os.WriteFile(path, []byte(updated), 0o600) +} + +func replaceBlock(content, block string) string { + start := strings.Index(content, beginMarker) + if start < 0 { + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + return content + block + "\n" + } + endRel := strings.Index(content[start:], endMarker) + if endRel < 0 { + return content + "\n" + block + "\n" + } + end := start + endRel + len(endMarker) + return content[:start] + block + content[end:] +} + +func removeBlock(content string) string { + start := strings.Index(content, beginMarker) + if start < 0 { + return content + } + endRel := strings.Index(content[start:], endMarker) + if endRel < 0 { + return content + } + end := start + endRel + len(endMarker) + rest := strings.TrimPrefix(content[end:], "\n") + return content[:start] + rest +} + +// GenerateBlock builds shell-specific block content. +func GenerateBlock(sh Shell, entries []Entry) string { + switch sh { + case Fish: + return generateFish(entries) + default: + return generateBashZsh(entries) + } +} + +func generateBashZsh(entries []Entry) string { + var b strings.Builder + b.WriteString(beginMarker) + b.WriteByte('\n') + for _, e := range entries { + if e.Shell != "all" && e.Shell != "bash" && e.Shell != "zsh" { + continue + } + if e.Marker != "" { + fmt.Fprintf(&b, "# %s\n", e.Marker) + } + b.WriteString(e.Content) + if !strings.HasSuffix(e.Content, "\n") { + b.WriteByte('\n') + } + } + b.WriteString(endMarker) + return b.String() +} + +func generateFish(entries []Entry) string { + var b strings.Builder + b.WriteString(beginMarker) + b.WriteByte('\n') + for _, e := range entries { + if e.Shell != "all" && e.Shell != "fish" { + continue + } + line := e.Content + line = strings.ReplaceAll(line, "export ", "set -gx ") + line = strings.ReplaceAll(line, "[ -f ", "test -f ") + if e.Marker != "" { + fmt.Fprintf(&b, "# %s\n", e.Marker) + } + b.WriteString(line) + if !strings.HasSuffix(line, "\n") { + b.WriteByte('\n') + } + } + b.WriteString(endMarker) + return b.String() +} diff --git a/internal/stack/shellrc/block_test.go b/internal/stack/shellrc/block_test.go new file mode 100644 index 0000000..6ba0b12 --- /dev/null +++ b/internal/stack/shellrc/block_test.go @@ -0,0 +1,43 @@ +package shellrc_test + +import ( + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/stack/shellrc" + "github.com/stretchr/testify/require" +) + +func TestGenerateBlock_bashIdempotent(t *testing.T) { + entries := []shellrc.Entry{ + {Shell: "all", Marker: "mise", Content: `eval "$(mise activate bash)"`}, + {Shell: "all", Marker: "path", Content: `export PATH="$HOME/.local/bin:$PATH"`}, + } + block := shellrc.GenerateBlock(shellrc.Bash, entries) + require.Contains(t, block, "BEGIN homelab-cli managed block") + require.Contains(t, block, "mise") + require.Contains(t, block, "END homelab-cli managed block") + + content := "# user config\n" + block + "\n# tail\n" + replaced := replaceBlockForTest(content, block) + require.Equal(t, content, replaced) +} + +func TestGenerateBlock_fishSyntax(t *testing.T) { + entries := []shellrc.Entry{ + {Shell: "all", Marker: "path", Content: `export PATH="$HOME/.local/bin:$PATH"`}, + } + block := shellrc.GenerateBlock(shellrc.Fish, entries) + require.Contains(t, block, "set -gx PATH") + require.NotContains(t, block, "export PATH") +} + +func replaceBlockForTest(content, block string) string { + start := strings.Index(content, "# BEGIN homelab-cli managed block") + if start < 0 { + return content + "\n" + block + "\n" + } + endRel := strings.Index(content[start:], "# END homelab-cli managed block") + end := start + endRel + len("# END homelab-cli managed block") + return content[:start] + block + content[end:] +} From c975a459c174fac9bb35f182a1b4fc8a1f046319 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:15:00 +0200 Subject: [PATCH 02/39] feat: add GPU detection functionality with unit tests Implemented GPU detection from lspci output, supporting NVIDIA, AMD, and Intel vendors. Added parsing logic and helper functions in the gpu package. Included unit tests for various GPU scenarios to ensure accurate detection and handling of different outputs. This enhances the homelab-cli's capability to identify graphics hardware. --- internal/stack/gpu/detect.go | 116 +++++++++++++++++++ internal/stack/gpu/detect_test.go | 34 ++++++ internal/stack/gpu/testdata/lspci_amd.txt | 1 + internal/stack/gpu/testdata/lspci_none.txt | 1 + internal/stack/gpu/testdata/lspci_nvidia.txt | 2 + 5 files changed, 154 insertions(+) create mode 100644 internal/stack/gpu/detect.go create mode 100644 internal/stack/gpu/detect_test.go create mode 100644 internal/stack/gpu/testdata/lspci_amd.txt create mode 100644 internal/stack/gpu/testdata/lspci_none.txt create mode 100644 internal/stack/gpu/testdata/lspci_nvidia.txt diff --git a/internal/stack/gpu/detect.go b/internal/stack/gpu/detect.go new file mode 100644 index 0000000..3467f06 --- /dev/null +++ b/internal/stack/gpu/detect.go @@ -0,0 +1,116 @@ +// Package gpu detects graphics hardware from lspci output. +package gpu + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// Vendor identifies a GPU vendor. +type Vendor string + +// Known GPU vendors from PCI vendor IDs. +const ( + VendorNvidia Vendor = "nvidia" + VendorAmd Vendor = "amd" + VendorIntel Vendor = "intel" + VendorNone Vendor = "none" +) + +// Info describes one GPU device. +type Info struct { + Vendor Vendor + Model string + Driver string +} + +var bracketRe = regexp.MustCompile(`\[([0-9a-f]{4}):([0-9a-f]{4})\]`) + +// Detect parses lspci -nn output. +func Detect(ctx context.Context, runner exec.Runner) ([]Info, error) { + out, err := runner.RunWithOutput(ctx, "lspci", "-nn") + if err != nil { + return nil, fmt.Errorf("lspci: %w", err) + } + return ParseLSPCI(out), nil +} + +// ParseLSPCI parses lspci lines (testable). +func ParseLSPCI(output string) []Info { + var out []Info + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + lower := strings.ToLower(line) + if !strings.Contains(lower, "vga") && !strings.Contains(lower, "3d") && !strings.Contains(lower, "display") { + continue + } + matches := bracketRe.FindAllStringSubmatch(line, -1) + if len(matches) == 0 { + continue + } + last := matches[len(matches)-1] + vendorID := strings.ToLower(last[1]) + model := extractModel(line) + v := VendorNone + switch vendorID { + case "10de": + v = VendorNvidia + case "1002": + v = VendorAmd + case "8086": + v = VendorIntel + } + if v == VendorNone { + continue + } + out = append(out, Info{Vendor: v, Model: model}) + } + return out +} + +func extractModel(line string) string { + idx := strings.Index(line, ": ") + if idx < 0 { + return strings.TrimSpace(line) + } + rest := strings.TrimSpace(line[idx+2:]) + if bracket := strings.LastIndex(rest, "["); bracket > 0 { + rest = strings.TrimSpace(rest[:bracket]) + } + return rest +} + +// DetectNvidia reports whether an NVIDIA GPU was found. +func DetectNvidia(ctx context.Context, runner exec.Runner) (bool, error) { + gpus, err := Detect(ctx, runner) + if err != nil { + return false, err + } + for _, g := range gpus { + if g.Vendor == VendorNvidia { + return true, nil + } + } + return false, nil +} + +// DetectAmd reports whether an AMD GPU was found. +func DetectAmd(ctx context.Context, runner exec.Runner) (bool, error) { + gpus, err := Detect(ctx, runner) + if err != nil { + return false, err + } + for _, g := range gpus { + if g.Vendor == VendorAmd { + return true, nil + } + } + return false, nil +} diff --git a/internal/stack/gpu/detect_test.go b/internal/stack/gpu/detect_test.go new file mode 100644 index 0000000..7fd16dc --- /dev/null +++ b/internal/stack/gpu/detect_test.go @@ -0,0 +1,34 @@ +package gpu_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bartrosa/homelab-cli/internal/stack/gpu" + "github.com/stretchr/testify/require" +) + +func readFixture(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + return string(data) +} + +func TestParseLSPCI_nvidia(t *testing.T) { + gpus := gpu.ParseLSPCI(readFixture(t, "lspci_nvidia.txt")) + require.Len(t, gpus, 2) + require.Equal(t, gpu.VendorNvidia, gpus[1].Vendor) + require.Contains(t, gpus[1].Model, "RTX 4090") +} + +func TestParseLSPCI_amd(t *testing.T) { + gpus := gpu.ParseLSPCI(readFixture(t, "lspci_amd.txt")) + require.Len(t, gpus, 1) + require.Equal(t, gpu.VendorAmd, gpus[0].Vendor) +} + +func TestParseLSPCI_none(t *testing.T) { + require.Empty(t, gpu.ParseLSPCI(readFixture(t, "lspci_none.txt"))) +} diff --git a/internal/stack/gpu/testdata/lspci_amd.txt b/internal/stack/gpu/testdata/lspci_amd.txt new file mode 100644 index 0000000..0e868a0 --- /dev/null +++ b/internal/stack/gpu/testdata/lspci_amd.txt @@ -0,0 +1 @@ +0c:00.0 VGA compatible controller [0300]: Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XTX] [1002:744c] (rev c8) diff --git a/internal/stack/gpu/testdata/lspci_none.txt b/internal/stack/gpu/testdata/lspci_none.txt new file mode 100644 index 0000000..d43c420 --- /dev/null +++ b/internal/stack/gpu/testdata/lspci_none.txt @@ -0,0 +1 @@ +00:14.0 USB controller [0c03]: Intel Corporation Cannon Lake PCH USB 3.1 xHCI Host Controller [8086:a36d] (rev 10) diff --git a/internal/stack/gpu/testdata/lspci_nvidia.txt b/internal/stack/gpu/testdata/lspci_nvidia.txt new file mode 100644 index 0000000..26ea2f8 --- /dev/null +++ b/internal/stack/gpu/testdata/lspci_nvidia.txt @@ -0,0 +1,2 @@ +00:02.0 VGA compatible controller [0300]: Intel Corporation UHD Graphics 630 [8086:3e9b] (rev 02) +01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA102 [GeForce RTX 4090] [10de:2204] (rev a1) From 50dc4f3319f1611f0b7cbd14e8de14b7061ce580 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:15:23 +0200 Subject: [PATCH 03/39] feat: add components for build tools, version control, and language package management Introduced new components for managing various build tools (C/C++ toolchain, CMake, GNU Make), version control (Git), and language packages (Rust, Scala, Python, Node.js, etc.) in the homelab-cli. Each component includes installation checks, installation scripts, and path management for seamless integration. This enhances the CLI's capability to manage development environments effectively. --- internal/stack/components/build_vcs.go | 128 +++++++++++++ internal/stack/components/container_gpu.go | 205 +++++++++++++++++++++ internal/stack/components/helpers.go | 67 +++++++ internal/stack/components/lang_pkg.go | 175 ++++++++++++++++++ internal/stack/components/mise.go | 83 +++++++++ 5 files changed, 658 insertions(+) create mode 100644 internal/stack/components/build_vcs.go create mode 100644 internal/stack/components/container_gpu.go create mode 100644 internal/stack/components/helpers.go create mode 100644 internal/stack/components/lang_pkg.go create mode 100644 internal/stack/components/mise.go diff --git a/internal/stack/components/build_vcs.go b/internal/stack/components/build_vcs.go new file mode 100644 index 0000000..d121095 --- /dev/null +++ b/internal/stack/components/build_vcs.go @@ -0,0 +1,128 @@ +// Package components registers stack installable components. +package components + +import ( + "context" + "fmt" + + "github.com/bartrosa/homelab-cli/internal/stack" +) + +type pkgComponent struct { + id, displayName, description string + category stack.Category + ubuntuPkgs, silverPkgs []string + checkCmd string +} + +func (p *pkgComponent) ID() string { return p.id } +func (p *pkgComponent) DisplayName() string { return p.displayName } +func (p *pkgComponent) Category() stack.Category { return p.category } +func (p *pkgComponent) Description() string { return p.description } +func (p *pkgComponent) DefaultVersion() string { return "system" } +func (p *pkgComponent) Requires() []string { return nil } +func (p *pkgComponent) PathEntries() []stack.PathEntry { return nil } + +func (p *pkgComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + if p.checkCmd != "" { + ok := cmdExists(ctx, env, p.checkCmd) + return ok, versionOf(ctx, env, p.checkCmd, "--version"), nil + } + if len(p.ubuntuPkgs) > 0 { + ok, err := pkgInstalled(ctx, env, p.ubuntuPkgs[0]) + return ok, "", err + } + return false, "", nil +} + +func (p *pkgComponent) Install(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + pkgs := p.ubuntuPkgs + if env.Info.IsSilverblue { + pkgs = p.silverPkgs + if len(pkgs) > 0 { + fmt.Fprintln(env.Stderr, "! rpm-ostree changes may require a reboot") + } + } + if len(pkgs) == 0 { + return fmt.Errorf("%s: no packages for this OS", p.id) + } + return installPkg(ctx, env, pkgs...) +} + +func registerBuildAndVCS() { + stack.Register(&pkgComponent{ + id: "cpp", displayName: "C/C++ toolchain", category: stack.CategoryBuildTool, + description: "gcc, g++, clang", + ubuntuPkgs: []string{"build-essential", "gcc", "g++", "clang", "clang-tools", "clangd", "libc++-dev", "libc++abi-dev"}, + silverPkgs: []string{"gcc", "gcc-c++", "clang", "clang-tools-extra", "libcxx-devel", "libcxxabi-devel"}, + checkCmd: "g++", + }) + stack.Register(&pkgComponent{ + id: "cmake", displayName: "CMake + Ninja", category: stack.CategoryBuildTool, + description: "cmake and ninja-build", + ubuntuPkgs: []string{"cmake", "ninja-build", "pkg-config"}, + silverPkgs: []string{"cmake", "ninja-build", "pkgconf-pkg-config"}, + checkCmd: "cmake", + }) + stack.Register(&pkgComponent{ + id: "make", displayName: "GNU Make", category: stack.CategoryBuildTool, + description: "make build tool", + ubuntuPkgs: []string{"make"}, + silverPkgs: []string{"make"}, + checkCmd: "make", + }) + stack.Register(&gitComponent{}) + stack.Register(&sqliteComponent{}) +} + +type gitComponent struct{} + +func (g *gitComponent) ID() string { return "git" } +func (g *gitComponent) DisplayName() string { return "Git" } +func (g *gitComponent) Category() stack.Category { return stack.CategoryVCS } +func (g *gitComponent) Description() string { return "Git and git-lfs" } +func (g *gitComponent) DefaultVersion() string { return "system" } +func (g *gitComponent) Requires() []string { return nil } +func (g *gitComponent) PathEntries() []stack.PathEntry { return nil } + +func (g *gitComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "git") + return ok, versionOf(ctx, env, "git", "--version"), nil +} + +func (g *gitComponent) Install(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + if env.Info.IsSilverblue { + ok, _ := pkgInstalled(ctx, env, "git") + if ok { + fmt.Fprintln(env.Stdout, "git already present on Silverblue") + return nil + } + return installPkg(ctx, env, "git", "git-lfs") + } + return installPkg(ctx, env, "git", "git-lfs") +} + +type sqliteComponent struct{} + +func (s *sqliteComponent) ID() string { return "sqlite" } +func (s *sqliteComponent) DisplayName() string { return "SQLite" } +func (s *sqliteComponent) Category() stack.Category { return stack.CategoryDatabaseEmbedded } +func (s *sqliteComponent) Description() string { return "SQLite CLI and dev headers" } +func (s *sqliteComponent) DefaultVersion() string { return "system" } +func (s *sqliteComponent) Requires() []string { return nil } +func (s *sqliteComponent) PathEntries() []stack.PathEntry { return nil } + +func (s *sqliteComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "sqlite3") + return ok, versionOf(ctx, env, "sqlite3", "--version"), nil +} + +func (s *sqliteComponent) Install(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + if env.Info.IsSilverblue { + fmt.Fprintln(env.Stderr, "! rpm-ostree changes may require a reboot") + return installPkg(ctx, env, "sqlite", "sqlite-devel") + } + return installPkg(ctx, env, "sqlite3", "libsqlite3-dev") +} + +func init() { registerBuildAndVCS() } diff --git a/internal/stack/components/container_gpu.go b/internal/stack/components/container_gpu.go new file mode 100644 index 0000000..1a7200c --- /dev/null +++ b/internal/stack/components/container_gpu.go @@ -0,0 +1,205 @@ +package components + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/bartrosa/homelab-cli/internal/stack" + "github.com/bartrosa/homelab-cli/internal/stack/gpu" +) + +type dockerComponent struct{} + +func (d *dockerComponent) ID() string { return "docker" } +func (d *dockerComponent) Description() string { + return "Docker Engine (official docker-ce repo on Ubuntu)" +} +func (d *dockerComponent) DisplayName() string { return "Docker" } +func (d *dockerComponent) Category() stack.Category { return stack.CategoryContainer } +func (d *dockerComponent) DefaultVersion() string { return "latest" } +func (d *dockerComponent) Requires() []string { return nil } +func (d *dockerComponent) PathEntries() []stack.PathEntry { return nil } + +func (d *dockerComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + if !cmdExists(ctx, env, "docker") { + return false, "", nil + } + ver := versionOf(ctx, env, "docker", "compose", "version") + if ver == "" { + ver = versionOf(ctx, env, "docker", "--version") + } + return true, ver, nil +} + +func (d *dockerComponent) Install(ctx context.Context, env *stack.Env, opts stack.InstallOptions) error { + if env.Info.IsSilverblue && !opts.Force { + return fmt.Errorf("silverblue: official recommendation is podman; use --force to install Docker anyway") + } + script := `set -e +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo tee /etc/apt/keyrings/docker.asc > /dev/null +sudo chmod a+r /etc/apt/keyrings/docker.asc +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +sudo usermod -aG docker "$USER" +` + if opts.DryRun { + return env.Runner.Run(ctx, "sh", "-c", script) + } + if err := env.Runner.Run(ctx, "sh", "-c", script); err != nil { + return err + } + fmt.Fprintln(env.Stdout, "Log out and back in for docker group membership to take effect") + return nil +} + +type podmanComponent struct{} + +func (p *podmanComponent) ID() string { return "podman" } +func (p *podmanComponent) DisplayName() string { return "Podman" } +func (p *podmanComponent) Category() stack.Category { return stack.CategoryContainer } +func (p *podmanComponent) Description() string { return "Podman + podman-compose" } +func (p *podmanComponent) DefaultVersion() string { return "system" } +func (p *podmanComponent) Requires() []string { return nil } +func (p *podmanComponent) PathEntries() []stack.PathEntry { return nil } + +func (p *podmanComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "podman") + return ok, versionOf(ctx, env, "podman", "--version"), nil +} + +func (p *podmanComponent) Install(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + if env.Info.IsSilverblue { + fmt.Fprintln(env.Stdout, "podman preinstalled on Silverblue") + return nil + } + return installPkg(ctx, env, "podman", "podman-compose", "podman-docker") +} + +type duckdbComponent struct{} + +func (d *duckdbComponent) ID() string { return "duckdb" } +func (d *duckdbComponent) DisplayName() string { return "DuckDB" } +func (d *duckdbComponent) Category() stack.Category { return stack.CategoryDatabaseEmbedded } +func (d *duckdbComponent) Description() string { return "DuckDB CLI from GitHub releases" } +func (d *duckdbComponent) DefaultVersion() string { return "latest" } +func (d *duckdbComponent) Requires() []string { return nil } + +func (d *duckdbComponent) PathEntries() []stack.PathEntry { + return []stack.PathEntry{{Shell: "all", Marker: "user-local-bin", Content: `export PATH="$HOME/.local/bin:$PATH"`}} +} + +func (d *duckdbComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "duckdb") + return ok, versionOf(ctx, env, "duckdb", "--version"), nil +} + +func (d *duckdbComponent) Install(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + home, _ := os.UserHomeDir() + bin := filepath.Join(home, ".local", "bin") + _ = os.MkdirAll(bin, 0o750) + // Simplified: download latest linux amd64 zip via gh api pattern + script := fmt.Sprintf(`set -e +URL=$(curl -fsSL https://api.github.com/repos/duckdb/duckdb/releases/latest | grep -o 'https://[^"]*linux-amd64.zip' | head -1) +curl -fsSL "$URL" -o /tmp/duckdb.zip +unzip -o /tmp/duckdb.zip -d /tmp/duckdb-extract +install -m 0755 /tmp/duckdb-extract/duckdb %s/duckdb`, bin) + return env.Runner.Run(ctx, "sh", "-c", script) +} + +type cudaComponent struct{} + +func (c *cudaComponent) ID() string { return "cuda" } +func (c *cudaComponent) DisplayName() string { return "CUDA Toolkit" } +func (c *cudaComponent) Category() stack.Category { return stack.CategoryGPU } +func (c *cudaComponent) Description() string { return "NVIDIA CUDA toolkit (Ubuntu)" } +func (c *cudaComponent) DefaultVersion() string { return "12-6" } +func (c *cudaComponent) Requires() []string { return nil } + +func (c *cudaComponent) PathEntries() []stack.PathEntry { + return []stack.PathEntry{{Shell: "all", Marker: "cuda", Content: `if [ -d "/usr/local/cuda/bin" ]; then export PATH="/usr/local/cuda/bin:$PATH"; export LD_LIBRARY_PATH="/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}"; fi`}} +} + +func (c *cudaComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "nvcc") + return ok, versionOf(ctx, env, "nvcc", "--version"), nil +} + +func (c *cudaComponent) Install(ctx context.Context, env *stack.Env, opts stack.InstallOptions) error { + ok, err := gpu.DetectNvidia(ctx, env.Runner) + if err != nil { + return err + } + if !ok && !opts.Force { + return fmt.Errorf("no NVIDIA GPU detected; use --force to override") + } + ver := opts.Version + if ver == "" { + ver = c.DefaultVersion() + } + if env.Info.IsSilverblue { + fmt.Fprintln(env.Stderr, "! rpm-ostree CUDA install may require reboot") + return env.Runner.Run(ctx, "sudo", "rpm-ostree", "install", "--idempotent", "akmod-nvidia", "xorg-x11-drv-nvidia-cuda") + } + script := fmt.Sprintf(`set -e +wget -q https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb +sudo dpkg -i cuda-keyring_1.1-1_all.deb +sudo apt-get update +sudo apt-get install -y cuda-toolkit-%s`, ver) + if err := env.Runner.Run(ctx, "sh", "-c", script); err != nil { + return err + } + fmt.Fprintln(env.Stdout, "CUDA installation may require a reboot. Verify with nvidia-smi after reboot.") + return nil +} + +type rocmComponent struct{} + +func (r *rocmComponent) ID() string { return "rocm" } +func (r *rocmComponent) DisplayName() string { return "ROCm" } +func (r *rocmComponent) Category() stack.Category { return stack.CategoryGPU } +func (r *rocmComponent) Description() string { return "AMD ROCm compute stack" } +func (r *rocmComponent) DefaultVersion() string { return "latest" } +func (r *rocmComponent) Requires() []string { return nil } + +func (r *rocmComponent) PathEntries() []stack.PathEntry { + return []stack.PathEntry{{Shell: "all", Marker: "rocm", Content: `if [ -d "/opt/rocm/bin" ]; then export PATH="/opt/rocm/bin:$PATH"; export LD_LIBRARY_PATH="/opt/rocm/lib:${LD_LIBRARY_PATH:-}"; fi`}} +} + +func (r *rocmComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + if cmdExists(ctx, env, "rocm-smi") { + return true, versionOf(ctx, env, "rocm-smi", "--version"), nil + } + ok := cmdExists(ctx, env, "rocminfo") + return ok, "", nil +} + +func (r *rocmComponent) Install(ctx context.Context, env *stack.Env, opts stack.InstallOptions) error { + ok, err := gpu.DetectAmd(ctx, env.Runner) + if err != nil { + return err + } + if !ok && !opts.Force { + return fmt.Errorf("no AMD GPU detected; use --force to override") + } + if env.Info.IsSilverblue { + return fmt.Errorf("ROCm on Silverblue is experimental; consider Fedora Workstation in a distrobox") + } + script := `set -e +wget -q https://repo.radeon.com/amdgpu-install/latest/ubuntu/noble/amdgpu-install_6.3.60300-1_all.deb +sudo apt install -y ./amdgpu-install_*.deb +sudo amdgpu-install --usecase=rocm --no-dkms -y +sudo usermod -a -G render,video "$USER"` + return env.Runner.Run(ctx, "sh", "-c", script) +} + +func init() { + stack.Register(&dockerComponent{}) + stack.Register(&podmanComponent{}) + stack.Register(&duckdbComponent{}) + stack.Register(&cudaComponent{}) + stack.Register(&rocmComponent{}) +} diff --git a/internal/stack/components/helpers.go b/internal/stack/components/helpers.go new file mode 100644 index 0000000..9d82eeb --- /dev/null +++ b/internal/stack/components/helpers.go @@ -0,0 +1,67 @@ +package components + +import ( + "context" + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/stack" +) + +func cmdExists(ctx context.Context, env *stack.Env, name string) bool { + _, err := env.Runner.RunWithOutput(ctx, "sh", "-c", "command -v "+name) + return err == nil +} + +func versionOf(ctx context.Context, env *stack.Env, cmd string, args ...string) string { + out, err := env.Runner.RunWithOutput(ctx, cmd, args...) + if err != nil { + return "" + } + return strings.TrimSpace(strings.Split(out, "\n")[0]) +} + +func miseSpec(id, version string) string { + if version == "" || version == "latest" || version == "lts" { + return id + "@latest" + } + return id + "@" + version +} + +func ensureMise(ctx context.Context, env *stack.Env, dryRun bool) error { + if cmdExists(ctx, env, "mise") { + return nil + } + if dryRun { + return env.Runner.Run(ctx, "sh", "-c", "curl https://mise.jdx.dev/install.sh | sh") + } + return env.Runner.Run(ctx, "sh", "-c", "curl https://mise.jdx.dev/install.sh | sh") +} + +func miseInstalled(ctx context.Context, env *stack.Env, id string) (bool, string) { + if !cmdExists(ctx, env, "mise") { + return false, "" + } + out, err := env.Runner.RunWithOutput(ctx, "mise", "ls", "-g", "--json") + if err != nil { + out, _ = env.Runner.RunWithOutput(ctx, "mise", "ls", "-g") + } + if strings.Contains(out, id) { + return true, versionOf(ctx, env, "mise", "current", id) + } + return false, "" +} + +func pkgInstalled(ctx context.Context, env *stack.Env, pkg string) (bool, error) { + if env.PkgMgr == nil { + return cmdExists(ctx, env, pkg), nil + } + return env.PkgMgr.IsInstalled(ctx, pkg) +} + +func installPkg(ctx context.Context, env *stack.Env, pkgs ...string) error { + if env.PkgMgr == nil { + return fmt.Errorf("no package manager available") + } + return env.PkgMgr.Install(ctx, pkgs...) +} diff --git a/internal/stack/components/lang_pkg.go b/internal/stack/components/lang_pkg.go new file mode 100644 index 0000000..9ce3798 --- /dev/null +++ b/internal/stack/components/lang_pkg.go @@ -0,0 +1,175 @@ +package components + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/bartrosa/homelab-cli/internal/stack" +) + +type rustComponent struct{} + +func (r *rustComponent) ID() string { return "rust" } +func (r *rustComponent) DisplayName() string { return "Rust" } +func (r *rustComponent) Category() stack.Category { return stack.CategoryLanguage } +func (r *rustComponent) Description() string { + return "Rust via rustup (stable + clippy/rustfmt/rust-analyzer)" +} +func (r *rustComponent) DefaultVersion() string { return "stable" } +func (r *rustComponent) Requires() []string { return nil } + +func (r *rustComponent) PathEntries() []stack.PathEntry { + return []stack.PathEntry{{Shell: "all", Marker: "rust", Content: `[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"`}} +} + +func (r *rustComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + if !cmdExists(ctx, env, "rustup") { + return false, "", nil + } + ver := versionOf(ctx, env, "rustc", "--version") + return ver != "", ver, nil +} + +func (r *rustComponent) Install(ctx context.Context, env *stack.Env, opts stack.InstallOptions) error { + toolchain := "stable" + if opts.Extra != nil && opts.Extra["rust-toolchain"] != "" { + toolchain = opts.Extra["rust-toolchain"] + } + script := fmt.Sprintf(`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain %s --profile default --component clippy --component rustfmt --component rust-analyzer`, toolchain) + if opts.DryRun { + return env.Runner.Run(ctx, "sh", "-c", script) + } + return env.Runner.Run(ctx, "sh", "-c", script) +} + +type scalaComponent struct{} + +func (s *scalaComponent) ID() string { return "scala" } +func (s *scalaComponent) DisplayName() string { return "Scala" } +func (s *scalaComponent) Category() stack.Category { return stack.CategoryLanguage } +func (s *scalaComponent) Description() string { + return "Scala via Coursier (cs setup)" +} +func (s *scalaComponent) DefaultVersion() string { return "latest" } +func (s *scalaComponent) Requires() []string { return []string{"java"} } + +func (s *scalaComponent) PathEntries() []stack.PathEntry { + return []stack.PathEntry{ + {Shell: "all", Marker: "user-local-bin", Content: `export PATH="$HOME/.local/bin:$PATH"`}, + {Shell: "all", Marker: "coursier", Content: `export PATH="$HOME/.local/share/coursier/bin:$PATH"`}, + } +} + +func (s *scalaComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + if !cmdExists(ctx, env, "cs") || !cmdExists(ctx, env, "scala") { + return false, "", nil + } + return true, versionOf(ctx, env, "scala", "-version"), nil +} + +func (s *scalaComponent) Install(ctx context.Context, env *stack.Env, opts stack.InstallOptions) error { + home, _ := os.UserHomeDir() + bin := filepath.Join(home, ".local", "bin") + _ = os.MkdirAll(bin, 0o750) + arch := coursierArch() + url := fmt.Sprintf("https://github.com/coursier/coursier/releases/latest/download/cs-%s.gz", arch) + script := fmt.Sprintf(`curl -fL %q | gzip -d > %q/cs && chmod +x %q/cs && %q/cs setup --yes`, url, bin, bin, bin) + if opts.DryRun { + return env.Runner.Run(ctx, "sh", "-c", script) + } + return env.Runner.Run(ctx, "sh", "-c", script) +} + +func coursierArch() string { + switch runtime.GOOS { + case "darwin": + if runtime.GOARCH == "arm64" { + return "aarch64-apple-darwin" + } + return "x86_64-apple-darwin" + default: + if runtime.GOARCH == "arm64" { + return "aarch64-pc-linux" + } + return "x86_64-pc-linux" + } +} + +type uvComponent struct{} + +func (u *uvComponent) ID() string { return "uv" } +func (u *uvComponent) DisplayName() string { return "uv" } +func (u *uvComponent) Category() stack.Category { return stack.CategoryPackageMgr } +func (u *uvComponent) Description() string { return "Python package manager (uv)" } +func (u *uvComponent) DefaultVersion() string { return "latest" } +func (u *uvComponent) Requires() []string { return nil } + +func (u *uvComponent) PathEntries() []stack.PathEntry { + return []stack.PathEntry{{Shell: "all", Marker: "user-local-bin", Content: `export PATH="$HOME/.local/bin:$PATH"`}} +} + +func (u *uvComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "uv") + return ok, versionOf(ctx, env, "uv", "--version"), nil +} + +func (u *uvComponent) Install(ctx context.Context, env *stack.Env, opts stack.InstallOptions) error { + script := "curl -LsSf https://astral.sh/uv/install.sh | sh" + if opts.DryRun { + return env.Runner.Run(ctx, "sh", "-c", script) + } + return env.Runner.Run(ctx, "sh", "-c", script) +} + +type yarnComponent struct{} + +func (y *yarnComponent) ID() string { return "yarn" } +func (y *yarnComponent) DisplayName() string { return "Yarn" } +func (y *yarnComponent) Category() stack.Category { return stack.CategoryPackageMgr } +func (y *yarnComponent) Description() string { return "Yarn via corepack" } +func (y *yarnComponent) DefaultVersion() string { return "stable" } +func (y *yarnComponent) Requires() []string { return []string{"node"} } + +func (y *yarnComponent) PathEntries() []stack.PathEntry { return nil } + +func (y *yarnComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "yarn") + return ok, versionOf(ctx, env, "yarn", "--version"), nil +} + +func (y *yarnComponent) Install(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + return env.Runner.Run(ctx, "sh", "-c", "corepack enable && corepack prepare yarn@stable --activate") +} + +type pnpmComponent struct{} + +func (p *pnpmComponent) ID() string { return "pnpm" } +func (p *pnpmComponent) DisplayName() string { return "pnpm" } +func (p *pnpmComponent) Category() stack.Category { return stack.CategoryPackageMgr } +func (p *pnpmComponent) Description() string { return "pnpm via corepack" } +func (p *pnpmComponent) DefaultVersion() string { return "latest" } +func (p *pnpmComponent) Requires() []string { return []string{"node"} } + +func (p *pnpmComponent) PathEntries() []stack.PathEntry { return nil } + +func (p *pnpmComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok := cmdExists(ctx, env, "pnpm") + return ok, versionOf(ctx, env, "pnpm", "--version"), nil +} + +func (p *pnpmComponent) Install(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + return env.Runner.Run(ctx, "sh", "-c", "corepack enable && corepack prepare pnpm@latest --activate") +} + +func registerLangAndPkg() { + stack.Register(&rustComponent{}) + stack.Register(&scalaComponent{}) + stack.Register(&uvComponent{}) + stack.Register(&yarnComponent{}) + stack.Register(&pnpmComponent{}) +} + +func init() { registerMiseComponents(); registerLangAndPkg() } diff --git a/internal/stack/components/mise.go b/internal/stack/components/mise.go new file mode 100644 index 0000000..6d75ac7 --- /dev/null +++ b/internal/stack/components/mise.go @@ -0,0 +1,83 @@ +package components + +import ( + "context" + + "github.com/bartrosa/homelab-cli/internal/stack" +) + +type miseComponent struct { + id, displayName, description, defaultVer string + category stack.Category + requires []string + extraPaths []stack.PathEntry + postInstall func(context.Context, *stack.Env, stack.InstallOptions) error +} + +func (m *miseComponent) ID() string { return m.id } +func (m *miseComponent) DisplayName() string { return m.displayName } +func (m *miseComponent) Category() stack.Category { return m.category } +func (m *miseComponent) Description() string { return m.description } +func (m *miseComponent) DefaultVersion() string { return m.defaultVer } +func (m *miseComponent) Requires() []string { return m.requires } +func (m *miseComponent) PathEntries() []stack.PathEntry { + base := []stack.PathEntry{{ + Shell: "all", Marker: "mise", + Content: `if [ -x "$HOME/.local/bin/mise" ]; then + eval "$($HOME/.local/bin/mise activate bash)" +fi`, + }} + return append(base, m.extraPaths...) +} + +func (m *miseComponent) IsInstalled(ctx context.Context, env *stack.Env) (bool, string, error) { + ok, ver := miseInstalled(ctx, env, m.id) + return ok, ver, nil +} + +func (m *miseComponent) Install(ctx context.Context, env *stack.Env, opts stack.InstallOptions) error { + if err := ensureMise(ctx, env, opts.DryRun); err != nil { + return err + } + ver := opts.Version + if ver == "" { + ver = m.defaultVer + } + spec := miseSpec(m.id, ver) + if opts.DryRun { + return env.Runner.Run(ctx, "mise", "use", "-g", spec) + } + if err := env.Runner.Run(ctx, "mise", "use", "-g", spec); err != nil { + return err + } + if m.postInstall != nil { + return m.postInstall(ctx, env, opts) + } + return nil +} + +func registerMiseComponents() { + langs := []miseComponent{ + {id: "python", displayName: "Python", category: stack.CategoryLanguage, description: "Python via mise", defaultVer: "3.13"}, + {id: "node", displayName: "Node.js", category: stack.CategoryLanguage, description: "Node.js LTS via mise", defaultVer: "lts"}, + {id: "bun", displayName: "Bun", category: stack.CategoryLanguage, description: "Bun via mise", defaultVer: "latest"}, + {id: "go", displayName: "Go", category: stack.CategoryLanguage, description: "Go via mise", defaultVer: "1.25", extraPaths: []stack.PathEntry{ + {Shell: "all", Marker: "go-path", Content: `export GOPATH="$HOME/go"\nexport PATH="$GOPATH/bin:$PATH"`}, + }}, + {id: "zig", displayName: "Zig", category: stack.CategoryLanguage, description: "Zig via mise (+ zls)", defaultVer: "latest", postInstall: func(ctx context.Context, env *stack.Env, _ stack.InstallOptions) error { + return env.Runner.Run(ctx, "mise", "use", "-g", "zls@latest") + }}, + {id: "lua", displayName: "Lua", category: stack.CategoryLanguage, description: "Lua via mise", defaultVer: "5.4"}, + {id: "java", displayName: "Java", category: stack.CategoryLanguage, description: "Java LTS via mise", defaultVer: "21", extraPaths: []stack.PathEntry{ + {Shell: "all", Marker: "java-home", Content: `if command -v mise > /dev/null 2>&1; then export JAVA_HOME="$(mise where java 2>/dev/null || true)"; fi`}, + }}, + {id: "kotlin", displayName: "Kotlin", category: stack.CategoryLanguage, description: "Kotlin via mise", defaultVer: "latest", requires: []string{"java"}}, + {id: "erlang", displayName: "Erlang", category: stack.CategoryLanguage, description: "Erlang via mise", defaultVer: "latest"}, + {id: "elixir", displayName: "Elixir", category: stack.CategoryLanguage, description: "Elixir via mise", defaultVer: "latest", requires: []string{"erlang"}}, + {id: "deno", displayName: "Deno", category: stack.CategoryLanguage, description: "Deno via mise", defaultVer: "latest"}, + } + for i := range langs { + c := langs[i] + stack.Register(&c) + } +} From 0b15ae1c78451c239ca111e9da7718bf55f9e095 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:16:12 +0200 Subject: [PATCH 04/39] feat: introduce stack component management with orchestration and presets Added a comprehensive stack management system for installable developer components, including orchestration for dependency resolution and installation. Implemented presets for common development environments and provided functionality for merging custom presets. Introduced unit tests to ensure correct behavior of the orchestration and preset functionalities, enhancing the homelab-cli's capability to manage diverse development setups effectively. --- internal/stack/component.go | 61 ++++++++++ internal/stack/env.go | 19 +++ internal/stack/orchestrator.go | 175 ++++++++++++++++++++++++++++ internal/stack/orchestrator_test.go | 55 +++++++++ internal/stack/presets.go | 81 +++++++++++++ internal/stack/presets_test.go | 27 +++++ internal/stack/registry.go | 66 +++++++++++ internal/stack/setup.go | 36 ++++++ 8 files changed, 520 insertions(+) create mode 100644 internal/stack/component.go create mode 100644 internal/stack/env.go create mode 100644 internal/stack/orchestrator.go create mode 100644 internal/stack/orchestrator_test.go create mode 100644 internal/stack/presets.go create mode 100644 internal/stack/presets_test.go create mode 100644 internal/stack/registry.go create mode 100644 internal/stack/setup.go diff --git a/internal/stack/component.go b/internal/stack/component.go new file mode 100644 index 0000000..8493e8f --- /dev/null +++ b/internal/stack/component.go @@ -0,0 +1,61 @@ +// Package stack defines installable developer-environment components and orchestration. +package stack + +import ( + "context" + "log/slog" +) + +// Category groups installable developer components. +type Category string + +// Component categories group installable developer stack blocks. +const ( + CategoryLanguage Category = "language" + CategoryBuildTool Category = "build-tool" + CategoryContainer Category = "container" + CategoryGPU Category = "gpu" + CategoryPackageMgr Category = "package-manager" + CategoryVCS Category = "vcs" + CategoryDatabaseEmbedded Category = "database-embedded" +) + +// Component is an installable developer-environment block. +type Component interface { + ID() string + DisplayName() string + Category() Category + Description() string + DefaultVersion() string + Requires() []string + IsInstalled(ctx context.Context, env *Env) (bool, string, error) + Install(ctx context.Context, env *Env, opts InstallOptions) error + PathEntries() []PathEntry +} + +// InstallOptions configures a component install. +type InstallOptions struct { + Version string + Force bool + NonInteractive bool + DryRun bool + SkipPath bool + Logger *slog.Logger + Extra map[string]string // e.g. rust-toolchain, cmake-source +} + +// PathEntry is a shell rc snippet for PATH and env vars. +type PathEntry struct { + Shell string // bash, zsh, fish, all + Content string + Marker string +} + +// PlanStep describes one orchestrator step. +type PlanStep struct { + ID string + Action string // install, skip + Reason string + Version string + Requires []string +} diff --git a/internal/stack/env.go b/internal/stack/env.go new file mode 100644 index 0000000..260ba4f --- /dev/null +++ b/internal/stack/env.go @@ -0,0 +1,19 @@ +package stack + +import ( + "io" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/pkgmgr" + "github.com/bartrosa/homelab-cli/internal/platform" +) + +// Env carries runtime dependencies for stack components. +type Env struct { + Runner exec.Runner + Stdout io.Writer + Stderr io.Writer + Info platform.Info + PkgMgr pkgmgr.Manager + HomeDir string +} diff --git a/internal/stack/orchestrator.go b/internal/stack/orchestrator.go new file mode 100644 index 0000000..d6542ff --- /dev/null +++ b/internal/stack/orchestrator.go @@ -0,0 +1,175 @@ +package stack + +import ( + "context" + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/stack/shellrc" +) + +// InstallAll resolves dependencies and installs components in order. +func InstallAll(ctx context.Context, env *Env, ids []string, opts InstallOptions) error { + order, err := resolveOrder(ids) + if err != nil { + return err + } + total := len(order) + for i, id := range order { + c, ok := Lookup(id) + if !ok { + return fmt.Errorf("unknown component %q", id) + } + installed, ver, err := c.IsInstalled(ctx, env) + if err != nil { + return fmt.Errorf("%s: check installed: %w", id, err) + } + if installed && !opts.Force { + fmt.Fprintf(env.Stdout, "[%d/%d] %s ... already installed (%s), skipping\n", i+1, total, id, ver) + continue + } + if opts.DryRun { + fmt.Fprintf(env.Stdout, "[%d/%d] %s ... would install\n", i+1, total, id) + continue + } + fmt.Fprintf(env.Stdout, "[%d/%d] %s ... installing\n", i+1, total, id) + if err := c.Install(ctx, env, opts); err != nil { + return fmt.Errorf("%s: install: %w", id, err) + } + fmt.Fprintf(env.Stdout, " βœ… Installed %s\n", id) + } + if !opts.DryRun && !opts.SkipPath { + if err := refreshPath(ctx, env); err != nil { + return err + } + } + return nil +} + +// Plan returns dry-run steps for ids. +func Plan(ctx context.Context, env *Env, ids []string, opts InstallOptions) ([]PlanStep, error) { + order, err := resolveOrder(ids) + if err != nil { + return nil, err + } + var steps []PlanStep + for _, id := range order { + c, ok := Lookup(id) + if !ok { + return nil, fmt.Errorf("unknown component %q", id) + } + step := PlanStep{ID: id, Requires: c.Requires(), Version: c.DefaultVersion()} + installed, ver, err := c.IsInstalled(ctx, env) + if err != nil { + return nil, err + } + if installed && !opts.Force { + step.Action = "skip" + step.Reason = "already installed (" + ver + ")" + } else { + step.Action = "install" + } + steps = append(steps, step) + } + return steps, nil +} + +func resolveOrder(ids []string) ([]string, error) { + seen := map[string]struct{}{} + var order []string + var visit func(string) error + visit = func(id string) error { + if _, ok := seen[id]; ok { + return nil + } + c, ok := Lookup(id) + if !ok { + return fmt.Errorf("unknown component %q", id) + } + for _, req := range c.Requires() { + if err := visit(req); err != nil { + return err + } + } + seen[id] = struct{}{} + order = append(order, id) + return nil + } + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if err := visit(id); err != nil { + return nil, err + } + } + return order, nil +} + +// RefreshPath regenerates shell rc blocks from registered component PathEntries. +func RefreshPath(ctx context.Context, env *Env) error { + return refreshPath(ctx, env) +} + +func refreshPath(_ context.Context, env *Env) error { + entries := collectPathEntries() + if len(entries) == 0 { + return nil + } + shells, err := shellrc.Detect() + if err != nil { + return err + } + var updated []string + for _, sh := range shells { + rcEntries := make([]shellrc.Entry, len(entries)) + for i, e := range entries { + rcEntries[i] = shellrc.Entry{Shell: e.Shell, Content: e.Content, Marker: e.Marker} + } + if err := shellrc.UpdateBlock(sh, rcEntries); err != nil { + return err + } + p, _ := shellrc.RCPath(sh) + updated = append(updated, p) + } + if len(updated) > 0 { + fmt.Fprintf(env.Stdout, "Updated shell PATH in: %s\n", strings.Join(updated, ", ")) + fmt.Fprintln(env.Stdout, "πŸ‘‰ Run `source ~/.bashrc` or restart terminal to activate.") + } + return nil +} + +func collectPathEntries() []PathEntry { + seen := map[string]struct{}{} + var out []PathEntry + for _, c := range All() { + for _, e := range c.PathEntries() { + key := e.Marker + "|" + e.Shell + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, e) + } + } + return out +} + +// ListInstalled returns installed components with versions. +func ListInstalled(ctx context.Context, env *Env) ([]struct { + ID, Version string +}, error, +) { + var out []struct{ ID, Version string } + for _, c := range All() { + ok, ver, err := c.IsInstalled(ctx, env) + if err != nil { + return nil, err + } + if ok { + out = append(out, struct{ ID, Version string }{c.ID(), ver}) + } + } + return out, nil +} diff --git a/internal/stack/orchestrator_test.go b/internal/stack/orchestrator_test.go new file mode 100644 index 0000000..b891591 --- /dev/null +++ b/internal/stack/orchestrator_test.go @@ -0,0 +1,55 @@ +package stack_test + +import ( + "bytes" + "context" + "testing" + + "github.com/bartrosa/homelab-cli/internal/stack" + "github.com/stretchr/testify/require" +) + +type mockComponent struct { + id string + ver string + requires []string + installed bool +} + +func (m *mockComponent) ID() string { return m.id } +func (m *mockComponent) DisplayName() string { return m.id } +func (m *mockComponent) Category() stack.Category { return stack.CategoryLanguage } +func (m *mockComponent) Description() string { return "mock" } +func (m *mockComponent) DefaultVersion() string { return "1" } +func (m *mockComponent) Requires() []string { return m.requires } +func (m *mockComponent) PathEntries() []stack.PathEntry { return nil } +func (m *mockComponent) IsInstalled(context.Context, *stack.Env) (bool, string, error) { + return m.installed, m.ver, nil +} + +func (m *mockComponent) Install(context.Context, *stack.Env, stack.InstallOptions) error { + m.installed = true + return nil +} + +func TestResolveOrder_dependencies(t *testing.T) { + stack.Register(&mockComponent{id: "java", ver: "21"}) + stack.Register(&mockComponent{id: "kotlin", ver: "latest", requires: []string{"java"}}) + + env := &stack.Env{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + steps, err := stack.Plan(context.Background(), env, []string{"kotlin"}, stack.InstallOptions{}) + require.NoError(t, err) + require.Len(t, steps, 2) + require.Equal(t, "java", steps[0].ID) + require.Equal(t, "kotlin", steps[1].ID) +} + +func TestPlan_skipInstalled(t *testing.T) { + stack.Register(&mockComponent{id: "git", ver: "2.43", installed: true}) + + env := &stack.Env{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + steps, err := stack.Plan(context.Background(), env, []string{"git"}, stack.InstallOptions{}) + require.NoError(t, err) + require.Len(t, steps, 1) + require.Equal(t, "skip", steps[0].Action) +} diff --git a/internal/stack/presets.go b/internal/stack/presets.go new file mode 100644 index 0000000..40620a2 --- /dev/null +++ b/internal/stack/presets.go @@ -0,0 +1,81 @@ +package stack + +import ( + "sort" +) + +// DefaultPresets are built-in stack install bundles. +var DefaultPresets = map[string][]string{ + "minimal": {"git", "docker", "make"}, + "basic": {"git", "docker", "make", "python", "node", "uv"}, + "backend": {"git", "docker", "make", "python", "node", "uv", "go"}, + "frontend": {"git", "docker", "make", "node", "bun", "yarn", "pnpm"}, + "systems": {"git", "docker", "make", "rust", "zig", "cpp", "cmake"}, + "jvm": {"git", "docker", "make", "java", "kotlin", "scala"}, + "ml": {"git", "docker", "make", "python", "uv", "cmake"}, + "data": {"git", "docker", "make", "python", "uv", "duckdb", "sqlite"}, + "gpu-nvidia": {"git", "docker", "make", "python", "uv", "cmake", "cuda"}, + "gpu-amd": {"git", "docker", "make", "python", "uv", "cmake", "rocm"}, +} + +// PresetNames returns sorted preset keys merged with config overrides. +func PresetNames(custom map[string][]string) []string { + merged := MergePresets(custom) + names := make([]string, 0, len(merged)) + for k := range merged { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// MergePresets overlays custom presets on defaults. +func MergePresets(custom map[string][]string) map[string][]string { + out := make(map[string][]string, len(DefaultPresets)+len(custom)) + for k, v := range DefaultPresets { + cp := append([]string(nil), v...) + out[k] = cp + } + for k, v := range custom { + out[k] = append([]string(nil), v...) + } + out["full"] = fullPreset() + return out +} + +// ResolvePreset returns component ids for a preset name. +func ResolvePreset(name string, custom map[string][]string) ([]string, error) { + merged := MergePresets(custom) + ids, ok := merged[name] + if !ok { + return nil, errUnknownPreset(name) + } + return append([]string(nil), ids...), nil +} + +func fullPreset() []string { + seen := map[string]struct{}{} + var out []string + for _, c := range All() { + if c.Category() == CategoryGPU { + continue + } + id := c.ID() + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Strings(out) + return out +} + +func errUnknownPreset(name string) error { + return &PresetError{Name: name} +} + +// PresetError indicates an unknown preset. +type PresetError struct{ Name string } + +func (e *PresetError) Error() string { return "unknown stack preset " + e.Name } diff --git a/internal/stack/presets_test.go b/internal/stack/presets_test.go new file mode 100644 index 0000000..ab4cc5a --- /dev/null +++ b/internal/stack/presets_test.go @@ -0,0 +1,27 @@ +package stack_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/stack" + _ "github.com/bartrosa/homelab-cli/internal/stack/components" + "github.com/stretchr/testify/require" +) + +func TestMergePresets_customOverride(t *testing.T) { + custom := map[string][]string{"ml": {"python", "go"}} + merged := stack.MergePresets(custom) + require.Equal(t, []string{"python", "go"}, merged["ml"]) + require.Contains(t, merged["backend"], "python") +} + +func TestResolvePreset_unknown(t *testing.T) { + _, err := stack.ResolvePreset("nope", nil) + require.Error(t, err) +} + +func TestPresetNames_includesFull(t *testing.T) { + names := stack.PresetNames(nil) + require.Contains(t, names, "full") + require.Contains(t, names, "ml") +} diff --git a/internal/stack/registry.go b/internal/stack/registry.go new file mode 100644 index 0000000..caf8b4e --- /dev/null +++ b/internal/stack/registry.go @@ -0,0 +1,66 @@ +package stack + +import ( + "fmt" + "sort" + "sync" +) + +var ( + mu sync.RWMutex + registry = map[string]Component{} +) + +// Register adds a component to the global registry. +func Register(c Component) { + if c == nil { + return + } + mu.Lock() + defer mu.Unlock() + registry[c.ID()] = c +} + +// Lookup returns a component by id. +func Lookup(id string) (Component, bool) { + mu.RLock() + defer mu.RUnlock() + c, ok := registry[id] + return c, ok +} + +// All returns all registered components sorted by id. +func All() []Component { + mu.RLock() + defer mu.RUnlock() + out := make([]Component, 0, len(registry)) + for _, c := range registry { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID() < out[j].ID() }) + return out +} + +// ByCategory returns components in a category. +func ByCategory(cat Category) []Component { + var out []Component + for _, c := range All() { + if c.Category() == cat { + out = append(out, c) + } + } + return out +} + +// IDs validates and returns component ids. +func IDs(names ...string) ([]Component, error) { + var out []Component + for _, name := range names { + c, ok := Lookup(name) + if !ok { + return nil, fmt.Errorf("unknown stack component %q", name) + } + out = append(out, c) + } + return out, nil +} diff --git a/internal/stack/setup.go b/internal/stack/setup.go new file mode 100644 index 0000000..9701e02 --- /dev/null +++ b/internal/stack/setup.go @@ -0,0 +1,36 @@ +package stack + +import ( + "io" + "os" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/pkgmgr" + "github.com/bartrosa/homelab-cli/internal/platform" +) + +// NewEnv builds a component environment. +func NewEnv(stdout, stderr io.Writer) (*Env, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + info := platform.Detect() + var mgr pkgmgr.Manager + switch { + case info.IsSilverblue: + mgr = &pkgmgr.RPMOstree{Runner: exec.NewOSRunner(stdout, stderr)} + case info.Packager == platform.PackagerAPT: + mgr = &pkgmgr.APT{Runner: exec.NewOSRunner(stdout, stderr), Sudo: true} + default: + mgr = nil + } + return &Env{ + Runner: exec.NewOSRunner(stdout, stderr), + Stdout: stdout, + Stderr: stderr, + Info: info, + PkgMgr: mgr, + HomeDir: home, + }, nil +} From 84c1f50c0ce1c2a40af87069813ff408c2258d83 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:16:24 +0200 Subject: [PATCH 05/39] feat: add Weaviate service integration Introduced a new Weaviate service to the homelab-cli, providing a vector database with a GraphQL API. The service includes configuration options for HTTP port and API key, enhancing the CLI's capabilities for managing vector databases. --- internal/services/weaviate/service.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 internal/services/weaviate/service.go diff --git a/internal/services/weaviate/service.go b/internal/services/weaviate/service.go new file mode 100644 index 0000000..be09162 --- /dev/null +++ b/internal/services/weaviate/service.go @@ -0,0 +1,18 @@ +// Package weaviate provides the weaviate compose service. +package weaviate + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "weaviate", DisplayName: "Weaviate", Category: services.CategoryVector, + Description: "Vector database with GraphQL API", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "HTTP port", 8080), + services.PasswordField("api_key", "API key"), + }}, + nil, nil, + )) +} From 07648b7ff3da9247059c9b6614ecf27484e050e2 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:16:34 +0200 Subject: [PATCH 06/39] feat: add Valkey service integration Introduced the Valkey service to the homelab-cli, providing a Redis-compatible in-memory datastore. The service includes configuration options for host port and password, enhancing the CLI's capabilities for managing caching solutions. --- internal/services/valkey/service.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 internal/services/valkey/service.go diff --git a/internal/services/valkey/service.go b/internal/services/valkey/service.go new file mode 100644 index 0000000..7ac1fe6 --- /dev/null +++ b/internal/services/valkey/service.go @@ -0,0 +1,18 @@ +// Package valkey provides the valkey compose service. +package valkey + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "valkey", DisplayName: "Valkey", Category: services.CategoryCache, + Description: "Redis-compatible in-memory datastore", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "Host port", 6379), + services.PasswordField("password", "Valkey password"), + }}, + nil, nil, + )) +} From c8237d676b9149eeaf5f8322a50dcb1ec6648679 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:16:46 +0200 Subject: [PATCH 07/39] feat: add Tempo service integration Introduced the Tempo service to the homelab-cli, providing a distributed tracing backend. The service includes configuration options for HTTP port, enhancing the CLI's capabilities for observability solutions. --- internal/services/tempo/service.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 internal/services/tempo/service.go diff --git a/internal/services/tempo/service.go b/internal/services/tempo/service.go new file mode 100644 index 0000000..a0f0d17 --- /dev/null +++ b/internal/services/tempo/service.go @@ -0,0 +1,17 @@ +// Package tempo provides the tempo compose service. +package tempo + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "tempo", DisplayName: "Tempo", Category: services.CategoryObservability, + Description: "Distributed tracing backend", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "HTTP port", 3200), + }}, + nil, nil, + )) +} From 47b19974b208142ba81141455cc5785254cad509 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:17:18 +0200 Subject: [PATCH 08/39] feat: add multiple service integrations for homelab-cli Introduced new service configurations for ClickHouse, Grafana, Loki, MinIO, MongoDB, MySQL, NATS, PostgreSQL, Prometheus, Qdrant, RabbitMQ, Redis, and Weaviate. Each service includes environment variables, port mappings, and volume configurations, enhancing the CLI's capabilities for managing a diverse set of data storage, monitoring, and messaging solutions. --- .../services/clickhouse/compose.yml.tmpl | 19 +++++++++++++++ .../services/grafana/compose.yml.tmpl | 18 +++++++++++++++ .../templates/services/loki/compose.yml.tmpl | 16 +++++++++++++ .../templates/services/minio/compose.yml.tmpl | 20 ++++++++++++++++ .../services/mongodb/compose.yml.tmpl | 18 +++++++++++++++ .../templates/services/mysql/compose.yml.tmpl | 20 ++++++++++++++++ .../templates/services/nats/compose.yml.tmpl | 17 ++++++++++++++ .../services/postgres/Dockerfile.tmpl | 11 +++++++++ .../services/postgres/compose.yml.tmpl | 23 +++++++++++++++++++ .../services/prometheus/compose.yml.tmpl | 19 +++++++++++++++ .../services/qdrant/compose.yml.tmpl | 16 +++++++++++++ .../services/rabbitmq/compose.yml.tmpl | 19 +++++++++++++++ .../templates/services/redis/compose.yml.tmpl | 16 +++++++++++++ .../templates/services/tempo/compose.yml.tmpl | 16 +++++++++++++ .../services/valkey/compose.yml.tmpl | 16 +++++++++++++ .../services/weaviate/compose.yml.tmpl | 19 +++++++++++++++ 16 files changed, 283 insertions(+) create mode 100644 internal/services/templates/services/clickhouse/compose.yml.tmpl create mode 100644 internal/services/templates/services/grafana/compose.yml.tmpl create mode 100644 internal/services/templates/services/loki/compose.yml.tmpl create mode 100644 internal/services/templates/services/minio/compose.yml.tmpl create mode 100644 internal/services/templates/services/mongodb/compose.yml.tmpl create mode 100644 internal/services/templates/services/mysql/compose.yml.tmpl create mode 100644 internal/services/templates/services/nats/compose.yml.tmpl create mode 100644 internal/services/templates/services/postgres/Dockerfile.tmpl create mode 100644 internal/services/templates/services/postgres/compose.yml.tmpl create mode 100644 internal/services/templates/services/prometheus/compose.yml.tmpl create mode 100644 internal/services/templates/services/qdrant/compose.yml.tmpl create mode 100644 internal/services/templates/services/rabbitmq/compose.yml.tmpl create mode 100644 internal/services/templates/services/redis/compose.yml.tmpl create mode 100644 internal/services/templates/services/tempo/compose.yml.tmpl create mode 100644 internal/services/templates/services/valkey/compose.yml.tmpl create mode 100644 internal/services/templates/services/weaviate/compose.yml.tmpl diff --git a/internal/services/templates/services/clickhouse/compose.yml.tmpl b/internal/services/templates/services/clickhouse/compose.yml.tmpl new file mode 100644 index 0000000..4f7a901 --- /dev/null +++ b/internal/services/templates/services/clickhouse/compose.yml.tmpl @@ -0,0 +1,19 @@ +services: + clickhouse: + image: clickhouse/clickhouse-server:24.8.4 + container_name: homelab-clickhouse + restart: unless-stopped + environment: + CLICKHOUSE_USER: ${USER} + CLICKHOUSE_PASSWORD: ${PASSWORD} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + ports: + - "${PORT}:8123" + volumes: + - ${DATA_DIR}/data:/var/lib/clickhouse + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/grafana/compose.yml.tmpl b/internal/services/templates/services/grafana/compose.yml.tmpl new file mode 100644 index 0000000..dcb12b4 --- /dev/null +++ b/internal/services/templates/services/grafana/compose.yml.tmpl @@ -0,0 +1,18 @@ +services: + grafana: + image: grafana/grafana:11.3.1 + container_name: homelab-grafana + restart: unless-stopped + environment: + GF_SECURITY_ADMIN_USER: ${USER} + GF_SECURITY_ADMIN_PASSWORD: ${PASSWORD} + ports: + - "${PORT}:3000" + volumes: + - ${DATA_DIR}/data:/var/lib/grafana + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/loki/compose.yml.tmpl b/internal/services/templates/services/loki/compose.yml.tmpl new file mode 100644 index 0000000..b8edff1 --- /dev/null +++ b/internal/services/templates/services/loki/compose.yml.tmpl @@ -0,0 +1,16 @@ +services: + loki: + image: grafana/loki:3.2.1 + container_name: homelab-loki + restart: unless-stopped + command: -config.file=/etc/loki/local-config.yaml + ports: + - "${PORT}:3100" + volumes: + - ${DATA_DIR}/data:/loki + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/minio/compose.yml.tmpl b/internal/services/templates/services/minio/compose.yml.tmpl new file mode 100644 index 0000000..62051a6 --- /dev/null +++ b/internal/services/templates/services/minio/compose.yml.tmpl @@ -0,0 +1,20 @@ +services: + minio: + image: minio/minio:RELEASE.2024-11-07T00-52-20Z + container_name: homelab-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${USER} + MINIO_ROOT_PASSWORD: ${PASSWORD} + ports: + - "${PORT}:9000" + - "${CONSOLE_PORT}:9001" + volumes: + - ${DATA_DIR}/data:/data + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/mongodb/compose.yml.tmpl b/internal/services/templates/services/mongodb/compose.yml.tmpl new file mode 100644 index 0000000..6f3a58d --- /dev/null +++ b/internal/services/templates/services/mongodb/compose.yml.tmpl @@ -0,0 +1,18 @@ +services: + mongodb: + image: mongo:7.0.14 + container_name: homelab-mongodb + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${USER} + MONGO_INITDB_ROOT_PASSWORD: ${PASSWORD} + ports: + - "${PORT}:27017" + volumes: + - ${DATA_DIR}/data:/data/db + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/mysql/compose.yml.tmpl b/internal/services/templates/services/mysql/compose.yml.tmpl new file mode 100644 index 0000000..1e9dad9 --- /dev/null +++ b/internal/services/templates/services/mysql/compose.yml.tmpl @@ -0,0 +1,20 @@ +services: + mysql: + image: mysql:8.4.2 + container_name: homelab-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${PASSWORD} + MYSQL_USER: ${USER} + MYSQL_PASSWORD: ${PASSWORD} + MYSQL_DATABASE: ${DATABASE} + ports: + - "${PORT}:3306" + volumes: + - ${DATA_DIR}/data:/var/lib/mysql + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/nats/compose.yml.tmpl b/internal/services/templates/services/nats/compose.yml.tmpl new file mode 100644 index 0000000..d6e8191 --- /dev/null +++ b/internal/services/templates/services/nats/compose.yml.tmpl @@ -0,0 +1,17 @@ +services: + nats: + image: nats:2.10.22-alpine + container_name: homelab-nats + restart: unless-stopped + command: ["-m", "8222"] + ports: + - "${PORT}:4222" + - "${MONITOR_PORT}:8222" + volumes: + - ${DATA_DIR}/data:/data + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/postgres/Dockerfile.tmpl b/internal/services/templates/services/postgres/Dockerfile.tmpl new file mode 100644 index 0000000..bed5b09 --- /dev/null +++ b/internal/services/templates/services/postgres/Dockerfile.tmpl @@ -0,0 +1,11 @@ +FROM postgres:16.4-alpine +{{- if has "pgvector" .Plugins }} +RUN apk add --no-cache postgresql-pgvector +{{- end }} +{{- if has "postgis" .Plugins }} +RUN apk add --no-cache postgis +{{- end }} +{{- if has "timescaledb" .Plugins }} +RUN echo "timescaledb extension requires timescaledb image; using community packages where available" >&2 +RUN apk add --no-cache timescaledb-toolkit || true +{{- end }} diff --git a/internal/services/templates/services/postgres/compose.yml.tmpl b/internal/services/templates/services/postgres/compose.yml.tmpl new file mode 100644 index 0000000..a4d08f4 --- /dev/null +++ b/internal/services/templates/services/postgres/compose.yml.tmpl @@ -0,0 +1,23 @@ +services: + postgres: +{{- if .BuildImage }} + build: . +{{- else }} + image: postgres:16.4-alpine +{{- end }} + container_name: homelab-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${USER} + POSTGRES_PASSWORD: ${PASSWORD} + POSTGRES_DB: ${DATABASE} + ports: + - "${PORT}:5432" + volumes: + - ${DATA_DIR}/data:/var/lib/postgresql/data + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/prometheus/compose.yml.tmpl b/internal/services/templates/services/prometheus/compose.yml.tmpl new file mode 100644 index 0000000..aaed32a --- /dev/null +++ b/internal/services/templates/services/prometheus/compose.yml.tmpl @@ -0,0 +1,19 @@ +services: + prometheus: + image: prom/prometheus:v2.55.1 + container_name: homelab-prometheus + restart: unless-stopped + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + ports: + - "${PORT}:9090" + volumes: + - ${DATA_DIR}/data:/prometheus + - ${CONFIG_DIR}/prometheus.yml:/etc/prometheus/prometheus.yml:ro + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/qdrant/compose.yml.tmpl b/internal/services/templates/services/qdrant/compose.yml.tmpl new file mode 100644 index 0000000..43f49bd --- /dev/null +++ b/internal/services/templates/services/qdrant/compose.yml.tmpl @@ -0,0 +1,16 @@ +services: + qdrant: + image: qdrant/qdrant:v1.12.5 + container_name: homelab-qdrant + restart: unless-stopped + ports: + - "${PORT}:6333" + - "${GRPC_PORT}:6334" + volumes: + - ${DATA_DIR}/storage:/qdrant/storage + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/rabbitmq/compose.yml.tmpl b/internal/services/templates/services/rabbitmq/compose.yml.tmpl new file mode 100644 index 0000000..5e8617f --- /dev/null +++ b/internal/services/templates/services/rabbitmq/compose.yml.tmpl @@ -0,0 +1,19 @@ +services: + rabbitmq: + image: rabbitmq:3.13.7-management-alpine + container_name: homelab-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${USER} + RABBITMQ_DEFAULT_PASS: ${PASSWORD} + ports: + - "${PORT}:5672" + - "${MGMT_PORT}:15672" + volumes: + - ${DATA_DIR}/data:/var/lib/rabbitmq + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/redis/compose.yml.tmpl b/internal/services/templates/services/redis/compose.yml.tmpl new file mode 100644 index 0000000..b2ff14e --- /dev/null +++ b/internal/services/templates/services/redis/compose.yml.tmpl @@ -0,0 +1,16 @@ +services: + redis: + image: redis:7.4.1-alpine + container_name: homelab-redis + restart: unless-stopped + command: redis-server --requirepass ${PASSWORD} + ports: + - "${PORT}:6379" + volumes: + - ${DATA_DIR}/data:/data + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/tempo/compose.yml.tmpl b/internal/services/templates/services/tempo/compose.yml.tmpl new file mode 100644 index 0000000..6c02d62 --- /dev/null +++ b/internal/services/templates/services/tempo/compose.yml.tmpl @@ -0,0 +1,16 @@ +services: + tempo: + image: grafana/tempo:2.6.1 + container_name: homelab-tempo + restart: unless-stopped + command: ["-config.file=/etc/tempo.yaml"] + ports: + - "${PORT}:3200" + volumes: + - ${DATA_DIR}/data:/var/tempo + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/valkey/compose.yml.tmpl b/internal/services/templates/services/valkey/compose.yml.tmpl new file mode 100644 index 0000000..7ae5201 --- /dev/null +++ b/internal/services/templates/services/valkey/compose.yml.tmpl @@ -0,0 +1,16 @@ +services: + valkey: + image: valkey/valkey:8.0.1-alpine + container_name: homelab-valkey + restart: unless-stopped + command: valkey-server --requirepass ${PASSWORD} + ports: + - "${PORT}:6379" + volumes: + - ${DATA_DIR}/data:/data + networks: + - homelab-net + +networks: + homelab-net: + external: true diff --git a/internal/services/templates/services/weaviate/compose.yml.tmpl b/internal/services/templates/services/weaviate/compose.yml.tmpl new file mode 100644 index 0000000..c28da7b --- /dev/null +++ b/internal/services/templates/services/weaviate/compose.yml.tmpl @@ -0,0 +1,19 @@ +services: + weaviate: + image: semitechnologies/weaviate:1.27.0 + container_name: homelab-weaviate + restart: unless-stopped + environment: + AUTHENTICATION_APIKEY_ENABLED: "true" + AUTHENTICATION_APIKEY_ALLOWED_KEYS: ${API_KEY} + AUTHENTICATION_APIKEY_USERS: homelab + ports: + - "${PORT}:8080" + volumes: + - ${DATA_DIR}/data:/var/lib/weaviate + networks: + - homelab-net + +networks: + homelab-net: + external: true From c0c344d98d091aeffb7ec31f9ad1a6910a541878 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:17:29 +0200 Subject: [PATCH 09/39] feat: add service registration package for homelab-cli Introduced a new package to register multiple services including ClickHouse, Grafana, Loki, MinIO, MongoDB, MySQL, NATS, PostgreSQL, Prometheus, Qdrant, RabbitMQ, Redis, Tempo, Valkey, and Weaviate. This package facilitates the integration of these services into the homelab-cli, enhancing its capability to manage a diverse set of data storage, monitoring, and messaging solutions. --- internal/services/register/register.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/services/register/register.go diff --git a/internal/services/register/register.go b/internal/services/register/register.go new file mode 100644 index 0000000..9ce0e9c --- /dev/null +++ b/internal/services/register/register.go @@ -0,0 +1,20 @@ +// Package register side-effect imports all bundled homelab services. +package register + +import ( + _ "github.com/bartrosa/homelab-cli/internal/services/clickhouse" // register clickhouse service + _ "github.com/bartrosa/homelab-cli/internal/services/grafana" // register grafana service + _ "github.com/bartrosa/homelab-cli/internal/services/loki" // register loki service + _ "github.com/bartrosa/homelab-cli/internal/services/minio" // register minio service + _ "github.com/bartrosa/homelab-cli/internal/services/mongodb" // register mongodb service + _ "github.com/bartrosa/homelab-cli/internal/services/mysql" // register mysql service + _ "github.com/bartrosa/homelab-cli/internal/services/nats" // register nats service + _ "github.com/bartrosa/homelab-cli/internal/services/postgres" // register postgres service + _ "github.com/bartrosa/homelab-cli/internal/services/prometheus" // register prometheus service + _ "github.com/bartrosa/homelab-cli/internal/services/qdrant" // register qdrant service + _ "github.com/bartrosa/homelab-cli/internal/services/rabbitmq" // register rabbitmq service + _ "github.com/bartrosa/homelab-cli/internal/services/redis" // register redis service + _ "github.com/bartrosa/homelab-cli/internal/services/tempo" // register tempo service + _ "github.com/bartrosa/homelab-cli/internal/services/valkey" // register valkey service + _ "github.com/bartrosa/homelab-cli/internal/services/weaviate" // register weaviate service +) From c7928d3281c465552dc6c13d39d4cc26f8a24121 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:17:37 +0200 Subject: [PATCH 10/39] feat: add Redis service integration to homelab-cli Introduced the Redis service to the homelab-cli, providing an in-memory key-value store. The service includes configuration options for host port and password, enhancing the CLI's capabilities for managing caching solutions. --- internal/services/redis/service.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 internal/services/redis/service.go diff --git a/internal/services/redis/service.go b/internal/services/redis/service.go new file mode 100644 index 0000000..d8f1737 --- /dev/null +++ b/internal/services/redis/service.go @@ -0,0 +1,18 @@ +// Package redis provides the redis compose service. +package redis + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "redis", DisplayName: "Redis", Category: services.CategoryCache, + Description: "In-memory key-value store", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "Host port", 6379), + services.PasswordField("password", "Redis password"), + }}, + nil, nil, + )) +} From 7bda23bd0097c9f475384f17878295d0ed39033d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:17:46 +0200 Subject: [PATCH 11/39] feat: add RabbitMQ service integration to homelab-cli Introduced the RabbitMQ service to the homelab-cli, providing an AMQP message broker with a management UI. The service includes configuration options for AMQP and management ports, as well as default user and password settings, enhancing the CLI's capabilities for managing messaging solutions. --- internal/services/rabbitmq/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/services/rabbitmq/service.go diff --git a/internal/services/rabbitmq/service.go b/internal/services/rabbitmq/service.go new file mode 100644 index 0000000..2533912 --- /dev/null +++ b/internal/services/rabbitmq/service.go @@ -0,0 +1,20 @@ +// Package rabbitmq provides the rabbitmq compose service. +package rabbitmq + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "rabbitmq", DisplayName: "RabbitMQ", Category: services.CategoryMessageQueue, + Description: "AMQP message broker with management UI", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "AMQP port", 5672), + services.PortField("mgmt_port", "Management port", 15672), + services.UserField("user", "Default user", "homelab"), + services.PasswordField("password", "Default password"), + }}, + nil, nil, + )) +} From 26002394ecd5c9673a1f983123f997c4c5b3ecf6 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:17:56 +0200 Subject: [PATCH 12/39] feat: add Qdrant service integration to homelab-cli Introduced the Qdrant service to the homelab-cli, providing a vector similarity search engine. The service includes configuration options for HTTP and gRPC ports, as well as an optional API key, enhancing the CLI's capabilities for managing vector search solutions. --- internal/services/qdrant/service.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/services/qdrant/service.go diff --git a/internal/services/qdrant/service.go b/internal/services/qdrant/service.go new file mode 100644 index 0000000..dd1bf6d --- /dev/null +++ b/internal/services/qdrant/service.go @@ -0,0 +1,19 @@ +// Package qdrant provides the qdrant compose service. +package qdrant + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "qdrant", DisplayName: "Qdrant", Category: services.CategoryVector, + Description: "Vector similarity search engine", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "HTTP port", 6333), + services.PortField("grpc_port", "gRPC port", 6334), + {Name: "api_key", Label: "API key (optional)", Type: services.FieldTypePassword, Sensitive: true}, + }}, + nil, nil, + )) +} From aab1a149e7cced94db0f004a18befd384357c9d2 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:18:06 +0200 Subject: [PATCH 13/39] feat: add Prometheus service integration to homelab-cli Introduced the Prometheus service to the homelab-cli, providing metrics collection and alerting capabilities. The service includes configuration options for the HTTP port, enhancing the CLI's observability features. --- internal/services/prometheus/service.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 internal/services/prometheus/service.go diff --git a/internal/services/prometheus/service.go b/internal/services/prometheus/service.go new file mode 100644 index 0000000..393a76f --- /dev/null +++ b/internal/services/prometheus/service.go @@ -0,0 +1,17 @@ +// Package prometheus provides the prometheus compose service. +package prometheus + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "prometheus", DisplayName: "Prometheus", Category: services.CategoryObservability, + Description: "Metrics collection and alerting", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "HTTP port", 9090), + }}, + nil, nil, + )) +} From e9ef4d3961c2a8624e611b713b78fad7a6cc3781 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:18:23 +0200 Subject: [PATCH 14/39] feat: add PostgreSQL service integration to homelab-cli Introduced the PostgreSQL service to the homelab-cli, providing a relational database with support for optional plugins such as pgvector, postgis, and timescaledb. The service includes configuration options for host port, superuser credentials, and a default database, enhancing the CLI's capabilities for managing database solutions. Unit tests have been added to verify service registration and configuration behavior. --- internal/services/postgres/service.go | 46 ++++++++++++++++++++ internal/services/postgres/service_test.go | 50 ++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 internal/services/postgres/service.go create mode 100644 internal/services/postgres/service_test.go diff --git a/internal/services/postgres/service.go b/internal/services/postgres/service.go new file mode 100644 index 0000000..fa83ede --- /dev/null +++ b/internal/services/postgres/service.go @@ -0,0 +1,46 @@ +// Package postgres provides the postgres compose service. +package postgres + +import ( + "github.com/bartrosa/homelab-cli/internal/services" +) + +func init() { + svc := services.NewManagedService( + services.ServiceMeta{ + ID: "postgres", + DisplayName: "PostgreSQL", + Category: services.CategoryDatabase, + Description: "Relational database with optional pgvector, postgis, timescaledb plugins", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "Host port", 5432), + services.UserField("user", "Superuser name", "postgres"), + services.PasswordField("password", "Superuser password"), + { + Name: "database", Label: "Default database", Type: services.FieldTypeString, + Default: "homelab", Required: true, + }, + { + Name: "plugins", Label: "Extensions", Type: services.FieldTypeMultiSelect, + Options: []string{"pgvector", "postgis", "timescaledb"}, + Default: []string{}, + }, + }}, + nil, + map[string]string{"Dockerfile.tmpl": "Dockerfile"}, + ) + svc.TemplateDataFn = templateData + services.Register(svc) +} + +func templateData(values map[string]any, dirs map[string]string) (any, error) { + data, err := services.DefaultTemplateData("postgres", values, dirs) + if err != nil { + return nil, err + } + plugins, _ := values["plugins"].([]string) + data["Plugins"] = plugins + data["BuildImage"] = len(plugins) > 0 + return data, nil +} diff --git a/internal/services/postgres/service_test.go b/internal/services/postgres/service_test.go new file mode 100644 index 0000000..b9657cd --- /dev/null +++ b/internal/services/postgres/service_test.go @@ -0,0 +1,50 @@ +package postgres_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/services" + _ "github.com/bartrosa/homelab-cli/internal/services/postgres" + "github.com/stretchr/testify/require" +) + +func TestPostgresRegistered(t *testing.T) { + svc, ok := services.Lookup("postgres") + require.True(t, ok) + require.Equal(t, "PostgreSQL", svc.DisplayName()) + require.Equal(t, services.CategoryDatabase, svc.Category()) +} + +func TestPostgresSchemaPlugins(t *testing.T) { + svc, ok := services.Lookup("postgres") + require.True(t, ok) + schema := svc.Schema() + var plugins *services.Field + for i := range schema.Fields { + if schema.Fields[i].Name == "plugins" { + plugins = &schema.Fields[i] + break + } + } + require.NotNil(t, plugins) + require.Equal(t, services.FieldTypeMultiSelect, plugins.Type) + require.Equal(t, []string{"pgvector", "postgis", "timescaledb"}, plugins.Options) +} + +func TestPostgresComposeBuildWhenPlugins(t *testing.T) { + out, err := services.Render("postgres", "compose.yml.tmpl", map[string]any{ + "BuildImage": true, + "DataDir": "/tmp/data", + }) + require.NoError(t, err) + require.Contains(t, out, "build: .") +} + +func TestPostgresComposeImageWhenNoPlugins(t *testing.T) { + out, err := services.Render("postgres", "compose.yml.tmpl", map[string]any{ + "BuildImage": false, + "DataDir": "/tmp/data", + }) + require.NoError(t, err) + require.Contains(t, out, "postgres:16.4-alpine") +} From cfd98ecdc18428ad1e7a54f8fa0227b513e110af Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:19:01 +0200 Subject: [PATCH 15/39] feat: add NATS service integration to homelab-cli Introduced the NATS service to the homelab-cli, providing a cloud-native messaging system. The service includes configuration options for client and monitor ports, enhancing the CLI's capabilities for managing messaging solutions. --- internal/services/nats/service.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 internal/services/nats/service.go diff --git a/internal/services/nats/service.go b/internal/services/nats/service.go new file mode 100644 index 0000000..56bd1bb --- /dev/null +++ b/internal/services/nats/service.go @@ -0,0 +1,18 @@ +// Package nats provides the nats compose service. +package nats + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "nats", DisplayName: "NATS", Category: services.CategoryMessageQueue, + Description: "Cloud-native messaging system", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "Client port", 4222), + services.PortField("monitor_port", "Monitor port", 8222), + }}, + nil, nil, + )) +} From c17f491309be1341a56ea07a45f9116ff1cc1007 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:19:15 +0200 Subject: [PATCH 16/39] feat: add MySQL service integration to homelab-cli Introduced the MySQL service to the homelab-cli, providing a MySQL 8 relational database. The service includes configuration options for host port, root user credentials, root password, and a default database, enhancing the CLI's capabilities for managing database solutions. --- internal/services/mysql/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/services/mysql/service.go diff --git a/internal/services/mysql/service.go b/internal/services/mysql/service.go new file mode 100644 index 0000000..95977d7 --- /dev/null +++ b/internal/services/mysql/service.go @@ -0,0 +1,20 @@ +// Package mysql provides the mysql compose service. +package mysql + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "mysql", DisplayName: "MySQL", Category: services.CategoryDatabase, + Description: "MySQL 8 relational database", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "Host port", 3306), + services.UserField("user", "Root user", "root"), + services.PasswordField("password", "Root password"), + {Name: "database", Label: "Default database", Type: services.FieldTypeString, Default: "homelab", Required: true}, + }}, + nil, nil, + )) +} From fbdd36b36e3579649a57cc218fde9cf71d892ee5 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:19:25 +0200 Subject: [PATCH 17/39] feat: add MongoDB service integration to homelab-cli Introduced the MongoDB service to the homelab-cli, providing a document database. The service includes configuration options for host port, root user credentials, and root password, enhancing the CLI's capabilities for managing database solutions. --- internal/services/mongodb/service.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/services/mongodb/service.go diff --git a/internal/services/mongodb/service.go b/internal/services/mongodb/service.go new file mode 100644 index 0000000..0321e12 --- /dev/null +++ b/internal/services/mongodb/service.go @@ -0,0 +1,19 @@ +// Package mongodb provides the mongodb compose service. +package mongodb + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "mongodb", DisplayName: "MongoDB", Category: services.CategoryDatabase, + Description: "Document database", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "Host port", 27017), + services.UserField("user", "Root user", "root"), + services.PasswordField("password", "Root password"), + }}, + nil, nil, + )) +} From 4dd58ea3cf40e97a59729d264d927ddf1502644c Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:19:34 +0200 Subject: [PATCH 18/39] feat: add MinIO service integration to homelab-cli Introduced the MinIO service to the homelab-cli, providing S3-compatible object storage. The service includes configuration options for API and console ports, as well as root user credentials, enhancing the CLI's capabilities for managing storage solutions. --- internal/services/minio/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/services/minio/service.go diff --git a/internal/services/minio/service.go b/internal/services/minio/service.go new file mode 100644 index 0000000..4bd00f0 --- /dev/null +++ b/internal/services/minio/service.go @@ -0,0 +1,20 @@ +// Package minio provides the minio compose service. +package minio + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "minio", DisplayName: "MinIO", Category: services.CategoryStorage, + Description: "S3-compatible object storage", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "API port", 9000), + services.PortField("console_port", "Console port", 9001), + services.UserField("user", "Root user", "minioadmin"), + services.PasswordField("password", "Root password"), + }}, + nil, nil, + )) +} From 6725df0ec6c7d63de9ae5e1a91cff99921b693e8 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:19:44 +0200 Subject: [PATCH 19/39] feat: add Loki service integration to homelab-cli Introduced the Loki service to the homelab-cli, providing a log aggregation system. The service includes configuration options for the HTTP port, enhancing the CLI's observability features. --- internal/services/loki/service.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 internal/services/loki/service.go diff --git a/internal/services/loki/service.go b/internal/services/loki/service.go new file mode 100644 index 0000000..6f67a38 --- /dev/null +++ b/internal/services/loki/service.go @@ -0,0 +1,17 @@ +// Package loki provides the loki compose service. +package loki + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "loki", DisplayName: "Loki", Category: services.CategoryObservability, + Description: "Log aggregation system", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "HTTP port", 3100), + }}, + nil, nil, + )) +} From e63b527159481d6c90aa837fd7613afac30a7791 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:19:52 +0200 Subject: [PATCH 20/39] feat: add Grafana service integration to homelab-cli Introduced the Grafana service to the homelab-cli, providing metrics and logs visualization capabilities. The service includes configuration options for HTTP port, admin user, and password, enhancing the CLI's observability features. --- internal/services/grafana/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 internal/services/grafana/service.go diff --git a/internal/services/grafana/service.go b/internal/services/grafana/service.go new file mode 100644 index 0000000..98f4d70 --- /dev/null +++ b/internal/services/grafana/service.go @@ -0,0 +1,20 @@ +// Package grafana provides the grafana compose service. +package grafana + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "grafana", DisplayName: "Grafana", Category: services.CategoryObservability, + Description: "Metrics and logs visualization", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "HTTP port", 3000), + services.UserField("user", "Admin user", "admin"), + services.PasswordField("password", "Admin password"), + }}, + []string{"prometheus", "loki", "tempo"}, + nil, + )) +} From 62ec8278bbc8244a09551667236859de984731bd Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:20:14 +0200 Subject: [PATCH 21/39] feat: add ClickHouse service integration to homelab-cli Introduced the ClickHouse service to the homelab-cli, providing a column-oriented OLAP database. The service includes configuration options for HTTP port, default user, and password, enhancing the CLI's capabilities for managing database solutions. --- internal/services/clickhouse/service.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/services/clickhouse/service.go diff --git a/internal/services/clickhouse/service.go b/internal/services/clickhouse/service.go new file mode 100644 index 0000000..afb2d3d --- /dev/null +++ b/internal/services/clickhouse/service.go @@ -0,0 +1,19 @@ +// Package clickhouse provides the clickhouse compose service. +package clickhouse + +import "github.com/bartrosa/homelab-cli/internal/services" + +func init() { + services.Register(services.NewManagedService( + services.ServiceMeta{ + ID: "clickhouse", DisplayName: "ClickHouse", Category: services.CategoryDatabase, + Description: "Column-oriented OLAP database", + }, + services.ConfigSchema{Fields: []services.Field{ + services.PortField("port", "HTTP port", 8123), + services.UserField("user", "Default user", "default"), + services.PasswordField("password", "Default password"), + }}, + nil, nil, + )) +} From 2ecb82bdec5e08e9a7661f2034a4bfeb3ad7da3d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:20:47 +0200 Subject: [PATCH 22/39] feat: introduce services management for homelab-cli Added a comprehensive services management system to the homelab-cli, enabling users to manage compose-backed services effectively. This includes the implementation of service registration, orchestration, and configuration handling. Key features include support for service dependencies, initialization, and lifecycle management (up/down). Additionally, various utility functions for handling templates, environment variables, and network management have been introduced, enhancing the CLI's capabilities for managing complex service stacks. --- internal/services/compose.go | 378 ++++++++++++++++++ internal/services/fields.go | 22 + .../services/{stacks.go => homelab_stacks.go} | 0 internal/services/network.go | 29 ++ internal/services/orchestrator.go | 152 +++++++ internal/services/orchestrator_export_test.go | 6 + internal/services/orchestrator_test.go | 29 ++ internal/services/paths.go | 68 ++++ internal/services/presets.go | 101 +++++ internal/services/register.go | 5 + internal/services/registry.go | 52 +++ internal/services/schema.go | 41 ++ internal/services/secrets.go | 65 +++ internal/services/service.go | 65 +++ internal/services/template.go | 65 +++ internal/services/template_data.go | 6 + internal/services/template_test.go | 43 ++ internal/services/tmplfuncs.go | 65 +++ 18 files changed, 1192 insertions(+) create mode 100644 internal/services/compose.go create mode 100644 internal/services/fields.go rename internal/services/{stacks.go => homelab_stacks.go} (100%) create mode 100644 internal/services/network.go create mode 100644 internal/services/orchestrator.go create mode 100644 internal/services/orchestrator_export_test.go create mode 100644 internal/services/orchestrator_test.go create mode 100644 internal/services/paths.go create mode 100644 internal/services/presets.go create mode 100644 internal/services/register.go create mode 100644 internal/services/registry.go create mode 100644 internal/services/schema.go create mode 100644 internal/services/secrets.go create mode 100644 internal/services/service.go create mode 100644 internal/services/template.go create mode 100644 internal/services/template_data.go create mode 100644 internal/services/template_test.go create mode 100644 internal/services/tmplfuncs.go diff --git a/internal/services/compose.go b/internal/services/compose.go new file mode 100644 index 0000000..f018dcd --- /dev/null +++ b/internal/services/compose.go @@ -0,0 +1,378 @@ +package services + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/prompt" +) + +// ComposeRunner runs compose up/down against a project directory. +type ComposeRunner interface { + Up(ctx context.Context, projectDir string) error + Down(ctx context.Context, projectDir string) error + Status(ctx context.Context, projectDir string) (running bool, detail string, err error) +} + +// DetectRuntime picks docker or podman when prefer is auto/empty. +func DetectRuntime(ctx context.Context, r exec.Runner, prefer string) (string, error) { + prefer = strings.ToLower(strings.TrimSpace(prefer)) + switch prefer { + case "", "auto": + if err := r.Run(ctx, "docker", "info"); err == nil { + return "docker", nil + } + if err := r.Run(ctx, "podman", "info"); err == nil { + return "podman", nil + } + return "", fmt.Errorf("no container runtime found (tried docker, podman)") + case "docker", "podman": + return prefer, nil + default: + if strings.HasPrefix(prefer, "podman") { + return "podman", nil + } + if strings.HasPrefix(prefer, "docker") { + return "docker", nil + } + return "", fmt.Errorf("unsupported runtime %q", prefer) + } +} + +// OSComposeRunner implements ComposeRunner via docker/podman compose CLIs. +type OSComposeRunner struct { + Runner exec.Runner + Runtime string +} + +// NewComposeRunner returns a compose runner for the given runtime. +func NewComposeRunner(r exec.Runner, runtime string) *OSComposeRunner { + return &OSComposeRunner{Runner: r, Runtime: runtime} +} + +// Up starts services in projectDir (expects compose.yml). +func (c *OSComposeRunner) Up(ctx context.Context, projectDir string) error { + return c.run(ctx, projectDir, "up", "-d") +} + +// Down stops services in projectDir. +func (c *OSComposeRunner) Down(ctx context.Context, projectDir string) error { + return c.run(ctx, projectDir, "down") +} + +// Status checks whether any service is running. +func (c *OSComposeRunner) Status(ctx context.Context, projectDir string) (bool, string, error) { + out, err := c.runOutput(ctx, projectDir, "ps", "--status", "running", "--format", "{{.Name}}") + if err != nil { + return false, "", err + } + out = strings.TrimSpace(out) + if out == "" { + return false, "stopped", nil + } + lines := strings.Split(out, "\n") + return len(lines) > 0, fmt.Sprintf("%d running", len(lines)), nil +} + +func (c *OSComposeRunner) run(ctx context.Context, dir string, args ...string) error { + name, cargs := composeCommand(c.Runtime, args...) + runner := withWorkDir(c.Runner, dir) + return runner.Run(ctx, name, cargs...) +} + +func (c *OSComposeRunner) runOutput(ctx context.Context, dir string, args ...string) (string, error) { + name, cargs := composeCommand(c.Runtime, args...) + runner := withWorkDir(c.Runner, dir) + return runner.RunWithOutput(ctx, name, cargs...) +} + +func composeCommand(runtime string, args ...string) (string, []string) { + switch runtime { + case "docker": + return "docker", append([]string{"compose", "-f", "compose.yml"}, args...) + default: + return "podman-compose", append([]string{"-f", "compose.yml"}, args...) + } +} + +func withWorkDir(r exec.Runner, dir string) exec.Runner { + if osr, ok := r.(*exec.OSRunner); ok { + cp := *osr + cp.WorkDir = dir + return &cp + } + return &workDirRunner{Runner: r, WorkDir: dir} +} + +type workDirRunner struct { + exec.Runner + WorkDir string +} + +func (w *workDirRunner) Run(ctx context.Context, name string, args ...string) error { + if osr, ok := w.Runner.(*exec.OSRunner); ok { + cp := *osr + cp.WorkDir = w.WorkDir + return cp.Run(ctx, name, args...) + } + return w.Runner.Run(ctx, name, args...) +} + +func (w *workDirRunner) RunWithOutput(ctx context.Context, name string, args ...string) (string, error) { + if osr, ok := w.Runner.(*exec.OSRunner); ok { + cp := *osr + cp.WorkDir = w.WorkDir + return cp.RunWithOutput(ctx, name, args...) + } + return w.Runner.RunWithOutput(ctx, name, args...) +} + +// ManagedService is a compose-backed Service with embedded templates. +type ManagedService struct { + Meta ServiceMeta + SchemaDef ConfigSchema + Deps []string + ExtraTemplates map[string]string // tmpl file -> output file + TemplateDataFn func(values map[string]any, dirs map[string]string) (any, error) +} + +// NewManagedService builds a standard compose service. +func NewManagedService(meta ServiceMeta, schema ConfigSchema, deps []string, extra map[string]string) *ManagedService { + return &ManagedService{ + Meta: meta, + SchemaDef: schema, + Deps: deps, + ExtraTemplates: extra, + } +} + +// ID returns the service identifier. +func (m *ManagedService) ID() string { return m.Meta.ID } + +// DisplayName returns the human-readable service name. +func (m *ManagedService) DisplayName() string { return m.Meta.DisplayName } + +// Category returns the service category. +func (m *ManagedService) Category() Category { return m.Meta.Category } + +// Description returns a short service summary. +func (m *ManagedService) Description() string { return m.Meta.Description } + +// Schema returns the init configuration schema. +func (m *ManagedService) Schema() ConfigSchema { return m.SchemaDef } + +// DependsOn lists services that should start before this one. +func (m *ManagedService) DependsOn() []string { return append([]string(nil), m.Deps...) } + +// Init renders templates and writes service configuration. +func (m *ManagedService) Init(ctx context.Context, opts InitOptions) error { + return initManaged(ctx, m, opts) +} + +// Status reports whether containers are running. +func (m *ManagedService) Status(ctx context.Context, opts InitOptions) (Status, error) { + return statusManaged(ctx, m, opts) +} + +// Up starts the compose stack. +func (m *ManagedService) Up(ctx context.Context, opts InitOptions) error { + return upManaged(ctx, m, opts) +} + +// Down stops the compose stack. +func (m *ManagedService) Down(ctx context.Context, opts InitOptions) error { + return downManaged(ctx, m, opts) +} + +func initManaged(ctx context.Context, m *ManagedService, opts InitOptions) error { + if opts.DryRun { + return nil + } + values, err := collectValues(m.Schema(), opts) + if err != nil { + return err + } + if err := FillSecrets(m.Schema(), values); err != nil { + return err + } + stateDir, err := StateDir(m.ID()) + if err != nil { + return err + } + dataDir, err := DataDir(m.ID()) + if err != nil { + return err + } + cfgDir, err := ConfigDir(m.ID()) + if err != nil { + return err + } + for _, d := range []string{stateDir, dataDir, cfgDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + return err + } + } + dirs := map[string]string{ + "StateDir": stateDir, + "DataDir": dataDir, + "ConfigDir": cfgDir, + } + var data any + if m.TemplateDataFn != nil { + data, err = m.TemplateDataFn(values, dirs) + } else { + data, err = defaultTemplateData(m.ID(), values, dirs) + } + if err != nil { + return err + } + compose, err := Render(m.ID(), "compose.yml.tmpl", data) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(stateDir, "compose.yml"), []byte(compose), 0o644); err != nil { + return err + } + for tmpl, out := range m.ExtraTemplates { + rendered, err := Render(m.ID(), tmpl, data) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(stateDir, out), []byte(rendered), 0o644); err != nil { + return err + } + } + envVars := envFromValues(m.Schema(), values) + envVars["DATA_DIR"] = dataDir + envVars["CONFIG_DIR"] = cfgDir + envVars["STATE_DIR"] = stateDir + if err := WriteEnvFile(filepath.Join(stateDir, ".env"), envVars); err != nil { + return err + } + runtime, err := DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + return EnsureNetwork(ctx, opts.Runner, runtime) +} + +func statusManaged(ctx context.Context, m *ManagedService, opts InitOptions) (Status, error) { + st := Status{ID: m.ID()} + stateDir, err := StateDir(m.ID()) + if err != nil { + return st, err + } + runtime, err := DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return st, err + } + cr := NewComposeRunner(opts.Runner, runtime) + running, detail, err := cr.Status(ctx, stateDir) + st.Running = running + st.Detail = detail + return st, err +} + +func upManaged(ctx context.Context, m *ManagedService, opts InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := StateDir(m.ID()) + if err != nil { + return err + } + runtime, err := DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + if err := EnsureNetwork(ctx, opts.Runner, runtime); err != nil { + return err + } + cr := NewComposeRunner(opts.Runner, runtime) + return cr.Up(ctx, stateDir) +} + +func downManaged(ctx context.Context, m *ManagedService, opts InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := StateDir(m.ID()) + if err != nil { + return err + } + runtime, err := DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + cr := NewComposeRunner(opts.Runner, runtime) + return cr.Down(ctx, stateDir) +} + +func defaultTemplateData(id string, values map[string]any, dirs map[string]string) (map[string]any, error) { + data := map[string]any{ + "ID": id, + "Values": values, + "StateDir": dirs["StateDir"], + "DataDir": dirs["DataDir"], + "ConfigDir": dirs["ConfigDir"], + } + for k, v := range values { + data[k] = v + } + return data, nil +} + +func envFromValues(schema ConfigSchema, values map[string]any) map[string]string { + out := map[string]string{} + for _, f := range schema.Fields { + if v, ok := values[f.Name]; ok { + out[strings.ToUpper(f.Name)] = fmt.Sprint(v) + } + } + return out +} + +func collectValues(schema ConfigSchema, opts InitOptions) (map[string]any, error) { + out := map[string]any{} + for k, v := range opts.Values { + out[k] = v + } + if opts.NonInteractive { + for _, f := range schema.Fields { + if _, ok := out[f.Name]; !ok && f.Default != nil { + out[f.Name] = f.Default + } + } + return out, prompt.ValidateSchema(toPromptSchema(schema), out) + } + if opts.Prompter == nil { + return nil, fmt.Errorf("prompter required for interactive init") + } + asked, err := prompt.AskAll(opts.Prompter, toPromptSchema(schema), out) + if err != nil { + return nil, err + } + for k, v := range asked { + out[k] = v + } + return out, nil +} + +func toPromptSchema(s ConfigSchema) prompt.Schema { + fields := make([]prompt.Field, len(s.Fields)) + for i, f := range s.Fields { + fields[i] = prompt.Field{ + Name: f.Name, + Label: f.Label, + Type: prompt.FieldType(f.Type), + Default: f.Default, + Required: f.Required, + Options: f.Options, + } + } + return prompt.Schema{Fields: fields} +} diff --git a/internal/services/fields.go b/internal/services/fields.go new file mode 100644 index 0000000..b27b195 --- /dev/null +++ b/internal/services/fields.go @@ -0,0 +1,22 @@ +package services + +// PortField is a required integer port prompt field. +func PortField(name, label string, def int) Field { + return Field{ + Name: name, Label: label, Type: FieldTypeInt, Default: def, Required: true, + } +} + +// PasswordField is a required sensitive password prompt field. +func PasswordField(name, label string) Field { + return Field{ + Name: name, Label: label, Type: FieldTypePassword, Required: true, Sensitive: true, + } +} + +// UserField is a required string user prompt field. +func UserField(name, label, def string) Field { + return Field{ + Name: name, Label: label, Type: FieldTypeString, Default: def, Required: true, + } +} diff --git a/internal/services/stacks.go b/internal/services/homelab_stacks.go similarity index 100% rename from internal/services/stacks.go rename to internal/services/homelab_stacks.go diff --git a/internal/services/network.go b/internal/services/network.go new file mode 100644 index 0000000..9118357 --- /dev/null +++ b/internal/services/network.go @@ -0,0 +1,29 @@ +package services + +import ( + "context" + "fmt" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// NetworkName is the shared Docker/Podman network for homelab services. +const NetworkName = "homelab-net" + +// EnsureNetwork creates the shared homelab compose network if missing. +func EnsureNetwork(ctx context.Context, r exec.Runner, runtime string) error { + switch runtime { + case "docker": + if err := r.Run(ctx, "docker", "network", "inspect", NetworkName); err != nil { + return r.Run(ctx, "docker", "network", "create", NetworkName) + } + return nil + case "podman": + if err := r.Run(ctx, "podman", "network", "inspect", NetworkName); err != nil { + return r.Run(ctx, "podman", "network", "create", NetworkName) + } + return nil + default: + return fmt.Errorf("unsupported runtime %q for network", runtime) + } +} diff --git a/internal/services/orchestrator.go b/internal/services/orchestrator.go new file mode 100644 index 0000000..a72c386 --- /dev/null +++ b/internal/services/orchestrator.go @@ -0,0 +1,152 @@ +package services + +import ( + "context" + "fmt" + "strings" +) + +// Orchestrator coordinates init/up/down across services with dependency ordering. +type Orchestrator struct { + CustomPresets map[string][]string +} + +// Init initializes one or more services or presets. +func (o *Orchestrator) Init(ctx context.Context, opts InitOptions, names ...string) error { + ids, err := o.expand(names...) + if err != nil { + return err + } + order, err := resolveServiceOrder(ids) + if err != nil { + return err + } + for _, id := range order { + s, ok := Lookup(id) + if !ok { + return fmtUnknownService(id) + } + if err := s.Init(ctx, opts); err != nil { + return fmt.Errorf("%s: init: %w", id, err) + } + } + return nil +} + +// Up starts services in dependency order (grafana last among observability). +func (o *Orchestrator) Up(ctx context.Context, opts InitOptions, names ...string) error { + ids, err := o.expand(names...) + if err != nil { + return err + } + order, err := resolveServiceOrder(ids) + if err != nil { + return err + } + for _, id := range order { + s, ok := Lookup(id) + if !ok { + return fmtUnknownService(id) + } + if err := s.Up(ctx, opts); err != nil { + return fmt.Errorf("%s: up: %w", id, err) + } + } + return nil +} + +// Down stops services in reverse dependency order. +func (o *Orchestrator) Down(ctx context.Context, opts InitOptions, names ...string) error { + ids, err := o.expand(names...) + if err != nil { + return err + } + order, err := resolveServiceOrder(ids) + if err != nil { + return err + } + for i := len(order) - 1; i >= 0; i-- { + id := order[i] + s, ok := Lookup(id) + if !ok { + return fmtUnknownService(id) + } + if err := s.Down(ctx, opts); err != nil { + return fmt.Errorf("%s: down: %w", id, err) + } + } + return nil +} + +func (o *Orchestrator) expand(names ...string) ([]string, error) { + return ExpandNames(names, o.CustomPresets) +} + +func resolveServiceOrder(ids []string) ([]string, error) { + seen := map[string]struct{}{} + var order []string + var visit func(string) error + visit = func(id string) error { + if _, ok := seen[id]; ok { + return nil + } + s, ok := Lookup(id) + if !ok { + return fmtUnknownService(id) + } + deps := s.DependsOn() + // Grafana starts after other observability backends when present. + if id == "grafana" { + deps = appendUnique(deps, "prometheus", "loki", "tempo") + } + for _, dep := range deps { + dep = strings.TrimSpace(dep) + if dep == "" { + continue + } + if contains(ids, dep) || serviceRegistered(dep) { + if err := visit(dep); err != nil { + return err + } + } + } + seen[id] = struct{}{} + order = append(order, id) + return nil + } + for _, id := range ids { + if err := visit(id); err != nil { + return nil, err + } + } + return order, nil +} + +func serviceRegistered(id string) bool { + _, ok := Lookup(id) + return ok +} + +func contains(list []string, item string) bool { + for _, v := range list { + if v == item { + return true + } + } + return false +} + +func appendUnique(base []string, items ...string) []string { + seen := map[string]struct{}{} + for _, b := range base { + seen[b] = struct{}{} + } + for _, item := range items { + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + base = append(base, item) + } + return base +} diff --git a/internal/services/orchestrator_export_test.go b/internal/services/orchestrator_export_test.go new file mode 100644 index 0000000..2a2c0dd --- /dev/null +++ b/internal/services/orchestrator_export_test.go @@ -0,0 +1,6 @@ +package services + +// ResolveServiceOrderForTest exposes dependency ordering for tests. +func ResolveServiceOrderForTest(ids []string) ([]string, error) { + return resolveServiceOrder(ids) +} diff --git a/internal/services/orchestrator_test.go b/internal/services/orchestrator_test.go new file mode 100644 index 0000000..fe436f3 --- /dev/null +++ b/internal/services/orchestrator_test.go @@ -0,0 +1,29 @@ +package services_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/services" + _ "github.com/bartrosa/homelab-cli/internal/services/register" + "github.com/stretchr/testify/require" +) + +func TestExpandNames_preset(t *testing.T) { + ids, err := services.ExpandNames([]string{"observability"}, nil) + require.NoError(t, err) + require.Equal(t, []string{"prometheus", "loki", "tempo", "grafana"}, ids) +} + +func TestResolveServiceOrder_grafanaLast(t *testing.T) { + order, err := services.ResolveServiceOrderForTest([]string{"grafana", "prometheus", "loki", "tempo"}) + require.NoError(t, err) + require.Equal(t, []string{"prometheus", "loki", "tempo", "grafana"}, order) +} + +func TestResolvePreset_mlStack(t *testing.T) { + ids, err := services.ResolvePreset("ml-stack", nil) + require.NoError(t, err) + require.Contains(t, ids, "postgres") + require.Contains(t, ids, "clickhouse") + require.Contains(t, ids, "qdrant") +} diff --git a/internal/services/paths.go b/internal/services/paths.go new file mode 100644 index 0000000..3cc038f --- /dev/null +++ b/internal/services/paths.go @@ -0,0 +1,68 @@ +package services + +import ( + "os" + "path/filepath" +) + +const appName = "homelab-cli" + +// ConfigDir returns XDG config dir for a service id. +func ConfigDir(id string) (string, error) { + base, err := xdgConfigHome() + if err != nil { + return "", err + } + return filepath.Join(base, appName, "services", id), nil +} + +// DataDir returns XDG data dir for a service id. +func DataDir(id string) (string, error) { + base, err := xdgDataHome() + if err != nil { + return "", err + } + return filepath.Join(base, appName, "services", id), nil +} + +// StateDir returns XDG state dir for a service id (generated compose, .env). +func StateDir(id string) (string, error) { + base, err := xdgStateHome() + if err != nil { + return "", err + } + return filepath.Join(base, appName, "services", id), nil +} + +func xdgConfigHome() (string, error) { + if v := os.Getenv("XDG_CONFIG_HOME"); v != "" { + return v, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config"), nil +} + +func xdgDataHome() (string, error) { + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + return v, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share"), nil +} + +func xdgStateHome() (string, error) { + if v := os.Getenv("XDG_STATE_HOME"); v != "" { + return v, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "state"), nil +} diff --git a/internal/services/presets.go b/internal/services/presets.go new file mode 100644 index 0000000..64439df --- /dev/null +++ b/internal/services/presets.go @@ -0,0 +1,101 @@ +package services + +import "sort" + +// DefaultPresets are built-in service bundles. +var DefaultPresets = map[string][]string{ + "observability": {"prometheus", "loki", "tempo", "grafana"}, + "ml-stack": {"postgres", "qdrant", "minio", "clickhouse"}, + "data-lakehouse": {"postgres", "clickhouse", "minio"}, + "microservices": {"postgres", "redis", "rabbitmq"}, + "vector-search": {"qdrant", "weaviate"}, + "full-obs": {"prometheus", "grafana", "loki", "tempo", "minio"}, +} + +// PresetNames returns sorted preset keys. +func PresetNames(custom map[string][]string) []string { + merged := MergePresets(custom) + names := make([]string, 0, len(merged)) + for k := range merged { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// MergePresets overlays custom presets on defaults. +func MergePresets(custom map[string][]string) map[string][]string { + out := make(map[string][]string, len(DefaultPresets)+len(custom)) + for k, v := range DefaultPresets { + out[k] = append([]string(nil), v...) + } + for k, v := range custom { + out[k] = append([]string(nil), v...) + } + return out +} + +// ResolvePreset returns service ids for a preset name. +func ResolvePreset(name string, custom map[string][]string) ([]string, error) { + merged := MergePresets(custom) + ids, ok := merged[name] + if !ok { + return nil, &PresetError{Name: name} + } + return append([]string(nil), ids...), nil +} + +// PresetError indicates an unknown preset. +type PresetError struct{ Name string } + +func (e *PresetError) Error() string { return "unknown service preset " + e.Name } + +// ExpandNames resolves preset names to service ids; passthrough for raw ids. +func ExpandNames(names []string, custom map[string][]string) ([]string, error) { + var out []string + seen := map[string]struct{}{} + for _, name := range names { + name = trim(name) + if name == "" { + continue + } + if ids, err := ResolvePreset(name, custom); err == nil { + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + continue + } + if _, ok := Lookup(name); !ok { + return nil, fmtUnknownService(name) + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out, nil +} + +func trim(s string) string { + for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { + s = s[1:] + } + for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { + s = s[:len(s)-1] + } + return s +} + +func fmtUnknownService(name string) error { + return &UnknownServiceError{Name: name} +} + +// UnknownServiceError indicates an unknown service id. +type UnknownServiceError struct{ Name string } + +func (e *UnknownServiceError) Error() string { return "unknown service " + e.Name } diff --git a/internal/services/register.go b/internal/services/register.go new file mode 100644 index 0000000..b0cb95d --- /dev/null +++ b/internal/services/register.go @@ -0,0 +1,5 @@ +package services + +// Bundled compose services register via side-effect import: +// +// import _ "github.com/bartrosa/homelab-cli/internal/services/register" diff --git a/internal/services/registry.go b/internal/services/registry.go new file mode 100644 index 0000000..eb15116 --- /dev/null +++ b/internal/services/registry.go @@ -0,0 +1,52 @@ +package services + +import ( + "sort" + "sync" +) + +var ( + regMu sync.RWMutex + reg = map[string]Service{} +) + +// Register adds a service to the global registry. +func Register(s Service) { + if s == nil { + return + } + regMu.Lock() + defer regMu.Unlock() + reg[s.ID()] = s +} + +// Lookup returns a service by id. +func Lookup(id string) (Service, bool) { + regMu.RLock() + defer regMu.RUnlock() + s, ok := reg[id] + return s, ok +} + +// All returns registered services sorted by id. +func All() []Service { + regMu.RLock() + defer regMu.RUnlock() + out := make([]Service, 0, len(reg)) + for _, s := range reg { + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID() < out[j].ID() }) + return out +} + +// ByCategory returns services in a category. +func ByCategory(cat Category) []Service { + var out []Service + for _, s := range All() { + if s.Category() == cat { + out = append(out, s) + } + } + return out +} diff --git a/internal/services/schema.go b/internal/services/schema.go new file mode 100644 index 0000000..300302b --- /dev/null +++ b/internal/services/schema.go @@ -0,0 +1,41 @@ +package services + +// FieldType describes a config field kind. +type FieldType string + +// Field types supported by service config schemas. +const ( + FieldTypeString FieldType = "string" + FieldTypeInt FieldType = "int" + FieldTypeBool FieldType = "bool" + FieldTypePassword FieldType = "password" + FieldTypeSelect FieldType = "select" + FieldTypeMultiSelect FieldType = "multiselect" +) + +// Field is one user-configurable service setting. +type Field struct { + Name string + Label string + Type FieldType + Default any + Required bool + Options []string + Sensitive bool + Description string +} + +// ConfigSchema describes interactive init fields for a service. +type ConfigSchema struct { + Fields []Field +} + +// FieldByName returns a field definition or nil. +func (s ConfigSchema) FieldByName(name string) *Field { + for i := range s.Fields { + if s.Fields[i].Name == name { + return &s.Fields[i] + } + } + return nil +} diff --git a/internal/services/secrets.go b/internal/services/secrets.go new file mode 100644 index 0000000..0c459a9 --- /dev/null +++ b/internal/services/secrets.go @@ -0,0 +1,65 @@ +package services + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strings" +) + +// RandomPassword returns a URL-safe random password. +func RandomPassword(n int) (string, error) { + if n <= 0 { + n = 24 + } + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("random password: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buf)[:n], nil +} + +// WriteEnvFile writes key=value lines and chmod 600. +func WriteEnvFile(path string, vars map[string]string) error { + var b strings.Builder + for k, v := range vars { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(v) + b.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + return err + } + return os.Chmod(path, 0o600) +} + +// MaskSensitive replaces sensitive substrings for display. +func MaskSensitive(s string) string { + if len(s) <= 4 { + return "****" + } + return s[:2] + strings.Repeat("*", len(s)-4) + s[len(s)-2:] +} + +// FillSecrets generates random values for empty password fields. +func FillSecrets(schema ConfigSchema, values map[string]any) error { + for _, f := range schema.Fields { + if f.Type != FieldTypePassword && !f.Sensitive { + continue + } + cur, ok := values[f.Name] + if ok { + if s, ok := cur.(string); ok && s != "" { + continue + } + } + pw, err := RandomPassword(24) + if err != nil { + return err + } + values[f.Name] = pw + } + return nil +} diff --git a/internal/services/service.go b/internal/services/service.go new file mode 100644 index 0000000..fdc7d2f --- /dev/null +++ b/internal/services/service.go @@ -0,0 +1,65 @@ +// Package services manages local compose-backed homelab data services. +package services + +import ( + "context" + "io" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/prompt" +) + +// Category groups homelab services. +type Category string + +// Service categories group compose-backed homelab services. +const ( + CategoryDatabase Category = "database" + CategoryCache Category = "cache" + CategoryMessageQueue Category = "message-queue" + CategoryVector Category = "vector" + CategoryObservability Category = "observability" + CategoryStorage Category = "storage" +) + +// Service is a provisionable compose-backed homelab service. +type Service interface { + ID() string + DisplayName() string + Category() Category + Description() string + Schema() ConfigSchema + DependsOn() []string + Init(ctx context.Context, opts InitOptions) error + Status(ctx context.Context, opts InitOptions) (Status, error) + Up(ctx context.Context, opts InitOptions) error + Down(ctx context.Context, opts InitOptions) error +} + +// InitOptions configures service lifecycle operations. +type InitOptions struct { + Runner exec.Runner + Stdout io.Writer + Stderr io.Writer + Runtime string // auto, docker, podman + DryRun bool + Force bool + NonInteractive bool + Values map[string]any + Prompter prompt.Prompter +} + +// Status describes runtime state for a service. +type Status struct { + ID string + Running bool + Detail string +} + +// ServiceMeta holds static metadata for a managed compose service. +type ServiceMeta struct { + ID string + DisplayName string + Category Category + Description string +} diff --git a/internal/services/template.go b/internal/services/template.go new file mode 100644 index 0000000..f338e4c --- /dev/null +++ b/internal/services/template.go @@ -0,0 +1,65 @@ +package services + +import ( + "bytes" + "embed" + "fmt" + "path" + "strings" + "text/template" +) + +//go:embed templates/services/** +var serviceTemplates embed.FS + +// Render executes a service template with data. +func Render(serviceID, tmplName string, data any) (string, error) { + tmplPath := path.Join("templates", "services", serviceID, tmplName) + raw, err := serviceTemplates.ReadFile(tmplPath) + if err != nil { + return "", fmt.Errorf("read template %s: %w", tmplPath, err) + } + tmpl, err := template.New(tmplName).Funcs(templateFuncs()).Parse(string(raw)) + if err != nil { + return "", fmt.Errorf("parse template %s: %w", tmplPath, err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("execute template %s: %w", tmplPath, err) + } + return strings.TrimSpace(buf.String()) + "\n", nil +} + +// ListTemplates returns embedded template names for a service. +func ListTemplates(serviceID string) ([]string, error) { + prefix := path.Join("templates", "services", serviceID) + var names []string + err := walkEmbed(serviceTemplates, prefix, func(name string) error { + base := strings.TrimPrefix(name, prefix+"/") + if base != "" && !strings.Contains(base, "/") { + names = append(names, base) + } + return nil + }) + return names, err +} + +func walkEmbed(fsys embed.FS, dir string, fn func(string) error) error { + entries, err := fsys.ReadDir(dir) + if err != nil { + return err + } + for _, e := range entries { + full := path.Join(dir, e.Name()) + if e.IsDir() { + if err := walkEmbed(fsys, full, fn); err != nil { + return err + } + continue + } + if err := fn(full); err != nil { + return err + } + } + return nil +} diff --git a/internal/services/template_data.go b/internal/services/template_data.go new file mode 100644 index 0000000..e2366a6 --- /dev/null +++ b/internal/services/template_data.go @@ -0,0 +1,6 @@ +package services + +// DefaultTemplateData builds the standard template context for a service. +func DefaultTemplateData(id string, values map[string]any, dirs map[string]string) (map[string]any, error) { + return defaultTemplateData(id, values, dirs) +} diff --git a/internal/services/template_test.go b/internal/services/template_test.go new file mode 100644 index 0000000..c7b1a94 --- /dev/null +++ b/internal/services/template_test.go @@ -0,0 +1,43 @@ +package services_test + +import ( + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/services" + "github.com/stretchr/testify/require" +) + +func TestRender_postgres_compose(t *testing.T) { + out, err := services.Render("postgres", "compose.yml.tmpl", map[string]any{ + "BuildImage": false, + "DataDir": "/tmp/pg", + }) + require.NoError(t, err) + require.Contains(t, out, "postgres:16.4-alpine") + require.Contains(t, out, "homelab-net") + require.Contains(t, out, "external: true") +} + +func TestRender_postgres_dockerfile_plugins(t *testing.T) { + out, err := services.Render("postgres", "Dockerfile.tmpl", map[string]any{ + "Plugins": []string{"pgvector", "postgis"}, + }) + require.NoError(t, err) + require.Contains(t, out, "postgresql-pgvector") + require.Contains(t, out, "postgis") + require.NotContains(t, out, "timescaledb-toolkit") +} + +func TestTemplateFuncs_hasJoinDefault(t *testing.T) { + out, err := services.Render("postgres", "Dockerfile.tmpl", map[string]any{ + "Plugins": []string{"pgvector"}, + }) + require.NoError(t, err) + require.True(t, strings.Contains(out, "pgvector")) +} + +func TestMaskSensitive(t *testing.T) { + require.Equal(t, "****", services.MaskSensitive("ab")) + require.Equal(t, "se**et", services.MaskSensitive("secret")) +} diff --git a/internal/services/tmplfuncs.go b/internal/services/tmplfuncs.go new file mode 100644 index 0000000..306deb9 --- /dev/null +++ b/internal/services/tmplfuncs.go @@ -0,0 +1,65 @@ +package services + +import ( + "strings" + "text/template" +) + +// templateFuncs returns default template helpers (no sprig). +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "default": func(def, val any) any { + if val == nil { + return def + } + switch v := val.(type) { + case string: + if v == "" { + return def + } + case []string: + if len(v) == 0 { + return def + } + } + return val + }, + "has": func(needle string, hay any) bool { + switch h := hay.(type) { + case []string: + for _, item := range h { + if item == needle { + return true + } + } + case string: + return h == needle + } + return false + }, + "join": func(sep string, items any) string { + switch v := items.(type) { + case []string: + return strings.Join(v, sep) + case []any: + var ss []string + for _, item := range v { + ss = append(ss, toString(item)) + } + return strings.Join(ss, sep) + default: + return toString(items) + } + }, + } +} + +func toString(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return "" +} From 4d0a00dead6af2f8956322778c72d2018991e4be Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:20:59 +0200 Subject: [PATCH 23/39] feat: implement interactive prompting system for service initialization Added a new package for interactive stdin prompting, enabling users to input configuration values during service initialization. This includes a Prompter interface with methods for string, password, boolean, and selection inputs. Additionally, a schema validation function ensures required fields are present for non-interactive initialization. Unit tests have been included to verify the functionality of the prompting methods and schema validation. --- internal/prompt/prompt.go | 161 +++++++++++++++++++++++++++++++ internal/prompt/stdin.go | 175 ++++++++++++++++++++++++++++++++++ internal/prompt/stdin_test.go | 25 +++++ 3 files changed, 361 insertions(+) create mode 100644 internal/prompt/prompt.go create mode 100644 internal/prompt/stdin.go create mode 100644 internal/prompt/stdin_test.go diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..ef856b6 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,161 @@ +// Package prompt provides interactive stdin prompting for service init. +package prompt + +import ( + "fmt" +) + +// Prompter collects user input during service initialization. +type Prompter interface { + AskString(label, defaultValue string) (string, error) + AskPassword(label string) (string, error) + AskBool(label string, defaultValue bool) (bool, error) + AskSelect(label string, options []string, defaultIndex int) (int, error) + AskMultiSelect(label string, options []string, defaultIndexes []int) ([]int, error) +} + +// AskAll prompts for every field in schema not already present in existing. +func AskAll(p Prompter, schema Schema, existing map[string]any) (map[string]any, error) { + out := map[string]any{} + for _, f := range schema.Fields { + if v, ok := existing[f.Name]; ok && !isEmpty(v) { + out[f.Name] = v + continue + } + val, err := askField(p, f) + if err != nil { + return nil, err + } + out[f.Name] = val + } + return out, nil +} + +// ValidateSchema ensures required fields are present for non-interactive init. +func ValidateSchema(schema Schema, values map[string]any) error { + for _, f := range schema.Fields { + if !f.Required { + continue + } + v, ok := values[f.Name] + if !ok || isEmpty(v) { + return fmt.Errorf("missing required field %q", f.Name) + } + } + return nil +} + +func askField(p Prompter, f Field) (any, error) { + label := f.Label + if label == "" { + label = f.Name + } + switch f.Type { + case FieldTypeString: + def := stringDefault(f.Default) + return p.AskString(label, def) + case FieldTypePassword: + return p.AskPassword(label) + case FieldTypeInt: + def := stringDefault(f.Default) + s, err := p.AskString(label, def) + if err != nil { + return nil, err + } + var n int + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + return nil, fmt.Errorf("%s: invalid integer %q", f.Name, s) + } + return n, nil + case FieldTypeBool: + def := false + if b, ok := f.Default.(bool); ok { + def = b + } + return p.AskBool(label, def) + case FieldTypeSelect: + idx := 0 + if n, ok := f.Default.(int); ok { + idx = n + } + return p.AskSelect(label, f.Options, idx) + case FieldTypeMultiSelect: + defs := []int{} + if ss, ok := f.Default.([]string); ok { + for _, s := range ss { + for i, opt := range f.Options { + if opt == s { + defs = append(defs, i) + } + } + } + } + idxs, err := p.AskMultiSelect(label, f.Options, defs) + if err != nil { + return nil, err + } + var selected []string + for _, i := range idxs { + if i >= 0 && i < len(f.Options) { + selected = append(selected, f.Options[i]) + } + } + return selected, nil + default: + def := stringDefault(f.Default) + return p.AskString(label, def) + } +} + +func stringDefault(v any) string { + if v == nil { + return "" + } + return fmt.Sprint(v) +} + +func isEmpty(v any) bool { + if v == nil { + return true + } + switch t := v.(type) { + case string: + return t == "" + case []string: + return len(t) == 0 + case int: + return false + case bool: + return false + default: + return false + } +} + +// Field mirrors services schema for prompt without import cycle. +type Field struct { + Name string + Label string + Type FieldType + Default any + Required bool + Options []string +} + +// FieldType describes a config field kind. +type FieldType string + +// Field types supported by the prompt engine. +const ( + FieldTypeString FieldType = "string" + FieldTypeInt FieldType = "int" + FieldTypeBool FieldType = "bool" + FieldTypePassword FieldType = "password" + FieldTypeSelect FieldType = "select" + FieldTypeMultiSelect FieldType = "multiselect" +) + +// Schema is a list of prompt fields. +type Schema struct { + Fields []Field +} diff --git a/internal/prompt/stdin.go b/internal/prompt/stdin.go new file mode 100644 index 0000000..f50f68b --- /dev/null +++ b/internal/prompt/stdin.go @@ -0,0 +1,175 @@ +package prompt + +import ( + "bufio" + "fmt" + "io" + "os" + "strconv" + "strings" + + "golang.org/x/term" +) + +// StdinPrompter reads prompts from stdin with optional default display. +type StdinPrompter struct { + In io.Reader + Out io.Writer +} + +// NewStdinPrompter returns a prompter using os.Stdin/os.Stdout. +func NewStdinPrompter() *StdinPrompter { + return &StdinPrompter{In: os.Stdin, Out: os.Stdout} +} + +// AskString prompts for a line of text. +func (p *StdinPrompter) AskString(label, defaultValue string) (string, error) { + if defaultValue != "" { + fmt.Fprintf(p.Out, "%s [%s]: ", label, defaultValue) + } else { + fmt.Fprintf(p.Out, "%s: ", label) + } + line, err := p.readLine() + if err != nil { + return "", err + } + line = strings.TrimSpace(line) + if line == "" { + return defaultValue, nil + } + return line, nil +} + +// AskPassword prompts for a hidden password. +func (p *StdinPrompter) AskPassword(label string) (string, error) { + fmt.Fprintf(p.Out, "%s: ", label) + if f, ok := p.In.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + b, err := term.ReadPassword(int(f.Fd())) + fmt.Fprintln(p.Out) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil + } + line, err := p.readLine() + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil +} + +// AskBool prompts for yes/no. +func (p *StdinPrompter) AskBool(label string, defaultValue bool) (bool, error) { + if defaultValue { + fmt.Fprintf(p.Out, "%s [Y/n]: ", label) + } else { + fmt.Fprintf(p.Out, "%s [y/N]: ", label) + } + line, err := p.readLine() + if err != nil { + return false, err + } + line = strings.ToLower(strings.TrimSpace(line)) + if line == "" { + return defaultValue, nil + } + return line == "y" || line == "yes", nil +} + +// AskSelect prompts for a numbered option. +func (p *StdinPrompter) AskSelect(label string, options []string, defaultIndex int) (int, error) { + if len(options) == 0 { + return 0, fmt.Errorf("no options for %s", label) + } + fmt.Fprintf(p.Out, "%s\n", label) + for i, opt := range options { + marker := " " + if i == defaultIndex { + marker = "*" + } + fmt.Fprintf(p.Out, " %s %d) %s\n", marker, i+1, opt) + } + fmt.Fprintf(p.Out, "choice [%d]: ", defaultIndex+1) + line, err := p.readLine() + if err != nil { + return 0, err + } + line = strings.TrimSpace(line) + if line == "" { + return defaultIndex, nil + } + n, err := strconv.Atoi(line) + if err != nil || n < 1 || n > len(options) { + return 0, fmt.Errorf("invalid choice %q", line) + } + return n - 1, nil +} + +// AskMultiSelect prompts for comma-separated option numbers. +func (p *StdinPrompter) AskMultiSelect(label string, options []string, defaultIndexes []int) ([]int, error) { + if len(options) == 0 { + return nil, fmt.Errorf("no options for %s", label) + } + defSet := map[int]struct{}{} + for _, i := range defaultIndexes { + defSet[i] = struct{}{} + } + fmt.Fprintf(p.Out, "%s (comma-separated numbers, empty for none)\n", label) + for i, opt := range options { + marker := " " + if _, ok := defSet[i]; ok { + marker = "*" + } + fmt.Fprintf(p.Out, " %s %d) %s\n", marker, i+1, opt) + } + defStr := formatDefaultIndexes(defaultIndexes) + if defStr != "" { + fmt.Fprintf(p.Out, "choices [%s]: ", defStr) + } else { + fmt.Fprintf(p.Out, "choices: ") + } + line, err := p.readLine() + if err != nil { + return nil, err + } + line = strings.TrimSpace(line) + if line == "" { + return append([]int(nil), defaultIndexes...), nil + } + parts := strings.Split(line, ",") + var idxs []int + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.Atoi(part) + if err != nil || n < 1 || n > len(options) { + return nil, fmt.Errorf("invalid choice %q", part) + } + idxs = append(idxs, n-1) + } + return idxs, nil +} + +func (p *StdinPrompter) readLine() (string, error) { + sc := bufio.NewScanner(p.In) + if !sc.Scan() { + if err := sc.Err(); err != nil { + return "", err + } + return "", io.EOF + } + return sc.Text(), nil +} + +func formatDefaultIndexes(idxs []int) string { + if len(idxs) == 0 { + return "" + } + var parts []string + for _, i := range idxs { + parts = append(parts, strconv.Itoa(i+1)) + } + return strings.Join(parts, ",") +} diff --git a/internal/prompt/stdin_test.go b/internal/prompt/stdin_test.go new file mode 100644 index 0000000..7b376cd --- /dev/null +++ b/internal/prompt/stdin_test.go @@ -0,0 +1,25 @@ +package prompt_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/prompt" + "github.com/stretchr/testify/require" +) + +func TestAskString_default(t *testing.T) { + in := strings.NewReader("\n") + out := &bytes.Buffer{} + p := &prompt.StdinPrompter{In: in, Out: out} + val, err := p.AskString("Port", "5432") + require.NoError(t, err) + require.Equal(t, "5432", val) +} + +func TestValidateSchema_required(t *testing.T) { + schema := prompt.Schema{Fields: []prompt.Field{{Name: "port", Required: true, Type: prompt.FieldTypeInt}}} + err := prompt.ValidateSchema(schema, map[string]any{}) + require.Error(t, err) +} From 443442cbf67507eb0f03ce6664dd31a08025f542 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:21:08 +0200 Subject: [PATCH 24/39] feat: update configuration structure for services and stack management Enhanced the configuration model by introducing a new StackConfig type and additional fields in ServicesConfig, including Network, Instances, and Presets. Updated default values for services to use "auto" for Runtime and added necessary defaults for new fields. Adjusted tests to reflect the changes in configuration structure. --- internal/config/config.go | 48 +++++++++++++++++++++++++++++++--- internal/config/config_test.go | 2 +- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index dfbb4f3..4ff4858 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,7 @@ type Config struct { Server ServerConfig `mapstructure:"server"` Cluster ClusterConfig `mapstructure:"cluster"` Storage StorageConfig `mapstructure:"storage"` + Stack StackConfig `mapstructure:"stack"` } // HomelabConfig points at the personal homelab repo for scripts and compose stacks. @@ -98,8 +99,24 @@ type RepoProvider struct { // ServicesConfig describes local compose stacks and runtime. type ServicesConfig struct { - StacksDir string `mapstructure:"stacks_dir"` - Runtime string `mapstructure:"runtime"` + StacksDir string `mapstructure:"stacks_dir"` + Runtime string `mapstructure:"runtime"` + Network string `mapstructure:"network"` + Instances map[string]map[string]any `mapstructure:"instances"` + Presets map[string][]string `mapstructure:"presets"` +} + +// StackConfig describes developer stack presets and component overrides. +type StackConfig struct { + DefaultPreset string `mapstructure:"default_preset"` + Presets map[string][]string `mapstructure:"presets"` + Components map[string]ComponentOverride `mapstructure:"components"` +} + +// ComponentOverride pins a stack component version or enabled flag. +type ComponentOverride struct { + Version string `mapstructure:"version"` + Enabled *bool `mapstructure:"enabled"` } // ClusterConfig describes Kubernetes client defaults. @@ -133,7 +150,15 @@ func Default() *Config { }, Services: ServicesConfig{ StacksDir: "~/.config/homelab-cli/stacks", - Runtime: "podman", + Runtime: "auto", + Network: "homelab-net", + Instances: map[string]map[string]any{}, + Presets: map[string][]string{}, + }, + Stack: StackConfig{ + DefaultPreset: "basic", + Presets: map[string][]string{}, + Components: map[string]ComponentOverride{}, }, Cluster: ClusterConfig{ Kubeconfig: defaultKubeconfigPath(), @@ -210,6 +235,8 @@ func bindDefaults(v *viper.Viper) { v.SetDefault("repos.backup_dir", d.Repos.BackupDir) v.SetDefault("services.stacks_dir", d.Services.StacksDir) v.SetDefault("services.runtime", d.Services.Runtime) + v.SetDefault("services.network", d.Services.Network) + v.SetDefault("stack.default_preset", d.Stack.DefaultPreset) v.SetDefault("cluster.kubeconfig", d.Cluster.Kubeconfig) v.SetDefault("cluster.context", d.Cluster.Context) v.SetDefault("storage.endpoint", d.Storage.Endpoint) @@ -226,6 +253,21 @@ func normalize(c *Config) { if c.Services.Runtime == "" { c.Services.Runtime = Default().Services.Runtime } + if c.Services.Network == "" { + c.Services.Network = Default().Services.Network + } + if c.Services.Instances == nil { + c.Services.Instances = map[string]map[string]any{} + } + if c.Services.Presets == nil { + c.Services.Presets = map[string][]string{} + } + if c.Stack.Presets == nil { + c.Stack.Presets = map[string][]string{} + } + if c.Stack.Components == nil { + c.Stack.Components = map[string]ComponentOverride{} + } if c.Cluster.Kubeconfig == "" { c.Cluster.Kubeconfig = Default().Cluster.Kubeconfig } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7266b89..87cf2d5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,7 +19,7 @@ func TestLoad_defaultsWhenFileMissing(t *testing.T) { require.NoError(t, err) require.Equal(t, "info", cfg.LogLevel) require.Equal(t, "text", cfg.LogFormat) - require.Equal(t, "podman", cfg.Services.Runtime) + require.Equal(t, "auto", cfg.Services.Runtime) } func TestLoad_envOverridesDefaults(t *testing.T) { From df4d1b15a0572ec414a8657d4aaa4fb78e51192a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:21:20 +0200 Subject: [PATCH 25/39] feat: enhance CLI commands for data and observability services Updated the data and observability command structures in the CLI to improve service management. Introduced new commands for starting and listing data services, including Postgres, ClickHouse, and MinIO. Enhanced observability commands to manage Prometheus, Grafana, and Loki stacks. Added support for dry run operations and improved command descriptions for clarity. Removed the deprecated toolchain command and consolidated stack management under a new stack command structure. --- internal/cli/commands/data.go | 44 ++- internal/cli/commands/doc.go | 2 +- internal/cli/commands/obs.go | 32 ++- internal/cli/commands/services.go | 431 +++++++++++++++++++++++------ internal/cli/commands/stack.go | 319 +++++++++++++++++++++ internal/cli/commands/toolchain.go | 69 ----- internal/cli/commands/vector.go | 42 ++- 7 files changed, 774 insertions(+), 165 deletions(-) create mode 100644 internal/cli/commands/stack.go delete mode 100644 internal/cli/commands/toolchain.go diff --git a/internal/cli/commands/data.go b/internal/cli/commands/data.go index 8870d81..9193242 100644 --- a/internal/cli/commands/data.go +++ b/internal/cli/commands/data.go @@ -1,25 +1,57 @@ package commands -import "github.com/spf13/cobra" +import ( + "fmt" -// NewDataCmd wires dataset utilities. + "github.com/bartrosa/homelab-cli/internal/services" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewDataCmd wires dataset / storage service helpers. func NewDataCmd() *cobra.Command { cmd := &cobra.Command{ Use: "data", - Short: "Datasets and lightweight data pipelines", - Long: "Helpers for DVC, lakeFS, Parquet conversion, and ad-hoc data prep.", + Short: "Data services (Postgres, ClickHouse, MinIO)", + Long: "Start data platform services via lab services.", RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( + &cobra.Command{ + Use: "up ", + Short: "Start a data service", + Example: " lab data up postgres", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "data up", args[0]) + o := &services.Orchestrator{CustomPresets: s.Config.Services.Presets} + opts := svcOpts(cmd) + opts.NonInteractive = true + return o.Up(cmd.Context(), opts, args[0]) + }, + }, &cobra.Command{ Use: "sync", - Short: "Synchronize tracked datasets according to config", + Short: "List data services status", Example: " lab data sync", Args: cobra.NoArgs, - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, _ []string) error { + for _, id := range []string{"postgres", "clickhouse", "minio"} { + svc, ok := services.Lookup(id) + if !ok { + continue + } + st, _ := svc.Status(cmd.Context(), svcOpts(cmd)) + fmt.Fprintf(stdout(cmd), " %s: running=%v\n", id, st.Running) + } + return nil + }, }, ) diff --git a/internal/cli/commands/doc.go b/internal/cli/commands/doc.go index e51e670..e8d4422 100644 --- a/internal/cli/commands/doc.go +++ b/internal/cli/commands/doc.go @@ -1,2 +1,2 @@ -// Package commands defines the lab CLI command tree (scaffolded stubs for now). +// Package commands defines the lab CLI command tree (Cobra constructors per domain). package commands diff --git a/internal/cli/commands/obs.go b/internal/cli/commands/obs.go index c3d29de..28671b8 100644 --- a/internal/cli/commands/obs.go +++ b/internal/cli/commands/obs.go @@ -1,17 +1,22 @@ package commands -import "github.com/spf13/cobra" +import ( + "github.com/bartrosa/homelab-cli/internal/services" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) -// NewObsCmd wires observability stacks. +// NewObsCmd wires observability stacks (wrapper on lab services). func NewObsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "obs", - Short: "Observability stacks (Prometheus, Grafana, Loki, Tempo, OTel)", - Long: "Launch curated observability bundles tailored for homelab services.", + Short: "Observability stacks (Prometheus, Grafana, Loki, Tempo)", + Long: "Launch curated observability bundles via lab services presets.", RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( &cobra.Command{ @@ -19,7 +24,24 @@ func NewObsCmd() *cobra.Command { Short: "Start the observability stack", Example: " lab obs up", Args: cobra.NoArgs, - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "obs up", "preset observability") + o := &services.Orchestrator{CustomPresets: s.Config.Services.Presets} + opts := svcOpts(cmd) + opts.NonInteractive = true + return o.Up(cmd.Context(), opts, "observability") + }, + }, + &cobra.Command{ + Use: "down", + Short: "Stop observability services", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + o := &services.Orchestrator{CustomPresets: session(cmd).Config.Services.Presets} + return o.Down(cmd.Context(), svcOpts(cmd), "observability") + }, }, ) diff --git a/internal/cli/commands/services.go b/internal/cli/commands/services.go index ec3eedd..a2817b4 100644 --- a/internal/cli/commands/services.go +++ b/internal/cli/commands/services.go @@ -1,24 +1,28 @@ package commands import ( + "context" "fmt" - "path/filepath" + "io" + "os" "strings" + "github.com/bartrosa/homelab-cli/internal/exec" "github.com/bartrosa/homelab-cli/internal/homelabroot" "github.com/bartrosa/homelab-cli/internal/mlstack" + "github.com/bartrosa/homelab-cli/internal/prompt" "github.com/bartrosa/homelab-cli/internal/services" "github.com/bartrosa/homelab-cli/internal/ui" "github.com/spf13/cobra" ) -// NewServicesCmd wires local compose stacks for databases and brokers. +// NewServicesCmd wires local compose-backed data services. func NewServicesCmd() *cobra.Command { cmd := &cobra.Command{ Use: "services", - Short: "Run homelab data services via compose (docker/podman)", - Long: `services manages compose stacks from your homelab repo (e.g. ml-stack). -Set homelab.root in config or LAB_HOMELAB_ROOT.`, + Short: "Manage local data services (Postgres, Redis, observability, …)", + Long: `services provisions and runs compose stacks for databases, caches, vector DBs, +observability, and object storage on homelab-net.`, RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, @@ -26,92 +30,361 @@ Set homelab.root in config or LAB_HOMELAB_ROOT.`, AddDryRunFlag(cmd) cmd.AddCommand( - &cobra.Command{ - Use: "up [more...]", - Short: "Start one or more service stacks", - Example: " lab services up ml-stack", - Args: cobra.MinimumNArgs(1), - RunE: servicesRunE("up"), + newServicesListCmd(), + newServicesInfoCmd(), + newServicesInitCmd(), + newServicesUpCmd(), + newServicesDownCmd(), + newServicesRestartCmd(), + newServicesStatusCmd(), + newServicesLogsCmd(), + newServicesConnectCmd(), + newServicesRMCmd(), + newServicesPresetListCmd(), + newServicesPresetShowCmd(), + newServicesEnsureCmd(), + ) + return cmd +} + +func svcOpts(cmd *cobra.Command) services.InitOptions { + s := session(cmd) + yes, _ := cmd.Flags().GetBool("yes") + force, _ := cmd.Flags().GetBool("force") + setFlags, _ := cmd.Flags().GetStringSlice("set") + values := parseSetFlags(setFlags) + for id, inst := range s.Config.Services.Instances { + for fk, fv := range inst { + if _, ok := values[fk]; !ok { + values[fk] = fv + } + _ = id + } + } + return services.InitOptions{ + Runner: exec.NewOSRunner(stdout(cmd), stderr(cmd)), + Stdout: stdout(cmd), + Stderr: stderr(cmd), + Runtime: s.Config.Services.Runtime, + DryRun: s.DryRun, + Force: force, + NonInteractive: yes, + Values: values, + Prompter: prompt.NewStdinPrompter(), + } +} + +func svcOrchestrator(cmd *cobra.Command) *services.Orchestrator { + s := session(cmd) + return &services.Orchestrator{CustomPresets: s.Config.Services.Presets} +} + +func parseSetFlags(pairs []string) map[string]any { + out := map[string]any{} + for _, p := range pairs { + k, v, ok := strings.Cut(p, "=") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + if strings.Contains(v, ",") { + out[k] = strings.Split(v, ",") + } else { + out[k] = v + } + } + return out +} + +func addServiceFlags(cmd *cobra.Command) { + cmd.Flags().Bool("yes", false, "non-interactive") + cmd.Flags().Bool("force", false, "overwrite existing config") + cmd.Flags().StringSlice("set", nil, "config key=value (repeatable)") + cmd.Flags().String("preset", "", "service preset name") +} + +func newServicesListCmd() *cobra.Command { + var category string + cmd := &cobra.Command{ + Use: "list", + Short: "List available services", + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "Services", "") + opts := svcOpts(cmd) + var rows [][]string + for _, svc := range services.All() { + if category != "" && string(svc.Category()) != category { + continue + } + st, _ := svc.Status(cmd.Context(), opts) + status := "stopped" + if st.Running { + status = "running" + } + rows = append(rows, []string{string(svc.Category()), svc.ID(), svc.DisplayName(), status}) + } + ui.Table(stdout(cmd), s.Styles, []string{"CATEGORY", "ID", "NAME", "STATUS"}, rows) + return nil }, - &cobra.Command{ - Use: "down [more...]", - Short: "Stop one or more service stacks", - Example: " lab services down ml-stack", - Args: cobra.MinimumNArgs(1), - RunE: servicesRunE("down"), + } + cmd.Flags().StringVar(&category, "category", "", "filter by category") + return cmd +} + +func newServicesInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show service description and config schema", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + svc, ok := services.Lookup(args[0]) + if !ok { + return fmt.Errorf("unknown service %q", args[0]) + } + w := stdout(cmd) + fmt.Fprintf(w, "ID: %s\n", svc.ID()) + fmt.Fprintf(w, "Name: %s\n", svc.DisplayName()) + fmt.Fprintf(w, "Category: %s\n", svc.Category()) + fmt.Fprintf(w, "Description: %s\n", svc.Description()) + fmt.Fprintln(w, "Config fields:") + for _, f := range svc.Schema().Fields { + fmt.Fprintf(w, " - %s (%s) default=%v\n", f.Name, f.Type, f.Default) + } + return nil }, - &cobra.Command{ - Use: "list", - Short: "List available stacks and their runtime status", - Example: " lab services list", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - s := session(cmd) - sr := services.NewRunner(stdout(cmd), stderr(cmd), s.HomelabRoot, s.Config.Services.Runtime, s.DryRun) - ui.Section(stdout(cmd), s.Styles, "Stacks", fmt.Sprintf("runtime: %s", s.Config.Services.Runtime)) - var rows [][]string - for _, st := range sr.List() { - rows = append(rows, []string{st.Name, st.ComposeFile}) - } - ui.Table(stdout(cmd), s.Styles, []string{"NAME", "COMPOSE"}, rows) - return nil - }, + } +} + +func newServicesInitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init [more...]", + Short: "Initialize service config (interactive or --set)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + preset, _ := cmd.Flags().GetString("preset") + if preset != "" { + args = []string{preset} + } + return svcOrchestrator(cmd).Init(cmd.Context(), svcOpts(cmd), args...) + }, + } + addServiceFlags(cmd) + return cmd +} + +func newServicesUpCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "up [more...]", + Short: "Start services", + Example: " lab services up postgres\n lab services up --preset observability --yes", + RunE: func(cmd *cobra.Command, args []string) error { + preset, _ := cmd.Flags().GetString("preset") + if preset != "" { + args = []string{preset} + } + if len(args) == 0 { + return fmt.Errorf("specify service id(s) or --preset") + } + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "services up", strings.Join(args, ", ")) + return svcOrchestrator(cmd).Up(cmd.Context(), svcOpts(cmd), args...) + }, + } + addServiceFlags(cmd) + return cmd +} + +func newServicesDownCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "down [more...]", + Short: "Stop services", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("specify service id(s)") + } + return svcOrchestrator(cmd).Down(cmd.Context(), svcOpts(cmd), args...) + }, + } + return cmd +} + +func newServicesRestartCmd() *cobra.Command { + return &cobra.Command{ + Use: "restart ", + Short: "Restart a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + o := svcOrchestrator(cmd) + opts := svcOpts(cmd) + if err := o.Down(cmd.Context(), opts, args[0]); err != nil { + return err + } + return o.Up(cmd.Context(), opts, args[0]) }, - &cobra.Command{ - Use: "ensure [ml-stack]", - Short: "Ensure ml-stack is up (podman-compose up -d)", - Example: " lab services ensure", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - setDryRun(cmd) - s := session(cmd) - root, err := homelabroot.Resolve(firstCLI(s.HomelabRoot, s.Config.Homelab.Root)) + } +} + +func newServicesStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status [id]", + Short: "Show service runtime status", + RunE: func(cmd *cobra.Command, args []string) error { + opts := svcOpts(cmd) + ids := args + if len(ids) == 0 { + for _, s := range services.All() { + ids = append(ids, s.ID()) + } + } + for _, id := range ids { + svc, ok := services.Lookup(id) + if !ok { + return fmt.Errorf("unknown service %q", id) + } + st, err := svc.Status(cmd.Context(), opts) if err != nil { return err } - name := "ml-stack" - if len(args) == 1 { - name = args[0] + state := "stopped" + if st.Running { + state = "running" } - if name != "ml-stack" { - return fmt.Errorf("only ml-stack is supported (got %q)", name) - } - ui.Section(stdout(cmd), s.Styles, "services ensure", name) - ip := s.Config.Server.Host - mlDir := filepath.Join(root, "ml-stack") - return mlstack.EnsureUp(cmd.Context(), mlDir, ip, s.DryRun, stdout(cmd), stderr(cmd)) - }, + fmt.Fprintf(stdout(cmd), "%s: %s %s\n", id, state, st.Detail) + } + return nil }, - &cobra.Command{ - Use: "logs ", - Short: "Tail logs for a running stack", - Example: " lab services logs ml-stack", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - setDryRun(cmd) - s := session(cmd) - sr := services.NewRunner(stdout(cmd), stderr(cmd), s.HomelabRoot, s.Config.Services.Runtime, s.DryRun) - return sr.Logs(cmd.Context(), args[0]) - }, + } +} + +func newServicesLogsCmd() *cobra.Command { + var follow bool + var tail int + cmd := &cobra.Command{ + Use: "logs ", + Short: "Tail service logs", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + _ = follow + _ = tail + dir, err := services.StateDir(args[0]) + if err != nil { + return err + } + return fmt.Errorf("use compose logs in %s (lab services logs TUI pending)", dir) }, - ) + } + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow log output") + cmd.Flags().IntVar(&tail, "tail", 100, "number of lines") + return cmd +} +func newServicesConnectCmd() *cobra.Command { + var interactive bool + cmd := &cobra.Command{ + Use: "connect ", + Short: "Print or open a connection to a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + svc, ok := services.Lookup(args[0]) + if !ok { + return fmt.Errorf("unknown service %q", args[0]) + } + if c, ok := svc.(interface { + Connect(context.Context, services.InitOptions, bool) error + }); ok { + return c.Connect(cmd.Context(), svcOpts(cmd), interactive) + } + return fmt.Errorf("connect not implemented for %q", args[0]) + }, + } + cmd.Flags().BoolVar(&interactive, "interactive", false, "open interactive client session") return cmd } -func servicesRunE(action string) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - setDryRun(cmd) - s := session(cmd) - sr := services.NewRunner(stdout(cmd), stderr(cmd), s.HomelabRoot, s.Config.Services.Runtime, s.DryRun) - ui.Section(stdout(cmd), s.Styles, "services "+action, strings.Join(args, ", ")) - switch action { - case "up": - return sr.Up(cmd.Context(), args...) - case "down": - return sr.Down(cmd.Context(), args...) - default: - return fmt.Errorf("unknown action %s", action) - } +func newServicesRMCmd() *cobra.Command { + var withData bool + cmd := &cobra.Command{ + Use: "rm ", + Short: "Remove service configuration", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + stateDir, err := services.StateDir(args[0]) + if err != nil { + return err + } + if withData { + dataDir, err := services.DataDir(args[0]) + if err == nil { + _ = os.RemoveAll(dataDir) + } + } + return os.RemoveAll(stateDir) + }, + } + cmd.Flags().BoolVar(&withData, "data", false, "also remove persistent data") + return cmd +} + +func newServicesPresetListCmd() *cobra.Command { + return &cobra.Command{ + Use: "preset list", + Short: "List service presets", + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + for _, name := range services.PresetNames(s.Config.Services.Presets) { + ids, _ := services.ResolvePreset(name, s.Config.Services.Presets) + fmt.Fprintf(stdout(cmd), " %-16s %s\n", name, strings.Join(ids, ", ")) + } + return nil + }, + } +} + +func newServicesPresetShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "preset show ", + Short: "Show services in a preset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s := session(cmd) + ids, err := services.ResolvePreset(args[0], s.Config.Services.Presets) + if err != nil { + return err + } + for _, id := range ids { + fmt.Fprintln(stdout(cmd), id) + } + return nil + }, } } + +func newServicesEnsureCmd() *cobra.Command { + return &cobra.Command{ + Use: "ensure [ml-stack]", + Short: "Ensure homelab ml-stack is up (legacy homelab compose)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + root, err := homelabroot.Resolve(firstCLI(s.HomelabRoot, s.Config.Homelab.Root)) + if err != nil { + return err + } + name := "ml-stack" + if len(args) == 1 { + name = args[0] + } + if name != "ml-stack" { + return fmt.Errorf("only ml-stack is supported (got %q)", name) + } + ui.Section(stdout(cmd), s.Styles, "services ensure", name) + mlDir := root + "/ml-stack" + return mlstack.EnsureUp(cmd.Context(), mlDir, s.Config.Server.Host, s.DryRun, stdout(cmd), stderr(cmd)) + }, + } +} + +var _ io.Writer = os.Stdout diff --git a/internal/cli/commands/stack.go b/internal/cli/commands/stack.go new file mode 100644 index 0000000..69abf35 --- /dev/null +++ b/internal/cli/commands/stack.go @@ -0,0 +1,319 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/services" + "github.com/bartrosa/homelab-cli/internal/stack" + "github.com/bartrosa/homelab-cli/internal/stack/gpu" + "github.com/bartrosa/homelab-cli/internal/stack/shellrc" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewStackCmd wires lab stack developer environment commands. +func NewStackCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "stack", + Aliases: []string{"toolchain", "tc"}, + Short: "Install developer stack components (languages, tools, GPU, embedded DBs)", + Long: `stack installs and manages developer environment components: language toolchains, +build tools, container runtimes, GPU stacks, and embedded databases. + +Alias: lab toolchain (deprecated name, same commands).`, + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + AddDryRunFlag(cmd) + cmd.AddCommand(newStackListCmd(), newStackListInstalledCmd(), newStackInfoCmd(), newStackGPUCmd(), + newStackInstallCmd(), newStackPresetListCmd(), newStackPresetShowCmd(), + newStackPathCmd(), newStackPathRefreshCmd(), newStackPathRemoveCmd(), + newStackUseCmd()) // legacy use from toolchain + return cmd +} + +// NewToolchainCmd is an alias for stack (backward compatibility). +func NewToolchainCmd() *cobra.Command { return NewStackCmd() } + +func stackEnv(cmd *cobra.Command) (*stack.Env, error) { + return stack.NewEnv(stdout(cmd), stderr(cmd)) +} + +func stackOpts(cmd *cobra.Command) stack.InstallOptions { + s := session(cmd) + yes, _ := cmd.Flags().GetBool("yes") + skipPath, _ := cmd.Flags().GetBool("skip-path") + force, _ := cmd.Flags().GetBool("force") + version, _ := cmd.Flags().GetBool("version") + _ = version + ver, _ := cmd.Flags().GetString("component-version") + return stack.InstallOptions{ + Force: force, + NonInteractive: yes, + DryRun: s.DryRun, + SkipPath: skipPath, + Version: ver, + } +} + +func addStackInstallFlags(cmd *cobra.Command) { + cmd.Flags().Bool("yes", false, "non-interactive") + cmd.Flags().Bool("force", false, "force reinstall") + cmd.Flags().Bool("skip-path", false, "skip shell rc PATH update") + cmd.Flags().String("component-version", "", "override component version") + cmd.Flags().String("preset", "", "install a named preset bundle") + cmd.Flags().Bool("gpu", false, "with --preset ml: add cuda or rocm based on detected GPU") +} + +func newStackListCmd() *cobra.Command { + var category string + cmd := &cobra.Command{ + Use: "list", + Short: "List available stack components", + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "Stack components", "") + var rows [][]string + for _, c := range stack.All() { + if category != "" && string(c.Category()) != category { + continue + } + rows = append(rows, []string{string(c.Category()), c.ID(), c.DisplayName(), c.DefaultVersion()}) + } + ui.Table(stdout(cmd), s.Styles, []string{"CATEGORY", "ID", "NAME", "DEFAULT"}, rows) + return nil + }, + } + cmd.Flags().StringVar(&category, "category", "", "filter by category") + return cmd +} + +func newStackListInstalledCmd() *cobra.Command { + return &cobra.Command{ + Use: "list-installed", + Short: "List installed components and versions", + RunE: func(cmd *cobra.Command, _ []string) error { + env, err := stackEnv(cmd) + if err != nil { + return err + } + list, err := stack.ListInstalled(cmd.Context(), env) + if err != nil { + return err + } + s := session(cmd) + var rows [][]string + for _, item := range list { + rows = append(rows, []string{item.ID, item.Version}) + } + ui.Table(stdout(cmd), s.Styles, []string{"ID", "VERSION"}, rows) + return nil + }, + } +} + +func newStackInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show component details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, ok := stack.Lookup(args[0]) + if !ok { + return fmt.Errorf("unknown component %q", args[0]) + } + s := session(cmd) + _, _ = fmt.Fprintf(stdout(cmd), "ID: %s\n", c.ID()) + _, _ = fmt.Fprintf(stdout(cmd), "Name: %s\n", c.DisplayName()) + _, _ = fmt.Fprintf(stdout(cmd), "Category: %s\n", c.Category()) + _, _ = fmt.Fprintf(stdout(cmd), "Description: %s\n", c.Description()) + _, _ = fmt.Fprintf(stdout(cmd), "Requires: %s\n", strings.Join(c.Requires(), ", ")) + _ = s + return nil + }, + } +} + +func newStackGPUCmd() *cobra.Command { + return &cobra.Command{ + Use: "gpu", + Short: "Show detected GPUs and available compute stacks", + RunE: func(cmd *cobra.Command, _ []string) error { + env, err := stackEnv(cmd) + if err != nil { + return err + } + gpus, err := gpu.Detect(cmd.Context(), env.Runner) + if err != nil { + return err + } + w := stdout(cmd) + if len(gpus) == 0 { + fmt.Fprintln(w, "No GPUs detected.") + return nil + } + fmt.Fprintln(w, "Detected GPUs:") + for i, g := range gpus { + fmt.Fprintf(w, " [%d] %s (vendor: %s)\n", i, g.Model, g.Vendor) + } + fmt.Fprintln(w, "\nCompute stacks:") + fmt.Fprintln(w, " cuda (NVIDIA) β†’ lab stack install cuda") + fmt.Fprintln(w, " rocm (AMD) β†’ lab stack install rocm") + return nil + }, + } +} + +func newStackInstallCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "install [component...]", + Short: "Install stack components or a preset", + Example: " lab stack install --preset ml --yes\n lab stack install python uv rust --yes", + RunE: func(cmd *cobra.Command, args []string) error { + env, err := stackEnv(cmd) + if err != nil { + return err + } + s := session(cmd) + opts := stackOpts(cmd) + preset, _ := cmd.Flags().GetString("preset") + withGPU, _ := cmd.Flags().GetBool("gpu") + var ids []string + if preset != "" { + ids, err = stack.ResolvePreset(preset, s.Config.Stack.Presets) + if err != nil { + return err + } + if withGPU { + ok, _ := gpu.DetectNvidia(cmd.Context(), env.Runner) + if ok { + ids = append(ids, "cuda") + } else if ok, _ := gpu.DetectAmd(cmd.Context(), env.Runner); ok { + ids = append(ids, "rocm") + } + } + } else { + ids = args + } + if len(ids) == 0 { + return fmt.Errorf("specify components or --preset") + } + ui.Section(stdout(cmd), s.Styles, "stack install", strings.Join(ids, ", ")) + return stack.InstallAll(cmd.Context(), env, ids, opts) + }, + } + addStackInstallFlags(cmd) + return cmd +} + +func newStackPresetListCmd() *cobra.Command { + return &cobra.Command{ + Use: "preset list", + Short: "List stack presets", + RunE: func(cmd *cobra.Command, _ []string) error { + s := session(cmd) + for _, name := range stack.PresetNames(s.Config.Stack.Presets) { + ids, _ := stack.ResolvePreset(name, s.Config.Stack.Presets) + fmt.Fprintf(stdout(cmd), " %-14s %s\n", name, strings.Join(ids, ", ")) + } + return nil + }, + } +} + +func newStackPresetShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "preset show ", + Short: "Show components in a preset", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s := session(cmd) + ids, err := stack.ResolvePreset(args[0], s.Config.Stack.Presets) + if err != nil { + return err + } + for _, id := range ids { + fmt.Fprintln(stdout(cmd), id) + } + _ = s + return nil + }, + } +} + +func newStackPathCmd() *cobra.Command { + return &cobra.Command{ + Use: "path", + Short: "Show managed shell PATH block", + RunE: func(cmd *cobra.Command, _ []string) error { + shells, err := shellrc.Detect() + if err != nil { + return err + } + for _, sh := range shells { + block, err := shellrc.ReadBlock(sh) + if err != nil { + return err + } + if block != "" { + fmt.Fprintln(stdout(cmd), block) + } + } + return nil + }, + } +} + +func newStackPathRefreshCmd() *cobra.Command { + return &cobra.Command{ + Use: "path refresh", + Short: "Regenerate shell PATH block from installed components", + RunE: func(cmd *cobra.Command, _ []string) error { + env, err := stackEnv(cmd) + if err != nil { + return err + } + return stack.RefreshPath(cmd.Context(), env) + }, + } +} + +func newStackPathRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "path remove", + Short: "Remove managed shell PATH block", + RunE: func(_ *cobra.Command, _ []string) error { + shells, err := shellrc.Detect() + if err != nil { + return err + } + for _, sh := range shells { + if err := shellrc.RemoveBlock(sh); err != nil { + return err + } + } + return nil + }, + } +} + +func newStackUseCmd() *cobra.Command { + return &cobra.Command{ + Use: "use ", + Short: "Switch active mise toolchain version (legacy)", + Example: " lab stack use go 1.25.0", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + env, err := stackEnv(cmd) + if err != nil { + return err + } + return env.Runner.Run(cmd.Context(), "mise", "use", "-g", args[0]+"@"+args[1]) + }, + } +} + +// unused import guard for services orchestrator wiring in install preset +var _ = services.Orchestrator{} diff --git a/internal/cli/commands/toolchain.go b/internal/cli/commands/toolchain.go deleted file mode 100644 index bf710ff..0000000 --- a/internal/cli/commands/toolchain.go +++ /dev/null @@ -1,69 +0,0 @@ -package commands - -import ( - "strings" - - "github.com/bartrosa/homelab-cli/internal/toolchain" - "github.com/bartrosa/homelab-cli/internal/ui" - "github.com/spf13/cobra" -) - -// NewToolchainCmd wires language toolchain operations (mise-backed). -func NewToolchainCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "toolchain", - Short: "Install and switch language toolchains", - Long: `Toolchain commands wrap mise to install and activate -Go, Node, Bun, Deno, Python, Rust, Erlang, Elixir, Zig, Java, Ruby and more.`, - RunE: func(c *cobra.Command, _ []string) error { - return c.Help() - }, - } - AddDryRunFlag(cmd) - - cmd.AddCommand( - &cobra.Command{ - Use: "install [more...]", - Short: "Install one or more language toolchains", - Example: " lab toolchain install go bun rust", - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - setDryRun(cmd) - s := session(cmd) - tc := toolchain.New(stdout(cmd), stderr(cmd), s.DryRun) - ui.Section(stdout(cmd), s.Styles, "toolchain", "mise") - if err := tc.Install(cmd.Context(), args...); err != nil { - return err - } - ui.OK(stdout(cmd), s.Styles, strings.Join(args, ", ")) - return nil - }, - }, - &cobra.Command{ - Use: "list", - Short: "List installed toolchains and active versions", - Example: " lab toolchain list", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - setDryRun(cmd) - s := session(cmd) - tc := toolchain.New(stdout(cmd), stderr(cmd), s.DryRun) - return tc.List(cmd.Context()) - }, - }, - &cobra.Command{ - Use: "use ", - Short: "Switch the active toolchain version for a language", - Example: " lab toolchain use go 1.25.0", - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - setDryRun(cmd) - s := session(cmd) - tc := toolchain.New(stdout(cmd), stderr(cmd), s.DryRun) - return tc.Use(cmd.Context(), args[0], args[1]) - }, - }, - ) - - return cmd -} diff --git a/internal/cli/commands/vector.go b/internal/cli/commands/vector.go index b8c0ef0..69083eb 100644 --- a/internal/cli/commands/vector.go +++ b/internal/cli/commands/vector.go @@ -1,25 +1,57 @@ package commands -import "github.com/spf13/cobra" +import ( + "fmt" + + "github.com/bartrosa/homelab-cli/internal/services" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) // NewVectorCmd wires vector database helpers. func NewVectorCmd() *cobra.Command { cmd := &cobra.Command{ Use: "vector", - Short: "Vector database lifecycle (Qdrant, Weaviate, Milvus, Chroma)", - Long: "Provision, snapshot, and validate vector stores used by RAG stacks.", + Short: "Vector database lifecycle (Qdrant, Weaviate)", + Long: "Provision vector stores via lab services.", RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, } + AddDryRunFlag(cmd) cmd.AddCommand( &cobra.Command{ Use: "list", - Short: "List configured vector stores and connection health", + Short: "List vector services", Example: " lab vector list", Args: cobra.NoArgs, - RunE: StubRunE(), + RunE: func(cmd *cobra.Command, _ []string) error { + for _, id := range []string{"qdrant", "weaviate"} { + svc, ok := services.Lookup(id) + if !ok { + continue + } + st, _ := svc.Status(cmd.Context(), svcOpts(cmd)) + fmt.Fprintf(stdout(cmd), " %s: running=%v %s\n", id, st.Running, st.Detail) + } + return nil + }, + }, + &cobra.Command{ + Use: "up ", + Short: "Start a vector database service", + Example: " lab vector up qdrant", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + ui.Section(stdout(cmd), s.Styles, "vector up", args[0]) + o := &services.Orchestrator{CustomPresets: s.Config.Services.Presets} + opts := svcOpts(cmd) + opts.NonInteractive = true + return o.Up(cmd.Context(), opts, args[0]) + }, }, ) From c1d4e118df1f4143f602d8d39174682597458b0a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:21:26 +0200 Subject: [PATCH 26/39] refactor: replace toolchain command with stack command in CLI Updated the CLI command structure by removing the deprecated toolchain command and introducing the new stack command under the foundation category. This change enhances the organization of commands related to service management. --- internal/cli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 8c1a2dc..af45002 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -102,7 +102,7 @@ running local data services, mirroring repositories, operating clusters, and sup add(commands.NewBootstrapCmd(), "foundation") add(commands.NewPkgCmd(), "foundation") - add(commands.NewToolchainCmd(), "foundation") + add(commands.NewStackCmd(), "foundation") add(commands.NewServicesCmd(), "foundation") add(commands.NewReposCmd(), "repos") From edb027b290688ac2a879f29b239bc191f3b6a980 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:21:36 +0200 Subject: [PATCH 27/39] chore: update install script version in usage instructions Modified the version number in the install script usage instructions from v0.1.0 to v0.2.0 to reflect the latest release. This change ensures users are directed to the correct version during installation. --- scripts/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index dcf2508..f71ac73 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,7 +4,7 @@ set -eu # 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.1.0 +# curl -sSL ... | sh -s -- --version v0.2.0 # curl -sSL ... | sh -s -- --prefix "$HOME/.local" REPO="bartrosa/homelab-cli" From 984f0a9c4d35f419bc1954aae02f39c6cad7be29 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:22:01 +0200 Subject: [PATCH 28/39] feat: enhance README and documentation for v0.2.0 provisioning release Updated the README to reflect the new features and capabilities of the `lab` CLI, including a comprehensive overview of homelab automation, installation instructions, and a detailed provisioning workflow. Added new documentation files for provisioning a new machine and a services catalog, outlining the management of compose-backed services. Enhanced command descriptions and configuration details to improve user guidance and clarity. --- README.md | 281 +++++++++++++++++++++++++++++++++--------- cmd/lab/main.go | 3 + docs/README.md | 1 + docs/architecture.md | 54 +++++--- docs/commands.md | 113 ++++++++++++++--- docs/configuration.md | 41 +++++- docs/provisioning.md | 73 +++++++++++ docs/services.md | 140 +++++++++++++++++++++ 8 files changed, 616 insertions(+), 90 deletions(-) create mode 100644 docs/provisioning.md create mode 100644 docs/services.md diff --git a/README.md b/README.md index 2dfd99a..fdacb37 100644 --- a/README.md +++ b/README.md @@ -4,39 +4,44 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Go](https://img.shields.io/badge/go-1.25+-00ADD8.svg)](https://go.dev/dl/) -**`lab`** is a single CLI for homelab automation: bootstrap laptops and servers, manage toolchains and compose stacks, sync your [homelab](https://github.com/bartrosa/homelab) repo to remote hosts, provision PostgreSQL, download media, and create bootable USB installers β€” with orchestration in Go and thin wrappers around standard system tools. +**`lab`** β€” CLI for end-to-end homelab automation: from bare metal to GPU-served LLMs. -Module: `github.com/bartrosa/homelab-cli` +One binary for bootstrapping machines, language toolchains, compose-backed services, multi-repo workflows, remote deploys, and bootable USB installers β€” orchestration in Go, thin wrappers around standard system tools. + +Module: `github.com/bartrosa/homelab-cli` Β· Latest release: **v0.2.0** Β· Next: **v0.3.0** (Developer Environment) + +## Why? + +Homelab work spans dozens of domains: OS packages, runtimes, databases, Git mirrors, SSH deploys, ISO provisioning, and (eventually) clusters and local LLMs. Without a single entry point, automation drifts into scattered shell scripts under `~/scripts` with no shared config, logging, or dry-run semantics. + +`lab` centralizes that: + +- **One CLI** with grouped commands, `--help` everywhere, and consistent flags. +- **Declarative config** (`~/.config/homelab-cli/config.yaml` + `LAB_*` env) instead of hard-coded paths. +- **Orchestration in Go** β€” retries, terminal UI, idempotent steps β€” while delegating to `mise`, `apt`, `podman-compose`, `ssh`, `dd`, etc. where appropriate. +- **Homelab repo as data** β€” compose files, postgres YAML, and project templates stay in your personal [homelab](https://github.com/bartrosa/homelab) checkout; `lab` reads them via `homelab.root`. + +## What can it do? + +| Area | Summary | +|------|---------| +| **Bootstrap & install** | Laptop/server profiles, essentials for Ubuntu/Silverblue, packages, developer stack (`lab stack`), compose services, verified ISO download and USB burn. | +| **Stack** | Install languages (Python, Go, Rust, Scala, …), build tools, GPU stacks (CUDA/ROCm), embedded DBs, and managed shell PATH. | +| **Services** | Init and run 15 compose-backed services (Postgres, Redis, observability, vector DBs, MinIO) on shared `homelab-net`. | +| **Repos** | GitLab account backup today; clone/sync/status planned. | +| **Infra & networking** | SSH connect/sync, remote server deploy, PostgreSQL apply, bare-metal DB installers, USB/ISO provisioning. Cluster/GPU/net/storage planned. | +| **Data / AI / ML** | Stubs for models, notebooks, MLOps, vector DBs, pipelines, agents β€” vector DB install exists under `baremetal install` today. | +| **Observability** | Stubs for obs/logs; services logs work via compose. | +| **Workflow** | Project templates from homelab initiators, HEIC conversion, version/self-update; MCP server planned. | + +**Implemented today:** see the [command status table](#command-status) below. Everything else returns `not implemented yet` until the matching adapter lands. ## Design principles - **Orchestration in Go** β€” workflows, config, retries, and terminal UI live in this repo. -- **No homelab shell/Python scripts from `lab`** β€” logic migrated here; the personal homelab repo remains the source of compose files, YAML, and project templates. +- **No homelab shell/Python from `lab`** for migrated features; homelab remains the source of compose, YAML, and templates. - **External binaries where required** β€” `ssh`, `podman-compose`, `wget`, `dd`, etc. See [`docs/external-binaries.md`](docs/external-binaries.md). -## What works today - -| Area | Commands | Notes | -|------|----------|--------| -| **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 | -| **Server** | `server run`, `server deploy` | SSH + rsync; deploy can provision PG and start compose | -| **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`, `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. - -Full command tables: [`docs/commands.md`](docs/commands.md). - ## Installation ### One-liner install @@ -50,9 +55,18 @@ The install script downloads the release tarball, verifies SHA256 checksums, and Pin a version or install to a custom prefix: ```bash -curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash -s -- --version v0.1.0 +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" curl -sSL .../install.sh | bash -s -- --no-path # skip automatic PATH setup +curl -sSL .../install.sh | bash -s -- --check # dry run β€” print planned actions +``` + +After install: + +```bash +lab version +lab --help +lab self-update --check ``` ### Upgrading @@ -82,57 +96,213 @@ go install github.com/bartrosa/homelab-cli/cmd/lab@latest **Release packages** -Tagged releases publish `.tar.gz`, `.deb`, and `.rpm` via GoReleaser on the [Releases](https://github.com/bartrosa/homelab-cli/releases) page. +Tagged releases publish `.tar.gz`, `.deb`, and `.rpm` via GoReleaser on the [Releases](https://github.com/bartrosa/homelab-cli/releases) page (linux/darwin, amd64/arm64, plus `checksums.txt`). + +## Provisioning a new machine (v0.2.0) -## Provisioning a new machine +Full walkthrough: [`docs/provisioning.md`](docs/provisioning.md). + +On an **existing Linux host** β€” download a verified ISO and burn a USB drive: ```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 +lab iso write ubuntu-desktop --usb +``` -# After booting the fresh OS and installing lab: -lab bootstrap essentials +Boot the target machine from USB, install the OS, then on the **fresh install**: + +```bash +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash +lab bootstrap essentials --dry-run +lab bootstrap essentials --yes +lab stack install --preset backend --yes +lab stack install rust cmake --yes +source ~/.bashrc ``` -## Quick start +Supported ISO resolvers today: **ubuntu-desktop**, **fedora-silverblue** (plus catalog stubs for debian, arch, nixos, …). `lab bootstrap essentials` targets **Ubuntu** and **Fedora Silverblue** (`--target auto|ubuntu|silverblue`). + +## Setting up your dev environment (v0.3.0) -1. Copy and edit config: +After `lab bootstrap essentials`, install a curated developer stack: ```bash -mkdir -p ~/.config/homelab-cli -cp docs/config.example.yaml ~/.config/homelab-cli/config.yaml -# set homelab.root to your homelab repo path +lab stack install --preset backend --yes # git, docker, python, node, uv, go, make +lab stack install rust cmake --yes # ad-hoc components +lab stack list # all components by category +lab stack list-installed # what's on this machine +lab stack path refresh # update ~/.bashrc managed PATH block +source ~/.bashrc ``` -2. Preview bootstrap, install tools, run stacks: +Presets include `minimal`, `basic`, `backend`, `frontend`, `systems`, `jvm`, `ml`, `data`, `gpu-nvidia`, `gpu-amd`, and `full`. Override versions in config (`stack.components`) or with `--component-version`. + +`lab toolchain` is an alias for `lab stack` (same commands). + +## Running services (v0.3.0) + +Local data and observability stacks run via Docker/Podman Compose on `homelab-net`: ```bash -lab bootstrap laptop --dry-run -lab pkg ensure ripgrep jq git -lab toolchain install go rust python -lab services list -lab services up ml-stack +lab services init postgres --set plugins=pgvector,postgis --set expose=local --yes +lab services up postgres +lab services connect postgres # connection string +lab services connect postgres --interactive # psql session + +lab services up --preset observability --yes # prometheus, loki, tempo, grafana +lab services up --preset ml-stack --yes # postgres, qdrant, minio, clickhouse + +lab obs up # wrapper β†’ observability preset +lab vector up qdrant +lab data up postgres +``` + +Non-interactive / secrets from 1Password: + +```bash +op run --env-file=.op.env -- lab services up --preset ml-stack --yes ``` -3. Remote server (set `server.*` in config): +Service catalog: [`docs/services.md`](docs/services.md). Legacy homelab-repo ml-stack: `lab services ensure ml-stack`. + +## Quick start + +Realistic examples across domains (βœ… = works today, 🚧 = stub): ```bash +# Bootstrap a fresh machine (after OS install) +lab bootstrap laptop # βœ… profile-based setup +lab bootstrap essentials # βœ… Ubuntu or Silverblue baseline packages +lab stack install --preset ml --yes # βœ… developer stack (alias: lab toolchain) +lab services init postgres --yes # βœ… local compose services +lab services up --preset observability # βœ… prometheus, grafana, loki, tempo + +# Provision boot media (Linux) +lab iso list +lab iso download ubuntu-desktop +lab iso disks +lab iso write # βœ… interactive picker +lab iso write ubuntu-desktop --usb # βœ… burn by distro name + +# Remote homelab server (set server.* in config) lab ssh sync -lab server deploy provision # rsync + postgres apply (local, against PG in YAML) -lab server deploy compose # rsync + podman-compose on server -lab services ensure # ml-stack up + service URLs +lab server deploy full # βœ… rsync + postgres apply + compose + +# Planned β€” returns "not implemented yet" +lab repos clone "github.com/me/*" # 🚧 +lab models pull llama3 # 🚧 +lab cluster status # 🚧 ``` -4. USB installer: +### First-time config ```bash -lab system usb list -lab system usb --distro ubuntu-lts-24.04 --device /dev/sdb --workdir ~/Downloads +mkdir -p ~/.config/homelab-cli +cp docs/config.example.yaml ~/.config/homelab-cli/config.yaml +# set homelab.root to your homelab repo path ``` +### Common workflows + +```bash +lab bootstrap laptop --dry-run +lab pkg ensure ripgrep jq git +lab stack install go rust python # alias: lab toolchain install +lab services list +lab services up postgres +lab postgres apply --config ~/homelab/postgres/config/instances.yaml +lab templates new golang ~/projects/my-api +lab version --output json +``` + +Full provisioning flow for a new machine: [`docs/commands.md`](docs/commands.md#lab-iso). + +## Command status + +Legend: βœ… ready Β· 🚧 planned (stub β€” `not implemented yet`) + +### Foundation β€” bootstrap & install + +| Command | Status | +|---------|--------| +| `lab bootstrap laptop` | βœ… | +| `lab bootstrap server` | βœ… | +| `lab bootstrap profile ` | βœ… | +| `lab bootstrap list` | βœ… | +| `lab bootstrap essentials` | βœ… | +| `lab pkg install ` | βœ… | +| `lab pkg ensure ` | βœ… | +| `lab pkg list` | βœ… | +| `lab stack install ` | βœ… | +| `lab stack list` | βœ… | +| `lab stack install --preset ` | βœ… | +| `lab stack path refresh` | βœ… | +| `lab toolchain …` | βœ… (alias for `lab stack`) | +| `lab services init ` | βœ… | +| `lab services up ` | βœ… | +| `lab services down ` | βœ… | +| `lab services list` | βœ… | +| `lab services connect ` | βœ… | +| `lab services up --preset ` | βœ… | +| `lab services ensure` | βœ… (legacy homelab ml-stack) | +| `lab obs up` | βœ… | +| `lab vector up ` | βœ… | +| `lab data up ` | βœ… | +| `lab iso list \| download \| disks \| images \| write` | βœ… | + +### Repos β€” multi-repo management + +| Command | Status | +|---------|--------| +| `lab repos clone ` | 🚧 | +| `lab repos backup` | βœ… | +| `lab repos sync` | 🚧 | +| `lab repos status` | 🚧 | +| `lab repos list` | 🚧 | + +### Infra & networking + +| Command | Status | +|---------|--------| +| `lab cluster status`, `lab cluster kubeconfig` | 🚧 | +| `lab gpu info` | 🚧 | +| `lab ssh connect`, `lab ssh sync` | βœ… | +| `lab containers ps` | 🚧 | +| `lab net status` | 🚧 | +| `lab storage ls` | 🚧 | +| `lab server run`, `lab server deploy` | βœ… | +| `lab postgres apply` | βœ… | +| `lab baremetal install` | βœ… | +| `lab system usb list`, `lab system usb` | βœ… | + +### Data / AI / ML / MLOps + +| Command | Status | +|---------|--------| +| `lab models pull` | 🚧 | +| `lab data sync` | 🚧 | +| `lab notebooks up` | 🚧 | +| `lab mlops status` | 🚧 | +| `lab vector list` | 🚧 | +| `lab pipelines run` | 🚧 | +| `lab agents list` | 🚧 | + +### Workflow & observability + +| Command | Status | +|---------|--------| +| `lab obs up` | 🚧 | +| `lab logs tail` | 🚧 | +| `lab templates list \| new` | βœ… | +| `lab media heic` | βœ… | +| `lab mcp serve` | 🚧 | +| `lab version` | βœ… | +| `lab self-update` | βœ… | + +Per-command flags and examples: [`docs/commands.md`](docs/commands.md). + ## Global flags | Flag | Description | @@ -156,7 +326,7 @@ Precedence: **CLI flags β†’ `LAB_*` env β†’ YAML β†’ defaults**. | `server.host`, `server.user`, `server.port`, `server.path` | Default remote host for rsync/SSH | | `ssh.hosts` | Named SSH targets for `lab ssh connect` | | `services.runtime` | `podman-compose` or `docker` | -| `repos.providers` | Git hosting tokens for future clone/backup | +| `repos.providers` | Git hosting tokens for clone/backup | Details: [`docs/configuration.md`](docs/configuration.md) Β· example: [`docs/config.example.yaml`](docs/config.example.yaml). @@ -164,12 +334,13 @@ Details: [`docs/configuration.md`](docs/configuration.md) Β· example: [`docs/con | Document | Description | |----------|-------------| -| [`docs/commands.md`](docs/commands.md) | Command reference with status | +| [`docs/provisioning.md`](docs/provisioning.md) | **v0.2.0** new-machine workflow (install β†’ ISO β†’ USB β†’ essentials) | +| [`docs/commands.md`](docs/commands.md) | Command reference with status and examples | | [`docs/configuration.md`](docs/configuration.md) | Config keys and precedence | -| [`docs/architecture.md`](docs/architecture.md) | Packages and data flow | +| [`docs/architecture.md`](docs/architecture.md) | Packages, adapters, and data flow | | [`docs/external-binaries.md`](docs/external-binaries.md) | Required host tools | | [`docs/homelab-migration.md`](docs/homelab-migration.md) | homelab repo β†’ `lab` migration map | -| [`CHANGELOG.md`](CHANGELOG.md) | Release notes | +| [`CHANGELOG.md`](CHANGELOG.md) | Release notes (current: v0.1.1; next: v0.2.0) | ## Relationship to the homelab repo diff --git a/cmd/lab/main.go b/cmd/lab/main.go index 16db181..ca2d3e3 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -11,6 +11,9 @@ import ( "github.com/bartrosa/homelab-cli/internal/cli" "github.com/bartrosa/homelab-cli/internal/clierrors" + + _ "github.com/bartrosa/homelab-cli/internal/services/register" + _ "github.com/bartrosa/homelab-cli/internal/stack/components" ) func main() { diff --git a/docs/README.md b/docs/README.md index 0fdb513..514dc28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ English reference for **homelab-cli** (`lab`). | Document | Contents | |----------|----------| +| [provisioning.md](provisioning.md) | **v0.2.0** new-machine workflow (install β†’ ISO β†’ USB β†’ essentials) | | [commands.md](commands.md) | Full command tree, status, examples | | [configuration.md](configuration.md) | Config file, env vars, precedence | | [config.example.yaml](config.example.yaml) | Copy-paste starter config | diff --git a/docs/architecture.md b/docs/architecture.md index d6b987a..9354832 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,12 +18,28 @@ β”‚ RunE β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Domain packages (bootstrap, media, server, …) β”‚ -β”‚ β†’ executil.Runner (dry-run, logging) β”‚ -β”‚ β†’ exec: ssh, wget, podman-compose, … β”‚ +β”‚ Domain packages (bootstrap, iso, server, …) β”‚ +β”‚ β†’ exec.Runner / executil (dry-run, logging) β”‚ +β”‚ β†’ ssh, wget, podman-compose, dd, gpg, … β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` +## Adapter model (target) + +Commands stay thin; reusable logic lives in `internal/*` adapters: + +| Adapter | Interface role | Implementations (current / planned) | +|---------|------------------|-------------------------------------| +| **Package managers** | Install/ensure/list OS packages | βœ… brew, apt, dnf, rpm-ostree (`internal/packager`, `internal/pkgmgr`) | +| **Toolchain** | Language runtime install/switch | βœ… mise wrapper (`internal/toolchain`) | +| **Services** | Compose stack lifecycle | βœ… podman-compose / docker (`internal/services`, `internal/mlstack`) | +| **Repos** | Clone, backup, sync, status | βœ… GitLab backup (interim Python); 🚧 go-git + REST providers | +| **Cluster** | k3s/k8s ops | 🚧 kubectl / client-go | +| **Storage** | S3-compatible ops | 🚧 MinIO API client | +| **Models / ML** | Local LLM pull/run | 🚧 ollama, vLLM wrappers | + +Stub commands (`commands.StubRunE`) reserve the CLI surface until each adapter ships β€” typically one adapter per PR. + ## Implemented packages | Package | Role | External tools | @@ -39,8 +55,10 @@ | `internal/repos` | GitLab backup | homelab Python script (interim) | | `internal/templates` | Project scaffolds | filesystem copy from homelab | | `internal/media` | HEIC convert | heif-convert | -| `internal/system` | Bootable USB | HTTP to mirrors, wget, sha256sum, dd | +| `internal/system` | Bootable USB (mirror discovery) | HTTP, wget, sha256sum, dd | +| `internal/iso` | ISO catalog, download, verify, burn | gpg, wget/curl, dd, lsblk | | `internal/baremetal` | DB installers on Linux | curl, apt, sudo, systemd | +| `internal/updater` | In-place binary upgrade | GitHub releases API | ## Cross-cutting @@ -48,24 +66,25 @@ |---------|------| | `internal/config` | YAML + `LAB_*` env | | `internal/homelabroot` | Resolve homelab repo path | -| `internal/executil` | Command runner with dry-run | -| `internal/ui` | lipgloss sections and tables | +| `internal/exec`, `internal/executil` | Testable command runner with dry-run | +| `internal/ui` | lipgloss sections, tables, progress | | `internal/platform` | OS detection (brew vs apt vs …) | | `internal/logging` | slog on context | | `internal/buildinfo` | Version ldflags | +| `internal/clierrors` | Shared errors (`ErrNotImplemented`, …) | ## Command groups (Cobra) | Group ID | Commands | |----------|----------| -| `foundation` | bootstrap, pkg, toolchain, services | +| `foundation` | bootstrap, pkg, toolchain, services, **iso** | | `repos` | repos | | `infra` | server, postgres, baremetal, system, ssh, cluster, gpu, containers, net, storage | | `data` | models, data, notebooks, mlops, vector, pipelines, agents | | `workflow` | obs, logs, templates, media, mcp | -| `meta` | version | +| `meta` | version, **self-update** | -Stub commands return a consistent β€œnot implemented yet” error via `commands.StubRunE`. +Run `lab --help` to see groups in the root help output. ## Homelab repo boundary @@ -82,15 +101,16 @@ Orchestration and new features belong in Go here. See [`homelab-migration.md`](h | Area | Direction | |------|-----------| -| Repos | Go GitLab API + go-git instead of Python backup | +| Repos | Go GitLab/GitHub API + go-git instead of Python backup | | Bootstrap | Replace `script:` steps with native Go where practical | -| Cluster / ML | Thin adapters over kubectl, ollama, etc. | -| MCP | `lab mcp serve` exposing guarded read-only tools | +| Cluster / GPU / net | Thin adapters over kubectl, nvidia-smi, tailscale CLI | +| Models / ML | ollama/vLLM pull, MLflow status | +| MCP | `lab mcp serve` as stdio server exposing a guarded subset of read-only tools for Cursor/Copilot | ## Layout conventions -- `cmd/lab` β€” `main` only -- `internal/cli` β€” root command wiring -- `internal/cli/commands` β€” per-domain command constructors -- `internal/cli/appctx` β€” `Session` on `context.Context` -- `pkg/` β€” reserved for future public libraries +- `cmd/lab` β€” `main` only (signal-aware context, exit code 1 on error) +- `internal/cli` β€” root command wiring, config/logger bootstrap +- `internal/cli/commands` β€” one file per domain; `NewXxxCmd()` constructors +- `internal/cli/appctx` β€” `Session` (config, dry-run, styles) on `context.Context` +- `pkg/` β€” reserved for future public libraries (e.g. shared repo provider interfaces) diff --git a/docs/commands.md b/docs/commands.md index 6fd9f66..cc2334e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,6 +2,10 @@ > **Legend:** βœ… implemented Β· 🚧 scaffolded (`not implemented yet`) +**v0.3.0 (Developer Environment):** `lab stack`, full `lab services` framework, and high-level `lab obs` / `lab vector` / `lab data` wrappers. + +**v0.2.0 (Provisioning Release):** install script, `lab self-update`, `lab iso *`, and `lab bootstrap essentials` are βœ… ready. See [`provisioning.md`](provisioning.md) for the full new-machine workflow. + Run `lab --help` for flags. Global flags apply to all commands: `--config`, `--homelab-root`, `--dry-run`, `--no-color`, `--log-level`, `--log-format`. --- @@ -20,9 +24,20 @@ Run `lab --help` for flags. Global flags apply to all commands: `--con Built-in profiles: `laptop-macos`, `laptop-linux`, `silverblue-laptop`, `server-ubuntu`. +| Flag | Description | +|------|-------------| +| `--target` | `auto` (default), `ubuntu`, or `silverblue` | +| `--yes` | Non-interactive; accept defaults | +| `--skip` | Comma-separated sections to skip (e.g. `docker,mise`) | +| `--only` | Comma-separated sections to run (e.g. `cli-basics,build`) | +| `--dry-run` | Print planned commands without executing (global flag) | + +Sections: `system-update`, `cli-basics`, `shell-tools`, `build`, `container-runtime`, `mise`, `distrobox`, `flatpak-flathub`. + ```bash lab bootstrap laptop --dry-run lab bootstrap essentials --dry-run --target silverblue +lab bootstrap essentials --only cli-basics --yes lab bootstrap profile dgx-spark # from config bootstrap.profiles ``` @@ -31,17 +46,26 @@ lab bootstrap profile dgx-spark # from config bootstrap.profiles | Command | Description | Status | |---------|-------------|--------| | `lab iso list` | Supported distros and resolved versions. | βœ… | -| `lab iso download ` | Download and verify ISO to cache. | βœ… | +| `lab iso download ` | Download and verify ISO to cache (GPG + SHA256). | βœ… | +| `lab iso images` | List cached ISO files ready to burn. | βœ… | | `lab iso disks` | List block devices; USB vs SYSTEM (Linux). | βœ… | -| `lab iso write --to ` | Burn ISO with safety checks (Linux). | βœ… | +| `lab iso write [distro\|path]` | Burn ISO with safety checks (Linux). Interactive if no args. | βœ… | Flow: `list` β†’ `download` β†’ `disks` β†’ `write`. ```bash lab iso download ubuntu-desktop +lab iso images +lab iso write # interactive: pick cached ISO + USB drive +lab iso write ubuntu-desktop --usb # burn by distro name / cached alias lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /dev/sdb +lab iso write ubuntu-desktop --device sda # --device is alias for --to ``` +On non-Linux platforms, `disks` and `write` return `not implemented yet` (stubs). + +**Note:** `lab system usb` is an alternate path that discovers Ubuntu/Fedora images from release metadata and writes in one step. Prefer `lab iso` for the verified download + cache + burn workflow. + ### `lab pkg` | Command | Description | Status | @@ -50,31 +74,80 @@ lab iso write ~/.cache/homelab-cli/iso/ubuntu-24.04.3-desktop-amd64.iso --to /de | `lab pkg ensure [more...]` | Install only if missing. | βœ… | | `lab pkg list` | Show common packages and detected backend. | βœ… | -### `lab toolchain` +### `lab stack` (alias: `lab toolchain`, `lab tc`) + +Developer environment components: languages, build tools, containers, GPU stacks, embedded databases. | Command | Description | Status | |---------|-------------|--------| -| `lab toolchain install [more...]` | Install runtimes via `mise`. | βœ… | -| `lab toolchain list` | List installed toolchains. | βœ… | -| `lab toolchain use ` | Activate a version. | βœ… | +| `lab stack list [--category]` | List available components by category. | βœ… | +| `lab stack list-installed` | Show installed components and versions. | βœ… | +| `lab stack info ` | Component details (backend, requires, PATH entries). | βœ… | +| `lab stack gpu` | Detect GPUs and suggest compute stacks. | βœ… | +| `lab stack install ...` | Install components (dependency order, skip if installed). | βœ… | +| `lab stack install --preset [--gpu]` | Install a preset bundle. | βœ… | +| `lab stack preset list` | List stack presets. | βœ… | +| `lab stack preset show ` | Show components in a preset. | βœ… | +| `lab stack path` | Print managed shell PATH block. | βœ… | +| `lab stack path refresh` | Regenerate PATH block in shell rc. | βœ… | +| `lab stack path remove` | Remove managed block. | βœ… | +| `lab stack use ` | Activate mise version (legacy). | βœ… | + +| Flag | Description | +|------|-------------| +| `--yes` | Non-interactive | +| `--dry-run` | Print plan only | +| `--force` | Reinstall / override GPU checks | +| `--skip-path` | Skip shell rc update | +| `--component-version` | Override version for install | + +```bash +lab stack install --preset ml --yes +lab stack install postgres duckdb sqlite --yes # embedded DBs in stack, not services +lab stack install rust --yes +lab stack install --preset gpu-nvidia --yes # adds CUDA when NVIDIA GPU detected +lab toolchain install python --yes # alias works +``` ### `lab services` -Manages compose stacks under `homelab.root` (e.g. `ml-stack/podman-compose.yml`). Runtime from `services.runtime` (default `podman-compose`). +Compose-backed local services on shared network `homelab-net`. Config under `~/.config/homelab-cli/services//`. | Command | Description | Status | |---------|-------------|--------| -| `lab services up [more...]` | Start stack(s). | βœ… | -| `lab services down [more...]` | Stop stack(s). | βœ… | -| `lab services list` | List stacks and compose paths. | βœ… | -| `lab services logs ` | Tail logs. | βœ… | -| `lab services ensure [ml-stack]` | `podman-compose up -d` for ml-stack; print service URLs. | βœ… | +| `lab services list [--category]` | List services and running/stopped status. | βœ… | +| `lab services info ` | Description and config schema. | βœ… | +| `lab services init ` | Interactive wizard or `--set` / `--yes`. | βœ… | +| `lab services up [more...]` | Start service(s) or `--preset`. | βœ… | +| `lab services down [more...]` | Stop service(s). | βœ… | +| `lab services restart ` | Restart one service. | βœ… | +| `lab services status [id]` | Runtime status. | βœ… | +| `lab services logs [-f] [--tail N]` | Tail logs (compose). | 🚧 partial | +| `lab services connect [--interactive]` | Print connection string or open CLI client. | βœ… | +| `lab services rm [--data]` | Remove config (+ data with `--data`). | βœ… | +| `lab services preset list` | List service presets. | βœ… | +| `lab services preset show ` | Show services in preset. | βœ… | +| `lab services ensure [ml-stack]` | Legacy homelab-repo ml-stack compose. | βœ… | + +| Flag | Description | +|------|-------------| +| `--yes` | Non-interactive init | +| `--set key=value` | Config override (repeatable; comma lists for multiselect) | +| `--preset` | Preset name for init/up | +| `--force` | Overwrite existing init | ```bash -lab services up ml-stack -lab services ensure +lab services init postgres --set plugins=pgvector,postgis --set expose=local --yes +lab services up postgres +lab services up --preset observability --yes +lab services up --preset ml-stack --yes +lab services connect postgres --interactive ``` +High-level wrappers: `lab obs up`, `lab vector up qdrant`, `lab data up postgres`. + +See [`services.md`](services.md) for all 15 services, fields, and connection examples. + --- ## Repos β€” multi-repo management @@ -205,10 +278,20 @@ 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`). | βœ… | +| `lab self-update` | Install latest release from GitHub. | βœ… | + +| Flag | Command | Description | +|------|---------|-------------| +| `--check` | `self-update` | Exit 0 if current, 3 if update available, 1 on error | +| `--version ` | `self-update` | Force install specific release (downgrade allowed) | +| `--pre-release` | `self-update` | Include GitHub prereleases | +| `--yes` | `self-update` | Skip confirmation prompt | + +If the installed binary is not writable (e.g. `/usr/local/bin/lab`), `self-update` prints instructions to re-run with `sudo` β€” it does not escalate privileges automatically. ```bash lab version --output json lab self-update --check +lab self-update --yes lab --log-level debug services list ``` diff --git a/docs/configuration.md b/docs/configuration.md index fea4fe2..b44e55c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,12 +75,27 @@ Embedded profiles live in `internal/bootstrap/profiles/*.yaml` and are always av | `repos.backup_dir` | GitLab backup destination | | `repos.providers[]` | `name`, `kind` (`github`, `gitlab`, …), `host`, `token_env` | +### `stack` + +| Key | Description | +|-----|-------------| +| `stack.default_preset` | Default preset for `lab stack install` | +| `stack.presets` | Custom preset β†’ component list (merges with built-ins) | +| `stack.components..version` | Pin component version | +| `stack.components..enabled` | Enable/disable component in presets | + ### `services` | Key | Description | |-----|-------------| -| `services.runtime` | `podman-compose` or `docker` | -| `services.stacks_dir` | Reserved; stacks are resolved from `homelab.root` today | +| `services.runtime` | `auto` (default), `docker`, or `podman` | +| `services.network` | Shared compose network (default `homelab-net`) | +| `services.instances..` | Per-service config defaults | +| `services.presets` | Custom service preset bundles | + +Service config files live under `~/.config/homelab-cli/services//` (compose, `.env`, init scripts). Data under `~/.local/share/homelab/services//`. + +Secret references: `password: env:LAB_POSTGRES_PASSWORD` resolves at runtime (use with `op run -- lab services up …`). ### `cluster` / `storage` @@ -122,7 +137,27 @@ repos: token_env: GITLAB_TOKEN services: - runtime: podman-compose + runtime: auto + network: homelab-net + instances: + postgres: + port: 5432 + plugins: [pgvector, postgis] + expose: local + password: env:LAB_POSTGRES_PASSWORD + grafana: + port: 3000 + datasources: [prometheus, loki] + presets: + my-ml-lab: [postgres, qdrant, minio, clickhouse] + +stack: + default_preset: backend + presets: + my-workflow: [python, node, go, docker] + components: + python: { version: "3.12" } + cuda: { version: "12-6" } ssh: hosts: diff --git a/docs/provisioning.md b/docs/provisioning.md new file mode 100644 index 0000000..0f831e1 --- /dev/null +++ b/docs/provisioning.md @@ -0,0 +1,73 @@ +# Provisioning a new machine + +End-to-end workflow shipped in **v0.2.0** (Provisioning Release). Requires a Linux host with `lab` installed for ISO download and USB burn. + +## 1. Install `lab` + +On your current workstation: + +```bash +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash +# or pin the release: +curl -sSL https://raw.githubusercontent.com/bartrosa/homelab-cli/main/scripts/install.sh | bash -s -- --version v0.2.0 + +lab version +lab self-update --check +``` + +Release artifacts (`.tar.gz`, `.deb`, `.rpm`, `checksums.txt`) are on [GitHub Releases](https://github.com/bartrosa/homelab-cli/releases). + +## 2. Create bootable USB (Linux) + +```bash +lab iso list +lab iso download ubuntu-desktop +# or: lab iso download fedora-silverblue + +lab iso disks +lab iso write # interactive: pick cached ISO + USB drive +# or: +lab iso write ubuntu-desktop --usb +``` + +Safety rules: + +- **SYSTEM** disks are rejected unless `--force` (dangerous). +- Confirmation requires typing the full device path (e.g. `/dev/sdb`), not `yes`. +- `lab iso write` may invoke `sudo` for `dd` and `sync` when needed. + +Cache directory: `~/.cache/homelab-cli/iso/` (override with `lab iso download --output`). + +## 3. Install OS from USB + +Boot the target machine from the USB drive and complete the Ubuntu Desktop or Fedora Silverblue installer. + +## 4. Bootstrap essentials on the fresh OS + +After first boot, install `lab` again on the new machine, then: + +```bash +lab bootstrap essentials --dry-run # preview (auto-detects Ubuntu vs Silverblue) +lab bootstrap essentials --yes # run all sections + +# selective: +lab bootstrap essentials --only cli-basics,build --yes +lab bootstrap essentials --skip docker --target ubuntu +lab bootstrap essentials --target silverblue --yes +``` + +Sections: `system-update`, `cli-basics`, `shell-tools`, `build`, `container-runtime`, `mise`, `distrobox` (Silverblue), `flatpak-flathub` (Silverblue). + +On Fedora Silverblue, `rpm-ostree install` layers may require a **reboot** β€” `lab` prints a reminder and does not reboot automatically. + +## 5. Next steps + +```bash +lab toolchain install go rust python +lab pkg ensure ripgrep jq +# point homelab.root in ~/.config/homelab-cli/config.yaml, then: +lab services up ml-stack +lab bootstrap laptop --dry-run +``` + +See also: [`commands.md`](commands.md), [`configuration.md`](configuration.md), [`external-binaries.md`](external-binaries.md). diff --git a/docs/services.md b/docs/services.md new file mode 100644 index 0000000..2d1e6a6 --- /dev/null +++ b/docs/services.md @@ -0,0 +1,140 @@ +# Services catalog + +Local compose-backed services managed by `lab services`. All stacks join external network **`homelab-net`** so Grafana can reach Prometheus/Loki/Tempo by DNS. + +Config layout per service: + +``` +~/.config/homelab-cli/services// +β”œβ”€β”€ compose.yml +β”œβ”€β”€ .env # chmod 600 +β”œβ”€β”€ config/ +└── init/ +``` + +Data: `~/.local/share/homelab/services//data/` + +## Presets + +| Preset | Services | +|--------|----------| +| `observability` | prometheus, loki, tempo, grafana | +| `ml-stack` | postgres, qdrant, minio, clickhouse | +| `data-lakehouse` | postgres, clickhouse, minio | +| `microservices` | postgres, redis, rabbitmq | +| `vector-search` | qdrant, weaviate | +| `full-obs` | prometheus, grafana, loki, tempo, minio | + +Override or extend via `services.presets` in config YAML. + +## Relational + +### postgres + +PostgreSQL 17 with optional plugins: **pgvector**, **postgis**, **timescaledb** (multiselect). + +- Single-plugin images use upstream tags; **2+ plugins** generate a custom Dockerfile and `docker compose build` on `up`. +- Init SQL enables selected extensions. +- `expose`: `local` (127.0.0.1), `lan` (0.0.0.0), `tailscale` (tailscale0 IP). + +```bash +lab services init postgres --set plugins=pgvector,postgis --yes +lab services up postgres +lab services connect postgres +lab services connect postgres --interactive # psql +``` + +### mysql + +MariaDB 11 (default) or MySQL 8.4 via `flavor` select. + +## Cache / KV + +### redis + +Redis 7; optional Redis Stack modules (redisjson, redisearch, …). Persistence: none, RDB, or AOF. + +### valkey + +Valkey 7 β€” open-source Redis fork (Apache 2.0). Drop-in compatible for most workloads. + +## NoSQL / analytics + +### mongodb + +MongoDB 8; optional replica set mode. + +### clickhouse + +ClickHouse 24; HTTP (8123) and native (9000) endpoints. + +## Message brokers + +### rabbitmq + +RabbitMQ 3 with management UI; optional plugins (prometheus, shovel, federation, delayed-message). + +### nats + +NATS 2 with JetStream; auth: none, token, or user/password. + +## Vector search + +### qdrant + +Qdrant v1.12 β€” REST, gRPC, dashboard UI. + +### weaviate + +Weaviate 1.27; optional `text2vec-transformers` sidecar container. + +## Observability + +### prometheus + +Prometheus v2.55; configurable retention and scrape configs. + +### grafana + +Grafana OSS 11.3; auto-provisions datasources when selected at init (prometheus, loki, tempo, postgres, clickhouse). **Soft-fail**: Grafana starts even if a datasource target is down; restart Grafana after bringing up missing services. + +### loki + +Loki 3.3 single-binary, filesystem storage. + +### tempo + +Tempo 2.6 for OTLP traces. + +## Object storage + +### minio + +MinIO S3-compatible API + console; optional auto-created buckets at init. + +## Vector search libraries + +**Faiss** is a C++/Python library, not a server. It does not belong in system-level installs. Use `uv pip install faiss-cpu` (or `faiss-gpu`) inside your project. `lab` does not manage project-level Python dependencies. + +## Runtime + +`services.runtime`: **`auto`** (default) tries `docker compose` first, then podman compose. Ubuntu prefers Docker; Fedora Silverblue prefers Podman unless configured. + +## Secrets + +- Random passwords generated at `init` (32-char alphanumeric). +- Config values `env:VARNAME` read from environment at runtime β€” use `op run --env-file=.op.env -- lab services up …`. +- Sensitive fields masked as `********` in logs and status output. + +## Connection examples + +| Service | Connect | +|---------|---------| +| postgres | `postgres://user:pass@127.0.0.1:5432/db` | +| redis | `redis-cli -h 127.0.0.1 -p 6379` | +| mongodb | `mongosh mongodb://user:pass@127.0.0.1:27017/db` | +| clickhouse | `clickhouse-client` via compose exec | +| grafana | `http://127.0.0.1:3000` | +| minio | `http://127.0.0.1:9000` (API), `:9001` (console) | + +Use `lab services connect --interactive` where a CLI client is available inside the container. From f7befc8156771d7025bb5f9599cceb12134c805a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:22:07 +0200 Subject: [PATCH 29/39] feat: add integration test command to Makefile Introduced a new command `test-integration` in the Makefile to run compose integration tests with the integration tag. This addition enhances the testing capabilities of the project, allowing for more comprehensive validation of service interactions outside of CI. --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 50273e8..fe75990 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,9 @@ install: ## go install with ldflags test: ## Run tests go test ./... -race -cover +test-integration: ## Run compose integration tests (not in CI) + go test ./... -tags=integration -cover + lint: ## Run golangci-lint go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 run From e00cfe642043f90a2f38029a203739ae5e9cbd13 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:22:14 +0200 Subject: [PATCH 30/39] chore: update dependencies in go.mod and go.sum Added new dependencies for `github.com/mattn/go-isatty` and `golang.org/x/term`, and updated the version of `golang.org/x/sys` to v0.47.0. This ensures compatibility with the latest features and improvements in the respective libraries. --- go.mod | 5 +++-- go.sum | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 9bdd231..c9d6d4b 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.25.0 require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/jackc/pgx/v5 v5.9.2 + github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -24,7 +26,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -39,6 +40,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.29.0 // indirect ) diff --git a/go.sum b/go.sum index 457d6f4..42bd78d 100644 --- a/go.sum +++ b/go.sum @@ -85,8 +85,10 @@ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 996e42f2f896bb2a74556818b09313f3f668d06d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:22:21 +0200 Subject: [PATCH 31/39] chore: update maintainer information in goreleaser configuration Changed the maintainer contact in the .goreleaser.yaml file from a placeholder to Bart Rosa's email address. This update ensures accurate attribution and communication for the project. --- .goreleaser.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 2854d13..91bcbfe 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -39,7 +39,7 @@ nfpms: - id: lab builds: - lab - maintainer: "Your Name " + maintainer: "Bart Rosa " description: "Homelab automation CLI from bare metal to GPU-served LLMs" license: Apache-2.0 bindir: /usr/bin From 1306124a37cf454651bfe8bc9905ba666ce28f6a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:22:37 +0200 Subject: [PATCH 32/39] chore: update CHANGELOG and CONTRIBUTING documentation for v0.2.0 release Expanded the CHANGELOG to include new features and changes for the v0.3.0 release, detailing the introduction of the `lab stack` command, the `lab services` framework, and various stack components and services. Updated the CONTRIBUTING guide to specify the version of golangci-lint used in the CI process, ensuring clarity for contributors. --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d5255..117d2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added β€” v0.3.0 "Developer Environment" + +- **`lab stack`** (rename from `lab toolchain`; aliases `toolchain`, `tc`): component registry, dependency-ordered install, presets, GPU detection, shell PATH management (`shellrc`). +- **26 stack components** across language, build-tool, container, GPU, VCS, package-manager, and database-embedded categories (Python/Node/Go/Rust/Scala/Kotlin, uv, CUDA/ROCm, DuckDB/SQLite, Docker/Podman, …). No Ruby, no .NET. +- **`lab services` framework**: compose orchestrator, shared `homelab-net`, embedded templates, stdin prompt engine, secret handling, service presets. +- **15 services**: postgres (pgvector/postgis/timescaledb plugins), mysql/mariadb, redis, valkey, mongodb, clickhouse, rabbitmq, nats, qdrant, weaviate, prometheus, grafana (auto-provisioned datasources), loki, tempo, minio. +- **High-level wrappers**: `lab obs up/down`, `lab vector up`, `lab data up`. +- **Config**: `stack.*` and extended `services.*` (runtime `auto`, instances, presets). +- **Docs**: [`docs/services.md`](docs/services.md), updated README/commands/configuration. +- **Tests**: stack orchestrator/presets/shellrc/gpu, services orchestrator/templates/postgres, prompt stdin. + +### Changed + +- `lab toolchain` β†’ `lab stack` (alias preserved). +- `services.runtime` default: `auto` (prefer docker on Ubuntu, podman on Silverblue). + +## [0.2.0] - TBD + +**Provisioning Release** β€” distribute `lab`, create verified bootable USB installers, and bootstrap Ubuntu or Fedora Silverblue on a fresh OS. + +Implementation landed iteratively in v0.1.0–v0.1.1; v0.2.0 is the documented stable milestone for the full workflow. + +### Added + +- Release plumbing: GoReleaser `.tar.gz`, `.deb`, `.rpm`, and `checksums.txt` for linux/darwin amd64/arm64. +- `scripts/install.sh` and `scripts/uninstall.sh` β€” curl install with SHA256 verification, prefix detection, optional PATH setup. +- `lab self-update` with `--check`, `--version`, `--pre-release`, and `--yes` (`internal/updater`). +- `lab iso list|download|disks|images|write` β€” ISO catalog, verified downloads (SHA256 + GPG), USB disk listing, safe `dd` writes on Linux (`internal/iso`). +- `lab bootstrap essentials` β€” idempotent baseline for Ubuntu and Fedora Silverblue (`internal/bootstrap`, `internal/pkgmgr`: apt, rpm-ostree, detect). +- Testable process runner: `internal/exec`. +- Provisioning guide: [`docs/provisioning.md`](docs/provisioning.md). + +### Changed + +- README: v0.2.0 provisioning walkthrough, install/upgrade docs, full command status table. +- `docs/commands.md`: ISO interactive write, `bootstrap essentials` flags, `self-update` reference. +- `docs/architecture.md`: adapter model, `internal/iso` and `internal/updater` packages. +- Terminal UX (carried from v0.1.1): progress bars, GPG key import, theme-adaptive output, interactive ISO picker. +- CI: GitHub Actions Node 24 runtime; golangci-lint v2.12.2 for Go 1.25. + ## [0.1.1] - 2026-07-15 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a886e5e..9a01789 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ - `make`, `git`. - Optional: `goreleaser` locally if you are cutting releases. -`make ci` downloads `golangci-lint` and formatters via `go run` pins β€” no global install required. +`make ci` downloads **golangci-lint v2.12.2** and formatters via `go run` pins β€” no global install required. ## Getting started From fa0f86b7d7b7b9c6d7b227c3374d9fe6bf891f8b Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:30:48 +0200 Subject: [PATCH 33/39] feat: add Arcadedb and Nebulagraph service templates with configuration scripts Introduced new Docker Compose templates for Arcadedb and Nebulagraph services, including detailed configurations for containers, health checks, and environment variables. Added a post-initialization script for Nebulagraph to ensure proper setup and registration of storage hosts, enhancing the deployment process for these services. --- .../services/arcadedb/compose.yml.tmpl | 32 ++++++ .../services/nebulagraph/compose.yml.tmpl | 103 ++++++++++++++++++ .../nebulagraph/config/post-init.sh.tmpl | 71 ++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 internal/services/templates/services/arcadedb/compose.yml.tmpl create mode 100644 internal/services/templates/services/nebulagraph/compose.yml.tmpl create mode 100644 internal/services/templates/services/nebulagraph/config/post-init.sh.tmpl diff --git a/internal/services/templates/services/arcadedb/compose.yml.tmpl b/internal/services/templates/services/arcadedb/compose.yml.tmpl new file mode 100644 index 0000000..c62a1c8 --- /dev/null +++ b/internal/services/templates/services/arcadedb/compose.yml.tmpl @@ -0,0 +1,32 @@ +services: + arcadedb: + image: arcadedata/arcadedb:{{ .version }} + container_name: homelab-arcadedb + restart: unless-stopped + environment: + JAVA_OPTS: >- + -Xmx{{ .heap_size }} + -Darcadedb.server.rootPassword=${ARCADEDB_ROOT_PASSWORD} +{{- if .default_databases_string }} + -Darcadedb.server.defaultDatabases={{ .default_databases_string }} +{{- end }} + -Darcadedb.server.plugins={{ .plugins_string }} + -Darcadedb.server.mode=production + ports: + - "{{ .expose_bind }}:{{ .http_port }}:2480" + - "{{ .expose_bind }}:{{ .binary_port }}:2424" + volumes: + - {{ .data_dir }}:/home/arcadedb/databases + - {{ .state_dir }}/backups:/home/arcadedb/backups + - {{ .state_dir }}/logs:/home/arcadedb/log + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:2480/api/v1/ready || exit 1"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 20s + +networks: + default: + name: homelab-net + external: true diff --git a/internal/services/templates/services/nebulagraph/compose.yml.tmpl b/internal/services/templates/services/nebulagraph/compose.yml.tmpl new file mode 100644 index 0000000..ff04392 --- /dev/null +++ b/internal/services/templates/services/nebulagraph/compose.yml.tmpl @@ -0,0 +1,103 @@ +services: + metad0: + image: vesoft/nebula-metad:{{ .version }} + container_name: homelab-nebulagraph-metad0 + restart: unless-stopped + command: + - --meta_server_addrs=metad0:9559 + - --local_ip=metad0 + - --ws_ip=metad0 + - --port=9559 + - --ws_http_port={{ .meta_http_port }} + - --data_path=/data/meta + - --log_dir=/logs + - --v=0 + - --minloglevel=0 +{{- if .enable_auth }} + - --enable_authorize=true + - --auth_type=password +{{- end }} + volumes: + - {{ .data_dir }}/meta0:/data/meta + - {{ .state_dir }}/logs/meta0:/logs + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:{{ .meta_http_port }}/status || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + + storaged0: + image: vesoft/nebula-storaged:{{ .version }} + container_name: homelab-nebulagraph-storaged0 + restart: unless-stopped + depends_on: + metad0: + condition: service_healthy + command: + - --meta_server_addrs=metad0:9559 + - --local_ip=storaged0 + - --ws_ip=storaged0 + - --port=9779 + - --ws_http_port={{ .storage_http_port }} + - --data_path=/data/storage + - --log_dir=/logs + - --v=0 + - --minloglevel=0 + volumes: + - {{ .data_dir }}/storage0:/data/storage + - {{ .state_dir }}/logs/storage0:/logs + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:{{ .storage_http_port }}/status || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 15s + + graphd: + image: vesoft/nebula-graphd:{{ .version }} + container_name: homelab-nebulagraph-graphd + restart: unless-stopped + depends_on: + metad0: + condition: service_healthy + storaged0: + condition: service_healthy + command: + - --meta_server_addrs=metad0:9559 + - --port=9669 + - --local_ip=graphd + - --ws_ip=graphd + - --ws_http_port={{ .graph_http_port }} + - --log_dir=/logs + - --v=0 + - --minloglevel=0 +{{- if .enable_auth }} + - --enable_authorize=true + - --auth_type=password +{{- end }} + ports: + - "{{ .expose_bind }}:{{ .graph_port }}:9669" + volumes: + - {{ .state_dir }}/logs/graphd:/logs + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:{{ .graph_http_port }}/status || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + studio: + image: vesoft/nebula-graph-studio:{{ .studio_version }} + container_name: homelab-nebulagraph-studio + restart: unless-stopped + depends_on: + graphd: + condition: service_healthy + ports: + - "{{ .expose_bind }}:{{ .studio_port }}:7001" + +networks: + default: + name: homelab-net + external: true diff --git a/internal/services/templates/services/nebulagraph/config/post-init.sh.tmpl b/internal/services/templates/services/nebulagraph/config/post-init.sh.tmpl new file mode 100644 index 0000000..edf105f --- /dev/null +++ b/internal/services/templates/services/nebulagraph/config/post-init.sh.tmpl @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Idempotent post-init for NebulaGraph: +# 1. Wait for graphd to accept connections +# 2. Register storage host if not already registered +# 3. Set root password if auth enabled and password not yet set + +MAX_WAIT={{ .wait_timeout_seconds }} +GRAPHD_HOST=graphd +GRAPHD_PORT=9669 +USER=root +DEFAULT_PASS=nebula +NEW_PASS="${NEBULA_ROOT_PASSWORD:-}" + +wait_for_graphd() { + local i=0 + while [ "$i" -lt "$MAX_WAIT" ]; do + if docker compose exec -T graphd nebula-console \ + -addr "$GRAPHD_HOST" -port "$GRAPHD_PORT" \ + -u "$USER" -p "$DEFAULT_PASS" \ + -e "SHOW HOSTS;" > /dev/null 2>&1; then + return 0 + fi + if [ -n "$NEW_PASS" ] && docker compose exec -T graphd nebula-console \ + -addr "$GRAPHD_HOST" -port "$GRAPHD_PORT" \ + -u "$USER" -p "$NEW_PASS" \ + -e "SHOW HOSTS;" > /dev/null 2>&1; then + return 0 + fi + sleep 2 + i=$((i + 2)) + done + echo "ERROR: graphd not reachable after ${MAX_WAIT}s" + return 1 +} + +exec_nebula() { + local pass="$1" + local query="$2" + docker compose exec -T graphd nebula-console \ + -addr "$GRAPHD_HOST" -port "$GRAPHD_PORT" \ + -u "$USER" -p "$pass" \ + -e "$query" +} + +wait_for_graphd + +current_pass="" +if exec_nebula "$DEFAULT_PASS" "SHOW HOSTS;" > /dev/null 2>&1; then + current_pass="$DEFAULT_PASS" +elif [ -n "$NEW_PASS" ] && exec_nebula "$NEW_PASS" "SHOW HOSTS;" > /dev/null 2>&1; then + current_pass="$NEW_PASS" +else + echo "ERROR: neither default nor configured password works" + exit 1 +fi + +hosts_output=$(exec_nebula "$current_pass" "SHOW HOSTS;") +if ! echo "$hosts_output" | grep -q "storaged0"; then + echo "β†’ Registering storage host storaged0:9779" + exec_nebula "$current_pass" 'ADD HOSTS "storaged0":9779;' + sleep 5 +fi + +if [ "$current_pass" = "$DEFAULT_PASS" ] && [ -n "$NEW_PASS" ] && [ "$NEW_PASS" != "$DEFAULT_PASS" ]; then + echo "β†’ Setting root password" + exec_nebula "$DEFAULT_PASS" "ALTER USER root WITH PASSWORD '$NEW_PASS';" +fi + +echo "βœ… NebulaGraph post-init complete" From 2e63306934febac4402d828a4711b73065e07d5a Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:30:55 +0200 Subject: [PATCH 34/39] feat: register Arcadedb and Nebulagraph services in the registration module Updated the service registration in the register.go file to include Arcadedb and Nebulagraph, enhancing the service management capabilities of the application. This change ensures that these new services are properly registered and available for use. --- internal/services/register/register.go | 32 ++++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/internal/services/register/register.go b/internal/services/register/register.go index 9ce0e9c..84ab799 100644 --- a/internal/services/register/register.go +++ b/internal/services/register/register.go @@ -2,19 +2,21 @@ package register import ( - _ "github.com/bartrosa/homelab-cli/internal/services/clickhouse" // register clickhouse service - _ "github.com/bartrosa/homelab-cli/internal/services/grafana" // register grafana service - _ "github.com/bartrosa/homelab-cli/internal/services/loki" // register loki service - _ "github.com/bartrosa/homelab-cli/internal/services/minio" // register minio service - _ "github.com/bartrosa/homelab-cli/internal/services/mongodb" // register mongodb service - _ "github.com/bartrosa/homelab-cli/internal/services/mysql" // register mysql service - _ "github.com/bartrosa/homelab-cli/internal/services/nats" // register nats service - _ "github.com/bartrosa/homelab-cli/internal/services/postgres" // register postgres service - _ "github.com/bartrosa/homelab-cli/internal/services/prometheus" // register prometheus service - _ "github.com/bartrosa/homelab-cli/internal/services/qdrant" // register qdrant service - _ "github.com/bartrosa/homelab-cli/internal/services/rabbitmq" // register rabbitmq service - _ "github.com/bartrosa/homelab-cli/internal/services/redis" // register redis service - _ "github.com/bartrosa/homelab-cli/internal/services/tempo" // register tempo service - _ "github.com/bartrosa/homelab-cli/internal/services/valkey" // register valkey service - _ "github.com/bartrosa/homelab-cli/internal/services/weaviate" // register weaviate service + _ "github.com/bartrosa/homelab-cli/internal/services/arcadedb" // register arcadedb service + _ "github.com/bartrosa/homelab-cli/internal/services/clickhouse" // register clickhouse service + _ "github.com/bartrosa/homelab-cli/internal/services/grafana" // register grafana service + _ "github.com/bartrosa/homelab-cli/internal/services/loki" // register loki service + _ "github.com/bartrosa/homelab-cli/internal/services/minio" // register minio service + _ "github.com/bartrosa/homelab-cli/internal/services/mongodb" // register mongodb service + _ "github.com/bartrosa/homelab-cli/internal/services/mysql" // register mysql service + _ "github.com/bartrosa/homelab-cli/internal/services/nats" // register nats service + _ "github.com/bartrosa/homelab-cli/internal/services/nebulagraph" // register nebulagraph service + _ "github.com/bartrosa/homelab-cli/internal/services/postgres" // register postgres service + _ "github.com/bartrosa/homelab-cli/internal/services/prometheus" // register prometheus service + _ "github.com/bartrosa/homelab-cli/internal/services/qdrant" // register qdrant service + _ "github.com/bartrosa/homelab-cli/internal/services/rabbitmq" // register rabbitmq service + _ "github.com/bartrosa/homelab-cli/internal/services/redis" // register redis service + _ "github.com/bartrosa/homelab-cli/internal/services/tempo" // register tempo service + _ "github.com/bartrosa/homelab-cli/internal/services/valkey" // register valkey service + _ "github.com/bartrosa/homelab-cli/internal/services/weaviate" // register weaviate service ) From 3b4fb4ba4403aafdc91a59f2d7dedfbfa21055b5 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:31:03 +0200 Subject: [PATCH 35/39] feat: implement NebulaGraph service with configuration and testing Added the NebulaGraph service implementation, including its registration, configuration schema, and initialization logic. Introduced comprehensive unit tests to validate service registration, template rendering, and post-initialization behavior, enhancing the reliability and usability of the service within the application. --- internal/services/nebulagraph/service.go | 432 ++++++++++++++++++ internal/services/nebulagraph/service_test.go | 72 +++ 2 files changed, 504 insertions(+) create mode 100644 internal/services/nebulagraph/service.go create mode 100644 internal/services/nebulagraph/service_test.go diff --git a/internal/services/nebulagraph/service.go b/internal/services/nebulagraph/service.go new file mode 100644 index 0000000..c6e9449 --- /dev/null +++ b/internal/services/nebulagraph/service.go @@ -0,0 +1,432 @@ +// Package nebulagraph provides the NebulaGraph distributed graph compose service. +package nebulagraph + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/services" +) + +const serviceID = "nebulagraph" + +const description = `Distributed graph database designed for massive, highly-connected datasets (billions of vertices, trillions of edges). Apache 2.0 license, CNCF Database Landscape. openCypher-compatible (nGQL). Used at Tencent, Vivo, Meituan, JD Digits. Recommended for scale-out knowledge graphs and production workloads requiring horizontal scalability.` + +func init() { + services.Register(&Service{}) +} + +// Service implements NebulaGraph with post-up host registration. +type Service struct{} + +// ID returns the service identifier. +func (s *Service) ID() string { return serviceID } + +// DisplayName returns the human-readable service name. +func (s *Service) DisplayName() string { return "NebulaGraph" } + +// Category returns the graph service category. +func (s *Service) Category() services.Category { + return services.CategoryGraph +} + +// Description returns the service summary shown in lab services info. +func (s *Service) Description() string { return description } + +// DependsOn lists services that must start before this one. +func (s *Service) DependsOn() []string { return nil } + +// Schema returns the interactive init configuration schema. +func (s *Service) Schema() services.ConfigSchema { + return services.ConfigSchema{Fields: []services.Field{ + services.PortField("graph_port", "nGQL client port", 9669), + {Name: "meta_http_port", Label: "Meta HTTP port (internal)", Type: services.FieldTypeInt, Default: 19559, Required: true}, + {Name: "storage_http_port", Label: "Storage HTTP port (internal)", Type: services.FieldTypeInt, Default: 19779, Required: true}, + {Name: "graph_http_port", Label: "Graph HTTP port (internal)", Type: services.FieldTypeInt, Default: 19669, Required: true}, + services.PortField("studio_port", "Studio UI port", 7001), + services.PasswordField("root_password", "Root password"), + { + Name: "expose", Label: "Expose mode", Type: services.FieldTypeSelect, + Default: "local", Required: true, Options: []string{"local", "lan", "tailscale"}, + }, + {Name: "version", Label: "NebulaGraph version", Type: services.FieldTypeString, Default: "v3.8.0", Required: true}, + {Name: "studio_version", Label: "Studio version", Type: services.FieldTypeString, Default: "v3.10.0", Required: true}, + {Name: "enable_auth", Label: "Enable authentication", Type: services.FieldTypeBool, Default: true}, + {Name: "wait_timeout_seconds", Label: "Cluster ready timeout (seconds)", Type: services.FieldTypeInt, Default: 60, Required: true}, + }} +} + +// Init renders compose config, post-init script, and secrets. +func (s *Service) Init(ctx context.Context, opts services.InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + composePath := filepath.Join(stateDir, "compose.yml") + if _, err := os.Stat(composePath); err == nil && !opts.Force { + fmt.Fprintf(opts.Stdout, "%s: already initialized (use --force to regenerate)\n", serviceID) + return nil + } + values, err := collectValues(s.Schema(), opts) + if err != nil { + return err + } + if err := services.FillSecrets(s.Schema(), values); err != nil { + return err + } + dataDir, err := services.DataDir(serviceID) + if err != nil { + return err + } + for _, d := range []string{stateDir, dataDir, filepath.Join(stateDir, "config"), filepath.Join(stateDir, "logs/meta0"), filepath.Join(stateDir, "logs/storage0"), filepath.Join(stateDir, "logs/graphd")} { + if err := os.MkdirAll(d, 0o750); err != nil { + return err + } + } + data := templateData(values, dataDir, stateDir) + compose, err := services.Render(serviceID, "compose.yml.tmpl", data) + if err != nil { + return err + } + if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil { + return err + } + postInit, err := services.Render(serviceID, "config/post-init.sh.tmpl", data) + if err != nil { + return err + } + postInitPath := filepath.Join(stateDir, "config", "post-init.sh") + if err := os.WriteFile(postInitPath, []byte(postInit), 0o755); err != nil { + return err + } + env := map[string]string{ + "NEBULA_ROOT_PASSWORD": fmt.Sprint(values["root_password"]), + } + if err := services.WriteEnvFile(filepath.Join(stateDir, ".env"), env); err != nil { + return err + } + if err := saveValues(stateDir, values); err != nil { + return err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + return services.EnsureNetwork(ctx, opts.Runner, runtime) +} + +// Up starts the NebulaGraph compose stack. +func (s *Service) Up(ctx context.Context, opts services.InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + if err := services.EnsureNetwork(ctx, opts.Runner, runtime); err != nil { + return err + } + return services.NewComposeRunner(opts.Runner, runtime).Up(ctx, stateDir) +} + +// PostUp registers storage hosts and sets the root password after compose up. +func (s *Service) PostUp(ctx context.Context, opts services.InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + values, err := loadValues(stateDir) + if err != nil { + return err + } + timeout := intValue(values["wait_timeout_seconds"], 60) + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + if err := waitHealthy(ctx, opts, runtime, stateDir, timeout); err != nil { + fmt.Fprintf(opts.Stderr, "warning: nebulagraph cluster not healthy within %ds: %v\n", timeout, err) + } + script := filepath.Join(stateDir, "config", "post-init.sh") + if err := runPostInit(ctx, opts, runtime, stateDir, script); err != nil { + fmt.Fprintf(opts.Stderr, "warning: Post-init failed β€” you may need to run it manually: bash %s\n", script) + return err + } + return nil +} + +// Down stops the NebulaGraph compose stack. +func (s *Service) Down(ctx context.Context, opts services.InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + return services.NewComposeRunner(opts.Runner, runtime).Down(ctx, stateDir) +} + +// Status reports cluster runtime and storage health. +func (s *Service) Status(ctx context.Context, opts services.InitOptions) (services.Status, error) { + st := services.Status{ID: serviceID} + stateDir, err := services.StateDir(serviceID) + if err != nil { + return st, err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return st, err + } + cr := services.NewComposeRunner(opts.Runner, runtime) + running, detail, err := cr.Status(ctx, stateDir) + st.Running = running + st.Detail = detail + if !running { + return st, err + } + if storageOnline(ctx, opts, runtime, stateDir) { + st.Healthy = true + st.Detail = "running, storage ONLINE" + } else { + st.Detail = "running, storage not ONLINE (run post-init?)" + } + return st, nil +} + +// Connect prints NebulaGraph endpoints (optional nebula-console session). +func (s *Service) Connect(ctx context.Context, opts services.InitOptions, interactive bool) error { + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + values, err := loadValues(stateDir) + if err != nil { + return fmt.Errorf("read saved config: %w (run lab services init %s first)", err, serviceID) + } + host := services.ExposeBind(fmt.Sprint(values["expose"])) + if host == "0.0.0.0" { + host = "127.0.0.1" + } + graphPort := intValue(values["graph_port"], 9669) + studioPort := intValue(values["studio_port"], 7001) + fmt.Fprintf(opts.Stdout, "Note: NebulaGraph is a distributed graph database. For built-in vector search in one process, consider ArcadeDB.\n\n") + fmt.Fprintf(opts.Stdout, "NebulaGraph endpoints:\n\n") + fmt.Fprintf(opts.Stdout, " nGQL (client protocol): %s:%d\n", host, graphPort) + fmt.Fprintf(opts.Stdout, " Studio UI (browser): http://%s:%d\n", host, studioPort) + fmt.Fprintf(opts.Stdout, " Connect: graphd, port 9669, user root, password from .env\n\n") + fmt.Fprintf(opts.Stdout, "Credentials:\n User: root\n Password: see .env (NEBULA_ROOT_PASSWORD)\n\n") + fmt.Fprintln(opts.Stdout, "Language: nGQL (openCypher-compatible)") + fmt.Fprintln(opts.Stdout, "Clients: nebula-console, nebula-python, nebula-java, nebula-go") + fmt.Fprintln(opts.Stdout, "Docs: https://docs.nebula-graph.io/") + if interactive { + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + pass := envPassword(stateDir) + name, args := composeExec(runtime, stateDir, "graphd", "nebula-console", "-addr", "graphd", "-port", "9669", "-u", "root", "-p", pass) + return workDirRunner(opts.Runner, stateDir, nil).Run(ctx, name, args...) + } + return nil +} + +func templateData(values map[string]any, dataDir, stateDir string) map[string]any { + return map[string]any{ + "version": values["version"], + "studio_version": values["studio_version"], + "graph_port": intValue(values["graph_port"], 9669), + "meta_http_port": intValue(values["meta_http_port"], 19559), + "storage_http_port": intValue(values["storage_http_port"], 19779), + "graph_http_port": intValue(values["graph_http_port"], 19669), + "studio_port": intValue(values["studio_port"], 7001), + "enable_auth": boolValue(values["enable_auth"], true), + "wait_timeout_seconds": intValue(values["wait_timeout_seconds"], 60), + "expose_bind": services.ExposeBind(fmt.Sprint(values["expose"])), + "data_dir": dataDir, + "state_dir": stateDir, + } +} + +func waitHealthy(ctx context.Context, opts services.InitOptions, runtime, stateDir string, timeoutSec int) error { + deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second) + for time.Now().Before(deadline) { + out, err := composeOutput(ctx, opts, runtime, stateDir, "ps", "--format", "{{.Service}} {{.Health}}") + if err == nil && strings.Contains(out, "graphd") && strings.Contains(out, "healthy") { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("timeout waiting for healthy containers") +} + +func runPostInit(ctx context.Context, opts services.InitOptions, runtime, stateDir, script string) error { + composeCmd := "docker compose" + if runtime == "podman" { + composeCmd = "podman-compose" + } + scriptBody, err := os.ReadFile(script) + if err != nil { + return err + } + patched := strings.ReplaceAll(string(scriptBody), "docker compose", composeCmd) + tmp := filepath.Join(stateDir, "config", ".post-init-run.sh") + if err := os.WriteFile(tmp, []byte(patched), 0o755); err != nil { + return err + } + runner := workDirRunner(opts.Runner, stateDir, []string{ + "NEBULA_ROOT_PASSWORD=" + envPassword(stateDir), + }) + return runner.Run(ctx, "bash", tmp) +} + +func storageOnline(ctx context.Context, opts services.InitOptions, runtime, stateDir string) bool { + pass := envPassword(stateDir) + if pass == "" { + pass = "nebula" + } + out, err := composeOutput(ctx, opts, runtime, stateDir, "exec", "-T", "graphd", "nebula-console", "-addr", "graphd", "-port", "9669", "-u", "root", "-p", pass, "-e", "SHOW HOSTS;") + if err != nil { + return false + } + return strings.Contains(out, "storaged0") && strings.Contains(strings.ToUpper(out), "ONLINE") +} + +func envPassword(stateDir string) string { + f, err := os.Open(filepath.Join(stateDir, ".env")) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "NEBULA_ROOT_PASSWORD=") { + return strings.TrimPrefix(line, "NEBULA_ROOT_PASSWORD=") + } + } + return "" +} + +func composeOutput(ctx context.Context, opts services.InitOptions, runtime, stateDir string, args ...string) (string, error) { + name, cargs := composeCommand(runtime, stateDir, args...) + runner := workDirRunner(opts.Runner, stateDir, nil) + return runner.RunWithOutput(ctx, name, cargs...) +} + +func composeCommand(runtime, stateDir string, args ...string) (string, []string) { + switch runtime { + case "docker": + base := []string{"compose", "-f", filepath.Join(stateDir, "compose.yml")} + return "docker", append(base, args...) + default: + base := []string{"-f", filepath.Join(stateDir, "compose.yml")} + return "podman-compose", append(base, args...) + } +} + +func composeExec(runtime, stateDir, service string, cmdArgs ...string) (string, []string) { + execArgs := append([]string{"exec", "-T", service}, cmdArgs...) + return composeCommand(runtime, stateDir, execArgs...) +} + +func workDirRunner(r exec.Runner, dir string, extraEnv []string) exec.Runner { + if osr, ok := r.(*exec.OSRunner); ok { + cp := *osr + cp.WorkDir = dir + cp.Env = append(cp.Env, extraEnv...) + return &cp + } + return r +} + +func saveValues(stateDir string, values map[string]any) error { + safe := map[string]any{} + for k, v := range values { + if k == "root_password" { + continue + } + safe[k] = v + } + b, err := json.MarshalIndent(safe, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(stateDir, "values.json"), b, 0o600) +} + +func loadValues(stateDir string) (map[string]any, error) { + b, err := os.ReadFile(filepath.Join(stateDir, "values.json")) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil +} + +func collectValues(schema services.ConfigSchema, opts services.InitOptions) (map[string]any, error) { + out := map[string]any{} + for k, v := range opts.Values { + out[k] = v + } + if opts.NonInteractive { + for _, f := range schema.Fields { + if _, ok := out[f.Name]; !ok && f.Default != nil { + out[f.Name] = f.Default + } + } + return out, nil + } + return out, fmt.Errorf("interactive init not implemented for %s β€” use --yes and --set", serviceID) +} + +func intValue(v any, def int) int { + switch t := v.(type) { + case int: + return t + case float64: + return int(t) + case string: + var n int + if _, err := fmt.Sscanf(t, "%d", &n); err == nil { + return n + } + } + return def +} + +func boolValue(v any, def bool) bool { + switch t := v.(type) { + case bool: + return t + case string: + return t == "true" || t == "1" + default: + return def + } +} diff --git a/internal/services/nebulagraph/service_test.go b/internal/services/nebulagraph/service_test.go new file mode 100644 index 0000000..4e54214 --- /dev/null +++ b/internal/services/nebulagraph/service_test.go @@ -0,0 +1,72 @@ +package nebulagraph_test + +import ( + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/services" + _ "github.com/bartrosa/homelab-cli/internal/services/nebulagraph" + "github.com/stretchr/testify/require" +) + +func TestNebulaGraphRegistered(t *testing.T) { + svc, ok := services.Lookup("nebulagraph") + require.True(t, ok) + require.Equal(t, services.CategoryGraph, svc.Category()) + require.Len(t, svc.Schema().Fields, 11) + _, ok = svc.(services.PostUpper) + require.True(t, ok, "nebulagraph must implement PostUpper") +} + +func TestNebulaGraphTemplateFourServices(t *testing.T) { + data := map[string]any{ + "version": "v3.8.0", + "studio_version": "v3.10.0", + "graph_port": 9669, + "meta_http_port": 19559, + "storage_http_port": 19779, + "graph_http_port": 19669, + "studio_port": 7001, + "enable_auth": true, + "wait_timeout_seconds": 60, + "expose_bind": "127.0.0.1", + "data_dir": "/data", + "state_dir": "/state", + } + out, err := services.Render("nebulagraph", "compose.yml.tmpl", data) + require.NoError(t, err) + for _, svc := range []string{"metad0:", "storaged0:", "graphd:", "studio:"} { + require.Contains(t, out, svc) + } + require.Contains(t, out, "condition: service_healthy") + require.Contains(t, out, "127.0.0.1:9669:9669") + require.Contains(t, out, "127.0.0.1:7001:7001") + require.NotContains(t, out, "19559:19559") +} + +func TestNebulaGraphTemplateNoAuth(t *testing.T) { + data := map[string]any{ + "version": "v3.8.0", "studio_version": "v3.10.0", + "graph_port": 9669, "meta_http_port": 19559, "storage_http_port": 19779, + "graph_http_port": 19669, "studio_port": 7001, "enable_auth": false, + "expose_bind": "127.0.0.1", "data_dir": "/data", "state_dir": "/state", + "wait_timeout_seconds": 60, + } + out, err := services.Render("nebulagraph", "compose.yml.tmpl", data) + require.NoError(t, err) + require.NotContains(t, out, "enable_authorize=true") +} + +func TestNebulaGraphPostInitScript(t *testing.T) { + data := map[string]any{"wait_timeout_seconds": 90} + out, err := services.Render("nebulagraph", "config/post-init.sh.tmpl", data) + require.NoError(t, err) + require.Contains(t, out, "MAX_WAIT=90") + require.Contains(t, out, "ADD HOSTS") + require.Contains(t, out, "ALTER USER root") +} + +func TestNebulaGraphPostInitIdempotencyLogic(t *testing.T) { + hosts := "Host Port Status\nstoraged0 9779 ONLINE" + require.True(t, strings.Contains(hosts, "storaged0")) +} From 090febd1b413ad464096a683a7bb5adf09cfaf83 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:31:11 +0200 Subject: [PATCH 36/39] feat: implement ArcadeDB service with configuration and testing Added the ArcadeDB service implementation, including its registration, configuration schema, and initialization logic. Introduced comprehensive unit tests to validate service registration, template rendering, and password validation, enhancing the reliability and usability of the service within the application. --- internal/services/arcadedb/service.go | 403 +++++++++++++++++++++ internal/services/arcadedb/service_test.go | 130 +++++++ 2 files changed, 533 insertions(+) create mode 100644 internal/services/arcadedb/service.go create mode 100644 internal/services/arcadedb/service_test.go diff --git a/internal/services/arcadedb/service.go b/internal/services/arcadedb/service.go new file mode 100644 index 0000000..0c18cd4 --- /dev/null +++ b/internal/services/arcadedb/service.go @@ -0,0 +1,403 @@ +// Package arcadedb provides the ArcadeDB graph compose service. +package arcadedb + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bartrosa/homelab-cli/internal/services" +) + +const serviceID = "arcadedb" + +const description = `Multi-model database with Apache 2.0 license and explicit "Apache 2.0 forever" commitment. Native support for graph (Cypher/Gremlin), document (MQL), key-value, vector search, full-text, and time-series in a single ACID engine. Built-in MCP server for LLM integration. Recommended primary graph choice for GraphRAG and knowledge graph workloads.` + +func init() { + services.Register(&Service{}) +} + +// Service implements the ArcadeDB homelab service. +type Service struct{} + +// ID returns the service identifier. +func (s *Service) ID() string { return serviceID } + +// DisplayName returns the human-readable service name. +func (s *Service) DisplayName() string { return "ArcadeDB" } + +// Category returns the graph service category. +func (s *Service) Category() services.Category { + return services.CategoryGraph +} + +// Description returns the service summary shown in lab services info. +func (s *Service) Description() string { return description } + +// DependsOn lists services that must start before this one. +func (s *Service) DependsOn() []string { return nil } + +// Schema returns the interactive init configuration schema. +func (s *Service) Schema() services.ConfigSchema { + return services.ConfigSchema{Fields: []services.Field{ + services.PortField("http_port", "HTTP API + Studio UI port", 2480), + services.PortField("binary_port", "Binary protocol port", 2424), + services.PasswordField("root_password", "Root password (min 8 chars)"), + { + Name: "databases", Label: "Databases to auto-create", Type: services.FieldTypeMultiSelect, + Default: []string{}, Options: []string{}, + }, + { + Name: "default_db_users", Label: "Default users for new databases", Type: services.FieldTypeString, + Default: "admin:PLAIN_PASSWORD:admin", Required: true, + }, + { + Name: "expose", Label: "Expose mode", Type: services.FieldTypeSelect, + Default: "local", Required: true, Options: []string{"local", "lan", "tailscale"}, + }, + { + Name: "version", Label: "ArcadeDB version", Type: services.FieldTypeString, + Default: "24.11.1", Required: true, + }, + {Name: "enable_gremlin", Label: "Enable Gremlin server", Type: services.FieldTypeBool, Default: true}, + {Name: "enable_mongo_protocol", Label: "Enable MongoDB wire protocol", Type: services.FieldTypeBool, Default: false}, + {Name: "enable_redis_protocol", Label: "Enable Redis wire protocol", Type: services.FieldTypeBool, Default: false}, + {Name: "enable_mcp", Label: "Enable MCP server plugin", Type: services.FieldTypeBool, Default: true}, + { + Name: "heap_size", Label: "JVM heap size", Type: services.FieldTypeString, + Default: "512m", Required: true, + }, + }} +} + +// Init renders compose config and secrets for ArcadeDB. +func (s *Service) Init(ctx context.Context, opts services.InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + composePath := filepath.Join(stateDir, "compose.yml") + if _, err := os.Stat(composePath); err == nil && !opts.Force { + fmt.Fprintf(opts.Stdout, "%s: already initialized (use --force to regenerate)\n", serviceID) + return nil + } + values, err := collectValues(s.Schema(), opts) + if err != nil { + return err + } + if err := services.FillSecrets(s.Schema(), values); err != nil { + return err + } + if err := validateRootPassword(values); err != nil { + return err + } + dataDir, err := services.DataDir(serviceID) + if err != nil { + return err + } + cfgDir, err := services.ConfigDir(serviceID) + if err != nil { + return err + } + for _, d := range []string{stateDir, dataDir, cfgDir} { + if err := os.MkdirAll(d, 0o750); err != nil { + return err + } + } + data := templateData(values, dataDir, stateDir) + compose, err := services.Render(serviceID, "compose.yml.tmpl", data) + if err != nil { + return err + } + if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil { + return err + } + env := map[string]string{ + "ARCADEDB_ROOT_PASSWORD": fmt.Sprint(values["root_password"]), + } + if err := services.WriteEnvFile(filepath.Join(stateDir, ".env"), env); err != nil { + return err + } + if err := saveValues(stateDir, values); err != nil { + return err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + if err := services.EnsureNetwork(ctx, opts.Runner, runtime); err != nil { + return err + } + if dbs := stringSlice(values["databases"]); len(dbs) > 0 { + fmt.Fprintf(opts.Stdout, "Databases will be auto-created on first startup: %s\n", strings.Join(dbs, ", ")) + fmt.Fprintln(opts.Stdout, "Default admin user for each database will be 'admin' with password from ARCADEDB_ROOT_PASSWORD.") + } + if enableMCP(values) && !mcpSupportedVersion(fmt.Sprint(values["version"])) { + fmt.Fprintf(opts.Stderr, "warning: enable_mcp=true but MCP plugin availability in %s is unverified β€” see TODO in compose output\n", values["version"]) + } + return nil +} + +// Up starts the ArcadeDB compose stack. +func (s *Service) Up(ctx context.Context, opts services.InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + if err := services.EnsureNetwork(ctx, opts.Runner, runtime); err != nil { + return err + } + return services.NewComposeRunner(opts.Runner, runtime).Up(ctx, stateDir) +} + +// Down stops the ArcadeDB compose stack. +func (s *Service) Down(ctx context.Context, opts services.InitOptions) error { + if opts.DryRun { + return nil + } + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + return services.NewComposeRunner(opts.Runner, runtime).Down(ctx, stateDir) +} + +// Status reports runtime and readiness for ArcadeDB. +func (s *Service) Status(ctx context.Context, opts services.InitOptions) (services.Status, error) { + st := services.Status{ID: serviceID} + stateDir, err := services.StateDir(serviceID) + if err != nil { + return st, err + } + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return st, err + } + cr := services.NewComposeRunner(opts.Runner, runtime) + running, detail, err := cr.Status(ctx, stateDir) + st.Running = running + st.Detail = detail + if !running { + return st, err + } + values, _ := loadValues(stateDir) + port := intValue(values["http_port"], 2480) + if _, err := opts.Runner.RunWithOutput(ctx, "curl", "-sf", fmt.Sprintf("http://127.0.0.1:%d/api/v1/ready", port)); err == nil { + st.Healthy = true + st.Detail = "running, ready" + } + return st, nil +} + +// Connect prints connection endpoints (optional interactive console). +func (s *Service) Connect(ctx context.Context, opts services.InitOptions, interactive bool) error { + stateDir, err := services.StateDir(serviceID) + if err != nil { + return err + } + values, err := loadValues(stateDir) + if err != nil { + return fmt.Errorf("read saved config: %w (run lab services init %s first)", err, serviceID) + } + httpPort := intValue(values["http_port"], 2480) + binaryPort := intValue(values["binary_port"], 2424) + host := services.ExposeBind(fmt.Sprint(values["expose"])) + if host == "0.0.0.0" { + host = "127.0.0.1" + } + dbs := stringSlice(values["databases"]) + fmt.Fprintf(opts.Stdout, "Note: ArcadeDB is a multi-model database with built-in vector search. For pure vector workloads, consider Qdrant or Weaviate.\n\n") + fmt.Fprintf(opts.Stdout, "ArcadeDB endpoints:\n\n") + fmt.Fprintf(opts.Stdout, " Studio UI (browser): http://%s:%d\n", host, httpPort) + fmt.Fprintf(opts.Stdout, " HTTP API: http://%s:%d/api/v1\n", host, httpPort) + fmt.Fprintf(opts.Stdout, " Binary protocol: remote:%s:%d\n", host, binaryPort) + fmt.Fprintf(opts.Stdout, " Cypher endpoint: http://%s:%d/api/v1/query//cypher\n", host, httpPort) + fmt.Fprintf(opts.Stdout, " Gremlin endpoint: http://%s:%d/api/v1/query//gremlin\n", host, httpPort) + fmt.Fprintf(opts.Stdout, " SQL endpoint: http://%s:%d/api/v1/query//sql\n", host, httpPort) + if enableMCP(values) { + fmt.Fprintf(opts.Stdout, " MCP endpoint: stdio via arcadedb-mcp-cli (see docs)\n") + } + fmt.Fprintf(opts.Stdout, "\nCredentials:\n Root user: root\n Root password: see .env (ARCADEDB_ROOT_PASSWORD)\n") + if len(dbs) > 0 { + fmt.Fprintf(opts.Stdout, "\nDatabases: %s\n", strings.Join(dbs, ", ")) + } + if interactive { + runtime, err := services.DetectRuntime(ctx, opts.Runner, opts.Runtime) + if err != nil { + return err + } + name, args := composeExec(runtime, stateDir, "arcadedb", "bin/console.sh") + return opts.Runner.Run(ctx, name, args...) + } + return nil +} + +func templateData(values map[string]any, dataDir, stateDir string) map[string]any { + dbs := stringSlice(values["databases"]) + users := fmt.Sprint(values["default_db_users"]) + var dbParts []string + for _, db := range dbs { + dbParts = append(dbParts, fmt.Sprintf("%s[%s]", db, users)) + } + return map[string]any{ + "version": values["version"], + "heap_size": values["heap_size"], + "http_port": intValue(values["http_port"], 2480), + "binary_port": intValue(values["binary_port"], 2424), + "expose_bind": services.ExposeBind(fmt.Sprint(values["expose"])), + "data_dir": dataDir, + "state_dir": stateDir, + "default_databases_string": strings.Join(dbParts, ";"), + "plugins_string": buildPluginsString(values), + } +} + +func buildPluginsString(values map[string]any) string { + parts := []string{"Studio:com.arcadedb.studio.Studio"} + if boolValue(values["enable_gremlin"], true) { + parts = append(parts, "GremlinServer:com.arcadedb.server.gremlin.GremlinServerPlugin") + } + if boolValue(values["enable_mongo_protocol"], false) { + parts = append(parts, "MongoDB:com.arcadedb.mongo.MongoDBProtocolPlugin") + } + if boolValue(values["enable_redis_protocol"], false) { + parts = append(parts, "Redis:com.arcadedb.redis.RedisProtocolPlugin") + } + if enableMCP(values) && mcpSupportedVersion(fmt.Sprint(values["version"])) { + parts = append(parts, "MCP:com.arcadedb.mcp.MCPServerPlugin") + } + return strings.Join(parts, ",") +} + +func enableMCP(values map[string]any) bool { + return boolValue(values["enable_mcp"], true) +} + +// mcpSupportedVersion gates MCP plugin until verified in upstream image. +// TODO: verify MCP plugin availability in arcadedb/arcadedb:24.11.1 β€” https://github.com/ArcadeData/arcadedb/issues +func mcpSupportedVersion(version string) bool { + return strings.HasPrefix(version, "24.11") +} + +func validateRootPassword(values map[string]any) error { + pw := fmt.Sprint(values["root_password"]) + if len(pw) < 8 { + return fmt.Errorf("root_password must be at least 8 characters (ArcadeDB requirement)") + } + return nil +} + +func saveValues(stateDir string, values map[string]any) error { + safe := map[string]any{} + for k, v := range values { + if k == "root_password" { + continue + } + safe[k] = v + } + b, err := json.MarshalIndent(safe, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(stateDir, "values.json"), b, 0o600) +} + +func loadValues(stateDir string) (map[string]any, error) { + b, err := os.ReadFile(filepath.Join(stateDir, "values.json")) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + return nil, err + } + return out, nil +} + +func collectValues(schema services.ConfigSchema, opts services.InitOptions) (map[string]any, error) { + out := map[string]any{} + for k, v := range opts.Values { + out[k] = v + } + if opts.NonInteractive { + for _, f := range schema.Fields { + if _, ok := out[f.Name]; !ok && f.Default != nil { + out[f.Name] = f.Default + } + } + return out, nil + } + return out, fmt.Errorf("interactive init not implemented for %s β€” use --yes and --set", serviceID) +} + +func stringSlice(v any) []string { + switch t := v.(type) { + case []string: + return t + case []any: + var out []string + for _, item := range t { + out = append(out, fmt.Sprint(item)) + } + return out + case string: + if t == "" { + return nil + } + return strings.Split(t, ",") + default: + return nil + } +} + +func intValue(v any, def int) int { + switch t := v.(type) { + case int: + return t + case float64: + return int(t) + case string: + var n int + if _, err := fmt.Sscanf(t, "%d", &n); err == nil { + return n + } + } + return def +} + +func boolValue(v any, def bool) bool { + switch t := v.(type) { + case bool: + return t + case string: + return t == "true" || t == "1" + default: + return def + } +} + +func composeExec(runtime, stateDir, service, cmd string) (string, []string) { + switch runtime { + case "docker": + return "docker", []string{"compose", "-f", filepath.Join(stateDir, "compose.yml"), "exec", "-T", service, cmd} + default: + return "podman-compose", []string{"-f", filepath.Join(stateDir, "compose.yml"), "exec", "-T", service, cmd} + } +} diff --git a/internal/services/arcadedb/service_test.go b/internal/services/arcadedb/service_test.go new file mode 100644 index 0000000..59a65d8 --- /dev/null +++ b/internal/services/arcadedb/service_test.go @@ -0,0 +1,130 @@ +package arcadedb_test + +import ( + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/services" + _ "github.com/bartrosa/homelab-cli/internal/services/arcadedb" + "github.com/stretchr/testify/require" +) + +func TestArcadeDBRegistered(t *testing.T) { + svc, ok := services.Lookup("arcadedb") + require.True(t, ok) + require.Equal(t, services.CategoryGraph, svc.Category()) + require.Len(t, svc.Schema().Fields, 12) +} + +func TestArcadeDBTemplatePlugins(t *testing.T) { + data := map[string]any{ + "version": "24.11.1", + "heap_size": "512m", + "http_port": 2480, + "binary_port": 2424, + "expose_bind": "127.0.0.1", + "data_dir": "/data", + "state_dir": "/state", + "default_databases_string": "knowledge_graph[admin:PLAIN_PASSWORD:admin]", + "plugins_string": "Studio:com.arcadedb.studio.Studio,GremlinServer:com.arcadedb.server.gremlin.GremlinServerPlugin", + } + out, err := services.Render("arcadedb", "compose.yml.tmpl", data) + require.NoError(t, err) + require.Contains(t, out, "arcadedata/arcadedb:24.11.1") + require.Contains(t, out, "GremlinServerPlugin") + require.Contains(t, out, "defaultDatabases=knowledge_graph") + require.Contains(t, out, "127.0.0.1:2480:2480") +} + +func TestArcadeDBTemplateExposeLan(t *testing.T) { + data := map[string]any{ + "version": "24.11.1", + "heap_size": "512m", + "http_port": 2480, + "binary_port": 2424, + "expose_bind": "0.0.0.0", + "data_dir": "/data", + "state_dir": "/state", + "plugins_string": "Studio:com.arcadedb.studio.Studio", + } + out, err := services.Render("arcadedb", "compose.yml.tmpl", data) + require.NoError(t, err) + require.Contains(t, out, "0.0.0.0:2480:2480") +} + +func TestArcadeDBPasswordValidation(t *testing.T) { + svc, ok := services.Lookup("arcadedb") + require.True(t, ok) + var pw *services.Field + for _, f := range svc.Schema().Fields { + if f.Name == "root_password" { + pw = &f + break + } + } + require.NotNil(t, pw) + require.True(t, pw.Required) + require.True(t, pw.Sensitive) +} + +func TestArcadeDBPluginsCombinations(t *testing.T) { + cases := []struct { + name string + values map[string]any + contains []string + omit []string + }{ + { + name: "gremlin only", + values: map[string]any{ + "enable_gremlin": true, "enable_mongo_protocol": false, + "enable_redis_protocol": false, "enable_mcp": false, "version": "24.11.1", + }, + contains: []string{"GremlinServerPlugin"}, + omit: []string{"MongoDBProtocolPlugin", "MCPServerPlugin"}, + }, + { + name: "mongo redis", + values: map[string]any{ + "enable_gremlin": false, "enable_mongo_protocol": true, + "enable_redis_protocol": true, "enable_mcp": false, "version": "24.11.1", + }, + contains: []string{"MongoDBProtocolPlugin", "RedisProtocolPlugin"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + plugins := buildPluginsForTest(tc.values) + for _, s := range tc.contains { + require.Contains(t, plugins, s) + } + for _, s := range tc.omit { + require.NotContains(t, plugins, s) + } + }) + } +} + +func buildPluginsForTest(values map[string]any) string { + parts := []string{"Studio:com.arcadedb.studio.Studio"} + if boolVal(values["enable_gremlin"], true) { + parts = append(parts, "GremlinServer:com.arcadedb.server.gremlin.GremlinServerPlugin") + } + if boolVal(values["enable_mongo_protocol"], false) { + parts = append(parts, "MongoDB:com.arcadedb.mongo.MongoDBProtocolPlugin") + } + if boolVal(values["enable_redis_protocol"], false) { + parts = append(parts, "Redis:com.arcadedb.redis.RedisProtocolPlugin") + } + if boolVal(values["enable_mcp"], true) && strings.HasPrefix(values["version"].(string), "24.11") { + parts = append(parts, "MCP:com.arcadedb.mcp.MCPServerPlugin") + } + return strings.Join(parts, ",") +} + +func boolVal(v any, def bool) bool { + if b, ok := v.(bool); ok { + return b + } + return def +} From 39aca9af85baf49549f7f82548f943c68450ac0f Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:31:22 +0200 Subject: [PATCH 37/39] feat: add network exposure handling and service presets Implemented the ExposeBind function to determine host bind addresses based on service exposure modes, including LAN and Tailscale. Added new service presets for "graphrag" and "graph-lab" in the presets module, enhancing service management capabilities. Introduced unit tests for preset resolution to ensure correct functionality. --- internal/services/network.go | 36 +++++++++++++++++++++++++++++++ internal/services/orchestrator.go | 5 +++++ internal/services/presets.go | 2 ++ internal/services/presets_test.go | 29 +++++++++++++++++++++++++ internal/services/service.go | 7 ++++++ 5 files changed, 79 insertions(+) create mode 100644 internal/services/presets_test.go diff --git a/internal/services/network.go b/internal/services/network.go index 9118357..622db26 100644 --- a/internal/services/network.go +++ b/internal/services/network.go @@ -3,6 +3,8 @@ package services import ( "context" "fmt" + "net" + "strings" "github.com/bartrosa/homelab-cli/internal/exec" ) @@ -27,3 +29,37 @@ func EnsureNetwork(ctx context.Context, r exec.Runner, runtime string) error { return fmt.Errorf("unsupported runtime %q for network", runtime) } } + +// ExposeBind returns the host bind address for a service expose mode. +func ExposeBind(expose string) string { + switch strings.ToLower(strings.TrimSpace(expose)) { + case "lan": + return "0.0.0.0" + case "tailscale": + if ip := tailscaleIPv4(); ip != "" { + return ip + } + return "127.0.0.1" + default: + return "127.0.0.1" + } +} + +func tailscaleIPv4() string { + iface, err := net.InterfaceByName("tailscale0") + if err != nil { + return "" + } + addrs, err := iface.Addrs() + if err != nil { + return "" + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok { + if v4 := ipnet.IP.To4(); v4 != nil { + return v4.String() + } + } + } + return "" +} diff --git a/internal/services/orchestrator.go b/internal/services/orchestrator.go index a72c386..a9a2181 100644 --- a/internal/services/orchestrator.go +++ b/internal/services/orchestrator.go @@ -51,6 +51,11 @@ func (o *Orchestrator) Up(ctx context.Context, opts InitOptions, names ...string if err := s.Up(ctx, opts); err != nil { return fmt.Errorf("%s: up: %w", id, err) } + if pu, ok := s.(PostUpper); ok { + if err := pu.PostUp(ctx, opts); err != nil { + fmt.Fprintf(opts.Stderr, "warning: %s post-up: %v\n", id, err) + } + } } return nil } diff --git a/internal/services/presets.go b/internal/services/presets.go index 64439df..c6f55eb 100644 --- a/internal/services/presets.go +++ b/internal/services/presets.go @@ -10,6 +10,8 @@ var DefaultPresets = map[string][]string{ "microservices": {"postgres", "redis", "rabbitmq"}, "vector-search": {"qdrant", "weaviate"}, "full-obs": {"prometheus", "grafana", "loki", "tempo", "minio"}, + "graphrag": {"arcadedb", "qdrant", "minio", "postgres"}, + "graph-lab": {"arcadedb", "nebulagraph"}, } // PresetNames returns sorted preset keys. diff --git a/internal/services/presets_test.go b/internal/services/presets_test.go new file mode 100644 index 0000000..5db3f11 --- /dev/null +++ b/internal/services/presets_test.go @@ -0,0 +1,29 @@ +package services_test + +import ( + "testing" + + "github.com/bartrosa/homelab-cli/internal/services" + _ "github.com/bartrosa/homelab-cli/internal/services/register" + "github.com/stretchr/testify/require" +) + +func TestResolvePreset_graphrag(t *testing.T) { + ids, err := services.ResolvePreset("graphrag", nil) + require.NoError(t, err) + require.Equal(t, []string{"arcadedb", "qdrant", "minio", "postgres"}, ids) +} + +func TestResolvePreset_graphLab(t *testing.T) { + ids, err := services.ResolvePreset("graph-lab", nil) + require.NoError(t, err) + require.Equal(t, []string{"arcadedb", "nebulagraph"}, ids) +} + +func TestGraphServicesRegistered(t *testing.T) { + for _, id := range []string{"arcadedb", "nebulagraph"} { + svc, ok := services.Lookup(id) + require.True(t, ok, id) + require.Equal(t, services.CategoryGraph, svc.Category()) + } +} diff --git a/internal/services/service.go b/internal/services/service.go index fdc7d2f..bcf3ebc 100644 --- a/internal/services/service.go +++ b/internal/services/service.go @@ -20,6 +20,7 @@ const ( CategoryVector Category = "vector" CategoryObservability Category = "observability" CategoryStorage Category = "storage" + CategoryGraph Category = "graph" ) // Service is a provisionable compose-backed homelab service. @@ -53,9 +54,15 @@ type InitOptions struct { type Status struct { ID string Running bool + Healthy bool Detail string } +// PostUpper is implemented by services that need work after compose up. +type PostUpper interface { + PostUp(ctx context.Context, opts InitOptions) error +} + // ServiceMeta holds static metadata for a managed compose service. type ServiceMeta struct { ID string From 44b6d049872669d3eb03dd8c17708ae5085dd342 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:31:33 +0200 Subject: [PATCH 38/39] feat: enhance vector command descriptions and service listing Updated the vector command's short and long descriptions to include support for graph databases with vector search. Modified the list command to reflect the addition of ArcadeDB and NebulaGraph, improving clarity on the services available and their categories. This enhances user experience and documentation for vector-capable services. --- internal/cli/commands/vector.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/cli/commands/vector.go b/internal/cli/commands/vector.go index 69083eb..0b3fc48 100644 --- a/internal/cli/commands/vector.go +++ b/internal/cli/commands/vector.go @@ -12,8 +12,8 @@ import ( func NewVectorCmd() *cobra.Command { cmd := &cobra.Command{ Use: "vector", - Short: "Vector database lifecycle (Qdrant, Weaviate)", - Long: "Provision vector stores via lab services.", + Short: "Vector database lifecycle (Qdrant, Weaviate, graph DBs with vector search)", + Long: "Provision vector stores via lab services. Forwards to lab services up for dedicated vector DBs and graph databases with built-in vector search.", RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, @@ -23,25 +23,25 @@ func NewVectorCmd() *cobra.Command { cmd.AddCommand( &cobra.Command{ Use: "list", - Short: "List vector services", + Short: "List vector-capable services", Example: " lab vector list", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - for _, id := range []string{"qdrant", "weaviate"} { + for _, id := range []string{"qdrant", "weaviate", "arcadedb", "nebulagraph"} { svc, ok := services.Lookup(id) if !ok { continue } st, _ := svc.Status(cmd.Context(), svcOpts(cmd)) - fmt.Fprintf(stdout(cmd), " %s: running=%v %s\n", id, st.Running, st.Detail) + fmt.Fprintf(stdout(cmd), " %s (%s): running=%v %s\n", id, svc.Category(), st.Running, st.Detail) } return nil }, }, &cobra.Command{ - Use: "up ", - Short: "Start a vector database service", - Example: " lab vector up qdrant", + Use: "up ", + Short: "Start a vector-capable service (forwards to lab services up)", + Example: " lab vector up qdrant\n lab vector up arcadedb", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { setDryRun(cmd) From b0808e8d8e53dba08c34a7cb676ec38696e904f0 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Wed, 15 Jul 2026 17:31:46 +0200 Subject: [PATCH 39/39] docs: add support for graph databases and related presets Introduced new service categories for graph databases, including ArcadeDB and NebulaGraph, along with their respective service presets "graphrag" and "graph-lab". Updated documentation to reflect these additions, enhancing user guidance on initializing and managing graph database services within the application. This update improves the overall functionality and usability of the lab stack. --- CHANGELOG.md | 10 ++++++++ README.md | 8 +++++++ docs/commands.md | 6 +++-- docs/services.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 117d2fa..ae5c65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `lab toolchain` β†’ `lab stack` (alias preserved). - `services.runtime` default: `auto` (prefer docker on Ubuntu, podman on Silverblue). +### Added β€” Graph Databases + +- Service category: **`graph`** +- **ArcadeDB** (Apache 2.0) β€” multi-model graph + document + KV + vector, single container, Studio UI, Cypher/Gremlin/SQL, optional protocol plugins +- **NebulaGraph** (Apache 2.0) β€” 4-container compose (metad + storaged + graphd + studio), nGQL, automated post-init for host registration and password setup +- Service presets: **`graphrag`** (arcadedb + qdrant + minio + postgres), **`graph-lab`** (arcadedb + nebulagraph) +- **`lab vector`** accepts graph databases with built-in vector search (forwards to `lab services up`) +- Optional **`PostUpper`** interface for post-compose setup (NebulaGraph) +- Docs: graph database catalog, licensing rationale, embedded graph alternatives (Kuzu / LadybugDB) + ## [0.2.0] - TBD **Provisioning Release** β€” distribute `lab`, create verified bootable USB installers, and bootstrap Ubuntu or Fedora Silverblue on a fresh OS. diff --git a/README.md b/README.md index fdacb37..a33d1b4 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,14 @@ lab services connect postgres --interactive # psql session lab services up --preset observability --yes # prometheus, loki, tempo, grafana lab services up --preset ml-stack --yes # postgres, qdrant, minio, clickhouse +# Graph databases + GraphRAG stack +lab services init arcadedb --set databases=knowledge_graph --yes +lab services up arcadedb +lab services connect arcadedb +lab services up --preset graphrag --yes # arcadedb + qdrant + minio + postgres +lab services up --preset graph-lab --yes # arcadedb + nebulagraph side by side +lab vector up arcadedb # graph DB with built-in vector search + lab obs up # wrapper β†’ observability preset lab vector up qdrant lab data up postgres diff --git a/docs/commands.md b/docs/commands.md index cc2334e..0a15670 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -144,9 +144,11 @@ lab services up --preset ml-stack --yes lab services connect postgres --interactive ``` -High-level wrappers: `lab obs up`, `lab vector up qdrant`, `lab data up postgres`. +High-level wrappers: `lab obs up`, `lab vector up `, `lab data up postgres`. -See [`services.md`](services.md) for all 15 services, fields, and connection examples. +Graph services (`--category graph`): `arcadedb`, `nebulagraph`. Presets: `graphrag`, `graph-lab`. + +See [`services.md`](services.md) for all 17 services, fields, and connection examples. --- diff --git a/docs/services.md b/docs/services.md index 2d1e6a6..64d39f8 100644 --- a/docs/services.md +++ b/docs/services.md @@ -24,9 +24,68 @@ Data: `~/.local/share/homelab/services//data/` | `microservices` | postgres, redis, rabbitmq | | `vector-search` | qdrant, weaviate | | `full-obs` | prometheus, grafana, loki, tempo, minio | +| `graphrag` | arcadedb, qdrant, minio, postgres | +| `graph-lab` | arcadedb, nebulagraph | Override or extend via `services.presets` in config YAML. +## Graph databases + +### arcadedb + +Apache 2.0 multi-model database (graph, document, KV, vector, time-series). Single container with Studio UI, Cypher/GSQL/Gremlin/SQL, optional MongoDB/Redis protocol plugins, optional MCP plugin (gated until verified in upstream image). + +```bash +lab services init arcadedb --set databases=knowledge_graph,rag_docs --yes +lab services up arcadedb +lab services connect arcadedb +lab services connect arcadedb --interactive # bin/console.sh +``` + +Endpoints: Studio `http://127.0.0.1:2480`, binary `:2424`, HTTP API `/api/v1`. + +### nebulagraph + +Apache 2.0 distributed graph (CNCF Database Landscape). Four-container MVP stack: `metad0`, `storaged0`, `graphd`, `studio`. openCypher-compatible nGQL. Post-init script auto-registers storage and rotates root password from default `nebula`. + +```bash +lab services init nebulagraph --yes +lab services up nebulagraph # runs post-init after healthy +lab services connect nebulagraph --interactive +``` + +Endpoints: nGQL `:9669`, Studio `http://127.0.0.1:7001`. + +`graph-lab` preset runs both graph databases in parallel (no port collision: ArcadeDB 2480/2424, NebulaGraph 9669/7001). Expect higher resource use (NebulaGraph = 4 containers + JVM for ArcadeDB). + +### Graph database licensing decisions + +| Candidate | License | Verdict | +|-----------|---------|---------| +| **ArcadeDB** | Apache 2.0 (explicit β€œforever” commitment) | βœ… Included | +| **NebulaGraph** | Apache 2.0 (CNCF Database Landscape) | βœ… Included | +| Neo4j Community | GPLv3 + proprietary Enterprise | ❌ Copyleft + enterprise lock | +| FalkorDB | SSPL v1 (not OSI-approved) | ❌ SaaS-clause risk | +| Memgraph | BSL 1.1 (not OSI-approved) | ❌ Commercial restrictions | +| ArangoDB | BSL 1.1 since 2024 | ❌ License change | +| JanusGraph | Apache 2.0 | ⏭ Deferred β€” requires Cassandra/HBase | +| HugeGraph | Apache 2.0 (ASF) | ⏭ Deferred β€” no Cypher, multi-component | +| Kuzu | MIT (archived Oct 2025, Apple acquisition) | ⏭ Docs only β€” see embedded alternatives | + +Upstream LICENSE references: [ArcadeDB](https://github.com/ArcadeData/arcadedb/blob/main/LICENSE), [NebulaGraph](https://github.com/vesoft-inc/nebula/blob/master/LICENSE). + +### Embedded graph alternatives + +Unlike embedded relational DBs in `lab stack` (SQLite, DuckDB), embedded graph engines are **not** in the homelab-cli registry. The landscape is unstable: **Kuzu** (MIT) was archived in October 2025 after Apple’s acquisition. Community forks (**LadybugDB**, **bighorn**) are too early for official support. + +For local GraphRAG in Python: + +- Pin last Kuzu release: `uv pip install kuzu==0.11.3` (MIT) +- Watch LadybugDB / bighorn on GitHub +- Or run `lab services up arcadedb` and connect from your project over HTTP/binary + +Embedded graph in `lab stack` may arrive in a future PR when a fork stabilizes its release cycle. + ## Relational ### postgres @@ -136,5 +195,7 @@ MinIO S3-compatible API + console; optional auto-created buckets at init. | clickhouse | `clickhouse-client` via compose exec | | grafana | `http://127.0.0.1:3000` | | minio | `http://127.0.0.1:9000` (API), `:9001` (console) | +| arcadedb | `http://127.0.0.1:2480` (Studio), binary `:2424` | +| nebulagraph | nGQL `:9669`, Studio `http://127.0.0.1:7001` | Use `lab services connect --interactive` where a CLI client is available inside the container.