Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>` 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
Expand Down Expand Up @@ -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_*`
Expand Down
108 changes: 73 additions & 35 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
}
Comment on lines +57 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

According to the OpenTofu specification, if the XDG_CONFIG_HOME environment variable is not set or is empty, OpenTofu falls back to looking for the configuration file at ~/.config/opentofu/tofurc.

Currently, the code only checks the XDG path if XDG_CONFIG_HOME is explicitly set to a non-empty value. This means that on standard systems where XDG_CONFIG_HOME is not set, tfvault status will fail to inspect the default ~/.config/opentofu/tofurc location.

We should fall back to ~/.config when XDG_CONFIG_HOME is empty.

	xdgConfig := os.Getenv("XDG_CONFIG_HOME")
	if xdgConfig == "" {
		xdgConfig = filepath.Join(home, ".config")
	}
	cands = append(cands, rcCandidate{filepath.Join(xdgConfig, "opentofu", "tofurc"), "opentofu"})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against OpenTofu's actual implementation before applying, and the claim doesn't hold: there is no fallback to ~/.config when XDG_CONFIG_HOME is unset. From internal/command/cliconfig/config_unix.go:

if xdgDir := os.Getenv("XDG_CONFIG_HOME"); xdgDir != "" && !cl.pathExists(legacyConfigFile) && !cl.pathExists(newConfigFile) {
    // a fresh install should not use terraform naming
    return filepath.Join(xdgDir, "opentofu", "tofurc"), nil
}

OpenTofu only consults the XDG path when XDG_CONFIG_HOME is explicitly non-empty (and neither ~/.tofurc nor ~/.terraformrc exists). With the variable unset, ~/.config/opentofu/tofurc is never read — so adding the suggested fallback would make status report a file the tool doesn't actually use. The current candidate logic intentionally mirrors the implementation, and cliConfigCandidates' doc comment describes exactly this. Keeping as is.

return cands, nil
}

// parseTerraformRC extracts credentials_helper and credentials blocks.
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand All @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions internal/cli/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading