diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e565ae90..7d21b02ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ENHANCEMENTS: * Added "base64gunzip" function. ([$800](https://github.com/opentofu/opentofu/issues/800)) * Added "cidrcontains" function. ([$366](https://github.com/opentofu/opentofu/issues/366)) * Allow test run blocks to reference previous run block's module outputs ([#1129](https://github.com/opentofu/opentofu/pull/1129)) +* Support the XDG Base Directory Specification ([#1200](https://github.com/opentofu/opentofu/pull/1200)) BUG FIXES: * `tofu test` resources cleanup at the end of tests changed to use simple reverse run block order. ([#1043](https://github.com/opentofu/opentofu/pull/1043)) diff --git a/cmd/tofu/main.go b/cmd/tofu/main.go index 1104e35674..184d01ca76 100644 --- a/cmd/tofu/main.go +++ b/cmd/tofu/main.go @@ -501,7 +501,7 @@ func extractChdirOption(args []string) (string, []string, error) { } // Creates the the configuration directory. -// `configDir` should refer to `~/.terraform.d` or its equivalent +// `configDir` should refer to `~/.terraform.d`, `$XDG_CONFIG_HOME/opentofu` or its equivalent // on non-UNIX platforms. func mkConfigDir(configDir string) error { err := os.Mkdir(configDir, os.ModePerm) diff --git a/cmd/tofu/plugins.go b/cmd/tofu/plugins.go index 5edfaf8820..b93d8c53f8 100644 --- a/cmd/tofu/plugins.go +++ b/cmd/tofu/plugins.go @@ -20,14 +20,16 @@ import ( // older versions where both satisfy the provider version constraints. func globalPluginDirs() []string { var ret []string - // Look in ~/.terraform.d/plugins/ , or its equivalent on non-UNIX - dir, err := cliconfig.ConfigDir() + // Look in ~/.terraform.d/plugins/, $XDG_DATA_HOME/opentofu/plugins, or its equivalent on non-UNIX platforms + dirs, err := cliconfig.DataDirs() if err != nil { - log.Printf("[ERROR] Error finding global config directory: %s", err) + log.Printf("[ERROR] Error finding global plugin directories: %s", err) } else { machineDir := fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH) - ret = append(ret, filepath.Join(dir, "plugins")) - ret = append(ret, filepath.Join(dir, "plugins", machineDir)) + for _, dir := range dirs { + ret = append(ret, filepath.Join(dir, "plugins")) + ret = append(ret, filepath.Join(dir, "plugins", machineDir)) + } } return ret diff --git a/cmd/tofu/provider_source.go b/cmd/tofu/provider_source.go index 8bae589a92..bd843b1c8c 100644 --- a/cmd/tofu/provider_source.go +++ b/cmd/tofu/provider_source.go @@ -96,7 +96,8 @@ func implicitProviderSource(services *disco.Disco) getproviders.Source { // way to include them in bundles uploaded to Terraform Cloud, where // there has historically otherwise been no way to use custom providers. // - The "plugins" subdirectory of the CLI config search directory. - // (thats ~/.terraform.d/plugins on Unix systems, equivalents elsewhere) + // (thats ~/.terraform.d/plugins or $XDG_DATA_HOME/opentofu/plugins + // on Unix systems, equivalents elsewhere) // - The "plugins" subdirectory of any platform-specific search paths, // following e.g. the XDG base directory specification on Unix systems, // Apple's guidelines on OS X, and "known folders" on Windows. @@ -144,9 +145,11 @@ func implicitProviderSource(services *disco.Disco) getproviders.Source { } addLocalDir("terraform.d/plugins") // our "vendor" directory - cliConfigDir, err := cliconfig.ConfigDir() + cliDataDirs, err := cliconfig.DataDirs() if err == nil { - addLocalDir(filepath.Join(cliConfigDir, "plugins")) + for _, cliDataDir := range cliDataDirs { + addLocalDir(filepath.Join(cliDataDir, "plugins")) + } } // This "userdirs" library implements an appropriate user-specific and diff --git a/internal/command/cliconfig/cliconfig.go b/internal/command/cliconfig/cliconfig.go index f301727a82..ee7af5609d 100644 --- a/internal/command/cliconfig/cliconfig.go +++ b/internal/command/cliconfig/cliconfig.go @@ -96,6 +96,11 @@ func ConfigDir() (string, error) { return configDir() } +// DataDirs returns the data directories for OpenTofu. +func DataDirs() ([]string, error) { + return dataDirs() +} + // LoadConfig reads the CLI configuration from the various filesystem locations // and from the environment, returning a merged configuration along with any // diagnostics (errors and warnings) encountered along the way. diff --git a/internal/command/cliconfig/config_unix.go b/internal/command/cliconfig/config_unix.go index 87501bb981..c7a7d1aef7 100644 --- a/internal/command/cliconfig/config_unix.go +++ b/internal/command/cliconfig/config_unix.go @@ -22,6 +22,11 @@ func configFile() (string, error) { newConfigFile := filepath.Join(dir, ".tofurc") legacyConfigFile := filepath.Join(dir, ".terraformrc") + if xdgDir := os.Getenv("XDG_CONFIG_HOME"); xdgDir != "" && !pathExists(legacyConfigFile) && !pathExists(newConfigFile) { + // a fresh install should not use terraform naming + return filepath.Join(xdgDir, "opentofu", "tofurc"), nil + } + return getNewOrLegacyPath(newConfigFile, legacyConfigFile) } @@ -31,7 +36,26 @@ func configDir() (string, error) { return "", err } - return filepath.Join(dir, ".terraform.d"), nil + configDir := filepath.Join(dir, ".terraform.d") + if xdgDir := os.Getenv("XDG_CONFIG_HOME"); !pathExists(configDir) && xdgDir != "" { + configDir = filepath.Join(xdgDir, "opentofu") + } + + return configDir, nil +} + +func dataDirs() ([]string, error) { + dir, err := homeDir() + if err != nil { + return nil, err + } + + dirs := []string{filepath.Join(dir, ".terraform.d")} + if xdgDir := os.Getenv("XDG_DATA_HOME"); xdgDir != "" { + dirs = append(dirs, filepath.Join(xdgDir, "opentofu")) + } + + return dirs, nil } func homeDir() (string, error) { @@ -40,7 +64,7 @@ func homeDir() (string, error) { // FIXME: homeDir gets called from globalPluginDirs during init, before // the logging is set up. We should move meta initializtion outside of // init, but in the meantime we just need to silence this output. - //log.Printf("[DEBUG] Detected home directory from env var: %s", home) + // log.Printf("[DEBUG] Detected home directory from env var: %s", home) return home, nil } @@ -57,3 +81,8 @@ func homeDir() (string, error) { return user.HomeDir, nil } + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/command/cliconfig/config_unix_test.go b/internal/command/cliconfig/config_unix_test.go new file mode 100644 index 0000000000..b8b476a2d0 --- /dev/null +++ b/internal/command/cliconfig/config_unix_test.go @@ -0,0 +1,151 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:build !windows +// +build !windows + +package cliconfig + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func TestConfigFileConfigDir(t *testing.T) { + homeDir := filepath.Join(t.TempDir(), "home") + + tests := []struct { + name string + xdgConfigHome string + files []string + testFunc func() (string, error) + expect string + }{ + { + name: "configFile: use home tofurc", + testFunc: configFile, + files: []string{filepath.Join(homeDir, ".tofurc")}, + expect: filepath.Join(homeDir, ".tofurc"), + }, + { + name: "configFile: use home terraformrc", + testFunc: configFile, + files: []string{filepath.Join(homeDir, ".terraformrc")}, + expect: filepath.Join(homeDir, ".terraformrc"), + }, + { + name: "configFile: use default fallback", + testFunc: configFile, + expect: filepath.Join(homeDir, ".tofurc"), + }, + { + name: "configFile: use XDG tofurc", + testFunc: configFile, + xdgConfigHome: filepath.Join(homeDir, "xdg"), + expect: filepath.Join(homeDir, "xdg", "opentofu", "tofurc"), + }, + { + name: "configFile: prefer home tofurc", + testFunc: configFile, + xdgConfigHome: filepath.Join(homeDir, "xdg"), + files: []string{filepath.Join(homeDir, ".tofurc")}, + expect: filepath.Join(homeDir, ".tofurc"), + }, + { + name: "configFile: prefer home terraformrc", + testFunc: configFile, + xdgConfigHome: filepath.Join(homeDir, "xdg"), + files: []string{filepath.Join(homeDir, ".terraformrc")}, + expect: filepath.Join(homeDir, ".terraformrc"), + }, + { + name: "configDir: use .terraform.d default", + testFunc: configDir, + expect: filepath.Join(homeDir, ".terraform.d"), + }, + { + name: "configDir: prefer .terraform.d", + testFunc: configDir, + xdgConfigHome: filepath.Join(homeDir, "xdg"), + files: []string{filepath.Join(homeDir, ".terraform.d", "placeholder")}, + expect: filepath.Join(homeDir, ".terraform.d"), + }, + { + name: "configDir: use XDG value", + testFunc: configDir, + xdgConfigHome: filepath.Join(homeDir, "xdg"), + expect: filepath.Join(homeDir, "xdg", "opentofu"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("HOME", homeDir) + t.Setenv("XDG_CONFIG_HOME", test.xdgConfigHome) + for _, f := range test.files { + createFile(t, f) + } + + file, err := test.testFunc() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if test.expect != file { + t.Fatalf("expected %q, but got %q", test.expect, file) + } + }) + } +} + +func TestDataDirs(t *testing.T) { + homeDir := filepath.Join(t.TempDir(), "home") + + tests := []struct { + name string + xdgDataHome string + expect []string + }{ + { + name: "use XDG data dir", + xdgDataHome: filepath.Join(homeDir, "xdg"), + expect: []string{ + filepath.Join(homeDir, ".terraform.d"), + filepath.Join(homeDir, "xdg", "opentofu"), + }, + }, + { + name: "use default", + expect: []string{ + filepath.Join(homeDir, ".terraform.d"), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("HOME", homeDir) + t.Setenv("XDG_DATA_HOME", test.xdgDataHome) + + dirs, err := dataDirs() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !slices.Equal(test.expect, dirs) { + t.Fatalf("expected %+v, but got %+v", test.expect, dirs) + } + }) + } +} + +func createFile(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(filepath.Dir(path)) }) +} diff --git a/internal/command/cliconfig/config_windows.go b/internal/command/cliconfig/config_windows.go index f11e274256..70fdc6e255 100644 --- a/internal/command/cliconfig/config_windows.go +++ b/internal/command/cliconfig/config_windows.go @@ -40,6 +40,14 @@ func configDir() (string, error) { return filepath.Join(dir, "terraform.d"), nil } +func dataDirs() ([]string, error) { + dir, err := configDir() + if err != nil { + return nil, err + } + return []string{dir}, nil +} + func homeDir() (string, error) { b := make([]uint16, syscall.MAX_PATH) diff --git a/website/docs/cli/config/config-file.mdx b/website/docs/cli/config/config-file.mdx index 139e5b2fbf..05d0533e35 100644 --- a/website/docs/cli/config/config-file.mdx +++ b/website/docs/cli/config/config-file.mdx @@ -24,9 +24,12 @@ on the host operating system: If both `terraform.rc` and `tofu.rc` files exists, the later would take precedence. * On all other systems, the file must be named `.tofurc` (note the leading period) and placed directly in the home directory - of the relevant user. + of the relevant user or be named `tofurc` and placed in a valid + [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) + config directory such as `$XDG_CONFIG_HOME/opentofu`. The `.terraformrc` is supported for backward-compatability purposes. - If both `.terraformrc` and `.tofurc` files exists, the later would take precedence. + If both `.terraformrc` and `.tofurc` files exists, the latter would take precedence. + When using an XDG config directory `.terraformrc` and `terraformrc` are ignored. On Windows, beware of Windows Explorer's default behavior of hiding filename extensions. OpenTofu will not recognize a file named `tofuc.rc.txt` as a @@ -275,12 +278,9 @@ the operating system where you are running OpenTofu: `~/Library/Application Support/io.terraform/plugins`, and `/Library/Application Support/io.terraform/plugins` * **Linux and other Unix-like systems**:`$HOME/.terraform.d/plugins` and - `terraform/plugins` located within a valid + `opentofu/plugins` located within a valid [XDG Base Directory](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html) - data directory such as `$XDG_DATA_HOME/terraform/plugins`. - Without any XDG environment variables set, OpenTofu will use - `~/.local/share/terraform/plugins`, - `/usr/local/share/terraform/plugins`, and `/usr/share/terraform/plugins`. + data directory such as `$XDG_DATA_HOME/opentofu/plugins`. If a `terraform.d/plugins` directory exists in the current working directory then OpenTofu will also include that directory, regardless of your operating diff --git a/website/docs/language/resources/provisioners/syntax.mdx b/website/docs/language/resources/provisioners/syntax.mdx index f6cd928863..a79ee98837 100644 --- a/website/docs/language/resources/provisioners/syntax.mdx +++ b/website/docs/language/resources/provisioners/syntax.mdx @@ -195,7 +195,7 @@ provisioners must connect to the remote system using SSH or WinRM. You must include [a `connection` block](/docs/language/resources/provisioners/connection) so that OpenTofu knows how to communicate with the server. OpenTofu includes several built-in provisioners. You can also use third-party provisioners as plugins, by placing them -in `%APPDATA%\terraform.d\plugins`, `~/.terraform.d/plugins`, or the same +in `%APPDATA%\terraform.d\plugins`, `~/.terraform.d/plugins`, `$XDG_DATA_HOME/opentofu/plugins`, or the same directory where the OpenTofu binary is installed. However, we do not recommend using any provisioners except the built-in `file`, `local-exec`, and `remote-exec` provisioners.