From 0343b43173fac29d7580399e3a9fb79286906a9d Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Sat, 18 Jul 2026 15:09:26 +0200 Subject: [PATCH 1/5] feat: add OpenTofu installation and orchestration functionality Introduced the OpenTofu package, which includes methods for installing the OpenTofu binary across different platforms (macOS, Linux, Windows) and orchestrating commands against IaC stack directories. Added comprehensive tests for installation detection methods and command execution, ensuring robust functionality and user experience. --- internal/tofu/doc.go | 4 + internal/tofu/install.go | 205 ++++++++++++++++++++++++++++++++++ internal/tofu/install_test.go | 118 +++++++++++++++++++ internal/tofu/run.go | 89 +++++++++++++++ internal/tofu/run_test.go | 66 +++++++++++ 5 files changed, 482 insertions(+) create mode 100644 internal/tofu/doc.go create mode 100644 internal/tofu/install.go create mode 100644 internal/tofu/install_test.go create mode 100644 internal/tofu/run.go create mode 100644 internal/tofu/run_test.go diff --git a/internal/tofu/doc.go b/internal/tofu/doc.go new file mode 100644 index 0000000..93d4b48 --- /dev/null +++ b/internal/tofu/doc.go @@ -0,0 +1,4 @@ +// Package tofu installs the OpenTofu binary and orchestrates tofu commands +// against IaC stack directories. Install delegates to OS-native methods; +// orchestration wraps the tofu CLI via the shared exec runner. +package tofu diff --git a/internal/tofu/install.go b/internal/tofu/install.go new file mode 100644 index 0000000..48c639a --- /dev/null +++ b/internal/tofu/install.go @@ -0,0 +1,205 @@ +package tofu + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/executil" + "github.com/bartrosa/homelab-cli/internal/platform" +) + +// Install method identifiers. +const ( + MethodBrew = "brew" + MethodScript = "script" + MethodSnap = "snap" + MethodWinget = "winget" +) + +const ( + opentofuBrewPkg = "opentofu" + opentofuWingetID = "OpenTofu.OpenTofu" + installScriptURL = "https://get.opentofu.org/install-opentofu.sh" +) + +// Method describes how OpenTofu will be installed on this host. +type Method struct { + Kind string // brew | script | snap | winget + Reason string + ScriptMethod string // deb | rpm when Kind is script (official installer flag; not "auto") +} + +// InstallOpts controls Install / Upgrade behaviour. +type InstallOpts struct { + Info platform.Info + DryRun bool + Stdout io.Writer + Stderr io.Writer +} + +// DetectMethod picks an install method from platform.Info. +// Snap is a Linux fallback when no native packager is detected. +func DetectMethod(info platform.Info) (Method, error) { + return DetectMethodWith(info, executil.CommandExists) +} + +// DetectMethodWith is like DetectMethod but uses hasCmd for binary presence checks (tests). +func DetectMethodWith(info platform.Info, hasCmd func(string) bool) (Method, error) { + return detectMethod(info, hasCmd) +} + +func detectMethod(info platform.Info, hasCmd func(string) bool) (Method, error) { + switch info.GOOS { + case platform.OSDarwin: + if info.Packager == platform.PackagerBrew || hasCmd("brew") { + return Method{ + Kind: MethodBrew, + Reason: "macOS with Homebrew", + }, nil + } + return Method{}, fmt.Errorf("Homebrew not found; install brew or OpenTofu manually") + + case platform.OSLinux: + switch info.Packager { + case platform.PackagerAPT: + return Method{ + Kind: MethodScript, + ScriptMethod: "deb", + Reason: "Linux with " + info.PackagerLabel() + "; official install-opentofu.sh (--install-method deb)", + }, nil + case platform.PackagerDNF: + return Method{ + Kind: MethodScript, + ScriptMethod: "rpm", + Reason: "Linux with " + info.PackagerLabel() + "; official install-opentofu.sh (--install-method rpm)", + }, nil + } + if hasCmd("snap") { + return Method{ + Kind: MethodSnap, + Reason: "Linux without apt/dnf; snap available", + }, nil + } + return Method{}, fmt.Errorf("no supported install method (need apt, dnf, rpm-ostree, or snap)") + + case "windows": + if hasCmd("winget") { + return Method{ + Kind: MethodWinget, + Reason: "Windows with winget", + }, nil + } + return Method{}, fmt.Errorf("winget not found; install OpenTofu manually") + + default: + return Method{}, fmt.Errorf("unsupported OS %q for OpenTofu install", info.GOOS) + } +} + +// PlannedCommands returns the argv lists that Install/Upgrade would run for method. +func PlannedCommands(method Method, upgrade bool) [][]string { + switch method.Kind { + case MethodBrew: + if upgrade { + return [][]string{{"brew", "upgrade", opentofuBrewPkg}} + } + return [][]string{{"brew", "install", opentofuBrewPkg}} + case MethodScript: + sm := method.ScriptMethod + if sm == "" { + sm = "standalone" + } + return [][]string{{"bash", "-c", officialInstallScript(sm)}} + case MethodSnap: + if upgrade { + return [][]string{{"sudo", "snap", "refresh", "opentofu"}} + } + return [][]string{{"sudo", "snap", "install", "--classic", "opentofu"}} + case MethodWinget: + if upgrade { + return [][]string{{"winget", "upgrade", "--id", opentofuWingetID}} + } + return [][]string{{"winget", "install", "--id", opentofuWingetID}} + default: + return nil + } +} + +func officialInstallScript(installMethod string) string { + // Official installer does not support --install-method auto; use deb/rpm/standalone/… + return strings.TrimSpace(` +set -euo pipefail +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +curl --proto '=https' --tlsv1.2 -fsSL ` + installScriptURL + ` -o "$tmp" +chmod +x "$tmp" +sudo "$tmp" --install-method ` + installMethod + ` +`) +} + +// IsInstalled reports whether `tofu` is on PATH and runnable. +// On success, version is the trimmed output of `tofu version`. +func IsInstalled(ctx context.Context, runner exec.Runner) (bool, string, error) { + out, err := runner.RunWithOutput(ctx, "tofu", "version") + if err != nil { + return false, "", nil + } + return true, strings.TrimSpace(out), nil +} + +// Install installs the OpenTofu binary using the method for opts.Info +// (or platform.Detect when Info.GOOS is empty). +func Install(ctx context.Context, runner exec.Runner, opts InstallOpts) error { + return installOrUpgrade(ctx, runner, opts, false) +} + +// Upgrade upgrades an existing OpenTofu install (brew upgrade / snap refresh / winget upgrade; +// script method re-runs the official installer). +func Upgrade(ctx context.Context, runner exec.Runner, opts InstallOpts) error { + return installOrUpgrade(ctx, runner, opts, true) +} + +func installOrUpgrade(ctx context.Context, runner exec.Runner, opts InstallOpts, upgrade bool) error { + info := opts.Info + if info.GOOS == "" { + info = platform.Detect() + } + method, err := DetectMethod(info) + if err != nil { + return err + } + + cmds := PlannedCommands(method, upgrade) + if opts.DryRun { + writeInstallPlan(opts.Stdout, method, upgrade, cmds) + return nil + } + + for _, argv := range cmds { + if len(argv) == 0 { + continue + } + if err := runner.Run(ctx, argv[0], argv[1:]...); err != nil { + return err + } + } + return nil +} + +func writeInstallPlan(w io.Writer, method Method, upgrade bool, cmds [][]string) { + if w == nil { + return + } + action := "install" + if upgrade { + action = "upgrade" + } + _, _ = fmt.Fprintf(w, "method: %s (%s)\n", method.Kind, method.Reason) + _, _ = fmt.Fprintf(w, "action: %s\n", action) + for _, argv := range cmds { + _, _ = fmt.Fprintf(w, " %s\n", strings.Join(argv, " ")) + } +} diff --git a/internal/tofu/install_test.go b/internal/tofu/install_test.go new file mode 100644 index 0000000..c0dde40 --- /dev/null +++ b/internal/tofu/install_test.go @@ -0,0 +1,118 @@ +package tofu_test + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/bartrosa/homelab-cli/internal/platform" + "github.com/bartrosa/homelab-cli/internal/tofu" + "github.com/stretchr/testify/require" +) + +func TestDetectMethod_brew(t *testing.T) { + m, err := tofu.DetectMethod(platform.Info{GOOS: platform.OSDarwin, Packager: platform.PackagerBrew}) + require.NoError(t, err) + require.Equal(t, tofu.MethodBrew, m.Kind) + require.Contains(t, m.Reason, "Homebrew") +} + +func TestDetectMethod_linuxScript(t *testing.T) { + cases := []struct { + packager string + script string + }{ + {platform.PackagerAPT, "deb"}, + {platform.PackagerDNF, "rpm"}, + } + for _, tc := range cases { + m, err := tofu.DetectMethod(platform.Info{GOOS: platform.OSLinux, Packager: tc.packager}) + require.NoError(t, err) + require.Equal(t, tofu.MethodScript, m.Kind) + require.Equal(t, tc.script, m.ScriptMethod) + cmds := tofu.PlannedCommands(m, false) + require.Len(t, cmds, 1) + require.Contains(t, cmds[0][2], "--install-method "+tc.script) + require.NotContains(t, cmds[0][2], "--install-method auto") + } +} + +func TestDetectMethod_linuxSnapFallback(t *testing.T) { + m, err := tofu.DetectMethodWith( + platform.Info{GOOS: platform.OSLinux, Packager: platform.PackagerUnknown}, + func(name string) bool { return name == "snap" }, + ) + require.NoError(t, err) + require.Equal(t, tofu.MethodSnap, m.Kind) + cmds := tofu.PlannedCommands(m, false) + require.Equal(t, []string{"sudo", "snap", "install", "--classic", "opentofu"}, cmds[0]) +} + +func TestDetectMethod_windowsWinget(t *testing.T) { + m, err := tofu.DetectMethodWith( + platform.Info{GOOS: "windows"}, + func(name string) bool { return name == "winget" }, + ) + require.NoError(t, err) + require.Equal(t, tofu.MethodWinget, m.Kind) + cmds := tofu.PlannedCommands(m, false) + require.Equal(t, []string{"winget", "install", "--id", "OpenTofu.OpenTofu"}, cmds[0]) + cmdsUp := tofu.PlannedCommands(m, true) + require.Equal(t, []string{"winget", "upgrade", "--id", "OpenTofu.OpenTofu"}, cmdsUp[0]) +} + +func TestDetectMethod_unsupported(t *testing.T) { + _, err := tofu.DetectMethod(platform.Info{GOOS: "plan9"}) + require.Error(t, err) +} + +func TestInstall_dryRun_doesNotCallRunner(t *testing.T) { + rec := &recordingRunner{} + var out bytes.Buffer + err := tofu.Install(context.Background(), rec, tofu.InstallOpts{ + Info: platform.Info{GOOS: platform.OSDarwin, Packager: platform.PackagerBrew}, + DryRun: true, + Stdout: &out, + }) + require.NoError(t, err) + require.Empty(t, rec.calls) + require.Contains(t, out.String(), "method: brew") + require.Contains(t, out.String(), "brew install opentofu") +} + +func TestUpgrade_dryRun_brew(t *testing.T) { + rec := &recordingRunner{} + var out bytes.Buffer + err := tofu.Upgrade(context.Background(), rec, tofu.InstallOpts{ + Info: platform.Info{GOOS: platform.OSDarwin, Packager: platform.PackagerBrew}, + DryRun: true, + Stdout: &out, + }) + require.NoError(t, err) + require.Empty(t, rec.calls) + require.Contains(t, out.String(), "brew upgrade opentofu") +} + +func TestInstall_brew_runsInstall(t *testing.T) { + rec := &recordingRunner{} + err := tofu.Install(context.Background(), rec, tofu.InstallOpts{ + Info: platform.Info{GOOS: platform.OSDarwin, Packager: platform.PackagerBrew}, + }) + require.NoError(t, err) + require.Equal(t, []string{"brew install opentofu"}, rec.calls) +} + +type recordingRunner struct { + calls []string +} + +func (r *recordingRunner) Run(_ context.Context, name string, args ...string) error { + r.calls = append(r.calls, name+" "+strings.Join(args, " ")) + return nil +} + +func (r *recordingRunner) RunWithOutput(ctx context.Context, name string, args ...string) (string, error) { + _ = r.Run(ctx, name, args...) + return "OpenTofu v1.9.0", nil +} diff --git a/internal/tofu/run.go b/internal/tofu/run.go new file mode 100644 index 0000000..0760c37 --- /dev/null +++ b/internal/tofu/run.go @@ -0,0 +1,89 @@ +package tofu + +import ( + "context" + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" +) + +// Stack is a directory containing OpenTofu (.tf) configuration. +type Stack struct { + Dir string +} + +// RunOpts controls orchestration of tofu against a stack. +// +// Env injects process environment variables for the tofu subprocess +// (e.g. TF_VAR_* / ROS_PASSWORD). Secrets come from op:// (1Password) +// injected by an `op run` wrapper — never from files on disk. +type RunOpts struct { + Env []string + AutoApprove bool + DryRun bool +} + +// Init runs `tofu -chdir= init`. +func Init(ctx context.Context, runner exec.Runner, stack Stack, opts RunOpts) error { + return runTofu(ctx, runner, stack, opts, "init") +} + +// Validate runs `tofu -chdir= validate`. +func Validate(ctx context.Context, runner exec.Runner, stack Stack, opts RunOpts) error { + return runTofu(ctx, runner, stack, opts, "validate") +} + +// Plan runs `tofu -chdir= plan`. +func Plan(ctx context.Context, runner exec.Runner, stack Stack, opts RunOpts) error { + return runTofu(ctx, runner, stack, opts, "plan") +} + +// Apply runs `tofu -chdir= apply`. When opts.AutoApprove is true, +// passes -auto-approve (CLI maps --yes to this). Default is interactive. +func Apply(ctx context.Context, runner exec.Runner, stack Stack, opts RunOpts) error { + extra := []string{} + if opts.AutoApprove { + extra = append(extra, "-auto-approve") + } + return runTofu(ctx, runner, stack, opts, "apply", extra...) +} + +// Fmt runs `tofu -chdir= fmt`. +func Fmt(ctx context.Context, runner exec.Runner, stack Stack, opts RunOpts) error { + return runTofu(ctx, runner, stack, opts, "fmt") +} + +// CommandLine returns the argv that would be passed to tofu for debugging / dry-run UI. +func CommandLine(stack Stack, subcommand string, extra ...string) []string { + args := []string{"-chdir=" + stack.Dir, subcommand} + args = append(args, extra...) + return args +} + +func runTofu(ctx context.Context, runner exec.Runner, stack Stack, opts RunOpts, subcommand string, extra ...string) error { + dir := strings.TrimSpace(stack.Dir) + if dir == "" { + return fmt.Errorf("stack directory is empty") + } + args := CommandLine(Stack{Dir: dir}, subcommand, extra...) + if opts.DryRun { + return nil + } + restore := applyEnv(runner, opts.Env) + defer restore() + return runner.Run(ctx, "tofu", args...) +} + +func applyEnv(runner exec.Runner, env []string) func() { + if len(env) == 0 { + return func() {} + } + or, ok := runner.(*exec.OSRunner) + if !ok { + return func() {} + } + prev := or.Env + or.Env = append(append([]string{}, prev...), env...) + return func() { or.Env = prev } +} diff --git a/internal/tofu/run_test.go b/internal/tofu/run_test.go new file mode 100644 index 0000000..77baf93 --- /dev/null +++ b/internal/tofu/run_test.go @@ -0,0 +1,66 @@ +package tofu_test + +import ( + "context" + "testing" + + "github.com/bartrosa/homelab-cli/internal/tofu" + "github.com/stretchr/testify/require" +) + +func TestCommandLine_chdir(t *testing.T) { + args := tofu.CommandLine(tofu.Stack{Dir: "/stacks/rb5009-core"}, "plan") + require.Equal(t, []string{"-chdir=/stacks/rb5009-core", "plan"}, args) +} + +func TestPlan_buildsChdirArgs(t *testing.T) { + rec := &recordingRunner{} + err := tofu.Plan(context.Background(), rec, tofu.Stack{Dir: "/tmp/stack"}, tofu.RunOpts{}) + require.NoError(t, err) + require.Equal(t, []string{"tofu -chdir=/tmp/stack plan"}, rec.calls) +} + +func TestApply_withoutYes_noAutoApprove(t *testing.T) { + rec := &recordingRunner{} + err := tofu.Apply(context.Background(), rec, tofu.Stack{Dir: "/tmp/stack"}, tofu.RunOpts{AutoApprove: false}) + require.NoError(t, err) + require.Equal(t, []string{"tofu -chdir=/tmp/stack apply"}, rec.calls) + require.NotContains(t, rec.calls[0], "-auto-approve") +} + +func TestApply_withYes_addsAutoApprove(t *testing.T) { + rec := &recordingRunner{} + err := tofu.Apply(context.Background(), rec, tofu.Stack{Dir: "/tmp/stack"}, tofu.RunOpts{AutoApprove: true}) + require.NoError(t, err) + require.Equal(t, []string{"tofu -chdir=/tmp/stack apply -auto-approve"}, rec.calls) +} + +func TestApply_dryRun_doesNotExecute(t *testing.T) { + rec := &recordingRunner{} + err := tofu.Apply(context.Background(), rec, tofu.Stack{Dir: "/tmp/stack"}, tofu.RunOpts{ + AutoApprove: true, + DryRun: true, + }) + require.NoError(t, err) + require.Empty(t, rec.calls) +} + +func TestInit_Validate_Fmt(t *testing.T) { + rec := &recordingRunner{} + stack := tofu.Stack{Dir: "hq-infra/tofu/stacks/rb5009-core"} + require.NoError(t, tofu.Init(context.Background(), rec, stack, tofu.RunOpts{})) + require.NoError(t, tofu.Validate(context.Background(), rec, stack, tofu.RunOpts{})) + require.NoError(t, tofu.Fmt(context.Background(), rec, stack, tofu.RunOpts{})) + require.Equal(t, []string{ + "tofu -chdir=hq-infra/tofu/stacks/rb5009-core init", + "tofu -chdir=hq-infra/tofu/stacks/rb5009-core validate", + "tofu -chdir=hq-infra/tofu/stacks/rb5009-core fmt", + }, rec.calls) +} + +func TestRun_emptyDir(t *testing.T) { + rec := &recordingRunner{} + err := tofu.Plan(context.Background(), rec, tofu.Stack{Dir: " "}, tofu.RunOpts{}) + require.Error(t, err) + require.Empty(t, rec.calls) +} From 99aa206872d793629fc4163f3ab6bacf4f94890e Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Sat, 18 Jul 2026 15:09:33 +0200 Subject: [PATCH 2/5] feat: add Tofu command for OpenTofu installation and stack orchestration Introduced the Tofu command, enabling users to install the OpenTofu binary and orchestrate Infrastructure as Code (IaC) stacks. The command includes subcommands for installation, version checking, and applying stack changes, enhancing the CLI's functionality for managing OpenTofu operations. --- internal/cli/commands/tofu.go | 181 ++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 internal/cli/commands/tofu.go diff --git a/internal/cli/commands/tofu.go b/internal/cli/commands/tofu.go new file mode 100644 index 0000000..619b0aa --- /dev/null +++ b/internal/cli/commands/tofu.go @@ -0,0 +1,181 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/bartrosa/homelab-cli/internal/exec" + "github.com/bartrosa/homelab-cli/internal/platform" + "github.com/bartrosa/homelab-cli/internal/tofu" + "github.com/bartrosa/homelab-cli/internal/ui" + "github.com/spf13/cobra" +) + +// NewTofuCmd wires OpenTofu install and stack orchestration. +func NewTofuCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "tofu", + Short: "Install OpenTofu and orchestrate IaC stacks", + Long: `Install the OpenTofu binary via OS-native methods (brew, official installer script, snap, winget), +then run tofu init/plan/apply/validate/fmt against a stack directory. + +Secrets for apply/plan (TF_VAR_*, passwords) should be injected by wrapping lab with +op run (1Password op:// refs) — never written to files.`, + RunE: func(c *cobra.Command, _ []string) error { + return c.Help() + }, + } + AddDryRunFlag(cmd) + + var upgrade bool + install := &cobra.Command{ + Use: "install", + Short: "Install OpenTofu binary (detects OS)", + Long: "Delegates to brew, the official get.opentofu.org script, snap, or winget — does not manage repo keys itself.", + Example: ` lab tofu install + lab tofu install --upgrade + lab tofu install --dry-run`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + info := platform.Detect() + method, err := tofu.DetectMethod(info) + if err != nil { + return err + } + action := "install" + if upgrade { + action = "upgrade" + } + detail := fmt.Sprintf("%s via %s — %s", action, method.Kind, method.Reason) + title := "tofu install" + if s.DryRun { + title = "tofu install (dry-run)" + } + ui.Section(stdout(cmd), s.Styles, title, detail) + + opts := tofu.InstallOpts{ + Info: info, + DryRun: s.DryRun, + Stdout: stdout(cmd), + Stderr: stderr(cmd), + } + runner := exec.NewOSRunner(stdout(cmd), stderr(cmd)) + if upgrade { + return tofu.Upgrade(cmd.Context(), runner, opts) + } + return tofu.Install(cmd.Context(), runner, opts) + }, + } + install.Flags().BoolVar(&upgrade, "upgrade", false, "upgrade existing OpenTofu install") + + version := &cobra.Command{ + Use: "version", + Short: "Print installed tofu version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + setDryRun(cmd) + s := session(cmd) + runner := exec.NewOSRunner(stdout(cmd), stderr(cmd)) + ok, ver, err := tofu.IsInstalled(cmd.Context(), runner) + if err != nil { + return err + } + if !ok { + ui.Warn(stdout(cmd), s.Styles, "tofu not found on PATH — run: lab tofu install") + return fmt.Errorf("tofu not installed") + } + ui.Section(stdout(cmd), s.Styles, "tofu version", ver) + return nil + }, + } + + var yes bool + apply := &cobra.Command{ + Use: "apply ", + Short: "Apply stack changes (requires --yes for -auto-approve)", + Long: `Runs tofu apply in the stack directory. +Without --yes, tofu prompts interactively. Pass --yes to add -auto-approve. + +TODO: resolve stack names relative to homelab.root/hq-infra from config when a bare name is given.`, + Example: ` lab tofu apply ./hq-infra/tofu/stacks/rb5009-core --yes + lab tofu apply ./stacks/rb5009-core --dry-run`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + stack := resolveStackArg(args[0]) + extra := "" + if yes { + extra = " (-auto-approve)" + } + title := "tofu apply" + if s.DryRun { + title = "tofu apply (dry-run)" + } + ui.Section(stdout(cmd), s.Styles, title, stack.Dir+extra) + opts := tofu.RunOpts{AutoApprove: yes, DryRun: s.DryRun} + runner := exec.NewOSRunner(stdout(cmd), stderr(cmd)) + return tofu.Apply(cmd.Context(), runner, stack, opts) + }, + } + apply.Flags().BoolVar(&yes, "yes", false, "pass -auto-approve to tofu apply") + + cmd.AddCommand( + install, + version, + newTofuStackCmd("init", "Initialize a stack working directory"), + newTofuStackCmd("validate", "Validate stack configuration"), + newTofuStackCmd("plan", "Show execution plan for a stack"), + apply, + newTofuStackCmd("fmt", "Format stack configuration"), + ) + + return cmd +} + +func newTofuStackCmd(name, short string) *cobra.Command { + return &cobra.Command{ + Use: name + " ", + Short: short, + Long: `Runs tofu ` + name + ` with -chdir=. + +TODO: resolve stack names relative to homelab.root/hq-infra from config when a bare name is given.`, + Example: fmt.Sprintf(" lab tofu %s ./hq-infra/tofu/stacks/rb5009-core", name), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + setDryRun(cmd) + s := session(cmd) + stack := resolveStackArg(args[0]) + title := "tofu " + name + if s.DryRun { + title += " (dry-run)" + ui.Section(stdout(cmd), s.Styles, title, "tofu "+strings.Join(tofu.CommandLine(stack, name), " ")) + } else { + ui.Section(stdout(cmd), s.Styles, title, stack.Dir) + } + opts := tofu.RunOpts{DryRun: s.DryRun} + runner := exec.NewOSRunner(stdout(cmd), stderr(cmd)) + switch name { + case "init": + return tofu.Init(cmd.Context(), runner, stack, opts) + case "validate": + return tofu.Validate(cmd.Context(), runner, stack, opts) + case "plan": + return tofu.Plan(cmd.Context(), runner, stack, opts) + case "fmt": + return tofu.Fmt(cmd.Context(), runner, stack, opts) + default: + return fmt.Errorf("unknown tofu subcommand %q", name) + } + }, + } +} + +// resolveStackArg treats the argument as a filesystem path to the stack directory. +// TODO: when arg is a bare name (no path separator), resolve under homelab.root/hq-infra +// (e.g. stacks/ or tofu/stacks/) from config. +func resolveStackArg(arg string) tofu.Stack { + return tofu.Stack{Dir: strings.TrimSpace(arg)} +} From 577f15e8c04d31e312a6a5cef4cd251f6e4310a4 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Sat, 18 Jul 2026 15:09:38 +0200 Subject: [PATCH 3/5] feat: register Tofu command in CLI for infrastructure management Added the Tofu command to the CLI under the "infra" category, enhancing the command set for managing OpenTofu operations. This integration supports improved user interaction with the Tofu functionalities. --- internal/cli/root.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cli/root.go b/internal/cli/root.go index af45002..e59f8f9 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -116,6 +116,7 @@ running local data services, mirroring repositories, operating clusters, and sup add(commands.NewSystemCmd(), "infra") add(commands.NewContainersCmd(), "infra") add(commands.NewNetCmd(), "infra") + add(commands.NewTofuCmd(), "infra") add(commands.NewStorageCmd(), "infra") add(commands.NewModelsCmd(), "data") From 11f1235b08e611485d204688562c195bac05bb05 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Sat, 18 Jul 2026 15:09:43 +0200 Subject: [PATCH 4/5] docs: update architecture documentation to include Tofu in infrastructure category Added the Tofu command to the architecture documentation, reflecting its role in managing OpenTofu installations and orchestration within the infrastructure category. This update enhances clarity on the command structure and available functionalities. --- docs/architecture.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9354832..1a04d50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,6 +59,7 @@ Stub commands (`commands.StubRunE`) reserve the CLI surface until each adapter s | `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 | +| `internal/tofu` | Install OpenTofu + orchestrate tofu on IaC stacks | tofu, brew/apt/dnf/snap, official installer script | ## Cross-cutting @@ -79,7 +80,7 @@ Stub commands (`commands.StubRunE`) reserve the CLI surface until each adapter s |----------|----------| | `foundation` | bootstrap, pkg, toolchain, services, **iso** | | `repos` | repos | -| `infra` | server, postgres, baremetal, system, ssh, cluster, gpu, containers, net, storage | +| `infra` | server, postgres, baremetal, system, ssh, cluster, gpu, containers, net, tofu, storage | | `data` | models, data, notebooks, mlops, vector, pipelines, agents | | `workflow` | obs, logs, templates, media, mcp | | `meta` | version, **self-update** | From d5e2d3ed925b2574527b3343f194dd6246188d16 Mon Sep 17 00:00:00 2001 From: Bart Rosa Date: Sat, 18 Jul 2026 15:12:16 +0200 Subject: [PATCH 5/5] fix: correct error message casing for Homebrew detection Updated the error message in the Tofu installation detection method to use lowercase for "homebrew," ensuring consistency and clarity in user feedback when Homebrew is not found on macOS. --- internal/tofu/install.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tofu/install.go b/internal/tofu/install.go index 48c639a..fb608b6 100644 --- a/internal/tofu/install.go +++ b/internal/tofu/install.go @@ -60,7 +60,7 @@ func detectMethod(info platform.Info, hasCmd func(string) bool) (Method, error) Reason: "macOS with Homebrew", }, nil } - return Method{}, fmt.Errorf("Homebrew not found; install brew or OpenTofu manually") + return Method{}, fmt.Errorf("homebrew not found; install brew or OpenTofu manually") case platform.OSLinux: switch info.Packager {