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** | 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)} +} 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") 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..fb608b6 --- /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) +}