Skip to content

Commit

Permalink
main: new global option -chdir
Browse files Browse the repository at this point in the history
This new option is intended to address the previous inconsistencies where
some older subcommands supported partially changing the target directory
(where Terraform would use the new directory inconsistently) where newer
commands did not support that override at all.

Instead, now Terraform will accept a -chdir command at the start of the
command line (before the subcommand) and will interpret it as a request
to direct all actions that would normally be taken in the current working
directory into the target directory instead. This is similar to options
offered by some other similar tools, such as the -C option in "make".

The new option is only accepted at the start of the command line (before
the subcommand) as a way to reflect that it is a global command (not
specific to a particular subcommand) and that it takes effect _before_
executing the subcommand. This also means it'll be forced to appear before
any other command-specific arguments that take file paths, which hopefully
communicates that those other arguments are interpreted relative to the
overridden path.

As a measure of pragmatism for existing uses, the path.cwd object in
the Terraform language will continue to return the _original_ working
directory (ignoring -chdir), in case that is important in some exceptional
workflows. The path.root object gives the root module directory, which
will always match the overriden working directory unless the user
simultaneously uses one of the legacy directory override arguments, which
is not a pattern we intend to support in the long run.

As a first step down the deprecation path, this commit adjusts the
documentation to de-emphasize the inconsistent old command line arguments,
including specific guidance on what to use instead for the main three
workflow commands, but all of those options remain supported in the same
way as they were before. In a later commit we'll make those arguments
produce a visible deprecation warning in Terraform's output, and then
in an even later commit we'll remove them entirely so that -chdir is the
single supported way to run Terraform from a directory other than the
one containing the root module configuration.
  • Loading branch information
apparentlymart committed Sep 4, 2020
1 parent 6b4ed24 commit 7c78959
Show file tree
Hide file tree
Showing 22 changed files with 397 additions and 102 deletions.
88 changes: 88 additions & 0 deletions command/e2etest/primary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/terraform/e2e"
"github.com/zclconf/go-cty/cty"
)

// The tests in this file are for the "primary workflow", which includes
Expand Down Expand Up @@ -126,3 +127,90 @@ func TestPrimarySeparatePlan(t *testing.T) {
}

}

func TestPrimaryChdirOption(t *testing.T) {
t.Parallel()

// This test case does not include any provider dependencies, so it's
// safe to run it even when network access is disallowed.

fixturePath := filepath.Join("testdata", "chdir-option")
tf := e2e.NewBinary(terraformBin, fixturePath)
defer tf.Close()

//// INIT
stdout, stderr, err := tf.Run("-chdir=subdir", "init")
if err != nil {
t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr)
}

//// PLAN
stdout, stderr, err = tf.Run("-chdir=subdir", "plan", "-out=tfplan")
if err != nil {
t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr)
}

if !strings.Contains(stdout, "0 to add, 0 to change, 0 to destroy") {
t.Errorf("incorrect plan tally; want 0 to add:\n%s", stdout)
}

if !strings.Contains(stdout, "This plan was saved to: tfplan") {
t.Errorf("missing \"This plan was saved to...\" message in plan output\n%s", stdout)
}
if !strings.Contains(stdout, "terraform apply \"tfplan\"") {
t.Errorf("missing next-step instruction in plan output\n%s", stdout)
}

// The saved plan is in the subdirectory because -chdir switched there
plan, err := tf.Plan("subdir/tfplan")
if err != nil {
t.Fatalf("failed to read plan file: %s", err)
}

diffResources := plan.Changes.Resources
if len(diffResources) != 0 {
t.Errorf("incorrect diff in plan; want no resource changes, but have:\n%s", spew.Sdump(diffResources))
}

//// APPLY
stdout, stderr, err = tf.Run("-chdir=subdir", "apply", "tfplan")
if err != nil {
t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr)
}

if !strings.Contains(stdout, "Resources: 0 added, 0 changed, 0 destroyed") {
t.Errorf("incorrect apply tally; want 0 added:\n%s", stdout)
}

// The state file is in subdir because -chdir changed the current working directory.
state, err := tf.StateFromFile("subdir/terraform.tfstate")
if err != nil {
t.Fatalf("failed to read state file: %s", err)
}

gotOutput := state.RootModule().OutputValues["cwd"]
wantOutputValue := cty.StringVal(tf.Path()) // path.cwd returns the original path, because path.root is how we get the overridden path
if gotOutput == nil || !wantOutputValue.RawEquals(gotOutput.Value) {
t.Errorf("incorrect value for cwd output\ngot: %#v\nwant Value: %#v", gotOutput, wantOutputValue)
}

gotOutput = state.RootModule().OutputValues["root"]
wantOutputValue = cty.StringVal(tf.Path("subdir")) // path.root is a relative path, but the text fixture uses abspath on it.
if gotOutput == nil || !wantOutputValue.RawEquals(gotOutput.Value) {
t.Errorf("incorrect value for root output\ngot: %#v\nwant Value: %#v", gotOutput, wantOutputValue)
}

if len(state.RootModule().Resources) != 0 {
t.Errorf("unexpected resources in state")
}

//// DESTROY
stdout, stderr, err = tf.Run("-chdir=subdir", "destroy", "-auto-approve")
if err != nil {
t.Fatalf("unexpected destroy error: %s\nstderr:\n%s", err, stderr)
}

if !strings.Contains(stdout, "Resources: 0 destroyed") {
t.Errorf("incorrect destroy tally; want 0 destroyed:\n%s", stdout)
}
}
7 changes: 7 additions & 0 deletions command/e2etest/testdata/chdir-option/subdir/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
output "cwd" {
value = path.cwd
}

output "root" {
value = abspath(path.root)
}
14 changes: 13 additions & 1 deletion command/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ type Meta struct {
// command with a Meta field. These are expected to be set externally
// (not from within the command itself).

// OriginalWorkingDir, if set, is the actual working directory where
// Terraform was run from. This might not be the _actual_ current working
// directory, because users can add the -chdir=... option to the beginning
// of their command line to ask Terraform to switch.
//
// Most things should just use the current working directory in order to
// respect the user's override, but we retain this for exceptional
// situations where we need to refer back to the original working directory
// for some reason.
OriginalWorkingDir string

Color bool // True if output should be colored
GlobalPluginDirs []string // Additional paths to search for plugins
PluginOverrides *PluginOverrides // legacy overrides from .terraformrc file
Expand Down Expand Up @@ -384,7 +395,8 @@ func (m *Meta) contextOpts() (*terraform.ContextOpts, error) {
}

opts.Meta = &terraform.ContextMeta{
Env: workspace,
Env: workspace,
OriginalWorkingDir: m.OriginalWorkingDir,
}

return &opts, nil
Expand Down
4 changes: 3 additions & 1 deletion commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const (
OutputPrefix = "o:"
)

func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc getproviders.Source, unmanagedProviders map[addrs.Provider]*plugin.ReattachConfig) {
func initCommands(originalWorkingDir string, config *cliconfig.Config, services *disco.Disco, providerSrc getproviders.Source, unmanagedProviders map[addrs.Provider]*plugin.ReattachConfig) {
var inAutomation bool
if v := os.Getenv(runningInAutomationEnvName); v != "" {
inAutomation = true
Expand All @@ -64,6 +64,8 @@ func initCommands(config *cliconfig.Config, services *disco.Disco, providerSrc g
dataDir := os.Getenv("TF_DATA_DIR")

meta := command.Meta{
OriginalWorkingDir: originalWorkingDir,

Color: true,
GlobalPluginDirs: globalPluginDirs(),
PluginOverrides: &PluginOverrides,
Expand Down
8 changes: 7 additions & 1 deletion e2e/e2e.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,13 @@ func (b *binary) FileExists(path ...string) bool {
// LocalState is a helper for easily reading the local backend's state file
// terraform.tfstate from the working directory.
func (b *binary) LocalState() (*states.State, error) {
f, err := b.OpenFile("terraform.tfstate")
return b.StateFromFile("terraform.tfstate")
}

// StateFromFile is a helper for easily reading a state snapshot from a file
// on disk relative to the working directory.
func (b *binary) StateFromFile(filename string) (*states.State, error) {
f, err := b.OpenFile(filename)
if err != nil {
return nil, err
}
Expand Down
9 changes: 8 additions & 1 deletion help.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func helpFunc(commands map[string]cli.CommandFactory) string {
// website/source/docs/commands/index.html.markdown; if you
// change this then consider updating that to match.
helpText := fmt.Sprintf(`
Usage: terraform [-version] [-help] <command> [args]
Usage: terraform [global options] <subcommand> [args]
The available commands for execution are listed below.
The most common, useful commands are shown first, followed by
Expand All @@ -44,6 +44,13 @@ Common commands:
%s
All other commands:
%s
Global options (use these before the subcommand, if any):
-chdir=DIR Switch to a different working directory before executing
the given subcommand.
-help Show this help output, or the help for a specified
subcommand.
-version An alias for the "version" subcommand.
`, listCommands(porcelain, maxKeyLen), listCommands(plumbing, maxKeyLen))

return strings.TrimSpace(helpText)
Expand Down
88 changes: 83 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ func wrappedMain() int {
log.Printf("[INFO] Go runtime version: %s", runtime.Version())
log.Printf("[INFO] CLI args: %#v", os.Args)

// NOTE: We're intentionally calling LoadConfig _before_ handling a possible
// -chdir=... option on the command line, so that a possible relative
// path in the TERRAFORM_CONFIG_FILE environment variable (though probably
// ill-advised) will be resolved relative to the true working directory,
// not the overridden one.
config, diags := cliconfig.LoadConfig()

if len(diags) > 0 {
Expand Down Expand Up @@ -203,9 +208,40 @@ func wrappedMain() int {
// Initialize the backends.
backendInit.Init(services)

// Get the command line args.
binName := filepath.Base(os.Args[0])
args := os.Args[1:]

originalWd, err := os.Getwd()
if err != nil {
// It would be very strange to end up here
Ui.Error(fmt.Sprintf("Failed to determine current working directory: %s", err))
return 1
}

// The arguments can begin with a -chdir option to ask Terraform to switch
// to a different working directory for the rest of its work. If that
// option is present then extractChdirOption returns a trimmed args with that option removed.
overrideWd, args, err := extractChdirOption(args)
if err != nil {
Ui.Error(fmt.Sprintf("Invalid -chdir option: %s", err))
return 1
}
if overrideWd != "" {
os.Chdir(overrideWd)
if err != nil {
Ui.Error(fmt.Sprintf("Error handling -chdir option: %s", err))
return 1
}
}

// In tests, Commands may already be set to provide mock commands
if Commands == nil {
initCommands(config, services, providerSrc, unmanagedProviders)
// Commands get to hold on to the original working directory here,
// in case they need to refer back to it for any special reason, though
// they should primarily be working with the override working directory
// that we've now switched to above.
initCommands(originalWd, config, services, providerSrc, unmanagedProviders)
}

// Run checkpoint
Expand All @@ -214,10 +250,6 @@ func wrappedMain() int {
// Make sure we clean up any managed plugins at the end of this
defer plugin.CleanupClients()

// Get the command line args.
binName := filepath.Base(os.Args[0])
args := os.Args[1:]

// Build the CLI so far, we do this so we can query the subcommand.
cliRunner := &cli.CLI{
Args: args,
Expand Down Expand Up @@ -433,3 +465,49 @@ func parseReattachProviders(in string) (map[addrs.Provider]*plugin.ReattachConfi
}
return unmanagedProviders, nil
}

func extractChdirOption(args []string) (string, []string, error) {
if len(args) == 0 {
return "", args, nil
}

const argName = "-chdir"
const argPrefix = argName + "="
var argValue string
var argPos int

for i, arg := range args {
if !strings.HasPrefix(arg, "-") {
// Because the chdir option is a subcommand-agnostic one, we require
// it to appear before any subcommand argument, so if we find a
// non-option before we find -chdir then we are finished.
break
}
if arg == argName || arg == argPrefix {
return "", args, fmt.Errorf("must include an equals sign followed by a directory path, like -chdir=example")
}
if strings.HasPrefix(arg, argPrefix) {
argPos = i
argValue = arg[len(argPrefix):]
}
}

// When we fall out here, we'll have populated argValue with a non-empty
// string if the -chdir=... option was present and valid, or left it
// empty if it wasn't present.
if argValue == "" {
return "", args, nil
}

// If we did find the option then we'll need to produce a new args that
// doesn't include it anymore.
if argPos == 0 {
// Easy case: we can just slice off the front
return argValue, args[1:], nil
}
// Otherwise we need to construct a new array and copy to it.
newArgs := make([]string, len(args)-1)
copy(newArgs, args[:argPos])
copy(newArgs[argPos:], args[argPos+1:])
return argValue, newArgs, nil
}
13 changes: 13 additions & 0 deletions terraform/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ type ContextOpts struct {
// initializer.
type ContextMeta struct {
Env string // Env is the state environment

// OriginalWorkingDir is the working directory where the Terraform CLI
// was run from, which may no longer actually be the current working
// directory if the user included the -chdir=... option.
//
// If this string is empty then the original working directory is the same
// as the current working directory.
//
// In most cases we should respect the user's override by ignoring this
// path and just using the current working directory, but this is here
// for some exceptional cases where the original working directory is
// needed.
OriginalWorkingDir string
}

// Context represents all the context that Terraform needs in order to
Expand Down
25 changes: 24 additions & 1 deletion terraform/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,29 @@ func (d *evaluationStateData) GetPathAttr(addr addrs.PathAttr, rng tfdiags.Sourc
switch addr.Name {

case "cwd":
wd, err := os.Getwd()
var err error
var wd string
if d.Evaluator.Meta != nil {
// Meta is always non-nil in the normal case, but some test cases
// are not so realistic.
wd = d.Evaluator.Meta.OriginalWorkingDir
}
if wd == "" {
wd, err = os.Getwd()
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Failed to get working directory`,
Detail: fmt.Sprintf(`The value for path.cwd cannot be determined due to a system error: %s`, err),
Subject: rng.ToHCL().Ptr(),
})
return cty.DynamicVal, diags
}
}
// The current working directory should always be absolute, whether we
// just looked it up or whether we were relying on ContextMeta's
// (possibly non-normalized) path.
wd, err = filepath.Abs(wd)
if err != nil {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Expand All @@ -563,6 +585,7 @@ func (d *evaluationStateData) GetPathAttr(addr addrs.PathAttr, rng tfdiags.Sourc
})
return cty.DynamicVal, diags
}

return cty.StringVal(filepath.ToSlash(wd)), diags

case "module":
Expand Down
Loading

0 comments on commit 7c78959

Please sign in to comment.