From ab1de96168feda7273c0db9fe36cb9fa60669bff Mon Sep 17 00:00:00 2001 From: MermaidMan Date: Thu, 4 Jun 2026 17:46:40 +0000 Subject: [PATCH] migrate to OpenTofu with Terraform fallback Add binary lookup in both terraform.go and destroy.go: tofu preferred, terraform fallback. Update all docs to reflect the OpenTofu-first approach. --- DETAILS.md | 22 +++++++++++----------- LLMs.md | 4 ++-- README.md | 4 ++-- internal/destroy/destroy.go | 14 ++++++++++++-- internal/terraform/terraform.go | 30 +++++++++++++++++++++--------- 5 files changed, 48 insertions(+), 26 deletions(-) diff --git a/DETAILS.md b/DETAILS.md index 1a51171..90739e4 100644 --- a/DETAILS.md +++ b/DETAILS.md @@ -53,7 +53,7 @@ obm/ │ │ │ └── hetzner_test.go │ │ └── provider_test.go │ ├── terraform/ -│ │ └── terraform.go # Runner: Init, Plan, Apply, Destroy wrappers +│ │ └── terraform.go # Runner: Init, Plan, Apply, Destroy (OpenTofu/Terraform) │ └── validation/ │ ├── validation.go # Check interface, Runner, CheckResult, Status enum │ └── validation_test.go @@ -82,7 +82,7 @@ obm/ | `obm deploy` | `--config ` | Interactive walkthrough (default) or non-interactive from YAML | | `obm validate` | `--env-file ` | Load `.env`, check required vars, validate API keys | | `obm status` | — | Show deployment state (not yet implemented) | -| `obm destroy` | — | Confirmation prompt → `terraform destroy` → state cleanup | +| `obm destroy` | — | Confirmation prompt → `tofu destroy` → state cleanup | | `obm version` | — | Print version with commit hash and build time | | `obm help` | — | Print usage | @@ -111,7 +111,7 @@ The `obm deploy` interactive flow runs 8 steps in sequence: 7. **Tailscale** — Optional VPN setup. Auth key and tailnet domain. 8. **Discord** — Optional bot integration. Bot token, server ID, user IDs. -Final step: summary display with cost estimate → confirm → write `.env` → optionally run `terraform init && terraform apply`. +Final step: summary display with cost estimate → confirm → write `.env` → optionally run `tofu init && tofu apply` (or `terraform` if installed). ### Framework-specific defaults @@ -355,15 +355,15 @@ DigitalOcean prices: --- -## Terraform Integration +## Terraform / OpenTofu Integration -`obm` generates the `.env` file that Terraform expects. The actual Terraform configs live in the separate [openboatmobile-ai](https://github.com/openboatmobile/openboatmobile-ai) repo. +`obm` generates the `.env` file that OpenTofu (or Terraform) expects. The actual infrastructure configs live in the separate [openboatmobile-ai](https://github.com/openboatmobile/openboatmobile-ai) repo. -The `internal/terraform/terraform.go` wrapper provides: -- `Runner.Init()` — `terraform init -input=false` -- `Runner.Plan(destroy bool)` — `terraform plan` (with optional `-destroy` flag) -- `Runner.Apply()` — `terraform apply -auto-approve` -- `Runner.Destroy()` — `terraform destroy -auto-approve` +The `internal/terraform/terraform.go` wrapper provides (auto-detects OpenTofu first, Terraform fallback): +- `Runner.Init()` — `tofu init -input=false` +- `Runner.Plan(destroy bool)` — `tofu plan` (with optional `-destroy` flag) +- `Runner.Apply()` — `tofu apply -auto-approve` +- `Runner.Destroy()` — `tofu destroy -auto-approve` All commands run in the `WorkDir` and capture combined output. @@ -391,7 +391,7 @@ User sets `TF_VAR_*` env vars → sourced from `.env` → Terraform reads them ## Destroy Flow -`obm destroy` reads `terraform.tfstate` to list resources, shows them, asks for confirmation, runs `terraform destroy`, then cleans up state files unless `--keep-state` is set. +`obm destroy` reads `terraform.tfstate` to list resources, shows them, asks for confirmation, runs `tofu destroy`, then cleans up state files unless `--keep-state` is set. --- diff --git a/LLMs.md b/LLMs.md index 8b9365d..4c85d01 100644 --- a/LLMs.md +++ b/LLMs.md @@ -85,7 +85,7 @@ When your human runs `obm deploy`, they'll go through these in order: 7. **Tailscale** — Optional VPN (auth key + tailnet) 8. **Discord** — Optional bot integration (token, server ID, user IDs) -After all 8 steps: summary + cost estimate → confirm → `.env` written → optional `terraform init && apply`. +After all 8 steps: summary + cost estimate → confirm → `.env` written → optional `tofu init && tofu apply` (or `terraform` if installed). --- @@ -200,7 +200,7 @@ This loads `.env`, checks required variables, and validates inference API keys a obm destroy ``` -It asks for confirmation. It reads `terraform.tfstate` to show what will be destroyed. +It asks for confirmation. It reads the state file (`terraform.tfstate`) to show what will be destroyed. ### "Set up CI/CD deployment" diff --git a/README.md b/README.md index ba3c2f4..edea6c4 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,9 @@ The AI model API costs are separate, and depend on your usage. Generally that's ## What happens under the hood? -`obm` generates a `.env` file that [Terraform](https://terraform.io) reads to provision your server, install the agent software, and configure everything. You don't need to know Terraform — `obm` handles it. +`obm` generates a `.env` file that [OpenTofu](https://opentofu.org) (or [Terraform](https://terraform.io) as a fallback) reads to provision your server, install the agent software, and configure everything. You don't need to know either — `obm` handles it. -The Terraform configs live in the [openboatmobile-ai](https://github.com/openboatmobile/openboatmobile-ai) repo. `obm` is the friendly CLI wrapper around them. +The infrastructure configs live in the [openboatmobile-ai](https://github.com/openboatmobile/openboatmobile-ai) repo. `obm` is the friendly CLI wrapper around them. --- diff --git a/internal/destroy/destroy.go b/internal/destroy/destroy.go index a2b5222..8ece268 100644 --- a/internal/destroy/destroy.go +++ b/internal/destroy/destroy.go @@ -12,6 +12,16 @@ import ( "github.com/openboatmobile/obm/internal/prompt" ) +// tfBinary returns the path to the IaC binary: tofu preferred, terraform fallback. +func tfBinary() string { + for _, name := range []string{"tofu", "terraform"} { + if path, err := exec.LookPath(name); err == nil { + return path + } + } + return "tofu" +} + // Options configures the destroy operation. type Options struct { WorkDir string // Working directory (default: current) @@ -198,7 +208,7 @@ func displayDestroyPlan(resources []Resource) { fmt.Println() } -// runTerraformDestroy executes terraform destroy. +// runTerraformDestroy executes tofu/terraform destroy. func runTerraformDestroy(workDir string, opts *Options) error { args := []string{"destroy", "-auto-approve"} @@ -207,7 +217,7 @@ func runTerraformDestroy(workDir string, opts *Options) error { args = append(args, "-var-file", vf) } - cmd := exec.Command("terraform", args...) + cmd := exec.Command(tfBinary(), args...) cmd.Dir = workDir // Stream output to stdout/stderr diff --git a/internal/terraform/terraform.go b/internal/terraform/terraform.go index fa55b91..e4a8580 100644 --- a/internal/terraform/terraform.go +++ b/internal/terraform/terraform.go @@ -1,4 +1,5 @@ -// Package terraform wraps Terraform operations (init, plan, apply, destroy). +// Package terraform wraps IaC operations (init, plan, apply, destroy). +// Supports OpenTofu (preferred) and Terraform (fallback). package terraform import ( @@ -6,22 +7,32 @@ import ( "os/exec" ) -// Runner executes Terraform commands. +// binary returns the path to the IaC binary: tofu preferred, terraform fallback. +func binary() string { + for _, name := range []string{"tofu", "terraform"} { + if path, err := exec.LookPath(name); err == nil { + return path + } + } + return "tofu" // fallback — will produce a useful error at runtime +} + +// Runner executes infrastructure-as-code commands. type Runner struct { WorkDir string } -// NewRunner creates a Terraform runner for the given working directory. +// NewRunner creates a runner for the given working directory. func NewRunner(workDir string) *Runner { return &Runner{WorkDir: workDir} } -// Init runs terraform init. +// Init runs init. func (r *Runner) Init() error { return r.run("init", "-input=false") } -// Plan runs terraform plan. +// Plan runs plan. func (r *Runner) Plan(destroy bool) error { args := []string{"plan"} if destroy { @@ -30,22 +41,23 @@ func (r *Runner) Plan(destroy bool) error { return r.run(args...) } -// Apply runs terraform apply. +// Apply runs apply. func (r *Runner) Apply() error { return r.run("apply", "-auto-approve") } -// Destroy runs terraform destroy. +// Destroy runs destroy. func (r *Runner) Destroy() error { return r.run("destroy", "-auto-approve") } func (r *Runner) run(args ...string) error { - cmd := exec.Command("terraform", args...) + bin := binary() + cmd := exec.Command(bin, args...) cmd.Dir = r.WorkDir output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("terraform %v: %w\n%s", args, err, output) + return fmt.Errorf("%s %v: %w\n%s", bin, args, err, output) } return nil }