diff --git a/README.md b/README.md index e54ba67..54c2468 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,22 @@ wired up and which profile and backend requests resolve to. The helper works for any Terraform-native service hostname, not just Terraform Cloud. +## OpenTofu + +tfvault works with [OpenTofu](https://opentofu.org) out of the box: +`tofu` discovers credentials helpers in the same +`~/.terraform.d/plugins` directory under the same +`terraform-credentials-` naming, so one `tfvault install` covers +both tools. Register the helper in `~/.tofurc` with the same +`credentials_helper` block as above — OpenTofu falls back to +`~/.terraformrc` when no tofurc exists, and both tools honor +`$TF_CLI_CONFIG_FILE`. + +Careful: once a `~/.tofurc` exists, OpenTofu ignores `~/.terraformrc` +entirely, so the helper must be registered in both files when both +tools are used. `tfvault status` checks Terraform's and OpenTofu's CLI +configs and warns about exactly this. + ## Multiple accounts on one machine The core feature: different `.terraformrc` files can use different @@ -243,8 +259,10 @@ tfvault version symlink (e.g. a binary copied by an old installer); pass `-f`/`--force` to replace it. -`status` reads the Terraform CLI config (`$TF_CLI_CONFIG_FILE`, else -`~/.terraformrc`) and reports the `credentials_helper` registration, +`status` reads the CLI configs of both Terraform and OpenTofu +(`$TF_CLI_CONFIG_FILE` when set, else `~/.terraformrc`, `~/.tofurc` and +`$XDG_CONFIG_HOME/opentofu/tofurc`) and reports each +`credentials_helper` registration, explicit `credentials` blocks that bypass the helper, and the profile, backend and stored hostnames the current setup resolves to. It also flags token sources Terraform consults before any helper — `TF_TOKEN_*` diff --git a/internal/cli/status.go b/internal/cli/status.go index d669314..000b6ac 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -29,17 +29,35 @@ type terraformRC struct { CredHosts []string } -// terraformRCPath mirrors Terraform's CLI config lookup on unix: -// $TF_CLI_CONFIG_FILE, else ~/.terraformrc. -func terraformRCPath() (string, error) { +// rcCandidate is one CLI config file a tool may read, labeled with the +// tool for display. +type rcCandidate struct { + path string + tool string +} + +// cliConfigCandidates returns the CLI config files status inspects. +// $TF_CLI_CONFIG_FILE overrides the lookup for both Terraform and +// OpenTofu, so it is the only candidate when set. Otherwise Terraform +// reads ~/.terraformrc while OpenTofu prefers ~/.tofurc (falling back +// to ~/.terraformrc) and, on machines with neither, uses +// $XDG_CONFIG_HOME/opentofu/tofurc. +func cliConfigCandidates() ([]rcCandidate, error) { if p := os.Getenv("TF_CLI_CONFIG_FILE"); p != "" { - return p, nil + return []rcCandidate{{p, "terraform and opentofu, via $TF_CLI_CONFIG_FILE"}}, nil } home, err := os.UserHomeDir() if err != nil { - return "", err + return nil, err + } + cands := []rcCandidate{ + {filepath.Join(home, ".terraformrc"), "terraform"}, + {filepath.Join(home, ".tofurc"), "opentofu"}, } - return filepath.Join(home, ".terraformrc"), nil + if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { + cands = append(cands, rcCandidate{filepath.Join(x, "opentofu", "tofurc"), "opentofu"}) + } + return cands, nil } // parseTerraformRC extracts credentials_helper and credentials blocks. @@ -101,10 +119,10 @@ func flagValue(args []string, name string) string { return "" } -// runStatus reports how Terraform will reach tfvault: the plugin -// symlink, the terraformrc helper registration, and which profile and -// backend requests will resolve to. Exit code is nonzero when the -// helper is not fully wired up. +// runStatus reports how Terraform and OpenTofu will reach tfvault: the +// plugin symlink (shared by both tools), the CLI config helper +// registrations, and which profile and backend requests will resolve +// to. Exit code is nonzero when the helper is not fully wired up. func runStatus(configPath, profileFlag string, pal *palette, stdout, stderr io.Writer) int { healthy := true @@ -118,45 +136,62 @@ func runStatus(configPath, profileFlag string, pal *palette, stdout, stderr io.W link := filepath.Join(dir, pluginBinary) healthy = reportLink(pal, stdout, link) && healthy - // Terraform CLI config. + // CLI config files of both tools. The setup is healthy when at + // least one existing file registers tfvault; files that exist + // without registering it get a warning (OpenTofu ignores + // ~/.terraformrc entirely once ~/.tofurc exists, so a stray tofurc + // silently disables the helper for tofu). fmt.Fprintln(stdout) - pal.sectionf(stdout, "Terraform CLI config:") - rc := &terraformRC{} - rcPath, err := terraformRCPath() + pal.sectionf(stdout, "CLI config (terraform / tofu):") + rc := &terraformRC{} // the first file registering tfvault drives profile resolution + cands, err := cliConfigCandidates() if err != nil { fmt.Fprintf(stderr, "tfvault: status: %v\n", err) return 1 } - src, err := os.ReadFile(rcPath) - switch { - case errors.Is(err, fs.ErrNotExist): - fmt.Fprintf(stdout, " %s %s does not exist\n", pal.fail("missing:"), rcPath) - healthy = false - case err != nil: - fmt.Fprintf(stdout, " %s %v\n", pal.fail("error:"), err) - healthy = false - default: - rc, err = parseTerraformRC(src, rcPath) + foundAny, registered := false, false + var checked []string + for _, cand := range cands { + checked = append(checked, cand.path) + src, err := os.ReadFile(cand.path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + foundAny = true if err != nil { fmt.Fprintf(stdout, " %s %v\n", pal.fail("error:"), err) healthy = false - rc = &terraformRC{} - break + continue } - switch { - case rc.HelperName == "": - fmt.Fprintf(stdout, " %s %s has no credentials_helper block\n", pal.warn("warning:"), rcPath) - healthy = false - case rc.HelperName != "tfvault": - fmt.Fprintf(stdout, " %s %s registers credentials_helper %q, not \"tfvault\"\n", pal.warn("warning:"), rcPath, rc.HelperName) + parsed, err := parseTerraformRC(src, cand.path) + if err != nil { + fmt.Fprintf(stdout, " %s %v\n", pal.fail("error:"), err) healthy = false + continue + } + switch { + case parsed.HelperName == "": + fmt.Fprintf(stdout, " %s %s has no credentials_helper block (read by %s)\n", pal.warn("warning:"), cand.path, cand.tool) + case parsed.HelperName != "tfvault": + fmt.Fprintf(stdout, " %s %s registers credentials_helper %q, not \"tfvault\" (read by %s)\n", pal.warn("warning:"), cand.path, parsed.HelperName, cand.tool) default: - fmt.Fprintf(stdout, " %s %s registers credentials_helper \"tfvault\" (args: %q)\n", pal.ok("ok:"), rcPath, rc.HelperArgs) + fmt.Fprintf(stdout, " %s %s registers credentials_helper \"tfvault\" (args: %q; read by %s)\n", pal.ok("ok:"), cand.path, parsed.HelperArgs, cand.tool) + if !registered { + rc = parsed + registered = true + } } - for _, h := range rc.CredHosts { + for _, h := range parsed.CredHosts { fmt.Fprintf(stdout, " %s credentials %q block takes precedence over the helper for that host\n", pal.warn("note:"), h) } } + switch { + case !foundAny: + fmt.Fprintf(stdout, " %s no CLI config found (checked %s)\n", pal.fail("missing:"), strings.Join(checked, ", ")) + healthy = false + case !registered: + healthy = false + } // Token sources Terraform consults before the helper. These may be // intentional, so they are notes rather than failures. @@ -175,7 +210,10 @@ func runStatus(configPath, profileFlag string, pal *palette, stdout, stderr io.W source := "--profile flag" if profile == "" { profile = flagValue(rc.HelperArgs, "profile") - source = "terraformrc helper args" + source = "helper args" + if rc.Path != "" { + source = filepath.Base(rc.Path) + " helper args" + } } b := reportProfile(pal, stdout, configPath, profile, source, stderr) diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index 53eb340..ec92f28 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -230,6 +230,107 @@ credentials_helper "tfvault" { } } +// TestStatusTofurcOnly wires an OpenTofu-only machine: no ~/.terraformrc, +// helper registered in ~/.tofurc. The setup must count as healthy. +func TestStatusTofurcOnly(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("TF_CLI_CONFIG_FILE", "") + t.Setenv("XDG_CONFIG_HOME", "") + + cfgPath := filepath.Join(home, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(`profiles: + tofu-prof: + backend: testbe +`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("TFVAULT_CONFIG", cfgPath) + + if err := os.WriteFile(filepath.Join(home, ".tofurc"), []byte(` +credentials_helper "tfvault" { + args = ["--profile", "tofu-prof"] +} +`), 0o600); err != nil { + t.Fatal(err) + } + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if _, err := installLink(exe, filepath.Join(home, ".terraform.d", "plugins"), false); err != nil { + t.Fatal(err) + } + + var out, errOut bytes.Buffer + code := Run([]string{"status"}, strings.NewReader(""), &out, &errOut) + got := out.String() + if code != 0 { + t.Errorf("exit code = %d, want 0 with tofurc registered\n%s", code, got) + } + for _, want := range []string{ + ".tofurc registers credentials_helper \"tfvault\"", + "read by opentofu", + "tofu-prof (from .tofurc helper args", + } { + if !strings.Contains(got, want) { + t.Errorf("output missing %q:\n%s", want, got) + } + } +} + +// TestStatusTofurcNotRegistered: terraform is wired up, but a stray +// ~/.tofurc without a helper block means OpenTofu silently skips +// tfvault — warn without failing the terraform-side health. +func TestStatusTofurcNotRegistered(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("TF_CLI_CONFIG_FILE", "") + t.Setenv("XDG_CONFIG_HOME", "") + + cfgPath := filepath.Join(home, "config.yaml") + if err := os.WriteFile(cfgPath, []byte(`profiles: + work: + backend: testbe +`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("TFVAULT_CONFIG", cfgPath) + + if err := os.WriteFile(filepath.Join(home, ".terraformrc"), []byte(` +credentials_helper "tfvault" { + args = ["--profile", "work"] +} +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".tofurc"), []byte(`plugin_cache_dir = "/tmp"`), 0o600); err != nil { + t.Fatal(err) + } + + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if _, err := installLink(exe, filepath.Join(home, ".terraform.d", "plugins"), false); err != nil { + t.Fatal(err) + } + + var out, errOut bytes.Buffer + code := Run([]string{"status"}, strings.NewReader(""), &out, &errOut) + got := out.String() + if code != 0 { + t.Errorf("exit code = %d, want 0 (terraform side is healthy)\n%s", code, got) + } + if !strings.Contains(got, ".tofurc has no credentials_helper block") { + t.Errorf("output missing tofurc warning:\n%s", got) + } + if !strings.Contains(got, ".terraformrc registers credentials_helper \"tfvault\"") { + t.Errorf("output missing terraformrc ok line:\n%s", got) + } +} + func TestStatusZeroConfig(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home)