diff --git a/backend/atlas/backend.go b/backend/atlas/backend.go index d29123c46..e4f247455 100644 --- a/backend/atlas/backend.go +++ b/backend/atlas/backend.go @@ -52,6 +52,11 @@ type Backend struct { var _ backend.Backend = (*Backend)(nil) +// New returns a new initialized Atlas backend. +func New() *Backend { + return &Backend{} +} + func (b *Backend) ConfigSchema() *configschema.Block { return &configschema.Block{ Attributes: map[string]*configschema.Attribute{ @@ -163,16 +168,16 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { } func (b *Backend) Workspaces() ([]string, error) { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } func (b *Backend) DeleteWorkspace(name string) error { - return backend.ErrNamedStatesNotSupported + return backend.ErrWorkspacesNotSupported } func (b *Backend) StateMgr(name string) (state.State, error) { if name != backend.DefaultStateName { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } return &remote.State{Client: b.stateClient}, nil diff --git a/backend/atlas/backend_test.go b/backend/atlas/backend_test.go index d42418865..b85eb3404 100644 --- a/backend/atlas/backend_test.go +++ b/backend/atlas/backend_test.go @@ -18,7 +18,7 @@ func TestConfigure_envAddr(t *testing.T) { defer os.Setenv("ATLAS_ADDRESS", os.Getenv("ATLAS_ADDRESS")) os.Setenv("ATLAS_ADDRESS", "http://foo.com") - b := &Backend{} + b := New() diags := b.Configure(cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("foo/bar"), "address": cty.NullVal(cty.String), @@ -37,7 +37,7 @@ func TestConfigure_envToken(t *testing.T) { defer os.Setenv("ATLAS_TOKEN", os.Getenv("ATLAS_TOKEN")) os.Setenv("ATLAS_TOKEN", "foo") - b := &Backend{} + b := New() diags := b.Configure(cty.ObjectVal(map[string]cty.Value{ "name": cty.StringVal("foo/bar"), "address": cty.NullVal(cty.String), diff --git a/backend/atlas/state_client_test.go b/backend/atlas/state_client_test.go index 355a537f0..28a2c701c 100644 --- a/backend/atlas/state_client_test.go +++ b/backend/atlas/state_client_test.go @@ -29,7 +29,7 @@ func testStateClient(t *testing.T, c map[string]string) remote.Client { } synthBody := configs.SynthBody("", vals) - b := backend.TestBackendConfig(t, &Backend{}, synthBody) + b := backend.TestBackendConfig(t, New(), synthBody) raw, err := b.StateMgr(backend.DefaultStateName) if err != nil { t.Fatalf("err: %s", err) diff --git a/backend/backend.go b/backend/backend.go index ef1da1885..5259af8ad 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -9,8 +9,6 @@ import ( "errors" "time" - "github.com/zclconf/go-cty/cty" - "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/configs" @@ -22,24 +20,35 @@ import ( "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" ) // DefaultStateName is the name of the default, initial state that every // backend must have. This state cannot be deleted. const DefaultStateName = "default" -// ErrWorkspacesNotSupported is an error returned when a caller attempts -// to perform an operation on a workspace other than "default" for a -// backend that doesn't support multiple workspaces. -// -// The caller can detect this to do special fallback behavior or produce -// a specific, helpful error message. -var ErrWorkspacesNotSupported = errors.New("workspaces not supported") +var ( + // ErrDefaultWorkspaceNotSupported is returned when an operation does not + // support using the default workspace, but requires a named workspace to + // be selected. + ErrDefaultWorkspaceNotSupported = errors.New("default workspace not supported\n" + + "You can create a new workspace with the \"workspace new\" command.") -// ErrNamedStatesNotSupported is an older name for ErrWorkspacesNotSupported. -// -// Deprecated: Use ErrWorkspacesNotSupported instead. -var ErrNamedStatesNotSupported = ErrWorkspacesNotSupported + // ErrOperationNotSupported is returned when an unsupported operation + // is detected by the configured backend. + ErrOperationNotSupported = errors.New("operation not supported") + + // ErrWorkspacesNotSupported is an error returned when a caller attempts + // to perform an operation on a workspace other than "default" for a + // backend that doesn't support multiple workspaces. + // + // The caller can detect this to do special fallback behavior or produce + // a specific, helpful error message. + ErrWorkspacesNotSupported = errors.New("workspaces not supported") +) + +// InitFn is used to initialize a new backend. +type InitFn func() Backend // Backend is the minimal interface that must be implemented to enable Terraform. type Backend interface { @@ -179,11 +188,12 @@ type Operation struct { // The options below are more self-explanatory and affect the runtime // behavior of the operation. + AutoApprove bool Destroy bool + DestroyForce bool + Parallelism int Targets []addrs.Targetable Variables map[string]UnparsedVariableValue - AutoApprove bool - DestroyForce bool // Input/output/control options. UIIn terraform.UIInput @@ -244,10 +254,6 @@ type RunningOperation struct { // operation has completed. Result OperationResult - // ExitCode can be used to set a custom exit code. This enables enhanced - // backends to set specific exit codes that miror any remote exit codes. - ExitCode int - // PlanEmpty is populated after a Plan operation completes without error // to note whether a plan is empty or has changes. PlanEmpty bool diff --git a/backend/init/init.go b/backend/init/init.go index 81286406b..0e4f7188b 100644 --- a/backend/init/init.go +++ b/backend/init/init.go @@ -3,27 +3,28 @@ package init import ( + "os" "sync" "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/svchost/disco" "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" - backendatlas "github.com/hashicorp/terraform/backend/atlas" - backendlocal "github.com/hashicorp/terraform/backend/local" - backendartifactory "github.com/hashicorp/terraform/backend/remote-state/artifactory" + backendAtlas "github.com/hashicorp/terraform/backend/atlas" + backendLocal "github.com/hashicorp/terraform/backend/local" + backendRemote "github.com/hashicorp/terraform/backend/remote" + backendArtifactory "github.com/hashicorp/terraform/backend/remote-state/artifactory" backendAzure "github.com/hashicorp/terraform/backend/remote-state/azure" - backendconsul "github.com/hashicorp/terraform/backend/remote-state/consul" - backendetcdv2 "github.com/hashicorp/terraform/backend/remote-state/etcdv2" - backendetcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3" + backendConsul "github.com/hashicorp/terraform/backend/remote-state/consul" + backendEtcdv2 "github.com/hashicorp/terraform/backend/remote-state/etcdv2" + backendEtcdv3 "github.com/hashicorp/terraform/backend/remote-state/etcdv3" backendGCS "github.com/hashicorp/terraform/backend/remote-state/gcs" - backendhttp "github.com/hashicorp/terraform/backend/remote-state/http" - backendinmem "github.com/hashicorp/terraform/backend/remote-state/inmem" + backendHTTP "github.com/hashicorp/terraform/backend/remote-state/http" + backendInmem "github.com/hashicorp/terraform/backend/remote-state/inmem" backendManta "github.com/hashicorp/terraform/backend/remote-state/manta" backendS3 "github.com/hashicorp/terraform/backend/remote-state/s3" backendSwift "github.com/hashicorp/terraform/backend/remote-state/swift" - - "github.com/zclconf/go-cty/cty" ) // backends is the list of available backends. This is a global variable @@ -37,27 +38,40 @@ import ( // complex structures and supporting that over the plugin system is currently // prohibitively difficult. For those wanting to implement a custom backend, // they can do so with recompilation. -var backends map[string]func() backend.Backend +var backends map[string]backend.InitFn var backendsLock sync.Mutex +// Init initializes the backends map with all our hardcoded backends. func Init(services *disco.Disco) { - // Our hardcoded backends. We don't need to acquire a lock here - // since init() code is serial and can't spawn goroutines. - backends = map[string]func() backend.Backend{ - "artifactory": func() backend.Backend { return backendartifactory.New() }, - "atlas": func() backend.Backend { return &backendatlas.Backend{} }, - "http": func() backend.Backend { return backendhttp.New() }, - "local": func() backend.Backend { return &backendlocal.Local{} }, - "consul": func() backend.Backend { return backendconsul.New() }, - "inmem": func() backend.Backend { return backendinmem.New() }, - "swift": func() backend.Backend { return backendSwift.New() }, - "s3": func() backend.Backend { return backendS3.New() }, - "azurerm": func() backend.Backend { return backendAzure.New() }, - "etcd": func() backend.Backend { return backendetcdv2.New() }, - "etcdv3": func() backend.Backend { return backendetcdv3.New() }, - "gcs": func() backend.Backend { return backendGCS.New() }, - "manta": func() backend.Backend { return backendManta.New() }, + backendsLock.Lock() + defer backendsLock.Unlock() + backends = map[string]backend.InitFn{ + // Enhanced backends. + "local": func() backend.Backend { return backendLocal.New() }, + "remote": func() backend.Backend { + b := backendRemote.New(services) + if os.Getenv("TF_FORCE_LOCAL_BACKEND") != "" { + return backendLocal.NewWithBackend(b) + } + return b + }, + + // Remote State backends. + "artifactory": func() backend.Backend { return backendArtifactory.New() }, + "atlas": func() backend.Backend { return backendAtlas.New() }, + "azurerm": func() backend.Backend { return backendAzure.New() }, + "consul": func() backend.Backend { return backendConsul.New() }, + "etcd": func() backend.Backend { return backendEtcdv2.New() }, + "etcdv3": func() backend.Backend { return backendEtcdv3.New() }, + "gcs": func() backend.Backend { return backendGCS.New() }, + "http": func() backend.Backend { return backendHTTP.New() }, + "inmem": func() backend.Backend { return backendInmem.New() }, + "manta": func() backend.Backend { return backendManta.New() }, + "s3": func() backend.Backend { return backendS3.New() }, + "swift": func() backend.Backend { return backendSwift.New() }, + + // Deprecated backends. "azure": func() backend.Backend { return deprecateBackend( backendAzure.New(), @@ -69,7 +83,7 @@ func Init(services *disco.Disco) { // Backend returns the initialization factory for the given backend, or // nil if none exists. -func Backend(name string) func() backend.Backend { +func Backend(name string) backend.InitFn { backendsLock.Lock() defer backendsLock.Unlock() return backends[name] @@ -82,7 +96,7 @@ func Backend(name string) func() backend.Backend { // This method sets this backend globally and care should be taken to do // this only before Terraform is executing to prevent odd behavior of backends // changing mid-execution. -func Set(name string, f func() backend.Backend) { +func Set(name string, f backend.InitFn) { backendsLock.Lock() defer backendsLock.Unlock() diff --git a/backend/init/init_test.go b/backend/init/init_test.go index 804033bdf..02eacb638 100644 --- a/backend/init/init_test.go +++ b/backend/init/init_test.go @@ -17,6 +17,7 @@ func TestInit_backend(t *testing.T) { Type string }{ {"local", "*local.Local"}, + {"remote", "*remote.Remote"}, {"atlas", "*atlas.Backend"}, {"azurerm", "*azure.Backend"}, {"consul", "*consul.Backend"}, @@ -53,6 +54,7 @@ func TestInit_forceLocalBackend(t *testing.T) { Type string }{ {"local", "nil"}, + {"remote", "*remote.Remote"}, } // Set the TF_FORCE_LOCAL_BACKEND flag so all enhanced backends will diff --git a/backend/local/backend.go b/backend/local/backend.go index 601c0289b..ce4edc0c3 100644 --- a/backend/local/backend.go +++ b/backend/local/backend.go @@ -12,14 +12,13 @@ import ( "strings" "sync" - "github.com/hashicorp/terraform/tfdiags" - "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/helper/schema" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" "github.com/mitchellh/cli" "github.com/mitchellh/colorstring" "github.com/zclconf/go-cty/cty" @@ -96,12 +95,25 @@ type Local struct { // exact commands that are being run. RunningInAutomation bool + // opLock locks operations opLock sync.Mutex - once sync.Once } var _ backend.Backend = (*Local)(nil) +// New returns a new initialized local backend. +func New() *Local { + return NewWithBackend(nil) +} + +// NewWithBackend returns a new local backend initialized with a +// dedicated backend for non-enhanced behavior. +func NewWithBackend(backend backend.Backend) *Local { + return &Local{ + Backend: backend, + } +} + func (b *Local) ConfigSchema() *configschema.Block { if b.Backend != nil { return b.Backend.ConfigSchema() @@ -116,8 +128,6 @@ func (b *Local) ConfigSchema() *configschema.Block { Type: cty.String, Optional: true, }, - // environment_dir was previously a deprecated alias for - // workspace_dir, but now removed. }, } } @@ -342,7 +352,7 @@ func (b *Local) Operation(ctx context.Context, op *backend.Operation) (*backend. return runningOp, nil } -// opWait wats for the operation to complete, and a stop signal or a +// opWait waits for the operation to complete, and a stop signal or a // cancelation signal. func (b *Local) opWait( doneCh <-chan struct{}, @@ -416,7 +426,10 @@ func (b *Local) ReportResult(op *backend.RunningOperation, diags tfdiags.Diagnos // Shouldn't generally happen, but if it does then we'll at least // make some noise in the logs to help us spot it. if len(diags) != 0 { - log.Printf("[ERROR] Local backend needs to report diagnostics but ShowDiagnostics callback is not set: %s", diags.ErrWithWarnings()) + log.Printf( + "[ERROR] Local backend needs to report diagnostics but ShowDiagnostics is not set:\n%s", + diags.ErrWithWarnings(), + ) } } } diff --git a/backend/local/backend_apply.go b/backend/local/backend_apply.go index 66c437971..ce118a863 100644 --- a/backend/local/backend_apply.go +++ b/backend/local/backend_apply.go @@ -8,7 +8,6 @@ import ( "log" "github.com/hashicorp/errwrap" - "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statefile" @@ -25,7 +24,6 @@ func (b *Local) opApply( log.Printf("[INFO] backend/local: starting Apply operation") var diags tfdiags.Diagnostics - var err error // If we have a nil module at this point, then set it to an empty tree // to avoid any potential crashes. @@ -33,7 +31,9 @@ func (b *Local) opApply( diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "No configuration files", - "Apply requires configuration to be present. Applying without a configuration would mark everything for destruction, which is normally not what is desired. If you would like to destroy everything, run 'terraform destroy' instead.", + "Apply requires configuration to be present. Applying without a configuration "+ + "would mark everything for destruction, which is normally not what is desired. "+ + "If you would like to destroy everything, run 'terraform destroy' instead.", )) b.ReportResult(runningOp, diags) return @@ -155,7 +155,7 @@ func (b *Local) opApply( // Store the final state runningOp.State = applyState - err = statemgr.WriteAndPersist(opState, applyState) + err := statemgr.WriteAndPersist(opState, applyState) if err != nil { diags = diags.Append(b.backupStateForError(applyState, err)) b.ReportResult(runningOp, diags) diff --git a/backend/local/backend_plan.go b/backend/local/backend_plan.go index 553d68e3a..950d83b77 100644 --- a/backend/local/backend_plan.go +++ b/backend/local/backend_plan.go @@ -26,13 +26,13 @@ func (b *Local) opPlan( log.Printf("[INFO] backend/local: starting Plan operation") var diags tfdiags.Diagnostics - var err error - if b.CLI != nil && op.PlanFile != nil { + if op.PlanFile != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Can't re-plan a saved plan", - "The plan command was given a saved plan file as its input. This command generates a new plan, and so it requires a configuration directory as its argument.", + "The plan command was given a saved plan file as its input. This command generates "+ + "a new plan, and so it requires a configuration directory as its argument.", )) b.ReportResult(runningOp, diags) return @@ -43,7 +43,10 @@ func (b *Local) opPlan( diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "No configuration files", - "Plan requires configuration to be present. Planning without a configuration would mark everything for destruction, which is normally not what is desired. If you would like to destroy everything, run plan with the -destroy option. Otherwise, create a Terraform configuration file (.tf file) and try again.", + "Plan requires configuration to be present. Planning without a configuration would "+ + "mark everything for destruction, which is normally not what is desired. If you "+ + "would like to destroy everything, run plan with the -destroy option. Otherwise, "+ + "create a Terraform configuration file (.tf file) and try again.", )) b.ReportResult(runningOp, diags) return @@ -122,7 +125,9 @@ func (b *Local) opPlan( if op.PlanOutBackend == nil { // This is always a bug in the operation caller; it's not valid // to set PlanOutPath without also setting PlanOutBackend. - diags = diags.Append(fmt.Errorf("PlanOutPath set without also setting PlanOutBackend (this is a bug in Terraform)")) + diags = diags.Append(fmt.Errorf( + "PlanOutPath set without also setting PlanOutBackend (this is a bug in Terraform)"), + ) b.ReportResult(runningOp, diags) return } @@ -134,7 +139,7 @@ func (b *Local) opPlan( plannedStateFile := statemgr.PlannedStateUpdate(opState, baseState) log.Printf("[INFO] backend/local: writing plan output to: %s", path) - err = planfile.Create(path, configSnap, plannedStateFile, plan) + err := planfile.Create(path, configSnap, plannedStateFile, plan) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, diff --git a/backend/local/backend_test.go b/backend/local/backend_test.go index de3f75c07..863020efa 100644 --- a/backend/local/backend_test.go +++ b/backend/local/backend_test.go @@ -15,14 +15,14 @@ import ( ) func TestLocal_impl(t *testing.T) { - var _ backend.Enhanced = new(Local) - var _ backend.Local = new(Local) - var _ backend.CLI = new(Local) + var _ backend.Enhanced = New() + var _ backend.Local = New() + var _ backend.CLI = New() } func TestLocal_backend(t *testing.T) { defer testTmpDir(t)() - b := &Local{} + b := New() backend.TestBackendStates(t, b) backend.TestBackendStateLocks(t, b, b) } @@ -49,7 +49,7 @@ func checkState(t *testing.T, path, expected string) { } func TestLocal_StatePaths(t *testing.T) { - b := &Local{} + b := New() // Test the defaults path, out, back := b.StatePaths("") @@ -94,7 +94,7 @@ func TestLocal_addAndRemoveStates(t *testing.T) { dflt := backend.DefaultStateName expectedStates := []string{dflt} - b := &Local{} + b := New() states, err := b.Workspaces() if err != nil { t.Fatal(err) @@ -207,13 +207,11 @@ func (b *testDelegateBackend) DeleteWorkspace(name string) error { // verify that the MultiState methods are dispatched to the correct Backend. func TestLocal_multiStateBackend(t *testing.T) { // assign a separate backend where we can read the state - b := &Local{ - Backend: &testDelegateBackend{ - stateErr: true, - statesErr: true, - deleteErr: true, - }, - } + b := NewWithBackend(&testDelegateBackend{ + stateErr: true, + statesErr: true, + deleteErr: true, + }) if _, err := b.StateMgr("test"); err != errTestDelegateState { t.Fatal("expected errTestDelegateState, got:", err) diff --git a/backend/local/testing.go b/backend/local/testing.go index bc833dc99..239706057 100644 --- a/backend/local/testing.go +++ b/backend/local/testing.go @@ -21,30 +21,30 @@ import ( // public fields without any locks. func TestLocal(t *testing.T) (*Local, func()) { t.Helper() - tempDir := testTempDir(t) - var local *Local - local = &Local{ - StatePath: filepath.Join(tempDir, "state.tfstate"), - StateOutPath: filepath.Join(tempDir, "state.tfstate"), - StateBackupPath: filepath.Join(tempDir, "state.tfstate.bak"), - StateWorkspaceDir: filepath.Join(tempDir, "state.tfstate.d"), - ContextOpts: &terraform.ContextOpts{}, - ShowDiagnostics: func(vals ...interface{}) { - var diags tfdiags.Diagnostics - diags = diags.Append(vals...) - for _, diag := range diags { - // NOTE: Since the caller here is not directly the TestLocal - // function, t.Helper doesn't apply and so the log source - // isn't correctly shown in the test log output. This seems - // unavoidable as long as this is happening so indirectly. - t.Log(diag.Description().Summary) - if local.CLI != nil { - local.CLI.Error(diag.Description().Summary) - } + + local := New() + local.StatePath = filepath.Join(tempDir, "state.tfstate") + local.StateOutPath = filepath.Join(tempDir, "state.tfstate") + local.StateBackupPath = filepath.Join(tempDir, "state.tfstate.bak") + local.StateWorkspaceDir = filepath.Join(tempDir, "state.tfstate.d") + local.ContextOpts = &terraform.ContextOpts{} + + local.ShowDiagnostics = func(vals ...interface{}) { + var diags tfdiags.Diagnostics + diags = diags.Append(vals...) + for _, diag := range diags { + // NOTE: Since the caller here is not directly the TestLocal + // function, t.Helper doesn't apply and so the log source + // isn't correctly shown in the test log output. This seems + // unavoidable as long as this is happening so indirectly. + t.Log(diag.Description().Summary) + if local.CLI != nil { + local.CLI.Error(diag.Description().Summary) } - }, + } } + cleanup := func() { if err := os.RemoveAll(tempDir); err != nil { t.Fatal("error cleanup up test:", err) @@ -86,36 +86,80 @@ func TestLocalProvider(t *testing.T, b *Local, name string, schema *terraform.Pr } -// TestNewLocalSingle is a factory for creating a TestLocalSingleState. -// This function matches the signature required for backend/init. -func TestNewLocalSingle() backend.Backend { - return &TestLocalSingleState{} -} - // TestLocalSingleState is a backend implementation that wraps Local // and modifies it to only support single states (returns -// ErrNamedStatesNotSupported for multi-state operations). +// ErrWorkspacesNotSupported for multi-state operations). // // This isn't an actual use case, this is exported just to provide a // easy way to test that behavior. type TestLocalSingleState struct { - Local + *Local } -func (b *TestLocalSingleState) State(name string) (statemgr.Full, error) { +// TestNewLocalSingle is a factory for creating a TestLocalSingleState. +// This function matches the signature required for backend/init. +func TestNewLocalSingle() backend.Backend { + return &TestLocalSingleState{Local: New()} +} + +func (b *TestLocalSingleState) Workspaces() ([]string, error) { + return nil, backend.ErrWorkspacesNotSupported +} + +func (b *TestLocalSingleState) DeleteWorkspace(string) error { + return backend.ErrWorkspacesNotSupported +} + +func (b *TestLocalSingleState) StateMgr(name string) (statemgr.Full, error) { if name != backend.DefaultStateName { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } return b.Local.StateMgr(name) } -func (b *TestLocalSingleState) States() ([]string, error) { - return nil, backend.ErrNamedStatesNotSupported +// TestLocalNoDefaultState is a backend implementation that wraps +// Local and modifies it to support named states, but not the +// default state. It returns ErrDefaultWorkspaceNotSupported when +// the DefaultStateName is used. +type TestLocalNoDefaultState struct { + *Local } -func (b *TestLocalSingleState) DeleteState(string) error { - return backend.ErrNamedStatesNotSupported +// TestNewLocalNoDefault is a factory for creating a TestLocalNoDefaultState. +// This function matches the signature required for backend/init. +func TestNewLocalNoDefault() backend.Backend { + return &TestLocalNoDefaultState{Local: New()} +} + +func (b *TestLocalNoDefaultState) Workspaces() ([]string, error) { + workspaces, err := b.Local.Workspaces() + if err != nil { + return nil, err + } + + filtered := workspaces[:0] + for _, name := range workspaces { + if name != backend.DefaultStateName { + filtered = append(filtered, name) + } + } + + return filtered, nil +} + +func (b *TestLocalNoDefaultState) DeleteWorkspace(name string) error { + if name == backend.DefaultStateName { + return backend.ErrDefaultWorkspaceNotSupported + } + return b.Local.DeleteWorkspace(name) +} + +func (b *TestLocalNoDefaultState) StateMgr(name string) (statemgr.Full, error) { + if name == backend.DefaultStateName { + return nil, backend.ErrDefaultWorkspaceNotSupported + } + return b.Local.StateMgr(name) } func testTempDir(t *testing.T) string { diff --git a/backend/remote-state/artifactory/backend.go b/backend/remote-state/artifactory/backend.go index c96901134..d085f21b5 100644 --- a/backend/remote-state/artifactory/backend.go +++ b/backend/remote-state/artifactory/backend.go @@ -83,16 +83,16 @@ func (b *Backend) configure(ctx context.Context) error { } func (b *Backend) Workspaces() ([]string, error) { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } func (b *Backend) DeleteWorkspace(string) error { - return backend.ErrNamedStatesNotSupported + return backend.ErrWorkspacesNotSupported } func (b *Backend) StateMgr(name string) (state.State, error) { if name != backend.DefaultStateName { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } return &remote.State{ Client: b.client, diff --git a/backend/remote-state/backend.go b/backend/remote-state/backend.go index a47aefc08..ef32356d1 100644 --- a/backend/remote-state/backend.go +++ b/backend/remote-state/backend.go @@ -50,11 +50,11 @@ func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics { } func (b *Backend) Workspaces() ([]string, error) { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } func (b *Backend) DeleteWorkspace(name string) error { - return backend.ErrNamedStatesNotSupported + return backend.ErrWorkspacesNotSupported } func (b *Backend) StateMgr(name string) (statemgr.Full, error) { @@ -64,7 +64,7 @@ func (b *Backend) StateMgr(name string) (statemgr.Full, error) { } if name != backend.DefaultStateName { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } s := &remote.State{Client: b.client} diff --git a/backend/remote-state/etcdv2/backend.go b/backend/remote-state/etcdv2/backend.go index 729789be3..fed0d2a1b 100644 --- a/backend/remote-state/etcdv2/backend.go +++ b/backend/remote-state/etcdv2/backend.go @@ -76,16 +76,16 @@ func (b *Backend) configure(ctx context.Context) error { } func (b *Backend) Workspaces() ([]string, error) { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } func (b *Backend) DeleteWorkspace(string) error { - return backend.ErrNamedStatesNotSupported + return backend.ErrWorkspacesNotSupported } func (b *Backend) StateMgr(name string) (state.State, error) { if name != backend.DefaultStateName { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } return &remote.State{ Client: &EtcdClient{ diff --git a/backend/remote-state/http/backend.go b/backend/remote-state/http/backend.go index 000140974..aaf2515fa 100644 --- a/backend/remote-state/http/backend.go +++ b/backend/remote-state/http/backend.go @@ -151,16 +151,16 @@ func (b *Backend) configure(ctx context.Context) error { func (b *Backend) StateMgr(name string) (state.State, error) { if name != backend.DefaultStateName { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } return &remote.State{Client: b.client}, nil } func (b *Backend) Workspaces() ([]string, error) { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } func (b *Backend) DeleteWorkspace(string) error { - return backend.ErrNamedStatesNotSupported + return backend.ErrWorkspacesNotSupported } diff --git a/backend/remote-state/manta/backend.go b/backend/remote-state/manta/backend.go index d651e6bd6..d4ec85c9c 100644 --- a/backend/remote-state/manta/backend.go +++ b/backend/remote-state/manta/backend.go @@ -61,18 +61,10 @@ func New() backend.Backend { Required: true, }, - "objectName": { - Type: schema.TypeString, - Optional: true, - Default: "terraform.tfstate", - Deprecated: "please use the object_name attribute", - }, - "object_name": { Type: schema.TypeString, Optional: true, - // Set this default once the objectName attribute is removed! - // Default: "terraform.tfstate", + Default: "terraform.tfstate", }, }, } diff --git a/backend/remote-state/manta/backend_test.go b/backend/remote-state/manta/backend_test.go index f10a14239..7e53b928e 100644 --- a/backend/remote-state/manta/backend_test.go +++ b/backend/remote-state/manta/backend_test.go @@ -31,8 +31,8 @@ func TestBackend(t *testing.T) { keyName := "testState" b := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{ - "path": directory, - "objectName": keyName, + "path": directory, + "object_name": keyName, })).(*Backend) createMantaFolder(t, b.storageClient, directory) @@ -48,13 +48,13 @@ func TestBackendLocked(t *testing.T) { keyName := "testState" b1 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{ - "path": directory, - "objectName": keyName, + "path": directory, + "object_name": keyName, })).(*Backend) b2 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{ - "path": directory, - "objectName": keyName, + "path": directory, + "object_name": keyName, })).(*Backend) createMantaFolder(t, b1.storageClient, directory) diff --git a/backend/remote-state/manta/client_test.go b/backend/remote-state/manta/client_test.go index 4daabf021..8a276a780 100644 --- a/backend/remote-state/manta/client_test.go +++ b/backend/remote-state/manta/client_test.go @@ -21,8 +21,8 @@ func TestRemoteClient(t *testing.T) { keyName := "testState" b := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{ - "path": directory, - "objectName": keyName, + "path": directory, + "object_name": keyName, })).(*Backend) createMantaFolder(t, b.storageClient, directory) @@ -42,13 +42,13 @@ func TestRemoteClientLocks(t *testing.T) { keyName := "testState" b1 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{ - "path": directory, - "objectName": keyName, + "path": directory, + "object_name": keyName, })).(*Backend) b2 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{ - "path": directory, - "objectName": keyName, + "path": directory, + "object_name": keyName, })).(*Backend) createMantaFolder(t, b1.storageClient, directory) diff --git a/backend/remote-state/swift/backend_state.go b/backend/remote-state/swift/backend_state.go index 42a15d614..6f0a92255 100644 --- a/backend/remote-state/swift/backend_state.go +++ b/backend/remote-state/swift/backend_state.go @@ -7,16 +7,16 @@ import ( ) func (b *Backend) Workspaces() ([]string, error) { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } func (b *Backend) DeleteWorkspace(name string) error { - return backend.ErrNamedStatesNotSupported + return backend.ErrWorkspacesNotSupported } func (b *Backend) StateMgr(name string) (state.State, error) { if name != backend.DefaultStateName { - return nil, backend.ErrNamedStatesNotSupported + return nil, backend.ErrWorkspacesNotSupported } client := &RemoteClient{ diff --git a/backend/remote/backend.go b/backend/remote/backend.go new file mode 100644 index 000000000..47a2f1a2e --- /dev/null +++ b/backend/remote/backend.go @@ -0,0 +1,677 @@ +package remote + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/state/remote" + "github.com/hashicorp/terraform/svchost" + "github.com/hashicorp/terraform/svchost/disco" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" + "github.com/hashicorp/terraform/version" + "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" + "github.com/zclconf/go-cty/cty" +) + +const ( + defaultHostname = "app.terraform.io" + defaultParallelism = 10 + serviceID = "tfe.v2" +) + +// Remote is an implementation of EnhancedBackend that performs all +// operations in a remote backend. +type Remote struct { + // CLI and Colorize control the CLI output. If CLI is nil then no CLI + // output will be done. If CLIColor is nil then no coloring will be done. + CLI cli.Ui + CLIColor *colorstring.Colorize + + // ShowDiagnostics prints diagnostic messages to the UI. + ShowDiagnostics func(vals ...interface{}) + + // ContextOpts are the base context options to set when initializing a + // new Terraform context. Many of these will be overridden or merged by + // Operation. See Operation for more details. + ContextOpts *terraform.ContextOpts + + // client is the remote backend API client + client *tfe.Client + + // hostname of the remote backend server + hostname string + + // organization is the organization that contains the target workspaces + organization string + + // workspace is used to map the default workspace to a remote workspace + workspace string + + // prefix is used to filter down a set of workspaces that use a single + // configuration + prefix string + + // schema defines the configuration for the backend + schema *schema.Backend + + // services is used for service discovery + services *disco.Disco + + // opLock locks operations + opLock sync.Mutex +} + +var _ backend.Backend = (*Remote)(nil) + +// New creates a new initialized remote backend. +func New(services *disco.Disco) *Remote { + return &Remote{ + services: services, + } +} + +func (b *Remote) ConfigSchema() *configschema.Block { + return &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "hostname": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["hostname"], + }, + "organization": { + Type: cty.String, + Required: true, + Description: schemaDescriptions["organization"], + }, + "token": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["token"], + }, + }, + + BlockTypes: map[string]*configschema.NestedBlock{ + "workspaces": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["name"], + }, + "prefix": { + Type: cty.String, + Optional: true, + Description: schemaDescriptions["prefix"], + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + } +} + +func (b *Remote) ValidateConfig(obj cty.Value) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + if val := obj.GetAttr("organization"); !val.IsNull() { + if val.AsString() == "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid organization value", + `The "organization" attribute value must not be empty.`, + cty.Path{cty.GetAttrStep{Name: "organization"}}, + )) + } + } + + var name, prefix string + if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { + if val := workspaces.GetAttr("name"); !val.IsNull() { + name = val.AsString() + } + if val := workspaces.GetAttr("prefix"); !val.IsNull() { + prefix = val.AsString() + } + } + + // Make sure that we have either a workspace name or a prefix. + if name == "" && prefix == "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + `Either workspace "name" or "prefix" is required.`, + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + )) + } + + // Make sure that only one of workspace name or a prefix is configured. + if name != "" && prefix != "" { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid workspaces configuration", + `Only one of workspace "name" or "prefix" is allowed.`, + cty.Path{cty.GetAttrStep{Name: "workspaces"}}, + )) + } + + return diags +} + +func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // Get the hostname. + if val := obj.GetAttr("hostname"); !val.IsNull() && val.AsString() != "" { + b.hostname = val.AsString() + } else { + b.hostname = defaultHostname + } + + // Get the organization. + if val := obj.GetAttr("organization"); !val.IsNull() { + b.organization = val.AsString() + } + + // Get the workspaces configuration block and retrieve the + // default workspace name and prefix. + if workspaces := obj.GetAttr("workspaces"); !workspaces.IsNull() { + if val := workspaces.GetAttr("name"); !val.IsNull() { + b.workspace = val.AsString() + } + if val := workspaces.GetAttr("prefix"); !val.IsNull() { + b.prefix = val.AsString() + } + } + + // Discover the service URL for this host to confirm that it provides + // a remote backend API and to discover the required base path. + service, err := b.discover(b.hostname) + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + strings.ToUpper(err.Error()[:1])+err.Error()[1:], + `If you are sure the hostname is correct, this could also indicate SSL `+ + `verification issues. Please use "openssl s_client -connect " to `+ + `identify any certificate or certificate chain issues.`, + cty.Path{cty.GetAttrStep{Name: "hostname"}}, + )) + return diags + } + + // Retrieve the token for this host as configured in the credentials + // section of the CLI Config File. + token, err := b.token(b.hostname) + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + strings.ToUpper(err.Error()[:1])+err.Error()[1:], + `If you are sure the hostname is correct, this could also indicate SSL `+ + `verification issues. Please use "openssl s_client -connect " to `+ + `identify any certificate or certificate chain issues.`, + cty.Path{cty.GetAttrStep{Name: "hostname"}}, + )) + return diags + } + if token == "" { + if val := obj.GetAttr("token"); !val.IsNull() { + token = val.AsString() + } + } + + cfg := &tfe.Config{ + Address: service.String(), + BasePath: service.Path, + Token: token, + Headers: make(http.Header), + } + + // Set the version header to the current version. + cfg.Headers.Set(version.Header, version.Version) + + // Create the remote backend API client. + b.client, err = tfe.NewClient(cfg) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to create the Terraform Enterprise client", + fmt.Sprintf( + `The "remote" backend encountered an unexpected error while creating the `+ + `Terraform Enterprise client: %s.`, err, + ), + )) + } + + return diags +} + +// discover the remote backend API service URL and token. +func (b *Remote) discover(hostname string) (*url.URL, error) { + host, err := svchost.ForComparison(hostname) + if err != nil { + return nil, err + } + service := b.services.DiscoverServiceURL(host, serviceID) + if service == nil { + return nil, fmt.Errorf("host %s does not provide a remote backend API", host) + } + return service, nil +} + +// token returns the token for this host as configured in the credentials +// section of the CLI Config File. If no token was configured, an empty +// string will be returned instead. +func (b *Remote) token(hostname string) (string, error) { + host, err := svchost.ForComparison(hostname) + if err != nil { + return "", err + } + creds, err := b.services.CredentialsForHost(host) + if err != nil { + log.Printf("[WARN] Failed to get credentials for %s: %s (ignoring)", host, err) + return "", nil + } + if creds != nil { + return creds.Token(), nil + } + return "", nil +} + +// Workspaces returns a filtered list of remote workspace names. +func (b *Remote) Workspaces() ([]string, error) { + if b.prefix == "" { + return nil, backend.ErrWorkspacesNotSupported + } + return b.workspaces() +} + +func (b *Remote) workspaces() ([]string, error) { + // Check if the configured organization exists. + _, err := b.client.Organizations.Read(context.Background(), b.organization) + if err != nil { + if err == tfe.ErrResourceNotFound { + return nil, fmt.Errorf("organization %s does not exist", b.organization) + } + return nil, err + } + + options := tfe.WorkspaceListOptions{} + switch { + case b.workspace != "": + options.Search = tfe.String(b.workspace) + case b.prefix != "": + options.Search = tfe.String(b.prefix) + } + + // Create a slice to contain all the names. + var names []string + + for { + wl, err := b.client.Workspaces.List(context.Background(), b.organization, options) + if err != nil { + return nil, err + } + + for _, w := range wl.Items { + if b.workspace != "" && w.Name == b.workspace { + names = append(names, backend.DefaultStateName) + continue + } + if b.prefix != "" && strings.HasPrefix(w.Name, b.prefix) { + names = append(names, strings.TrimPrefix(w.Name, b.prefix)) + } + } + + // Exit the loop when we've seen all pages. + if wl.CurrentPage >= wl.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = wl.NextPage + } + + // Sort the result so we have consistent output. + sort.StringSlice(names).Sort() + + return names, nil +} + +// DeleteWorkspace removes the remote workspace if it exists. +func (b *Remote) DeleteWorkspace(name string) error { + if b.workspace == "" && name == backend.DefaultStateName { + return backend.ErrDefaultWorkspaceNotSupported + } + if b.prefix == "" && name != backend.DefaultStateName { + return backend.ErrWorkspacesNotSupported + } + + // Configure the remote workspace name. + switch { + case name == backend.DefaultStateName: + name = b.workspace + case b.prefix != "" && !strings.HasPrefix(name, b.prefix): + name = b.prefix + name + } + + // Check if the configured organization exists. + _, err := b.client.Organizations.Read(context.Background(), b.organization) + if err != nil { + if err == tfe.ErrResourceNotFound { + return fmt.Errorf("organization %s does not exist", b.organization) + } + return err + } + + client := &remoteClient{ + client: b.client, + organization: b.organization, + workspace: name, + } + + return client.Delete() +} + +// StateMgr returns the latest state of the given remote workspace. The +// workspace will be created if it doesn't exist. +func (b *Remote) StateMgr(name string) (state.State, error) { + if b.workspace == "" && name == backend.DefaultStateName { + return nil, backend.ErrDefaultWorkspaceNotSupported + } + if b.prefix == "" && name != backend.DefaultStateName { + return nil, backend.ErrWorkspacesNotSupported + } + + workspaces, err := b.workspaces() + if err != nil { + return nil, fmt.Errorf("Error retrieving workspaces: %v", err) + } + + exists := false + for _, workspace := range workspaces { + if name == workspace { + exists = true + break + } + } + + // Configure the remote workspace name. + switch { + case name == backend.DefaultStateName: + name = b.workspace + case b.prefix != "" && !strings.HasPrefix(name, b.prefix): + name = b.prefix + name + } + + if !exists { + options := tfe.WorkspaceCreateOptions{ + Name: tfe.String(name), + } + + // We only set the Terraform Version for the new workspace if this is + // a release candidate or a final release. + if version.Prerelease == "" || strings.HasPrefix(version.Prerelease, "rc") { + options.TerraformVersion = tfe.String(version.String()) + } + + _, err = b.client.Workspaces.Create(context.Background(), b.organization, options) + if err != nil { + return nil, fmt.Errorf("Error creating workspace %s: %v", name, err) + } + } + + client := &remoteClient{ + client: b.client, + organization: b.organization, + workspace: name, + + // This is optionally set during Terraform Enterprise runs. + runID: os.Getenv("TFE_RUN_ID"), + } + + return &remote.State{Client: client}, nil +} + +// Operation implements backend.Enhanced +func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) { + // Configure the remote workspace name. + switch { + case op.Workspace == backend.DefaultStateName: + op.Workspace = b.workspace + case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix): + op.Workspace = b.prefix + op.Workspace + } + + // Determine the function to call for our operation + var f func(context.Context, context.Context, *backend.Operation) (*tfe.Run, error) + switch op.Type { + case backend.OperationTypePlan: + f = b.opPlan + case backend.OperationTypeApply: + f = b.opApply + default: + return nil, fmt.Errorf( + "\n\nThe \"remote\" backend does not support the %q operation.\n"+ + "Please use the remote backend web UI for running this operation:\n"+ + "https://%s/app/%s/%s", op.Type, b.hostname, b.organization, op.Workspace) + } + + // Lock + b.opLock.Lock() + + // Build our running operation + // the runninCtx is only used to block until the operation returns. + runningCtx, done := context.WithCancel(context.Background()) + runningOp := &backend.RunningOperation{ + Context: runningCtx, + PlanEmpty: true, + } + + // stopCtx wraps the context passed in, and is used to signal a graceful Stop. + stopCtx, stop := context.WithCancel(ctx) + runningOp.Stop = stop + + // cancelCtx is used to cancel the operation immediately, usually + // indicating that the process is exiting. + cancelCtx, cancel := context.WithCancel(context.Background()) + runningOp.Cancel = cancel + + // Do it. + go func() { + defer done() + defer stop() + defer cancel() + + defer b.opLock.Unlock() + + r, opErr := f(stopCtx, cancelCtx, op) + if opErr != nil && opErr != context.Canceled { + b.ReportResult(runningOp, opErr) + return + } + + if r != nil { + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(cancelCtx, r.ID) + if err != nil { + b.ReportResult(runningOp, generalError("Failed to retrieve run", err)) + return + } + + // Record if there are any changes. + runningOp.PlanEmpty = !r.HasChanges + + if opErr == context.Canceled { + if err := b.cancel(cancelCtx, op, r); err != nil { + b.ReportResult(runningOp, generalError("Failed to retrieve run", err)) + return + } + } + + if r.Status == tfe.RunErrored { + runningOp.Result = backend.OperationFailure + } + } + }() + + // Return the running operation. + return runningOp, nil +} + +func (b *Remote) cancel(cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if r.Status == tfe.RunPending && r.Actions.IsCancelable { + // Only ask if the remote operation should be canceled + // if the auto approve flag is not set. + if !op.AutoApprove { + v, err := op.UIIn.Input(&terraform.InputOpts{ + Id: "cancel", + Query: "\nDo you want to cancel the pending remote operation?", + Description: "Only 'yes' will be accepted to cancel.", + }) + if err != nil { + return generalError("Failed asking to cancel", err) + } + if v != "yes" { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationNotCanceled))) + } + return nil + } + } else { + if b.CLI != nil { + // Insert a blank line to separate the ouputs. + b.CLI.Output("") + } + } + + // Try to cancel the remote operation. + err := b.client.Runs.Cancel(cancelCtx, r.ID, tfe.RunCancelOptions{}) + if err != nil { + return generalError("Failed to cancel run", err) + } + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(operationCanceled))) + } + } + + return nil +} + +// ReportResult is a helper for the common chore of setting the status of +// a running operation and showing any diagnostics produced during that +// operation. +// +// If the given diagnostics contains errors then the operation's result +// will be set to backend.OperationFailure. It will be set to +// backend.OperationSuccess otherwise. It will then use b.ShowDiagnostics +// to show the given diagnostics before returning. +// +// Callers should feel free to do each of these operations separately in +// more complex cases where e.g. diagnostics are interleaved with other +// output, but terminating immediately after reporting error diagnostics is +// common and can be expressed concisely via this method. +func (b *Remote) ReportResult(op *backend.RunningOperation, err error) { + var diags tfdiags.Diagnostics + + diags = diags.Append(err) + if diags.HasErrors() { + op.Result = backend.OperationFailure + } else { + op.Result = backend.OperationSuccess + } + + if b.ShowDiagnostics != nil { + b.ShowDiagnostics(diags) + } else { + // Shouldn't generally happen, but if it does then we'll at least + // make some noise in the logs to help us spot it. + if len(diags) != 0 { + log.Printf( + "[ERROR] Remote backend needs to report diagnostics but ShowDiagnostics is not set:\n%s", + diags.ErrWithWarnings(), + ) + } + } +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is guaranteed to always return a non-nil value and so useful +// as a helper to wrap any potentially colored strings. +// func (b *Remote) Colorize() *colorstring.Colorize { +// if b.CLIColor != nil { +// return b.CLIColor +// } + +// return &colorstring.Colorize{ +// Colors: colorstring.DefaultColors, +// Disable: true, +// } +// } + +func generalError(msg string, err error) error { + var diags tfdiags.Diagnostics + + if urlErr, ok := err.(*url.Error); ok { + err = urlErr.Err + } + + switch err { + case context.Canceled: + return err + case tfe.ErrResourceNotFound: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("%s: %v", msg, err), + `The configured "remote" backend returns '404 Not Found' errors for resources `+ + `that do not exist, as well as for resources that a user doesn't have access `+ + `to. When the resource does exists, please check the rights for the used token.`, + )) + return diags.Err() + default: + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + fmt.Sprintf("%s: %v", msg, err), + `The configured "remote" backend encountered an unexpected error. Sometimes `+ + `this is caused by network connection problems, in which case you could retry `+ + `the command. If the issue persists please open a support ticket to get help `+ + `resolving the problem.`, + )) + return diags.Err() + } +} + +const operationCanceled = ` +[reset][red]The remote operation was successfully cancelled.[reset] +` + +const operationNotCanceled = ` +[reset][red]The remote operation was not cancelled.[reset] +` + +var schemaDescriptions = map[string]string{ + "hostname": "The remote backend hostname to connect to (defaults to app.terraform.io).", + "organization": "The name of the organization containing the targeted workspace(s).", + "token": "The token used to authenticate with the remote backend. If credentials for the\n" + + "host are configured in the CLI Config File, then those will be used instead.", + "name": "A workspace name used to map the default workspace to a named remote workspace.\n" + + "When configured only the default workspace can be used. This option conflicts\n" + + "with \"prefix\"", + "prefix": "A prefix used to filter workspaces using a single configuration. New workspaces\n" + + "will automatically be prefixed with this prefix. If omitted only the default\n" + + "workspace can be used. This option conflicts with \"name\"", +} diff --git a/backend/remote/backend_apply.go b/backend/remote/backend_apply.go new file mode 100644 index 000000000..ae6d1eeac --- /dev/null +++ b/backend/remote/backend_apply.go @@ -0,0 +1,237 @@ +package remote + +import ( + "bufio" + "context" + "fmt" + "log" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" +) + +func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation) (*tfe.Run, error) { + log.Printf("[INFO] backend/remote: starting Apply operation") + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace) + if err != nil { + return nil, generalError("Failed to retrieve workspace", err) + } + + var diags tfdiags.Diagnostics + + if !w.Permissions.CanUpdate { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to apply changes", + "The provided credentials have insufficient rights to apply changes. In order "+ + "to apply changes at least write permissions on the workspace are required.", + )) + return nil, diags.Err() + } + + if w.VCSRepo != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Apply not allowed for workspaces with a VCS connection", + "A workspace that is connected to a VCS requires the VCS-driven workflow "+ + "to ensure that the VCS remains the single source of truth.", + )) + return nil, diags.Err() + } + + if op.Parallelism != defaultParallelism { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Custom parallelism values are currently not supported", + `The "remote" backend does not support setting a custom parallelism `+ + `value at this time.`, + )) + } + + if op.PlanFile != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Applying a saved plan is currently not supported", + `The "remote" backend currently requires configuration to be present and `+ + `does not accept an existing saved plan as an argument at this time.`, + )) + } + + if !op.PlanRefresh { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Applying without refresh is currently not supported", + `Currently the "remote" backend will always do an in-memory refresh of `+ + `the Terraform state prior to generating the plan.`, + )) + } + + if op.Targets != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Resource targeting is currently not supported", + `The "remote" backend does not support resource targeting at this time.`, + )) + } + + variables, parseDiags := b.parseVariableValues(op) + diags = diags.Append(parseDiags) + + if len(variables) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Run variables are currently not supported", + fmt.Sprintf( + "The \"remote\" backend does not support setting run variables at this time. "+ + "Currently the only to way to pass variables to the remote backend is by "+ + "creating a '*.auto.tfvars' variables file. This file will automatically "+ + "be loaded by the \"remote\" backend when the workspace is configured to use "+ + "Terraform v0.10.0 or later.\n\nAdditionally you can also set variables on "+ + "the workspace in the web UI:\nhttps://%s/app/%s/%s/variables", + b.hostname, b.organization, op.Workspace, + ), + )) + } + + if !op.HasConfig() && !op.Destroy { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files found", + `Apply requires configuration to be present. Applying without a configuration `+ + `would mark everything for destruction, which is normally not what is desired. `+ + `If you would like to destroy everything, please run 'terraform destroy' which `+ + `does not require any configuration files.`, + )) + } + + // Return if there are any errors. + if diags.HasErrors() { + return nil, diags.Err() + } + + // Run the plan phase. + r, err := b.plan(stopCtx, cancelCtx, op, w) + if err != nil { + return r, err + } + + // This check is also performed in the plan method to determine if + // the policies should be checked, but we need to check the values + // here again to determine if we are done and should return. + if !r.HasChanges || r.Status == tfe.RunErrored { + return r, nil + } + + // Retrieve the run to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // Return if the run cannot be confirmed. + if !w.AutoApply && !r.Actions.IsConfirmable { + return r, nil + } + + // Since we already checked the permissions before creating the run + // this should never happen. But it doesn't hurt to keep this in as + // a safeguard for any unexpected situations. + if !w.AutoApply && !r.Permissions.CanApply { + // Make sure we discard the run if possible. + if r.Actions.IsDiscardable { + err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) + if err != nil { + if op.Destroy { + return r, generalError("Failed to discard destroy", err) + } + return r, generalError("Failed to discard apply", err) + } + } + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to approve the pending changes", + fmt.Sprintf("There are pending changes, but the provided credentials have "+ + "insufficient rights to approve them. The run will be discarded to prevent "+ + "it from blocking the queue waiting for external approval. To queue a run "+ + "that can be approved by someone else, please use the 'Queue Plan' button in "+ + "the web UI:\nhttps://%s/app/%s/%s/runs", b.hostname, b.organization, op.Workspace), + )) + return r, diags.Err() + } + + mustConfirm := (op.UIIn != nil && op.UIOut != nil) && + ((op.Destroy && (!op.DestroyForce && !op.AutoApprove)) || (!op.Destroy && !op.AutoApprove)) + + if !w.AutoApply { + if mustConfirm { + opts := &terraform.InputOpts{Id: "approve"} + + if op.Destroy { + opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + + "There is no undo. Only 'yes' will be accepted to confirm." + } else { + opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will perform the actions described above.\n" + + "Only 'yes' will be accepted to approve." + } + + if err = b.confirm(stopCtx, op, opts, r, "yes"); err != nil { + return r, err + } + } + + err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}) + if err != nil { + return r, generalError("Failed to approve the apply command", err) + } + } + + // If we don't need to ask for confirmation, insert a blank + // line to separate the ouputs. + if w.AutoApply || !mustConfirm { + if b.CLI != nil { + b.CLI.Output("") + } + } + + r, err = b.waitForRun(stopCtx, cancelCtx, op, "apply", r, w) + if err != nil { + return r, err + } + + logs, err := b.client.Applies.Logs(stopCtx, r.Apply.ID) + if err != nil { + return r, generalError("Failed to retrieve logs", err) + } + scanner := bufio.NewScanner(logs) + + skip := 0 + for scanner.Scan() { + // Skip the first 3 lines to prevent duplicate output. + if skip < 3 { + skip++ + continue + } + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(scanner.Text())) + } + } + if err := scanner.Err(); err != nil { + return r, generalError("Failed to read logs", err) + } + + return r, nil +} + +const applyDefaultHeader = ` +[reset][yellow]Running apply in the remote backend. Output will stream here. Pressing Ctrl-C +will cancel the remote apply if its still pending. If the apply started it +will stop streaming the logs, but will not stop the apply running remotely. +To view this run in a browser, visit: +https://%s/app/%s/%s/runs/%s[reset] +` diff --git a/backend/remote/backend_apply_test.go b/backend/remote/backend_apply_test.go new file mode 100644 index 000000000..be7345e60 --- /dev/null +++ b/backend/remote/backend_apply_test.go @@ -0,0 +1,880 @@ +package remote + +import ( + "context" + "os" + "os/signal" + "strings" + "syscall" + "testing" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/configs/configload" + "github.com/hashicorp/terraform/plans/planfile" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +func testOperationApply(t *testing.T, configDir string) (*backend.Operation, func()) { + t.Helper() + + _, configLoader, configCleanup := configload.MustLoadConfigForTests(t, configDir) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + Parallelism: defaultParallelism, + PlanRefresh: true, + Type: backend.OperationTypeApply, + }, configCleanup +} + +func TestRemote_applyBasic(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyWithoutPermissions(t *testing.T) { + b := testBackendNoDefault(t) + + // Create a named workspace without permissions. + w, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + w.Permissions.CanUpdate = false + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + op.UIOut = b.CLI + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "Insufficient rights to apply changes") { + t.Fatalf("expected a permissions error, got: %v", errOutput) + } +} + +func TestRemote_applyWithVCS(t *testing.T) { + b := testBackendNoDefault(t) + + // Create a named workspace with a VCS. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "prod"), + VCSRepo: &tfe.VCSRepoOptions{}, + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "not allowed for workspaces with a VCS") { + t.Fatalf("expected a VCS error, got: %v", errOutput) + } +} + +func TestRemote_applyWithParallelism(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + op.Parallelism = 3 + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "parallelism values are currently not supported") { + t.Fatalf("expected a parallelism error, got: %v", errOutput) + } +} + +func TestRemote_applyWithPlan(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + op.PlanFile = &planfile.Reader{} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "saved plan is currently not supported") { + t.Fatalf("expected a saved plan error, got: %v", errOutput) + } +} + +func TestRemote_applyWithoutRefresh(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + op.PlanRefresh = false + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "refresh is currently not supported") { + t.Fatalf("expected a refresh error, got: %v", errOutput) + } +} + +func TestRemote_applyWithTarget(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "targeting is currently not supported") { + t.Fatalf("expected a targeting error, got: %v", errOutput) + } +} + +func TestRemote_applyWithVariables(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-variables") + defer configCleanup() + + op.Variables = testVariables(terraform.ValueFromNamedFile, "foo", "bar") + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "variables are currently not supported") { + t.Fatalf("expected a variables error, got: %v", errOutput) + } +} + +func TestRemote_applyNoConfig(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/empty") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "configuration files found") { + t.Fatalf("expected configuration files error, got: %v", errOutput) + } +} + +func TestRemote_applyNoChanges(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-no-changes") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "No changes. Infrastructure is up-to-date.") { + t.Fatalf("expected no changes in plan summery: %s", output) + } +} + +func TestRemote_applyNoApprove(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "no", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "Apply discarded") { + t.Fatalf("expected an apply discarded error, got: %v", errOutput) + } +} + +func TestRemote_applyAutoApprove(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "no", + }) + + op.AutoApprove = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyWithAutoApply(t *testing.T) { + b := testBackendNoDefault(t) + + // Create a named workspace that auto applies. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + AutoApply: tfe.Bool(true), + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyLockTimeout(t *testing.T) { + b := testBackendDefault(t) + ctx := context.Background() + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspace) + if err != nil { + t.Fatalf("error retrieving workspace: %v", err) + } + + // Create a new configuration version. + c, err := b.client.ConfigurationVersions.Create(ctx, w.ID, tfe.ConfigurationVersionCreateOptions{}) + if err != nil { + t.Fatalf("error creating configuration version: %v", err) + } + + // Create a pending run to block this run. + _, err = b.client.Runs.Create(ctx, tfe.RunCreateOptions{ + ConfigurationVersion: c, + Workspace: w, + }) + if err != nil { + t.Fatalf("error creating pending run: %v", err) + } + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply") + defer configCleanup() + + input := testInput(t, map[string]string{ + "cancel": "yes", + "approve": "yes", + }) + + op.StateLockTimeout = 5 * time.Second + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + _, err = b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, syscall.SIGINT) + select { + case <-sigint: + // Stop redirecting SIGINT signals. + signal.Stop(sigint) + case <-time.After(10 * time.Second): + t.Fatalf("expected lock timeout after 5 seconds, waited 10 seconds") + } + + if len(input.answers) != 2 { + t.Fatalf("expected unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Lock timeout exceeded") { + t.Fatalf("missing lock timout error in output: %s", output) + } + if strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("unexpected plan summery in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestRemote_applyDestroy(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-destroy") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.Destroy = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "0 to add, 0 to change, 1 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "0 added, 0 changed, 1 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyDestroyNoConfig(t *testing.T) { + b := testBackendDefault(t) + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op, configCleanup := testOperationApply(t, "./test-fixtures/empty") + defer configCleanup() + + op.Destroy = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } +} + +func TestRemote_applyPolicyPass(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-policy-passed") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("missing polic check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyPolicyHardFail(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-policy-hard-failed") + defer configCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answers, got: %v", input.answers) + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "hard failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing policy check result in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestRemote_applyPolicySoftFail(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-policy-soft-failed") + defer configCleanup() + + input := testInput(t, map[string]string{ + "override": "override", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyPolicySoftFailAutoApprove(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-policy-soft-failed") + defer configCleanup() + + input := testInput(t, map[string]string{ + "override": "override", + }) + + op.AutoApprove = true + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answers, got: %v", input.answers) + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "soft failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing policy check result in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestRemote_applyPolicySoftFailAutoApply(t *testing.T) { + b := testBackendDefault(t) + + // Create a named workspace that auto applies. + _, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + AutoApply: tfe.Bool(true), + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-policy-soft-failed") + defer configCleanup() + + input := testInput(t, map[string]string{ + "override": "override", + "approve": "yes", + }) + + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + if len(input.answers) != 1 { + t.Fatalf("expected an unused answer, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing policy check result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyWithRemoteError(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationApply(t, "./test-fixtures/apply-with-error") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected apply operation to fail") + } + if run.Result.ExitStatus() != 1 { + t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "null_resource.foo: 1 error") { + t.Fatalf("missing apply error in output: %s", output) + } +} diff --git a/backend/remote/backend_common.go b/backend/remote/backend_common.go new file mode 100644 index 000000000..03ba2b733 --- /dev/null +++ b/backend/remote/backend_common.go @@ -0,0 +1,334 @@ +package remote + +import ( + "bufio" + "context" + "errors" + "fmt" + "math" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" +) + +// backoff will perform exponential backoff based on the iteration and +// limited by the provided min and max (in milliseconds) durations. +func backoff(min, max float64, iter int) time.Duration { + backoff := math.Pow(2, float64(iter)/5) * min + if backoff > max { + backoff = max + } + return time.Duration(backoff) * time.Millisecond +} + +func (b *Remote) waitForRun(stopCtx, cancelCtx context.Context, op *backend.Operation, opType string, r *tfe.Run, w *tfe.Workspace) (*tfe.Run, error) { + started := time.Now() + updated := started + for i := 0; ; i++ { + select { + case <-stopCtx.Done(): + return r, stopCtx.Err() + case <-cancelCtx.Done(): + return r, cancelCtx.Err() + case <-time.After(backoff(1000, 3000, i)): + // Timer up, show status + } + + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // Return if the run is no longer pending. + if r.Status != tfe.RunPending && r.Status != tfe.RunConfirmed { + if i == 0 && opType == "plan" && b.CLI != nil { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf("Waiting for the %s to start...\n", opType))) + } + if i > 0 && b.CLI != nil { + // Insert a blank line to separate the ouputs. + b.CLI.Output("") + } + return r, nil + } + + // Check if 30 seconds have passed since the last update. + current := time.Now() + if b.CLI != nil && (i == 0 || current.Sub(updated).Seconds() > 30) { + updated = current + position := 0 + elapsed := "" + + // Calculate and set the elapsed time. + if i > 0 { + elapsed = fmt.Sprintf( + " (%s elapsed)", current.Sub(started).Truncate(30*time.Second)) + } + + // Retrieve the workspace used to run this operation in. + w, err = b.client.Workspaces.Read(stopCtx, b.organization, w.Name) + if err != nil { + return nil, generalError("Failed to retrieve workspace", err) + } + + // If the workspace is locked the run will not be queued and we can + // update the status without making any expensive calls. + if w.Locked && w.CurrentRun != nil { + cr, err := b.client.Runs.Read(stopCtx, w.CurrentRun.ID) + if err != nil { + return r, generalError("Failed to retrieve current run", err) + } + if cr.Status == tfe.RunPending { + b.CLI.Output(b.Colorize().Color( + "Waiting for the manually locked workspace to be unlocked..." + elapsed)) + continue + } + } + + // Skip checking the workspace queue when we are the current run. + if w.CurrentRun == nil || w.CurrentRun.ID != r.ID { + found := false + options := tfe.RunListOptions{} + runlist: + for { + rl, err := b.client.Runs.List(stopCtx, w.ID, options) + if err != nil { + return r, generalError("Failed to retrieve run list", err) + } + + // Loop through all runs to calculate the workspace queue position. + for _, item := range rl.Items { + if !found { + if r.ID == item.ID { + found = true + } + continue + } + + // If the run is in a final state, ignore it and continue. + switch item.Status { + case tfe.RunApplied, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunErrored: + continue + case tfe.RunPlanned: + if op.Type == backend.OperationTypePlan { + continue + } + } + + // Increase the workspace queue position. + position++ + + // Stop searching when we reached the current run. + if w.CurrentRun != nil && w.CurrentRun.ID == item.ID { + break runlist + } + } + + // Exit the loop when we've seen all pages. + if rl.CurrentPage >= rl.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = rl.NextPage + } + + if position > 0 { + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for %d run(s) to finish before being queued...%s", + position, + elapsed, + ))) + continue + } + } + + options := tfe.RunQueueOptions{} + search: + for { + rq, err := b.client.Organizations.RunQueue(stopCtx, b.organization, options) + if err != nil { + return r, generalError("Failed to retrieve queue", err) + } + + // Search through all queued items to find our run. + for _, item := range rq.Items { + if r.ID == item.ID { + position = item.PositionInQueue + break search + } + } + + // Exit the loop when we've seen all pages. + if rq.CurrentPage >= rq.TotalPages { + break + } + + // Update the page number to get the next page. + options.PageNumber = rq.NextPage + } + + if position > 0 { + c, err := b.client.Organizations.Capacity(stopCtx, b.organization) + if err != nil { + return r, generalError("Failed to retrieve capacity", err) + } + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for %d queued run(s) to finish before starting...%s", + position-c.Running, + elapsed, + ))) + continue + } + + b.CLI.Output(b.Colorize().Color(fmt.Sprintf( + "Waiting for the %s to start...%s", opType, elapsed))) + } + } +} + +func (b *Remote) parseVariableValues(op *backend.Operation) (terraform.InputValues, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + result := make(terraform.InputValues) + + // Load the configuration using the caller-provided configuration loader. + config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) + diags = diags.Append(configDiags) + if diags.HasErrors() { + return nil, diags + } + + variables, varDiags := backend.ParseVariableValues(op.Variables, config.Module.Variables) + diags = diags.Append(varDiags) + if diags.HasErrors() { + return nil, diags + } + + // Save only the explicitly defined variables. + for k, v := range variables { + switch v.SourceType { + case terraform.ValueFromCLIArg, terraform.ValueFromNamedFile: + result[k] = v + } + } + + return result, diags +} + +func (b *Remote) checkPolicy(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { + if b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------\n") + } + for i, pc := range r.PolicyChecks { + logs, err := b.client.PolicyChecks.Logs(stopCtx, pc.ID) + if err != nil { + return generalError("Failed to retrieve policy check logs", err) + } + scanner := bufio.NewScanner(logs) + + // Retrieve the policy check to get its current status. + pc, err := b.client.PolicyChecks.Read(stopCtx, pc.ID) + if err != nil { + return generalError("Failed to retrieve policy check", err) + } + + var msgPrefix string + switch pc.Scope { + case tfe.PolicyScopeOrganization: + msgPrefix = "Organization policy check" + case tfe.PolicyScopeWorkspace: + msgPrefix = "Workspace policy check" + default: + msgPrefix = fmt.Sprintf("Unknown policy check (%s)", pc.Scope) + } + + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(msgPrefix + ":\n")) + } + + for scanner.Scan() { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(scanner.Text())) + } + } + if err := scanner.Err(); err != nil { + return generalError("Failed to read logs", err) + } + + switch pc.Status { + case tfe.PolicyPasses: + if (op.Type == backend.OperationTypeApply || i < len(r.PolicyChecks)-1) && b.CLI != nil { + b.CLI.Output("\n------------------------------------------------------------------------") + } + continue + case tfe.PolicyErrored: + return fmt.Errorf(msgPrefix + " errored.") + case tfe.PolicyHardFailed: + return fmt.Errorf(msgPrefix + " hard failed.") + case tfe.PolicySoftFailed: + if op.Type == backend.OperationTypePlan || op.UIOut == nil || op.UIIn == nil || + op.AutoApprove || !pc.Actions.IsOverridable || !pc.Permissions.CanOverride { + return fmt.Errorf(msgPrefix + " soft failed.") + } + default: + return fmt.Errorf("Unknown or unexpected policy state: %s", pc.Status) + } + + opts := &terraform.InputOpts{ + Id: "override", + Query: "\nDo you want to override the soft failed policy check?", + Description: "Only 'override' will be accepted to override.", + } + + if err = b.confirm(stopCtx, op, opts, r, "override"); err != nil { + return err + } + + if _, err = b.client.PolicyChecks.Override(stopCtx, pc.ID); err != nil { + return generalError("Failed to override policy check", err) + } + + if b.CLI != nil { + b.CLI.Output("------------------------------------------------------------------------") + } + } + + return nil +} + +func (b *Remote) confirm(stopCtx context.Context, op *backend.Operation, opts *terraform.InputOpts, r *tfe.Run, keyword string) error { + v, err := op.UIIn.Input(opts) + if err != nil { + return fmt.Errorf("Error asking %s: %v", opts.Id, err) + } + if v != keyword { + // Retrieve the run again to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return generalError("Failed to retrieve run", err) + } + + // Make sure we discard the run if possible. + if r.Actions.IsDiscardable { + err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) + if err != nil { + if op.Destroy { + return generalError("Failed to discard destroy", err) + } + return generalError("Failed to discard apply", err) + } + } + + // Even if the run was disarding successfully, we still + // return an error as the apply command was cancelled. + if op.Destroy { + return errors.New("Destroy discarded.") + } + return errors.New("Apply discarded.") + } + + return nil +} diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go new file mode 100644 index 000000000..eac6b6839 --- /dev/null +++ b/backend/remote/backend_mock.go @@ -0,0 +1,998 @@ +package remote + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "strings" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/terraform" +) + +type mockClient struct { + Applies *mockApplies + ConfigurationVersions *mockConfigurationVersions + Organizations *mockOrganizations + Plans *mockPlans + PolicyChecks *mockPolicyChecks + Runs *mockRuns + StateVersions *mockStateVersions + Workspaces *mockWorkspaces +} + +func newMockClient() *mockClient { + c := &mockClient{} + c.Applies = newMockApplies(c) + c.ConfigurationVersions = newMockConfigurationVersions(c) + c.Organizations = newMockOrganizations(c) + c.Plans = newMockPlans(c) + c.PolicyChecks = newMockPolicyChecks(c) + c.Runs = newMockRuns(c) + c.StateVersions = newMockStateVersions(c) + c.Workspaces = newMockWorkspaces(c) + return c +} + +type mockApplies struct { + client *mockClient + applies map[string]*tfe.Apply + logs map[string]string +} + +func newMockApplies(client *mockClient) *mockApplies { + return &mockApplies{ + client: client, + applies: make(map[string]*tfe.Apply), + logs: make(map[string]string), + } +} + +// create is a helper function to create a mock apply that uses the configured +// working directory to find the logfile. +func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { + c, ok := m.client.ConfigurationVersions.configVersions[cvID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + if c.Speculative { + // Speculative means its plan-only so we don't create a Apply. + return nil, nil + } + + id := generateID("apply-") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + a := &tfe.Apply{ + ID: id, + LogReadURL: url, + Status: tfe.ApplyPending, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if w.AutoApply { + a.Status = tfe.ApplyRunning + } + + m.logs[url] = filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "apply.log", + ) + m.applies[a.ID] = a + + return a, nil +} + +func (m *mockApplies) Read(ctx context.Context, applyID string) (*tfe.Apply, error) { + a, ok := m.applies[applyID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + // Together with the mockLogReader this allows testing queued runs. + if a.Status == tfe.ApplyRunning { + a.Status = tfe.ApplyFinished + } + return a, nil +} + +func (m *mockApplies) Logs(ctx context.Context, applyID string) (io.Reader, error) { + a, err := m.Read(ctx, applyID) + if err != nil { + return nil, err + } + + logfile, ok := m.logs[a.LogReadURL] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + done := func() (bool, error) { + a, err := m.Read(ctx, applyID) + if err != nil { + return false, err + } + if a.Status != tfe.ApplyFinished { + return false, nil + } + return true, nil + } + + return &mockLogReader{ + done: done, + logs: bytes.NewBuffer(logs), + }, nil +} + +type mockConfigurationVersions struct { + client *mockClient + configVersions map[string]*tfe.ConfigurationVersion + uploadPaths map[string]string + uploadURLs map[string]*tfe.ConfigurationVersion +} + +func newMockConfigurationVersions(client *mockClient) *mockConfigurationVersions { + return &mockConfigurationVersions{ + client: client, + configVersions: make(map[string]*tfe.ConfigurationVersion), + uploadPaths: make(map[string]string), + uploadURLs: make(map[string]*tfe.ConfigurationVersion), + } +} + +func (m *mockConfigurationVersions) List(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionListOptions) (*tfe.ConfigurationVersionList, error) { + cvl := &tfe.ConfigurationVersionList{} + for _, cv := range m.configVersions { + cvl.Items = append(cvl.Items, cv) + } + + cvl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(cvl.Items), + } + + return cvl, nil +} + +func (m *mockConfigurationVersions) Create(ctx context.Context, workspaceID string, options tfe.ConfigurationVersionCreateOptions) (*tfe.ConfigurationVersion, error) { + id := generateID("cv-") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + cv := &tfe.ConfigurationVersion{ + ID: id, + Status: tfe.ConfigurationPending, + UploadURL: url, + } + + m.configVersions[cv.ID] = cv + m.uploadURLs[url] = cv + + return cv, nil +} + +func (m *mockConfigurationVersions) Read(ctx context.Context, cvID string) (*tfe.ConfigurationVersion, error) { + cv, ok := m.configVersions[cvID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return cv, nil +} + +func (m *mockConfigurationVersions) Upload(ctx context.Context, url, path string) error { + cv, ok := m.uploadURLs[url] + if !ok { + return errors.New("404 not found") + } + m.uploadPaths[cv.ID] = path + cv.Status = tfe.ConfigurationUploaded + return nil +} + +// mockInput is a mock implementation of terraform.UIInput. +type mockInput struct { + answers map[string]string +} + +func (m *mockInput) Input(opts *terraform.InputOpts) (string, error) { + v, ok := m.answers[opts.Id] + if !ok { + return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) + } + delete(m.answers, opts.Id) + return v, nil +} + +type mockOrganizations struct { + client *mockClient + organizations map[string]*tfe.Organization +} + +func newMockOrganizations(client *mockClient) *mockOrganizations { + return &mockOrganizations{ + client: client, + organizations: make(map[string]*tfe.Organization), + } +} + +func (m *mockOrganizations) List(ctx context.Context, options tfe.OrganizationListOptions) (*tfe.OrganizationList, error) { + orgl := &tfe.OrganizationList{} + for _, org := range m.organizations { + orgl.Items = append(orgl.Items, org) + } + + orgl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(orgl.Items), + } + + return orgl, nil +} + +// mockLogReader is a mock logreader that enables testing queued runs. +type mockLogReader struct { + done func() (bool, error) + logs *bytes.Buffer +} + +func (m *mockLogReader) Read(l []byte) (int, error) { + for { + if written, err := m.read(l); err != io.ErrNoProgress { + return written, err + } + time.Sleep(500 * time.Millisecond) + } +} + +func (m *mockLogReader) read(l []byte) (int, error) { + done, err := m.done() + if err != nil { + return 0, err + } + if !done { + return 0, io.ErrNoProgress + } + return m.logs.Read(l) +} + +func (m *mockOrganizations) Create(ctx context.Context, options tfe.OrganizationCreateOptions) (*tfe.Organization, error) { + org := &tfe.Organization{Name: *options.Name} + m.organizations[org.Name] = org + return org, nil +} + +func (m *mockOrganizations) Read(ctx context.Context, name string) (*tfe.Organization, error) { + org, ok := m.organizations[name] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return org, nil +} + +func (m *mockOrganizations) Update(ctx context.Context, name string, options tfe.OrganizationUpdateOptions) (*tfe.Organization, error) { + org, ok := m.organizations[name] + if !ok { + return nil, tfe.ErrResourceNotFound + } + org.Name = *options.Name + return org, nil + +} + +func (m *mockOrganizations) Delete(ctx context.Context, name string) error { + delete(m.organizations, name) + return nil +} + +func (m *mockOrganizations) Capacity(ctx context.Context, name string) (*tfe.Capacity, error) { + var pending, running int + for _, r := range m.client.Runs.runs { + if r.Status == tfe.RunPending { + pending++ + continue + } + running++ + } + return &tfe.Capacity{Pending: pending, Running: running}, nil +} + +func (m *mockOrganizations) RunQueue(ctx context.Context, name string, options tfe.RunQueueOptions) (*tfe.RunQueue, error) { + rq := &tfe.RunQueue{} + + for _, r := range m.client.Runs.runs { + rq.Items = append(rq.Items, r) + } + + rq.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(rq.Items), + } + + return rq, nil +} + +type mockPlans struct { + client *mockClient + logs map[string]string + plans map[string]*tfe.Plan +} + +func newMockPlans(client *mockClient) *mockPlans { + return &mockPlans{ + client: client, + logs: make(map[string]string), + plans: make(map[string]*tfe.Plan), + } +} + +// create is a helper function to create a mock plan that uses the configured +// working directory to find the logfile. +func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { + id := generateID("plan-") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + p := &tfe.Plan{ + ID: id, + LogReadURL: url, + Status: tfe.PlanPending, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + m.logs[url] = filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "plan.log", + ) + m.plans[p.ID] = p + + return p, nil +} + +func (m *mockPlans) Read(ctx context.Context, planID string) (*tfe.Plan, error) { + p, ok := m.plans[planID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + // Together with the mockLogReader this allows testing queued runs. + if p.Status == tfe.PlanRunning { + p.Status = tfe.PlanFinished + } + return p, nil +} + +func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) { + p, err := m.Read(ctx, planID) + if err != nil { + return nil, err + } + + logfile, ok := m.logs[p.LogReadURL] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + done := func() (bool, error) { + p, err := m.Read(ctx, planID) + if err != nil { + return false, err + } + if p.Status != tfe.PlanFinished { + return false, nil + } + return true, nil + } + + return &mockLogReader{ + done: done, + logs: bytes.NewBuffer(logs), + }, nil +} + +type mockPolicyChecks struct { + client *mockClient + checks map[string]*tfe.PolicyCheck + logs map[string]string +} + +func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { + return &mockPolicyChecks{ + client: client, + checks: make(map[string]*tfe.PolicyCheck), + logs: make(map[string]string), + } +} + +// create is a helper function to create a mock policy check that uses the +// configured working directory to find the logfile. +func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { + id := generateID("pc-") + + pc := &tfe.PolicyCheck{ + ID: id, + Actions: &tfe.PolicyActions{}, + Permissions: &tfe.PolicyPermissions{}, + Scope: tfe.PolicyScopeOrganization, + Status: tfe.PolicyPending, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile := filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "policy.log", + ) + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return nil, nil + } + + m.logs[pc.ID] = logfile + m.checks[pc.ID] = pc + + return pc, nil +} + +func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { + _, ok := m.client.Runs.runs[runID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + pcl := &tfe.PolicyCheckList{} + for _, pc := range m.checks { + pcl.Items = append(pcl.Items, pc) + } + + pcl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(pcl.Items), + } + + return pcl, nil +} + +func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile, ok := m.logs[pc.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return nil, fmt.Errorf("logfile does not exist") + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + switch { + case bytes.Contains(logs, []byte("Sentinel Result: true")): + pc.Status = tfe.PolicyPasses + case bytes.Contains(logs, []byte("Sentinel Result: false")): + switch { + case bytes.Contains(logs, []byte("hard-mandatory")): + pc.Status = tfe.PolicyHardFailed + case bytes.Contains(logs, []byte("soft-mandatory")): + pc.Actions.IsOverridable = true + pc.Permissions.CanOverride = true + pc.Status = tfe.PolicySoftFailed + } + default: + // As this is an unexpected state, we say the policy errored. + pc.Status = tfe.PolicyErrored + } + + return pc, nil +} + +func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + pc.Status = tfe.PolicyOverridden + return pc, nil +} + +func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile, ok := m.logs[pc.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + switch { + case bytes.Contains(logs, []byte("Sentinel Result: true")): + pc.Status = tfe.PolicyPasses + case bytes.Contains(logs, []byte("Sentinel Result: false")): + switch { + case bytes.Contains(logs, []byte("hard-mandatory")): + pc.Status = tfe.PolicyHardFailed + case bytes.Contains(logs, []byte("soft-mandatory")): + pc.Actions.IsOverridable = true + pc.Permissions.CanOverride = true + pc.Status = tfe.PolicySoftFailed + } + default: + // As this is an unexpected state, we say the policy errored. + pc.Status = tfe.PolicyErrored + } + + return bytes.NewBuffer(logs), nil +} + +type mockRuns struct { + client *mockClient + runs map[string]*tfe.Run + workspaces map[string][]*tfe.Run +} + +func newMockRuns(client *mockClient) *mockRuns { + return &mockRuns{ + client: client, + runs: make(map[string]*tfe.Run), + workspaces: make(map[string][]*tfe.Run), + } +} + +func (m *mockRuns) List(ctx context.Context, workspaceID string, options tfe.RunListOptions) (*tfe.RunList, error) { + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + rl := &tfe.RunList{} + for _, r := range m.workspaces[w.ID] { + rl.Items = append(rl.Items, r) + } + + rl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(rl.Items), + } + + return rl, nil +} + +func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*tfe.Run, error) { + a, err := m.client.Applies.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + + p, err := m.client.Plans.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + + pc, err := m.client.PolicyChecks.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + + r := &tfe.Run{ + ID: generateID("run-"), + Actions: &tfe.RunActions{IsCancelable: true}, + Apply: a, + HasChanges: false, + Permissions: &tfe.RunPermissions{}, + Plan: p, + Status: tfe.RunPending, + } + + if pc != nil { + r.PolicyChecks = []*tfe.PolicyCheck{pc} + } + + if options.IsDestroy != nil { + r.IsDestroy = *options.IsDestroy + } + + w, ok := m.client.Workspaces.workspaceIDs[options.Workspace.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + if w.CurrentRun == nil { + w.CurrentRun = r + } + + m.runs[r.ID] = r + m.workspaces[options.Workspace.ID] = append(m.workspaces[options.Workspace.ID], r) + + return r, nil +} + +func (m *mockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) { + r, ok := m.runs[runID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + pending := false + for _, r := range m.runs { + if r.ID != runID && r.Status == tfe.RunPending { + pending = true + break + } + } + + if !pending && r.Status == tfe.RunPending { + // Only update the status if there are no other pending runs. + r.Status = tfe.RunPlanning + r.Plan.Status = tfe.PlanRunning + } + + logs, _ := ioutil.ReadFile(m.client.Plans.logs[r.Plan.LogReadURL]) + if r.Plan.Status == tfe.PlanFinished { + if r.IsDestroy || bytes.Contains(logs, []byte("1 to add, 0 to change, 0 to destroy")) { + r.Actions.IsCancelable = false + r.Actions.IsConfirmable = true + r.HasChanges = true + r.Permissions.CanApply = true + } + + if bytes.Contains(logs, []byte("null_resource.foo: 1 error")) { + r.Actions.IsCancelable = false + r.HasChanges = false + r.Status = tfe.RunErrored + } + } + + return r, nil +} + +func (m *mockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error { + r, ok := m.runs[runID] + if !ok { + return tfe.ErrResourceNotFound + } + if r.Status != tfe.RunPending { + // Only update the status if the run is not pending anymore. + r.Status = tfe.RunApplying + r.Apply.Status = tfe.ApplyRunning + } + return nil +} + +func (m *mockRuns) Cancel(ctx context.Context, runID string, options tfe.RunCancelOptions) error { + panic("not implemented") +} + +func (m *mockRuns) ForceCancel(ctx context.Context, runID string, options tfe.RunForceCancelOptions) error { + panic("not implemented") +} + +func (m *mockRuns) Discard(ctx context.Context, runID string, options tfe.RunDiscardOptions) error { + panic("not implemented") +} + +type mockStateVersions struct { + client *mockClient + states map[string][]byte + stateVersions map[string]*tfe.StateVersion + workspaces map[string][]string +} + +func newMockStateVersions(client *mockClient) *mockStateVersions { + return &mockStateVersions{ + client: client, + states: make(map[string][]byte), + stateVersions: make(map[string]*tfe.StateVersion), + workspaces: make(map[string][]string), + } +} + +func (m *mockStateVersions) List(ctx context.Context, options tfe.StateVersionListOptions) (*tfe.StateVersionList, error) { + svl := &tfe.StateVersionList{} + for _, sv := range m.stateVersions { + svl.Items = append(svl.Items, sv) + } + + svl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(svl.Items), + } + + return svl, nil +} + +func (m *mockStateVersions) Create(ctx context.Context, workspaceID string, options tfe.StateVersionCreateOptions) (*tfe.StateVersion, error) { + id := generateID("sv-") + runID := os.Getenv("TFE_RUN_ID") + url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) + + if runID != "" && (options.Run == nil || runID != options.Run.ID) { + return nil, fmt.Errorf("option.Run.ID does not contain the ID exported by TFE_RUN_ID") + } + + sv := &tfe.StateVersion{ + ID: id, + DownloadURL: url, + Serial: *options.Serial, + } + + state, err := base64.StdEncoding.DecodeString(*options.State) + if err != nil { + return nil, err + } + + m.states[sv.DownloadURL] = state + m.stateVersions[sv.ID] = sv + m.workspaces[workspaceID] = append(m.workspaces[workspaceID], sv.ID) + + return sv, nil +} + +func (m *mockStateVersions) Read(ctx context.Context, svID string) (*tfe.StateVersion, error) { + sv, ok := m.stateVersions[svID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return sv, nil +} + +func (m *mockStateVersions) Current(ctx context.Context, workspaceID string) (*tfe.StateVersion, error) { + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + svs, ok := m.workspaces[w.ID] + if !ok || len(svs) == 0 { + return nil, tfe.ErrResourceNotFound + } + + sv, ok := m.stateVersions[svs[len(svs)-1]] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + return sv, nil +} + +func (m *mockStateVersions) Download(ctx context.Context, url string) ([]byte, error) { + state, ok := m.states[url] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return state, nil +} + +type mockWorkspaces struct { + client *mockClient + workspaceIDs map[string]*tfe.Workspace + workspaceNames map[string]*tfe.Workspace +} + +func newMockWorkspaces(client *mockClient) *mockWorkspaces { + return &mockWorkspaces{ + client: client, + workspaceIDs: make(map[string]*tfe.Workspace), + workspaceNames: make(map[string]*tfe.Workspace), + } +} + +func (m *mockWorkspaces) List(ctx context.Context, organization string, options tfe.WorkspaceListOptions) (*tfe.WorkspaceList, error) { + dummyWorkspaces := 10 + wl := &tfe.WorkspaceList{} + + // Get the prefix from the search options. + prefix := "" + if options.Search != nil { + prefix = *options.Search + } + + // Get all the workspaces that match the prefix. + var ws []*tfe.Workspace + for _, w := range m.workspaceIDs { + if strings.HasPrefix(w.Name, prefix) { + ws = append(ws, w) + } + } + + // Return an empty result if we have no matches. + if len(ws) == 0 { + wl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + } + return wl, nil + } + + // Return dummy workspaces for the first page to test pagination. + if options.PageNumber <= 1 { + for i := 0; i < dummyWorkspaces; i++ { + wl.Items = append(wl.Items, &tfe.Workspace{ + ID: generateID("ws-"), + Name: fmt.Sprintf("dummy-workspace-%d", i), + }) + } + + wl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 2, + TotalPages: 2, + TotalCount: len(wl.Items) + len(ws), + } + + return wl, nil + } + + // Return the actual workspaces that matched as the second page. + wl.Items = ws + wl.Pagination = &tfe.Pagination{ + CurrentPage: 2, + PreviousPage: 1, + TotalPages: 2, + TotalCount: len(wl.Items) + dummyWorkspaces, + } + + return wl, nil +} + +func (m *mockWorkspaces) Create(ctx context.Context, organization string, options tfe.WorkspaceCreateOptions) (*tfe.Workspace, error) { + w := &tfe.Workspace{ + ID: generateID("ws-"), + Name: *options.Name, + Permissions: &tfe.WorkspacePermissions{ + CanQueueRun: true, + CanUpdate: true, + }, + } + if options.AutoApply != nil { + w.AutoApply = *options.AutoApply + } + if options.VCSRepo != nil { + w.VCSRepo = &tfe.VCSRepo{} + } + m.workspaceIDs[w.ID] = w + m.workspaceNames[w.Name] = w + return w, nil +} + +func (m *mockWorkspaces) Read(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { + w, ok := m.workspaceNames[workspace] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return w, nil +} + +func (m *mockWorkspaces) Update(ctx context.Context, organization, workspace string, options tfe.WorkspaceUpdateOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceNames[workspace] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if options.Name != nil { + w.Name = *options.Name + } + if options.TerraformVersion != nil { + w.TerraformVersion = *options.TerraformVersion + } + if options.WorkingDirectory != nil { + w.WorkingDirectory = *options.WorkingDirectory + } + + delete(m.workspaceNames, workspace) + m.workspaceNames[w.Name] = w + + return w, nil +} + +func (m *mockWorkspaces) Delete(ctx context.Context, organization, workspace string) error { + if w, ok := m.workspaceNames[workspace]; ok { + delete(m.workspaceIDs, w.ID) + } + delete(m.workspaceNames, workspace) + return nil +} + +func (m *mockWorkspaces) Lock(ctx context.Context, workspaceID string, options tfe.WorkspaceLockOptions) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + w.Locked = true + return w, nil +} + +func (m *mockWorkspaces) Unlock(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { + w, ok := m.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + w.Locked = false + return w, nil +} + +func (m *mockWorkspaces) AssignSSHKey(ctx context.Context, workspaceID string, options tfe.WorkspaceAssignSSHKeyOptions) (*tfe.Workspace, error) { + panic("not implemented") +} + +func (m *mockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { + panic("not implemented") +} + +const alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func generateID(s string) string { + b := make([]byte, 16) + for i := range b { + b[i] = alphanumeric[rand.Intn(len(alphanumeric))] + } + return s + string(b) +} diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go new file mode 100644 index 000000000..2fdea7781 --- /dev/null +++ b/backend/remote/backend_plan.go @@ -0,0 +1,304 @@ +package remote + +import ( + "bufio" + "context" + "errors" + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/tfdiags" +) + +func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation) (*tfe.Run, error) { + log.Printf("[INFO] backend/remote: starting Plan operation") + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(stopCtx, b.organization, op.Workspace) + if err != nil { + return nil, generalError("Failed to retrieve workspace", err) + } + + var diags tfdiags.Diagnostics + + if !w.Permissions.CanQueueRun { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Insufficient rights to generate a plan", + "The provided credentials have insufficient rights to generate a plan. In order "+ + "to generate plans, at least plan permissions on the workspace are required.", + )) + return nil, diags.Err() + } + + if op.Parallelism != defaultParallelism { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Custom parallelism values are currently not supported", + `The "remote" backend does not support setting a custom parallelism `+ + `value at this time.`, + )) + } + + if op.PlanFile != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Displaying a saved plan is currently not supported", + `The "remote" backend currently requires configuration to be present and `+ + `does not accept an existing saved plan as an argument at this time.`, + )) + } + + if op.PlanOutPath != "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Saving a generated plan is currently not supported", + `The "remote" backend does not support saving the generated execution `+ + `plan locally at this time.`, + )) + } + + if !op.PlanRefresh { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Planning without refresh is currently not supported", + `Currently the "remote" backend will always do an in-memory refresh of `+ + `the Terraform state prior to generating the plan.`, + )) + } + + if op.Targets != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Resource targeting is currently not supported", + `The "remote" backend does not support resource targeting at this time.`, + )) + } + + variables, parseDiags := b.parseVariableValues(op) + diags = diags.Append(parseDiags) + + if len(variables) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Run variables are currently not supported", + fmt.Sprintf( + "The \"remote\" backend does not support setting run variables at this time. "+ + "Currently the only to way to pass variables to the remote backend is by "+ + "creating a '*.auto.tfvars' variables file. This file will automatically "+ + "be loaded by the \"remote\" backend when the workspace is configured to use "+ + "Terraform v0.10.0 or later.\n\nAdditionally you can also set variables on "+ + "the workspace in the web UI:\nhttps://%s/app/%s/%s/variables", + b.hostname, b.organization, op.Workspace, + ), + )) + } + + if !op.HasConfig() && !op.Destroy { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "No configuration files found", + `Plan requires configuration to be present. Planning without a configuration `+ + `would mark everything for destruction, which is normally not what is desired. `+ + `If you would like to destroy everything, please run plan with the "-destroy" `+ + `flag or create a single empty configuration file. Otherwise, please create `+ + `a Terraform configuration file in the path being executed and try again.`, + )) + } + + // Return if there are any errors. + if diags.HasErrors() { + return nil, diags.Err() + } + + return b.plan(stopCtx, cancelCtx, op, w) +} + +func (b *Remote) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { + configOptions := tfe.ConfigurationVersionCreateOptions{ + AutoQueueRuns: tfe.Bool(false), + Speculative: tfe.Bool(op.Type == backend.OperationTypePlan), + } + + cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions) + if err != nil { + return nil, generalError("Failed to create configuration version", err) + } + + var configDir string + if op.ConfigDir != "" { + // Make sure to take the working directory into account by removing + // the working directory from the current path. This will result in + // a path that points to the expected root of the workspace. + configDir = filepath.Clean(strings.TrimSuffix( + filepath.Clean(op.ConfigDir), + filepath.Clean(w.WorkingDirectory), + )) + } else { + // We did a check earlier to make sure we either have a config dir, + // or the plan is run with -destroy. So this else clause will only + // be executed when we are destroying and doesn't need the config. + configDir, err = ioutil.TempDir("", "tf") + if err != nil { + return nil, generalError("Failed to create temporary directory", err) + } + defer os.RemoveAll(configDir) + + // Make sure the configured working directory exists. + err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700) + if err != nil { + return nil, generalError( + "Failed to create temporary working directory", err) + } + } + + err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir) + if err != nil { + return nil, generalError("Failed to upload configuration files", err) + } + + uploaded := false + for i := 0; i < 60 && !uploaded; i++ { + select { + case <-stopCtx.Done(): + return nil, context.Canceled + case <-cancelCtx.Done(): + return nil, context.Canceled + case <-time.After(500 * time.Millisecond): + cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID) + if err != nil { + return nil, generalError("Failed to retrieve configuration version", err) + } + + if cv.Status == tfe.ConfigurationUploaded { + uploaded = true + } + } + } + + if !uploaded { + return nil, generalError( + "Failed to upload configuration files", errors.New("operation timed out")) + } + + runOptions := tfe.RunCreateOptions{ + IsDestroy: tfe.Bool(op.Destroy), + Message: tfe.String("Queued manually using Terraform"), + ConfigurationVersion: cv, + Workspace: w, + } + + r, err := b.client.Runs.Create(stopCtx, runOptions) + if err != nil { + return r, generalError("Failed to create run", err) + } + + // When the lock timeout is set, + if op.StateLockTimeout > 0 { + go func() { + select { + case <-stopCtx.Done(): + return + case <-cancelCtx.Done(): + return + case <-time.After(op.StateLockTimeout): + // Retrieve the run to get its current status. + r, err := b.client.Runs.Read(cancelCtx, r.ID) + if err != nil { + log.Printf("[ERROR] error reading run: %v", err) + return + } + + if r.Status == tfe.RunPending && r.Actions.IsCancelable { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr))) + } + + // We abuse the auto aprove flag to indicate that we do not + // want to ask if the remote operation should be canceled. + op.AutoApprove = true + + p, err := os.FindProcess(os.Getpid()) + if err != nil { + log.Printf("[ERROR] error searching process ID: %v", err) + return + } + p.Signal(syscall.SIGINT) + } + } + }() + } + + if b.CLI != nil { + header := planDefaultHeader + if op.Type == backend.OperationTypeApply { + header = applyDefaultHeader + } + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf( + header, b.hostname, b.organization, op.Workspace, r.ID)) + "\n")) + } + + r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w) + if err != nil { + return r, err + } + + logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID) + if err != nil { + return r, generalError("Failed to retrieve logs", err) + } + scanner := bufio.NewScanner(logs) + + for scanner.Scan() { + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(scanner.Text())) + } + } + if err := scanner.Err(); err != nil { + return r, generalError("Failed to read logs", err) + } + + // Retrieve the run to get its current status. + r, err = b.client.Runs.Read(stopCtx, r.ID) + if err != nil { + return r, generalError("Failed to retrieve run", err) + } + + // Return if there are no changes or the run errored. We return + // without an error, even if the run errored, as the error is + // already displayed by the output of the remote run. + if !r.HasChanges || r.Status == tfe.RunErrored { + return r, nil + } + + // Check any configured sentinel policies. + if len(r.PolicyChecks) > 0 { + err = b.checkPolicy(stopCtx, cancelCtx, op, r) + if err != nil { + return r, err + } + } + + return r, nil +} + +const planDefaultHeader = ` +[reset][yellow]Running plan in the remote backend. Output will stream here. Pressing Ctrl-C +will stop streaming the logs, but will not stop the plan running remotely. +To view this run in a browser, visit: +https://%s/app/%s/%s/runs/%s[reset] +` + +// The newline in this error is to make it look good in the CLI! +const lockTimeoutErr = ` +[reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation. +[reset] +` diff --git a/backend/remote/backend_plan_test.go b/backend/remote/backend_plan_test.go new file mode 100644 index 000000000..6ce609d43 --- /dev/null +++ b/backend/remote/backend_plan_test.go @@ -0,0 +1,561 @@ +package remote + +import ( + "context" + "os" + "os/signal" + "strings" + "syscall" + "testing" + "time" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/configs/configload" + "github.com/hashicorp/terraform/plans/planfile" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +func testOperationPlan(t *testing.T, configDir string) (*backend.Operation, func()) { + t.Helper() + + _, configLoader, configCleanup := configload.MustLoadConfigForTests(t, configDir) + + return &backend.Operation{ + ConfigDir: configDir, + ConfigLoader: configLoader, + Parallelism: defaultParallelism, + PlanRefresh: true, + Type: backend.OperationTypePlan, + }, configCleanup +} + +func TestRemote_planBasic(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } +} + +func TestRemote_planWithoutPermissions(t *testing.T) { + b := testBackendNoDefault(t) + + // Create a named workspace without permissions. + w, err := b.client.Workspaces.Create( + context.Background(), + b.organization, + tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.prefix + "prod"), + }, + ) + if err != nil { + t.Fatalf("error creating named workspace: %v", err) + } + w.Permissions.CanQueueRun = false + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + op.Workspace = "prod" + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "Insufficient rights to generate a plan") { + t.Fatalf("expected a permissions error, got: %v", errOutput) + } +} + +func TestRemote_planWithParallelism(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + op.Parallelism = 3 + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "parallelism values are currently not supported") { + t.Fatalf("expected a parallelism error, got: %v", errOutput) + } +} + +func TestRemote_planWithPlan(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + op.PlanFile = &planfile.Reader{} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "saved plan is currently not supported") { + t.Fatalf("expected a saved plan error, got: %v", errOutput) + } +} + +func TestRemote_planWithPath(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + op.PlanOutPath = "./test-fixtures/plan" + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "generated plan is currently not supported") { + t.Fatalf("expected a generated plan error, got: %v", errOutput) + } +} + +func TestRemote_planWithoutRefresh(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + op.PlanRefresh = false + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "refresh is currently not supported") { + t.Fatalf("expected a refresh error, got: %v", errOutput) + } +} + +func TestRemote_planWithTarget(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + addr, _ := addrs.ParseAbsResourceStr("null_resource.foo") + + op.Targets = []addrs.Targetable{addr} + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "targeting is currently not supported") { + t.Fatalf("expected a targeting error, got: %v", errOutput) + } +} + +func TestRemote_planWithVariables(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan-variables") + defer configCleanup() + + op.Variables = testVariables(terraform.ValueFromCLIArg, "foo", "bar") + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "variables are currently not supported") { + t.Fatalf("expected a variables error, got: %v", errOutput) + } +} + +func TestRemote_planNoConfig(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/empty") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "configuration files found") { + t.Fatalf("expected configuration files error, got: %v", errOutput) + } +} + +func TestRemote_planLockTimeout(t *testing.T) { + b := testBackendDefault(t) + ctx := context.Background() + + // Retrieve the workspace used to run this operation in. + w, err := b.client.Workspaces.Read(ctx, b.organization, b.workspace) + if err != nil { + t.Fatalf("error retrieving workspace: %v", err) + } + + // Create a new configuration version. + c, err := b.client.ConfigurationVersions.Create(ctx, w.ID, tfe.ConfigurationVersionCreateOptions{}) + if err != nil { + t.Fatalf("error creating configuration version: %v", err) + } + + // Create a pending run to block this run. + _, err = b.client.Runs.Create(ctx, tfe.RunCreateOptions{ + ConfigurationVersion: c, + Workspace: w, + }) + if err != nil { + t.Fatalf("error creating pending run: %v", err) + } + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + input := testInput(t, map[string]string{ + "cancel": "yes", + "approve": "yes", + }) + + op.StateLockTimeout = 5 * time.Second + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + _, err = b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, syscall.SIGINT) + select { + case <-sigint: + // Stop redirecting SIGINT signals. + signal.Stop(sigint) + case <-time.After(10 * time.Second): + t.Fatalf("expected lock timeout after 5 seconds, waited 10 seconds") + } + + if len(input.answers) != 2 { + t.Fatalf("expected unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "Lock timeout exceeded") { + t.Fatalf("missing lock timout error in output: %s", output) + } + if strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("unexpected plan summery in output: %s", output) + } +} + +func TestRemote_planDestroy(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") + defer configCleanup() + + op.Destroy = true + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } +} + +func TestRemote_planDestroyNoConfig(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/empty") + defer configCleanup() + + op.Destroy = true + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } +} + +func TestRemote_planWithWorkingDirectory(t *testing.T) { + b := testBackendDefault(t) + + options := tfe.WorkspaceUpdateOptions{ + WorkingDirectory: tfe.String("terraform"), + } + + // Configure the workspace to use a custom working direcrtory. + _, err := b.client.Workspaces.Update(context.Background(), b.organization, b.workspace, options) + if err != nil { + t.Fatalf("error configuring working directory: %v", err) + } + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan-with-working-directory/terraform") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } +} + +func TestRemote_planPolicyPass(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan-policy-passed") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatalf("expected a non-empty plan") + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("missing polic check result in output: %s", output) + } +} + +func TestRemote_planPolicyHardFail(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan-policy-hard-failed") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "hard failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing policy check result in output: %s", output) + } +} + +func TestRemote_planPolicySoftFail(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan-policy-soft-failed") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := b.CLI.(*cli.MockUi).ErrorWriter.String() + if !strings.Contains(errOutput, "soft failed") { + t.Fatalf("expected a policy check error, got: %v", errOutput) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing policy check result in output: %s", output) + } +} + +func TestRemote_planWithRemoteError(t *testing.T) { + b := testBackendDefault(t) + + op, configCleanup := testOperationPlan(t, "./test-fixtures/plan-with-error") + defer configCleanup() + + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if run.Result.ExitStatus() != 1 { + t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus()) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "null_resource.foo: 1 error") { + t.Fatalf("missing plan error in output: %s", output) + } +} diff --git a/backend/remote/backend_state.go b/backend/remote/backend_state.go new file mode 100644 index 000000000..99b795b4e --- /dev/null +++ b/backend/remote/backend_state.go @@ -0,0 +1,181 @@ +package remote + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/base64" + "fmt" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/state/remote" + "github.com/hashicorp/terraform/states/statefile" +) + +type remoteClient struct { + client *tfe.Client + lockInfo *state.LockInfo + organization string + runID string + workspace string +} + +// Get the remote state. +func (r *remoteClient) Get() (*remote.Payload, error) { + ctx := context.Background() + + // Retrieve the workspace for which to create a new state. + w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace) + if err != nil { + if err == tfe.ErrResourceNotFound { + // If no state exists, then return nil. + return nil, nil + } + return nil, fmt.Errorf("Error retrieving workspace: %v", err) + } + + sv, err := r.client.StateVersions.Current(ctx, w.ID) + if err != nil { + if err == tfe.ErrResourceNotFound { + // If no state exists, then return nil. + return nil, nil + } + return nil, fmt.Errorf("Error retrieving remote state: %v", err) + } + + state, err := r.client.StateVersions.Download(ctx, sv.DownloadURL) + if err != nil { + return nil, fmt.Errorf("Error downloading remote state: %v", err) + } + + // If the state is empty, then return nil. + if len(state) == 0 { + return nil, nil + } + + // Get the MD5 checksum of the state. + sum := md5.Sum(state) + + return &remote.Payload{ + Data: state, + MD5: sum[:], + }, nil +} + +// Put the remote state. +func (r *remoteClient) Put(state []byte) error { + ctx := context.Background() + + // Retrieve the workspace for which to create a new state. + w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace) + if err != nil { + return fmt.Errorf("Error retrieving workspace: %v", err) + } + + // Read the raw state into a Terraform state. + stateFile, err := statefile.Read(bytes.NewReader(state)) + if err != nil { + return fmt.Errorf("Error reading state: %s", err) + } + + options := tfe.StateVersionCreateOptions{ + Lineage: tfe.String(stateFile.Lineage), + Serial: tfe.Int64(int64(stateFile.Serial)), + MD5: tfe.String(fmt.Sprintf("%x", md5.Sum(state))), + State: tfe.String(base64.StdEncoding.EncodeToString(state)), + } + + // If we have a run ID, make sure to add it to the options + // so the state will be properly associated with the run. + if r.runID != "" { + options.Run = &tfe.Run{ID: r.runID} + } + + // Create the new state. + _, err = r.client.StateVersions.Create(ctx, w.ID, options) + if err != nil { + return fmt.Errorf("Error creating remote state: %v", err) + } + + return nil +} + +// Delete the remote state. +func (r *remoteClient) Delete() error { + err := r.client.Workspaces.Delete(context.Background(), r.organization, r.workspace) + if err != nil && err != tfe.ErrResourceNotFound { + return fmt.Errorf("Error deleting workspace %s: %v", r.workspace, err) + } + + return nil +} + +// Lock the remote state. +func (r *remoteClient) Lock(info *state.LockInfo) (string, error) { + ctx := context.Background() + + lockErr := &state.LockError{Info: r.lockInfo} + + // Retrieve the workspace to lock. + w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace) + if err != nil { + lockErr.Err = err + return "", lockErr + } + + // Check if the workspace is already locked. + if w.Locked { + lockErr.Err = fmt.Errorf( + "remote state already\nlocked (lock ID: \"%s/%s\")", r.organization, r.workspace) + return "", lockErr + } + + // Lock the workspace. + w, err = r.client.Workspaces.Lock(ctx, w.ID, tfe.WorkspaceLockOptions{ + Reason: tfe.String("Locked by Terraform"), + }) + if err != nil { + lockErr.Err = err + return "", lockErr + } + + r.lockInfo = info + + return r.lockInfo.ID, nil +} + +// Unlock the remote state. +func (r *remoteClient) Unlock(id string) error { + ctx := context.Background() + + lockErr := &state.LockError{Info: r.lockInfo} + + // Verify the expected lock ID. + if r.lockInfo != nil && r.lockInfo.ID != id { + lockErr.Err = fmt.Errorf("lock ID does not match existing lock") + return lockErr + } + + // Verify the optional force-unlock lock ID. + if r.lockInfo == nil && r.organization+"/"+r.workspace != id { + lockErr.Err = fmt.Errorf("lock ID does not match existing lock") + return lockErr + } + + // Retrieve the workspace to lock. + w, err := r.client.Workspaces.Read(ctx, r.organization, r.workspace) + if err != nil { + lockErr.Err = err + return lockErr + } + + // Unlock the workspace. + w, err = r.client.Workspaces.Unlock(ctx, w.ID) + if err != nil { + lockErr.Err = err + return lockErr + } + + return nil +} diff --git a/backend/remote/backend_state_test.go b/backend/remote/backend_state_test.go new file mode 100644 index 000000000..c68f5e9c8 --- /dev/null +++ b/backend/remote/backend_state_test.go @@ -0,0 +1,58 @@ +package remote + +import ( + "bytes" + "os" + "testing" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/state/remote" + "github.com/hashicorp/terraform/terraform" +) + +func TestRemoteClient_impl(t *testing.T) { + var _ remote.Client = new(remoteClient) +} + +func TestRemoteClient(t *testing.T) { + client := testRemoteClient(t) + remote.TestClient(t, client) +} + +func TestRemoteClient_stateLock(t *testing.T) { + b := testBackendDefault(t) + + s1, err := b.StateMgr(backend.DefaultStateName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + s2, err := b.StateMgr(backend.DefaultStateName) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + remote.TestRemoteLocks(t, s1.(*remote.State).Client, s2.(*remote.State).Client) +} + +func TestRemoteClient_withRunID(t *testing.T) { + // Set the TFE_RUN_ID environment variable before creating the client! + if err := os.Setenv("TFE_RUN_ID", generateID("run-")); err != nil { + t.Fatalf("error setting env var TFE_RUN_ID: %v", err) + } + + // Create a new test client. + client := testRemoteClient(t) + + // Create a new empty state. + state := bytes.NewBuffer(nil) + if err := terraform.WriteState(terraform.NewState(), state); err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Store the new state to verify (this will be done + // by the mock that is used) that the run ID is set. + if err := client.Put(state.Bytes()); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} diff --git a/backend/remote/backend_test.go b/backend/remote/backend_test.go new file mode 100644 index 000000000..6e348d397 --- /dev/null +++ b/backend/remote/backend_test.go @@ -0,0 +1,234 @@ +package remote + +import ( + "reflect" + "strings" + "testing" + + "github.com/hashicorp/terraform/backend" + "github.com/zclconf/go-cty/cty" +) + +func TestRemote(t *testing.T) { + var _ backend.Enhanced = New(nil) + var _ backend.CLI = New(nil) +} + +func TestRemote_backendDefault(t *testing.T) { + b := testBackendDefault(t) + backend.TestBackendStates(t, b) + backend.TestBackendStateLocks(t, b, b) + backend.TestBackendStateForceUnlock(t, b, b) +} + +func TestRemote_backendNoDefault(t *testing.T) { + b := testBackendNoDefault(t) + backend.TestBackendStates(t, b) +} + +func TestRemote_config(t *testing.T) { + cases := map[string]struct { + config cty.Value + confErr string + valErr string + }{ + "with_a_name": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + }, + "with_a_prefix": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.StringVal("my-app-"), + }), + }), + }, + "without_either_a_name_and_a_prefix": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.NullVal(cty.String), + }), + }), + valErr: `Either workspace "name" or "prefix" is required`, + }, + "with_both_a_name_and_a_prefix": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.StringVal("my-app-"), + }), + }), + valErr: `Only one of workspace "name" or "prefix" is allowed`, + }, + "with_an_unknown_host": { + config: cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.StringVal("nonexisting.local"), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }), + confErr: "Host nonexisting.local does not provide a remote backend API", + }, + } + + for name, tc := range cases { + s := testServer(t) + b := New(testDisco(s)) + + // Validate + valDiags := b.ValidateConfig(tc.config) + if (valDiags.Err() == nil && tc.valErr != "") || + (valDiags.Err() != nil && !strings.Contains(valDiags.Err().Error(), tc.valErr)) { + t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err()) + } + + // Configure + confDiags := b.Configure(tc.config) + if (confDiags.Err() == nil && tc.confErr != "") || + (confDiags.Err() != nil && !strings.Contains(confDiags.Err().Error(), tc.confErr)) { + t.Fatalf("%s: unexpected configure result: %v", name, valDiags.Err()) + } + } +} + +func TestRemote_nonexistingOrganization(t *testing.T) { + msg := "does not exist" + + b := testBackendNoDefault(t) + b.organization = "nonexisting" + + if _, err := b.StateMgr("prod"); err == nil || !strings.Contains(err.Error(), msg) { + t.Fatalf("expected %q error, got: %v", msg, err) + } + + if err := b.DeleteWorkspace("prod"); err == nil || !strings.Contains(err.Error(), msg) { + t.Fatalf("expected %q error, got: %v", msg, err) + } + + if _, err := b.Workspaces(); err == nil || !strings.Contains(err.Error(), msg) { + t.Fatalf("expected %q error, got: %v", msg, err) + } +} + +func TestRemote_addAndRemoveWorkspacesDefault(t *testing.T) { + b := testBackendDefault(t) + if _, err := b.Workspaces(); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) + } + + if _, err := b.StateMgr(backend.DefaultStateName); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if _, err := b.StateMgr("prod"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) + } + + if err := b.DeleteWorkspace(backend.DefaultStateName); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if err := b.DeleteWorkspace("prod"); err != backend.ErrWorkspacesNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) + } +} + +func TestRemote_addAndRemoveWorkspacesNoDefault(t *testing.T) { + b := testBackendNoDefault(t) + states, err := b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces := []string(nil) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected states %#+v, got %#+v", expectedWorkspaces, states) + } + + if _, err := b.StateMgr(backend.DefaultStateName); err != backend.ErrDefaultWorkspaceNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrDefaultWorkspaceNotSupported, err) + } + + expectedA := "test_A" + if _, err := b.StateMgr(expectedA); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = append(expectedWorkspaces, expectedA) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) + } + + expectedB := "test_B" + if _, err := b.StateMgr(expectedB); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = append(expectedWorkspaces, expectedB) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) + } + + if err := b.DeleteWorkspace(backend.DefaultStateName); err != backend.ErrDefaultWorkspaceNotSupported { + t.Fatalf("expected error %v, got %v", backend.ErrDefaultWorkspaceNotSupported, err) + } + + if err := b.DeleteWorkspace(expectedA); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = []string{expectedB} + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v got %#+v", expectedWorkspaces, states) + } + + if err := b.DeleteWorkspace(expectedB); err != nil { + t.Fatal(err) + } + + states, err = b.Workspaces() + if err != nil { + t.Fatal(err) + } + + expectedWorkspaces = []string(nil) + if !reflect.DeepEqual(states, expectedWorkspaces) { + t.Fatalf("expected %#+v, got %#+v", expectedWorkspaces, states) + } +} diff --git a/backend/remote/cli.go b/backend/remote/cli.go new file mode 100644 index 000000000..a6aa1103f --- /dev/null +++ b/backend/remote/cli.go @@ -0,0 +1,14 @@ +package remote + +import ( + "github.com/hashicorp/terraform/backend" +) + +// CLIInit implements backend.CLI +func (b *Remote) CLIInit(opts *backend.CLIOpts) error { + b.CLI = opts.CLI + b.CLIColor = opts.CLIColor + b.ShowDiagnostics = opts.ShowDiagnostics + b.ContextOpts = opts.ContextOpts + return nil +} diff --git a/backend/remote/colorize.go b/backend/remote/colorize.go new file mode 100644 index 000000000..0f877c007 --- /dev/null +++ b/backend/remote/colorize.go @@ -0,0 +1,47 @@ +package remote + +import ( + "regexp" + + "github.com/mitchellh/colorstring" +) + +// colorsRe is used to find ANSI escaped color codes. +var colorsRe = regexp.MustCompile("\033\\[\\d{1,3}m") + +// Colorer is the interface that must be implemented to colorize strings. +type Colorer interface { + Color(v string) string +} + +// Colorize is used to print output when the -no-color flag is used. It will +// strip all ANSI escaped color codes which are set while the operation was +// executed in Terraform Enterprise. +// +// When Terraform Enterprise supports run specific variables, this code can be +// removed as we can then pass the CLI flag to the backend and prevent the color +// codes from being written to the output. +type Colorize struct { + cliColor *colorstring.Colorize +} + +// Color will strip all ANSI escaped color codes and return a uncolored string. +func (c *Colorize) Color(v string) string { + return colorsRe.ReplaceAllString(c.cliColor.Color(v), "") +} + +// Colorize returns the Colorize structure that can be used for colorizing +// output. This is guaranteed to always return a non-nil value and so is useful +// as a helper to wrap any potentially colored strings. +func (b *Remote) Colorize() Colorer { + if b.CLIColor != nil && !b.CLIColor.Disable { + return b.CLIColor + } + if b.CLIColor != nil { + return &Colorize{cliColor: b.CLIColor} + } + return &Colorize{cliColor: &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + }} +} diff --git a/backend/remote/test-fixtures/apply-destroy/apply.log b/backend/remote/test-fixtures/apply-destroy/apply.log new file mode 100644 index 000000000..34adfcd6b --- /dev/null +++ b/backend/remote/test-fixtures/apply-destroy/apply.log @@ -0,0 +1,4 @@ +null_resource.hello: Destroying... (ID: 8657651096157629581) +null_resource.hello: Destruction complete after 0s + +Apply complete! Resources: 0 added, 0 changed, 1 destroyed. diff --git a/backend/remote/test-fixtures/apply-destroy/main.tf b/backend/remote/test-fixtures/apply-destroy/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-destroy/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-destroy/plan.log b/backend/remote/test-fixtures/apply-destroy/plan.log new file mode 100644 index 000000000..1d38d4168 --- /dev/null +++ b/backend/remote/test-fixtures/apply-destroy/plan.log @@ -0,0 +1,22 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + - destroy + +Terraform will perform the following actions: + + - null_resource.hello + + +Plan: 0 to add, 0 to change, 1 to destroy. diff --git a/backend/remote/test-fixtures/apply-no-changes/main.tf b/backend/remote/test-fixtures/apply-no-changes/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-no-changes/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-no-changes/plan.log b/backend/remote/test-fixtures/apply-no-changes/plan.log new file mode 100644 index 000000000..704168151 --- /dev/null +++ b/backend/remote/test-fixtures/apply-no-changes/plan.log @@ -0,0 +1,17 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +null_resource.hello: Refreshing state... (ID: 8657651096157629581) + +------------------------------------------------------------------------ + +No changes. Infrastructure is up-to-date. + +This means that Terraform did not detect any differences between your +configuration and real physical resources that exist. As a result, no +actions need to be performed. diff --git a/backend/remote/test-fixtures/apply-policy-hard-failed/main.tf b/backend/remote/test-fixtures/apply-policy-hard-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-hard-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-policy-hard-failed/plan.log b/backend/remote/test-fixtures/apply-policy-hard-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-hard-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/apply-policy-hard-failed/policy.log b/backend/remote/test-fixtures/apply-policy-hard-failed/policy.log new file mode 100644 index 000000000..5d6e6935b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-hard-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (hard-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/apply-policy-passed/apply.log b/backend/remote/test-fixtures/apply-policy-passed/apply.log new file mode 100644 index 000000000..89c0dbc42 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-passed/apply.log @@ -0,0 +1,4 @@ +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/backend/remote/test-fixtures/apply-policy-passed/main.tf b/backend/remote/test-fixtures/apply-policy-passed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-passed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-policy-passed/plan.log b/backend/remote/test-fixtures/apply-policy-passed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-passed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/apply-policy-passed/policy.log b/backend/remote/test-fixtures/apply-policy-passed/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-passed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/apply.log b/backend/remote/test-fixtures/apply-policy-soft-failed/apply.log new file mode 100644 index 000000000..89c0dbc42 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/apply.log @@ -0,0 +1,4 @@ +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/main.tf b/backend/remote/test-fixtures/apply-policy-soft-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/plan.log b/backend/remote/test-fixtures/apply-policy-soft-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/policy.log b/backend/remote/test-fixtures/apply-policy-soft-failed/policy.log new file mode 100644 index 000000000..3e4ebedf6 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/apply-variables/apply.log b/backend/remote/test-fixtures/apply-variables/apply.log new file mode 100644 index 000000000..89c0dbc42 --- /dev/null +++ b/backend/remote/test-fixtures/apply-variables/apply.log @@ -0,0 +1,4 @@ +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/backend/remote/test-fixtures/apply-variables/main.tf b/backend/remote/test-fixtures/apply-variables/main.tf new file mode 100644 index 000000000..955e8b4c0 --- /dev/null +++ b/backend/remote/test-fixtures/apply-variables/main.tf @@ -0,0 +1,4 @@ +variable "foo" {} +variable "bar" {} + +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-variables/plan.log b/backend/remote/test-fixtures/apply-variables/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply-variables/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/apply-with-error/main.tf b/backend/remote/test-fixtures/apply-with-error/main.tf new file mode 100644 index 000000000..bc45f28f5 --- /dev/null +++ b/backend/remote/test-fixtures/apply-with-error/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + random = "${guid()}" + } +} diff --git a/backend/remote/test-fixtures/apply-with-error/plan.log b/backend/remote/test-fixtures/apply-with-error/plan.log new file mode 100644 index 000000000..4344a3722 --- /dev/null +++ b/backend/remote/test-fixtures/apply-with-error/plan.log @@ -0,0 +1,10 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... + +Error: null_resource.foo: 1 error(s) occurred: + +* null_resource.foo: 1:3: unknown function called: guid in: + +${guid()} diff --git a/backend/remote/test-fixtures/apply/apply.log b/backend/remote/test-fixtures/apply/apply.log new file mode 100644 index 000000000..89c0dbc42 --- /dev/null +++ b/backend/remote/test-fixtures/apply/apply.log @@ -0,0 +1,4 @@ +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/backend/remote/test-fixtures/apply/main.tf b/backend/remote/test-fixtures/apply/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply/plan.log b/backend/remote/test-fixtures/apply/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/empty/.gitignore b/backend/remote/test-fixtures/empty/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/backend/remote/test-fixtures/plan-policy-hard-failed/main.tf b/backend/remote/test-fixtures/plan-policy-hard-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-hard-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/plan-policy-hard-failed/plan.log b/backend/remote/test-fixtures/plan-policy-hard-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-hard-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/plan-policy-hard-failed/policy.log b/backend/remote/test-fixtures/plan-policy-hard-failed/policy.log new file mode 100644 index 000000000..5d6e6935b --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-hard-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (hard-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/plan-policy-passed/main.tf b/backend/remote/test-fixtures/plan-policy-passed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-passed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/plan-policy-passed/plan.log b/backend/remote/test-fixtures/plan-policy-passed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-passed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/plan-policy-passed/policy.log b/backend/remote/test-fixtures/plan-policy-passed/policy.log new file mode 100644 index 000000000..b0cb1e598 --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-passed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: true + +This result means that Sentinel policies returned true and the protected +behavior is allowed by Sentinel policies. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: true + +TRUE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/plan-policy-soft-failed/main.tf b/backend/remote/test-fixtures/plan-policy-soft-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-soft-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/plan-policy-soft-failed/plan.log b/backend/remote/test-fixtures/plan-policy-soft-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-soft-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/plan-policy-soft-failed/policy.log b/backend/remote/test-fixtures/plan-policy-soft-failed/policy.log new file mode 100644 index 000000000..3e4ebedf6 --- /dev/null +++ b/backend/remote/test-fixtures/plan-policy-soft-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/plan-variables/main.tf b/backend/remote/test-fixtures/plan-variables/main.tf new file mode 100644 index 000000000..955e8b4c0 --- /dev/null +++ b/backend/remote/test-fixtures/plan-variables/main.tf @@ -0,0 +1,4 @@ +variable "foo" {} +variable "bar" {} + +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/plan-variables/plan.log b/backend/remote/test-fixtures/plan-variables/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/plan-variables/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/plan-with-error/main.tf b/backend/remote/test-fixtures/plan-with-error/main.tf new file mode 100644 index 000000000..bc45f28f5 --- /dev/null +++ b/backend/remote/test-fixtures/plan-with-error/main.tf @@ -0,0 +1,5 @@ +resource "null_resource" "foo" { + triggers { + random = "${guid()}" + } +} diff --git a/backend/remote/test-fixtures/plan-with-error/plan.log b/backend/remote/test-fixtures/plan-with-error/plan.log new file mode 100644 index 000000000..4344a3722 --- /dev/null +++ b/backend/remote/test-fixtures/plan-with-error/plan.log @@ -0,0 +1,10 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... + +Error: null_resource.foo: 1 error(s) occurred: + +* null_resource.foo: 1:3: unknown function called: guid in: + +${guid()} diff --git a/backend/remote/test-fixtures/plan-with-working-directory/terraform/main.tf b/backend/remote/test-fixtures/plan-with-working-directory/terraform/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/plan-with-working-directory/terraform/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/plan-with-working-directory/terraform/plan.log b/backend/remote/test-fixtures/plan-with-working-directory/terraform/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/plan-with-working-directory/terraform/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/plan/main.tf b/backend/remote/test-fixtures/plan/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/plan/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/plan/plan.log b/backend/remote/test-fixtures/plan/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/plan/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/testing.go b/backend/remote/testing.go new file mode 100644 index 000000000..0bb8d66c9 --- /dev/null +++ b/backend/remote/testing.go @@ -0,0 +1,182 @@ +package remote + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/configs" + "github.com/hashicorp/terraform/state/remote" + "github.com/hashicorp/terraform/svchost" + "github.com/hashicorp/terraform/svchost/auth" + "github.com/hashicorp/terraform/svchost/disco" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" + "github.com/mitchellh/cli" + "github.com/zclconf/go-cty/cty" +) + +const ( + testCred = "test-auth-token" +) + +var ( + tfeHost = svchost.Hostname(defaultHostname) + credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ + tfeHost: {"token": testCred}, + }) +) + +func testInput(t *testing.T, answers map[string]string) *mockInput { + return &mockInput{answers: answers} +} + +func testBackendDefault(t *testing.T) *Remote { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("prod"), + "prefix": cty.NullVal(cty.String), + }), + }) + return testBackend(t, obj) +} + +func testBackendNoDefault(t *testing.T) *Remote { + obj := cty.ObjectVal(map[string]cty.Value{ + "hostname": cty.NullVal(cty.String), + "organization": cty.StringVal("hashicorp"), + "token": cty.NullVal(cty.String), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.NullVal(cty.String), + "prefix": cty.StringVal("my-app-"), + }), + }) + return testBackend(t, obj) +} + +func testRemoteClient(t *testing.T) remote.Client { + b := testBackendDefault(t) + raw, err := b.StateMgr(backend.DefaultStateName) + if err != nil { + t.Fatalf("error: %v", err) + } + s := raw.(*remote.State) + return s.Client +} + +func testBackend(t *testing.T, obj cty.Value) *Remote { + s := testServer(t) + b := New(testDisco(s)) + + // Configure the backend so the client is created. + valDiags := b.ValidateConfig(obj) + if len(valDiags) != 0 { + t.Fatal(valDiags.ErrWithWarnings()) + } + + confDiags := b.Configure(obj) + if len(confDiags) != 0 { + t.Fatal(confDiags.ErrWithWarnings()) + } + + // Get a new mock client. + mc := newMockClient() + + // Replace the services we use with our mock services. + b.CLI = cli.NewMockUi() + b.client.Applies = mc.Applies + b.client.ConfigurationVersions = mc.ConfigurationVersions + b.client.Organizations = mc.Organizations + b.client.Plans = mc.Plans + b.client.PolicyChecks = mc.PolicyChecks + b.client.Runs = mc.Runs + b.client.StateVersions = mc.StateVersions + b.client.Workspaces = mc.Workspaces + + b.ShowDiagnostics = func(vals ...interface{}) { + var diags tfdiags.Diagnostics + for _, diag := range diags.Append(vals...) { + b.CLI.Error(diag.Description().Summary) + } + } + + ctx := context.Background() + + // Create the organization. + _, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ + Name: tfe.String(b.organization), + }) + if err != nil { + t.Fatalf("error: %v", err) + } + + // Create the default workspace if required. + if b.workspace != "" { + _, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{ + Name: tfe.String(b.workspace), + }) + if err != nil { + t.Fatalf("error: %v", err) + } + } + + return b +} + +// testServer returns a *httptest.Server used for local testing. +func testServer(t *testing.T) *httptest.Server { + mux := http.NewServeMux() + + // Respond to service discovery calls. + mux.HandleFunc("/well-known/terraform.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"tfe.v2":"/api/v2/"}`) + }) + + return httptest.NewServer(mux) +} + +// testDisco returns a *disco.Disco mapping app.terraform.io and +// localhost to a local test server. +func testDisco(s *httptest.Server) *disco.Disco { + services := map[string]interface{}{ + "tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL), + } + d := disco.NewWithCredentialsSource(credsSrc) + + d.ForceHostServices(svchost.Hostname(defaultHostname), services) + d.ForceHostServices(svchost.Hostname("localhost"), services) + return d +} + +type unparsedVariableValue struct { + value string + source terraform.ValueSourceType +} + +func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { + return &terraform.InputValue{ + Value: cty.StringVal(v.value), + SourceType: v.source, + }, tfdiags.Diagnostics{} +} + +// testVariable returns a backend.UnparsedVariableValue used for testing. +func testVariables(s terraform.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue { + vars := make(map[string]backend.UnparsedVariableValue, len(vs)) + for _, v := range vs { + vars[v] = &unparsedVariableValue{ + value: v, + source: s, + } + } + return vars +} diff --git a/backend/testing.go b/backend/testing.go index 7fd85c74e..d44073ad9 100644 --- a/backend/testing.go +++ b/backend/testing.go @@ -43,13 +43,13 @@ func TestBackendConfig(t *testing.T, b Backend, c hcl.Body) Backend { diags = diags.Append(valDiags.InConfigBody(c)) if len(diags) != 0 { - t.Fatal(diags) + t.Fatal(diags.ErrWithWarnings()) } confDiags := b.Configure(obj) if len(confDiags) != 0 { confDiags = confDiags.InConfigBody(c) - t.Fatal(confDiags) + t.Fatal(confDiags.ErrWithWarnings()) } return b @@ -69,19 +69,31 @@ func TestWrapConfig(raw map[string]interface{}) hcl.Body { // TestBackend will test the functionality of a Backend. The backend is // assumed to already be configured. This will test state functionality. // If the backend reports it doesn't support multi-state by returning the -// error ErrNamedStatesNotSupported, then it will not test that. +// error ErrWorkspacesNotSupported, then it will not test that. func TestBackendStates(t *testing.T, b Backend) { t.Helper() + noDefault := false + if _, err := b.StateMgr(DefaultStateName); err != nil { + if err == ErrDefaultWorkspaceNotSupported { + noDefault = true + } else { + t.Fatalf("error: %v", err) + } + } + workspaces, err := b.Workspaces() - if err == ErrNamedStatesNotSupported { - t.Logf("TestBackend: workspaces not supported in %T, skipping", b) - return + if err != nil { + if err == ErrWorkspacesNotSupported { + t.Logf("TestBackend: workspaces not supported in %T, skipping", b) + return + } + t.Fatalf("error: %v", err) } // Test it starts with only the default - if len(workspaces) != 1 || workspaces[0] != DefaultStateName { - t.Fatalf("should only have default to start: %#v", workspaces) + if !noDefault && (len(workspaces) != 1 || workspaces[0] != DefaultStateName) { + t.Fatalf("should only default to start: %#v", workspaces) } // Create a couple states @@ -111,8 +123,8 @@ func TestBackendStates(t *testing.T, b Backend) { { // We'll use two distinct states here and verify that changing one // does not also change the other. - barState := states.NewState() fooState := states.NewState() + barState := states.NewState() // write a known state to foo if err := foo.WriteState(fooState); err != nil { @@ -171,7 +183,7 @@ func TestBackendStates(t *testing.T, b Backend) { t.Fatal("after writing a resource to bar and re-reading foo, foo now has resources too") } - // fetch the bar again from the backend + // fetch the bar again from the backend bar, err = b.StateMgr("bar") if err != nil { t.Fatal("error re-fetching state:", err) @@ -190,11 +202,14 @@ func TestBackendStates(t *testing.T, b Backend) { // we determined that named stated are supported earlier workspaces, err := b.Workspaces() if err != nil { - t.Fatal(err) + t.Fatalf("err: %s", err) } sort.Strings(workspaces) expected := []string{"bar", "default", "foo"} + if noDefault { + expected = []string{"bar", "foo"} + } if !reflect.DeepEqual(workspaces, expected) { t.Fatalf("wrong workspaces list\ngot: %#v\nwant: %#v", workspaces, expected) } @@ -230,16 +245,18 @@ func TestBackendStates(t *testing.T, b Backend) { // Verify deletion { - states, err := b.Workspaces() - if err == ErrWorkspacesNotSupported { - t.Logf("TestBackend: named states not supported in %T, skipping", b) - return + workspaces, err := b.Workspaces() + if err != nil { + t.Fatalf("err: %s", err) } - sort.Strings(states) + sort.Strings(workspaces) expected := []string{"bar", "default"} - if !reflect.DeepEqual(states, expected) { - t.Fatalf("bad: %#v", states) + if noDefault { + expected = []string{"bar"} + } + if !reflect.DeepEqual(workspaces, expected) { + t.Fatalf("wrong workspaces list\ngot: %#v\nwant: %#v", workspaces, expected) } } } diff --git a/builtin/providers/terraform/data_source_state.go b/builtin/providers/terraform/data_source_state.go index 7f88591fc..b075fa16f 100644 --- a/builtin/providers/terraform/data_source_state.go +++ b/builtin/providers/terraform/data_source_state.go @@ -4,13 +4,13 @@ import ( "fmt" "log" - "github.com/zclconf/go-cty/cty" - "github.com/hashicorp/terraform/backend" - backendinit "github.com/hashicorp/terraform/backend/init" "github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" + + backendInit "github.com/hashicorp/terraform/backend/init" ) func dataSourceRemoteStateGetSchema() providers.Schema { @@ -58,7 +58,7 @@ func dataSourceRemoteStateRead(d *cty.Value) (cty.Value, tfdiags.Diagnostics) { // Create the client to access our remote state log.Printf("[DEBUG] Initializing remote state backend: %s", backendType) - f := backendinit.Backend(backendType) + f := backendInit.Backend(backendType) if f == nil { diags = diags.Append(tfdiags.AttributeValue( tfdiags.Error, diff --git a/builtin/providers/terraform/provider_test.go b/builtin/providers/terraform/provider_test.go index 2baa338d3..2a3a2bfe9 100644 --- a/builtin/providers/terraform/provider_test.go +++ b/builtin/providers/terraform/provider_test.go @@ -3,19 +3,22 @@ package terraform import ( "testing" - backendinit "github.com/hashicorp/terraform/backend/init" "github.com/hashicorp/terraform/providers" + + backendInit "github.com/hashicorp/terraform/backend/init" ) var testAccProviders map[string]*Provider var testAccProvider *Provider func init() { + // Initialize the backends + backendInit.Init(nil) + testAccProvider = NewProvider() testAccProviders = map[string]*Provider{ "terraform": testAccProvider, } - backendinit.Init(nil) } func TestProvider_impl(t *testing.T) { diff --git a/command/apply.go b/command/apply.go index 5a317b8d6..0ce426b00 100644 --- a/command/apply.go +++ b/command/apply.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/hashicorp/go-getter" - "github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/config/hcl2shim" @@ -178,18 +177,19 @@ func (c *ApplyCommand) Run(args []string) int { // Build the operation opReq := c.Operation(be) opReq.AutoApprove = autoApprove - opReq.Destroy = c.Destroy opReq.ConfigDir = configPath + opReq.Destroy = c.Destroy + opReq.DestroyForce = destroyForce opReq.PlanFile = planFile opReq.PlanRefresh = refresh opReq.Type = backend.OperationTypeApply - opReq.AutoApprove = autoApprove - opReq.DestroyForce = destroyForce + opReq.ConfigLoader, err = c.initConfigLoader() if err != nil { c.showDiagnostics(err) return 1 } + { var moreDiags tfdiags.Diagnostics opReq.Variables, moreDiags = c.collectVariableValues() diff --git a/command/command_test.go b/command/command_test.go index b473e9664..f5a17cd63 100644 --- a/command/command_test.go +++ b/command/command_test.go @@ -19,10 +19,7 @@ import ( "syscall" "testing" - "github.com/zclconf/go-cty/cty" - "github.com/hashicorp/terraform/addrs" - backendinit "github.com/hashicorp/terraform/backend/init" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/configs/configload" "github.com/hashicorp/terraform/configs/configschema" @@ -36,6 +33,9 @@ import ( "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/version" + "github.com/zclconf/go-cty/cty" + + backendInit "github.com/hashicorp/terraform/backend/init" ) // This is the directory where our test fixtures are. @@ -47,6 +47,9 @@ var testingDir string func init() { test = true + // Initialize the backends + backendInit.Init(nil) + // Expand the fixture dir on init because we change the working // directory in some tests. var err error @@ -74,7 +77,7 @@ func TestMain(m *testing.M) { } // Make sure backend init is initialized, since our tests tend to assume it. - backendinit.Init(nil) + backendInit.Init(nil) os.Exit(m.Run()) } diff --git a/command/init.go b/command/init.go index f14c6e127..0dd6c79f5 100644 --- a/command/init.go +++ b/command/init.go @@ -12,7 +12,6 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/backend" - backendinit "github.com/hashicorp/terraform/backend/init" "github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/configs/configschema" @@ -21,6 +20,8 @@ import ( "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" + + backendInit "github.com/hashicorp/terraform/backend/init" ) // InitCommand is a Command implementation that takes a Terraform @@ -153,10 +154,12 @@ func (c *InitCommand) Run(args []string) int { // If our directory is empty, then we're done. We can't get or setup // the backend with an empty directory. - if empty, err := config.IsEmptyDir(path); err != nil { + empty, err := config.IsEmptyDir(path) + if err != nil { diags = diags.Append(fmt.Errorf("Error checking configuration: %s", err)) return 1 - } else if empty { + } + if empty { c.Ui.Output(c.Colorize().Color(strings.TrimSpace(outputInitEmpty))) return 0 } @@ -212,7 +215,7 @@ func (c *InitCommand) Run(args []string) int { c.Ui.Output(c.Colorize().Color(fmt.Sprintf("\n[reset][bold]Initializing the backend..."))) backendType := config.Backend.Type - bf := backendinit.Backend(backendType) + bf := backendInit.Backend(backendType) if bf == nil { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -275,14 +278,12 @@ func (c *InitCommand) Run(args []string) int { if back != nil { sMgr, err := back.StateMgr(c.Workspace()) if err != nil { - c.Ui.Error(fmt.Sprintf( - "Error loading state: %s", err)) + c.Ui.Error(fmt.Sprintf("Error loading state: %s", err)) return 1 } if err := sMgr.RefreshState(); err != nil { - c.Ui.Error(fmt.Sprintf( - "Error refreshing state: %s", err)) + c.Ui.Error(fmt.Sprintf("Error refreshing state: %s", err)) return 1 } diff --git a/command/meta.go b/command/meta.go index dbc1437d0..f8a637be0 100644 --- a/command/meta.go +++ b/command/meta.go @@ -26,7 +26,6 @@ import ( "github.com/hashicorp/terraform/helper/wrappedstreams" "github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/provisioners" - "github.com/hashicorp/terraform/svchost/auth" "github.com/hashicorp/terraform/svchost/disco" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" @@ -52,10 +51,6 @@ type Meta struct { // "terraform-native' services running at a specific user-facing hostname. Services *disco.Disco - // Credentials provides access to credentials for "terraform-native" - // services, which are accessed by a service hostname. - Credentials auth.CredentialsSource - // RunningInAutomation indicates that commands are being run by an // automated system rather than directly at a command prompt. // diff --git a/command/meta_backend.go b/command/meta_backend.go index 2208d4df9..8f9e51580 100644 --- a/command/meta_backend.go +++ b/command/meta_backend.go @@ -10,23 +10,24 @@ import ( "fmt" "log" "path/filepath" + "strconv" "strings" "github.com/hashicorp/errwrap" "github.com/hashicorp/hcl2/hcl" "github.com/hashicorp/hcl2/hcldec" - "github.com/zclconf/go-cty/cty" - ctyjson "github.com/zclconf/go-cty/cty/json" - "github.com/hashicorp/terraform/backend" - backendinit "github.com/hashicorp/terraform/backend/init" - backendlocal "github.com/hashicorp/terraform/backend/local" "github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/state" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" + + backendInit "github.com/hashicorp/terraform/backend/init" + backendLocal "github.com/hashicorp/terraform/backend/local" ) // BackendOpts are the options used to initialize a backend.Backend. @@ -91,7 +92,7 @@ func (m *Meta) Backend(opts *BackendOpts) (backend.Enhanced, tfdiags.Diagnostics log.Printf("[INFO] command: backend initialized: %T", b) } - // Setup the CLI opts we pass into backends that support it + // Setup the CLI opts we pass into backends that support it. cliOpts := m.backendCLIOpts() cliOpts.Validation = true @@ -122,7 +123,7 @@ func (m *Meta) Backend(opts *BackendOpts) (backend.Enhanced, tfdiags.Diagnostics } // Build the local backend - local := &backendlocal.Local{Backend: b} + local := backendLocal.NewWithBackend(b) if err := local.CLIInit(cliOpts); err != nil { // Local backend isn't allowed to fail. It would be a bug. panic(err) @@ -163,7 +164,7 @@ func (m *Meta) Backend(opts *BackendOpts) (backend.Enhanced, tfdiags.Diagnostics func (m *Meta) BackendForPlan(settings plans.Backend) (backend.Enhanced, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics - f := backendinit.Backend(settings.Type) + f := backendInit.Backend(settings.Type) if f == nil { diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), settings.Type)) return nil, diags @@ -209,7 +210,7 @@ func (m *Meta) BackendForPlan(settings plans.Backend) (backend.Enhanced, tfdiags // to cause any operations to be run locally. cliOpts := m.backendCLIOpts() cliOpts.Validation = false // don't validate here in case config contains file(...) calls where the file doesn't exist - local := &backendlocal.Local{Backend: b} + local := backendLocal.NewWithBackend(b) if err := local.CLIInit(cliOpts); err != nil { // Local backend should never fail, so this is always a bug. panic(err) @@ -238,7 +239,7 @@ func (m *Meta) backendCLIOpts() *backend.CLIOpts { // for some checks that require a remote backend. func (m *Meta) IsLocalBackend(b backend.Backend) bool { // Is it a local backend? - bLocal, ok := b.(*backendlocal.Local) + bLocal, ok := b.(*backendLocal.Local) // If it is, does it not have an alternate state backend? if ok { @@ -267,6 +268,7 @@ func (m *Meta) Operation(b backend.Backend) *backend.Operation { return &backend.Operation{ PlanOutBackend: planOutBackend, + Parallelism: m.parallelism, Targets: m.targets, UIIn: m.UIInput(), UIOut: m.Ui, @@ -303,7 +305,7 @@ func (m *Meta) backendConfig(opts *BackendOpts) (*configs.Backend, int, tfdiags. return nil, 0, nil } - bf := backendinit.Backend(c.Type) + bf := backendInit.Backend(c.Type) if bf == nil { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, @@ -598,22 +600,31 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta return nil, diags } - workspace := m.Workspace() - - localState, err := localB.StateMgr(workspace) + workspaces, err := localB.Workspaces() if err != nil { diags = diags.Append(fmt.Errorf(errBackendLocalRead, err)) return nil, diags } - if err := localState.RefreshState(); err != nil { - diags = diags.Append(fmt.Errorf(errBackendLocalRead, err)) - return nil, diags + + var localStates []state.State + for _, workspace := range workspaces { + localState, err := localB.StateMgr(workspace) + if err != nil { + diags = diags.Append(fmt.Errorf(errBackendLocalRead, err)) + return nil, diags + } + if err := localState.RefreshState(); err != nil { + diags = diags.Append(fmt.Errorf(errBackendLocalRead, err)) + return nil, diags + } + + // We only care about non-empty states. + if localS := localState.State(); !localS.Empty() { + localStates = append(localStates, localState) + } } - // If the local state is not empty, we need to potentially do a - // state migration to the new backend (with user permission), unless the - // destination is also "local" - if localS := localState.State(); !localS.Empty() { + if len(localStates) > 0 { // Perform the migration err = m.backendMigrateState(&backendMigrateOpts{ OneType: "local", @@ -631,8 +642,8 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta // can get us here too. Don't delete our state if the old and new paths // are the same. erase := true - if newLocalB, ok := b.(*backendlocal.Local); ok { - if localB, ok := localB.(*backendlocal.Local); ok { + if newLocalB, ok := b.(*backendLocal.Local); ok { + if localB, ok := localB.(*backendLocal.Local); ok { if newLocalB.StatePath == localB.StatePath { erase = false } @@ -640,14 +651,16 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta } if erase { - // We always delete the local state, unless that was our new state too. - if err := localState.WriteState(nil); err != nil { - diags = diags.Append(fmt.Errorf(errBackendMigrateLocalDelete, err)) - return nil, diags - } - if err := localState.PersistState(); err != nil { - diags = diags.Append(fmt.Errorf(errBackendMigrateLocalDelete, err)) - return nil, diags + for _, localState := range localStates { + // We always delete the local state, unless that was our new state too. + if err := localState.WriteState(nil); err != nil { + diags = diags.Append(fmt.Errorf(errBackendMigrateLocalDelete, err)) + return nil, diags + } + if err := localState.PersistState(); err != nil { + diags = diags.Append(fmt.Errorf(errBackendMigrateLocalDelete, err)) + return nil, diags + } } } } @@ -687,6 +700,13 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta return nil, diags } + // Its possible that the currently selected workspace is not migrated, + // so we call selectWorkspace to ensure a valid workspace is selected. + if err := m.selectWorkspace(b); err != nil { + diags = diags.Append(err) + return nil, diags + } + m.Ui.Output(m.Colorize().Color(fmt.Sprintf( "[reset][green]\n"+strings.TrimSpace(successBackendSet), s.Backend.Type))) @@ -694,6 +714,53 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta return b, diags } +// selectWorkspace gets a list of migrated workspaces and then checks +// if the currently selected workspace is valid. If not, it will ask +// the user to select a workspace from the list. +func (m *Meta) selectWorkspace(b backend.Backend) error { + workspaces, err := b.Workspaces() + if err != nil { + return fmt.Errorf("Failed to get migrated workspaces: %s", err) + } + if len(workspaces) == 0 { + return fmt.Errorf(errBackendNoMigratedWorkspaces) + } + + // Get the currently selected workspace. + workspace := m.Workspace() + + // Check if any of the migrated workspaces match the selected workspace + // and create a numbered list with migrated workspaces. + var list strings.Builder + for i, w := range workspaces { + if w == workspace { + return nil + } + fmt.Fprintf(&list, "%d. %s\n", i+1, w) + } + + // If the selected workspace is not migrated, ask the user to select + // a workspace from the list of migrated workspaces. + v, err := m.UIInput().Input(&terraform.InputOpts{ + Id: "select-workspace", + Query: fmt.Sprintf( + "[reset][bold][yellow]The currently selected workspace (%s) is not migrated.[reset]", + workspace), + Description: fmt.Sprintf( + strings.TrimSpace(inputBackendSelectWorkspace), list.String()), + }) + if err != nil { + return fmt.Errorf("Error asking to select workspace: %s", err) + } + + idx, err := strconv.Atoi(v) + if err != nil || (idx < 1 || idx > len(workspaces)) { + return fmt.Errorf("Error selecting workspace: input not a valid number") + } + + return m.SetWorkspace(workspaces[idx-1]) +} + // Changing a previously saved backend. func (m *Meta) backend_C_r_S_changed(c *configs.Backend, cHash int, sMgr *state.LocalState, output bool) (backend.Backend, tfdiags.Diagnostics) { if output { @@ -797,7 +864,7 @@ func (m *Meta) backend_C_r_S_unchanged(c *configs.Backend, cHash int, sMgr *stat } // Get the backend - f := backendinit.Backend(s.Backend.Type) + f := backendInit.Backend(s.Backend.Type) if f == nil { diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), s.Backend.Type)) return nil, diags @@ -861,7 +928,7 @@ func (m *Meta) backendInitFromConfig(c *configs.Backend) (backend.Backend, cty.V var diags tfdiags.Diagnostics // Get the backend - f := backendinit.Backend(c.Type) + f := backendInit.Backend(c.Type) if f == nil { diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendNewUnknown), c.Type)) return nil, cty.NilVal, diags @@ -901,7 +968,7 @@ func (m *Meta) backendInitFromSaved(s *terraform.BackendState) (backend.Backend, var diags tfdiags.Diagnostics // Get the backend - f := backendinit.Backend(s.Type) + f := backendInit.Backend(s.Type) if f == nil { diags = diags.Append(fmt.Errorf(strings.TrimSpace(errBackendSavedUnknown), s.Type)) return nil, diags diff --git a/command/meta_backend_migrate.go b/command/meta_backend_migrate.go index 2976cd63b..06e46883b 100644 --- a/command/meta_backend_migrate.go +++ b/command/meta_backend_migrate.go @@ -10,15 +10,25 @@ import ( "sort" "strings" - "github.com/hashicorp/terraform/states" - "github.com/hashicorp/terraform/states/statemgr" - "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/command/clistate" "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" ) +type backendMigrateOpts struct { + OneType, TwoType string + One, Two backend.Backend + + // Fields below are set internally when migrate is called + + oneEnv string // source env + twoEnv string // dest env + force bool // if true, won't ask for confirmation +} + // backendMigrateState handles migrating (copying) state from one backend // to another. This function handles asking the user for confirmation // as well as the copy itself. @@ -212,7 +222,47 @@ func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { errMigrateSingleLoadDefault), opts.OneType, err) } + // Do not migrate workspaces without state. + if stateOne.State().Empty() { + return nil + } + stateTwo, err := opts.Two.StateMgr(opts.twoEnv) + if err == backend.ErrDefaultWorkspaceNotSupported { + // If the backend doesn't support using the default state, we ask the user + // for a new name and migrate the default state to the given named state. + stateTwo, err = func() (statemgr.Full, error) { + name, err := m.UIInput().Input(&terraform.InputOpts{ + Id: "new-state-name", + Query: fmt.Sprintf( + "[reset][bold][yellow]The %q backend configuration only allows "+ + "named workspaces![reset]", + opts.TwoType), + Description: strings.TrimSpace(inputBackendNewWorkspaceName), + }) + if err != nil { + return nil, fmt.Errorf("Error asking for new state name: %s", err) + } + + // Update the name of the target state. + opts.twoEnv = name + + stateTwo, err := opts.Two.StateMgr(opts.twoEnv) + if err != nil { + return nil, err + } + + // If the currently selected workspace is the default workspace, then set + // the named workspace as the new selected workspace. + if m.Workspace() == backend.DefaultStateName { + if err := m.SetWorkspace(opts.twoEnv); err != nil { + return nil, fmt.Errorf("Failed to set new workspace: %s", err) + } + } + + return stateTwo, nil + }() + } if err != nil { return fmt.Errorf(strings.TrimSpace( errMigrateSingleLoadDefault), opts.TwoType, err) @@ -381,17 +431,6 @@ func (m *Meta) backendMigrateNonEmptyConfirm( return m.confirm(inputOpts) } -type backendMigrateOpts struct { - OneType, TwoType string - One, Two backend.Backend - - // Fields below are set internally when migrate is called - - oneEnv string // source env - twoEnv string // dest env - force bool // if true, won't ask for confirmation -} - const errMigrateLoadStates = ` Error inspecting states in the %q backend: %s @@ -414,8 +453,8 @@ above error and try again. ` const errMigrateMulti = ` -Error migrating the workspace %q from the previous %q backend to the newly -configured %q backend: +Error migrating the workspace %q from the previous %q backend +to the newly configured %q backend: %s Terraform copies workspaces in alphabetical order. Any workspaces @@ -428,13 +467,22 @@ This will attempt to copy (with permission) all workspaces again. ` const errBackendStateCopy = ` -Error copying state from the previous %q backend to the newly configured %q backend: +Error copying state from the previous %q backend to the newly configured +%q backend: %s The state in the previous backend remains intact and unmodified. Please resolve the error above and try again. ` +const errBackendNoMigratedWorkspaces = ` +No workspaces are migrated. Use the "terraform workspace" command to create +and select a new workspace. + +If the backend already contains existing workspaces, you may need to update +the workspace name or prefix in the backend configuration. +` + const inputBackendMigrateEmpty = ` Pre-existing state was found while migrating the previous %q backend to the newly configured %q backend. No existing state was found in the newly @@ -466,9 +514,9 @@ up, or cancel altogether, answer "no" and Terraform will abort. ` const inputBackendMigrateMultiToMulti = ` -Both the existing %[1]q backend and the newly configured %[2]q backend support -workspaces. When migrating between backends, Terraform will copy all -workspaces (with the same names). THIS WILL OVERWRITE any conflicting +Both the existing %[1]q backend and the newly configured %[2]q backend +support workspaces. When migrating between backends, Terraform will copy +all workspaces (with the same names). THIS WILL OVERWRITE any conflicting states in the destination. Terraform initialization doesn't currently migrate only select workspaces. @@ -478,3 +526,15 @@ pull and push those states. If you answer "yes", Terraform will migrate all states. If you answer "no", Terraform will abort. ` + +const inputBackendNewWorkspaceName = ` +Please provide a new workspace name (e.g. dev, test) that will be used +to migrate the existing default workspace. +` + +const inputBackendSelectWorkspace = ` +This is expected behavior when the selected workspace did not have an +existing non-empty state. Please enter a number to select a workspace: + +%s +` diff --git a/command/meta_backend_test.go b/command/meta_backend_test.go index ab8e58e4e..e3a59cdd5 100644 --- a/command/meta_backend_test.go +++ b/command/meta_backend_test.go @@ -8,12 +8,7 @@ import ( "sort" "testing" - "github.com/mitchellh/cli" - "github.com/zclconf/go-cty/cty" - "github.com/hashicorp/terraform/backend" - backendinit "github.com/hashicorp/terraform/backend/init" - backendlocal "github.com/hashicorp/terraform/backend/local" "github.com/hashicorp/terraform/configs" "github.com/hashicorp/terraform/helper/copy" "github.com/hashicorp/terraform/plans" @@ -22,6 +17,11 @@ import ( "github.com/hashicorp/terraform/states/statefile" "github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" + "github.com/zclconf/go-cty/cty" + + backendInit "github.com/hashicorp/terraform/backend/init" + backendLocal "github.com/hashicorp/terraform/backend/local" ) // Test empty directory with no config/state creates a local state. @@ -745,8 +745,8 @@ func TestMetaBackend_reconfigureChange(t *testing.T) { defer testChdir(t, td)() // Register the single-state backend - backendinit.Set("local-single", backendlocal.TestNewLocalSingle) - defer backendinit.Set("local-single", nil) + backendInit.Set("local-single", backendLocal.TestNewLocalSingle) + defer backendInit.Set("local-single", nil) // Setup the meta m := testMetaBackend(t, nil) @@ -844,12 +844,11 @@ func TestMetaBackend_configuredChangeCopy_singleState(t *testing.T) { defer testChdir(t, td)() // Register the single-state backend - backendinit.Set("local-single", backendlocal.TestNewLocalSingle) - defer backendinit.Set("local-single", nil) + backendInit.Set("local-single", backendLocal.TestNewLocalSingle) + defer backendInit.Set("local-single", nil) // Ask input defer testInputMap(t, map[string]string{ - "backend-migrate-to-new": "yes", "backend-migrate-copy-to-empty": "yes", })() @@ -900,12 +899,11 @@ func TestMetaBackend_configuredChangeCopy_multiToSingleDefault(t *testing.T) { defer testChdir(t, td)() // Register the single-state backend - backendinit.Set("local-single", backendlocal.TestNewLocalSingle) - defer backendinit.Set("local-single", nil) + backendInit.Set("local-single", backendLocal.TestNewLocalSingle) + defer backendInit.Set("local-single", nil) // Ask input defer testInputMap(t, map[string]string{ - "backend-migrate-to-new": "yes", "backend-migrate-copy-to-empty": "yes", })() @@ -955,12 +953,11 @@ func TestMetaBackend_configuredChangeCopy_multiToSingle(t *testing.T) { defer testChdir(t, td)() // Register the single-state backend - backendinit.Set("local-single", backendlocal.TestNewLocalSingle) - defer backendinit.Set("local-single", nil) + backendInit.Set("local-single", backendLocal.TestNewLocalSingle) + defer backendInit.Set("local-single", nil) // Ask input defer testInputMap(t, map[string]string{ - "backend-migrate-to-new": "yes", "backend-migrate-multistate-to-single": "yes", "backend-migrate-copy-to-empty": "yes", })() @@ -1001,7 +998,7 @@ func TestMetaBackend_configuredChangeCopy_multiToSingle(t *testing.T) { } // Verify existing workspaces exist - envPath := filepath.Join(backendlocal.DefaultWorkspaceDir, "env2", backendlocal.DefaultStateFilename) + envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename) if _, err := os.Stat(envPath); err != nil { t.Fatal("env should exist") } @@ -1022,12 +1019,11 @@ func TestMetaBackend_configuredChangeCopy_multiToSingleCurrentEnv(t *testing.T) defer testChdir(t, td)() // Register the single-state backend - backendinit.Set("local-single", backendlocal.TestNewLocalSingle) - defer backendinit.Set("local-single", nil) + backendInit.Set("local-single", backendLocal.TestNewLocalSingle) + defer backendInit.Set("local-single", nil) // Ask input defer testInputMap(t, map[string]string{ - "backend-migrate-to-new": "yes", "backend-migrate-multistate-to-single": "yes", "backend-migrate-copy-to-empty": "yes", })() @@ -1073,7 +1069,7 @@ func TestMetaBackend_configuredChangeCopy_multiToSingleCurrentEnv(t *testing.T) } // Verify existing workspaces exist - envPath := filepath.Join(backendlocal.DefaultWorkspaceDir, "env2", backendlocal.DefaultStateFilename) + envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename) if _, err := os.Stat(envPath); err != nil { t.Fatal("env should exist") } @@ -1090,7 +1086,6 @@ func TestMetaBackend_configuredChangeCopy_multiToMulti(t *testing.T) { // Ask input defer testInputMap(t, map[string]string{ - "backend-migrate-to-new": "yes", "backend-migrate-multistate-to-multistate": "yes", })() @@ -1104,15 +1099,15 @@ func TestMetaBackend_configuredChangeCopy_multiToMulti(t *testing.T) { } // Check resulting states - states, err := b.Workspaces() + workspaces, err := b.Workspaces() if err != nil { t.Fatalf("unexpected error: %s", err) } - sort.Strings(states) + sort.Strings(workspaces) expected := []string{"default", "env2"} - if !reflect.DeepEqual(states, expected) { - t.Fatalf("bad: %#v", states) + if !reflect.DeepEqual(workspaces, expected) { + t.Fatalf("bad: %#v", workspaces) } { @@ -1158,7 +1153,7 @@ func TestMetaBackend_configuredChangeCopy_multiToMulti(t *testing.T) { { // Verify existing workspaces exist - envPath := filepath.Join(backendlocal.DefaultWorkspaceDir, "env2", backendlocal.DefaultStateFilename) + envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename) if _, err := os.Stat(envPath); err != nil { t.Fatal("env should exist") } @@ -1166,7 +1161,159 @@ func TestMetaBackend_configuredChangeCopy_multiToMulti(t *testing.T) { { // Verify new workspaces exist - envPath := filepath.Join("envdir-new", "env2", backendlocal.DefaultStateFilename) + envPath := filepath.Join("envdir-new", "env2", backendLocal.DefaultStateFilename) + if _, err := os.Stat(envPath); err != nil { + t.Fatal("env should exist") + } + } +} + +// Changing a configured backend that supports multi-state to a +// backend that also supports multi-state, but doesn't allow a +// default state while the default state is non-empty. +func TestMetaBackend_configuredChangeCopy_multiToNoDefaultWithDefault(t *testing.T) { + // Create a temporary working directory that is empty + td := tempDir(t) + copy.CopyDir(testFixturePath("backend-change-multi-to-no-default-with-default"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + // Register the single-state backend + backendInit.Set("local-no-default", backendLocal.TestNewLocalNoDefault) + defer backendInit.Set("local-no-default", nil) + + // Ask input + defer testInputMap(t, map[string]string{ + "backend-migrate-multistate-to-multistate": "yes", + "new-state-name": "env1", + })() + + // Setup the meta + m := testMetaBackend(t, nil) + + // Get the backend + b, diags := m.Backend(&BackendOpts{Init: true}) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + // Check resulting states + workspaces, err := b.Workspaces() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + sort.Strings(workspaces) + expected := []string{"env1", "env2"} + if !reflect.DeepEqual(workspaces, expected) { + t.Fatalf("bad: %#v", workspaces) + } + + { + // Check the renamed default state + s, err := b.StateMgr("env1") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.RefreshState(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + state := s.State() + if state == nil { + t.Fatal("state should not be nil") + } + if testStateMgrCurrentLineage(s) != "backend-change-env1" { + t.Fatalf("bad: %#v", state) + } + } + + { + // Verify existing workspaces exist + envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename) + if _, err := os.Stat(envPath); err != nil { + t.Fatal("env should exist") + } + } + + { + // Verify new workspaces exist + envPath := filepath.Join("envdir-new", "env2", backendLocal.DefaultStateFilename) + if _, err := os.Stat(envPath); err != nil { + t.Fatal("env should exist") + } + } +} + +// Changing a configured backend that supports multi-state to a +// backend that also supports multi-state, but doesn't allow a +// default state while the default state is empty. +func TestMetaBackend_configuredChangeCopy_multiToNoDefaultWithoutDefault(t *testing.T) { + // Create a temporary working directory that is empty + td := tempDir(t) + copy.CopyDir(testFixturePath("backend-change-multi-to-no-default-without-default"), td) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + // Register the single-state backend + backendInit.Set("local-no-default", backendLocal.TestNewLocalNoDefault) + defer backendInit.Set("local-no-default", nil) + + // Ask input + defer testInputMap(t, map[string]string{ + "backend-migrate-multistate-to-multistate": "yes", + "select-workspace": "1", + })() + + // Setup the meta + m := testMetaBackend(t, nil) + + // Get the backend + b, diags := m.Backend(&BackendOpts{Init: true}) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + // Check resulting states + workspaces, err := b.Workspaces() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + sort.Strings(workspaces) + expected := []string{"default", "env2"} + if !reflect.DeepEqual(workspaces, expected) { + t.Fatalf("bad: %#v", workspaces) + } + + { + // Check the named state + s, err := b.StateMgr("env2") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if err := s.RefreshState(); err != nil { + t.Fatalf("unexpected error: %s", err) + } + state := s.State() + if state == nil { + t.Fatal("state should not be nil") + } + if testStateMgrCurrentLineage(s) != "backend-change-env2" { + t.Fatalf("bad: %#v", state) + } + } + + { + // Verify existing workspaces exist + envPath := filepath.Join(backendLocal.DefaultWorkspaceDir, "env2", backendLocal.DefaultStateFilename) + if _, err := os.Stat(envPath); err != nil { + t.Fatal("env should exist") + } + } + + { + // Verify new workspaces exist + envPath := filepath.Join("envdir-new", "env2", backendLocal.DefaultStateFilename) if _, err := os.Stat(envPath); err != nil { t.Fatal("env should exist") } @@ -1611,7 +1758,7 @@ func TestMetaBackend_configureWithExtra(t *testing.T) { } // Check the state - s := testDataStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename)) + s := testDataStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename)) if s.Backend.Hash != cHash { t.Fatal("mismatched state and config backend hashes") } @@ -1627,7 +1774,7 @@ func TestMetaBackend_configureWithExtra(t *testing.T) { } // Check the state - s = testDataStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename)) + s = testDataStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename)) if s.Backend.Hash != cHash { t.Fatal("mismatched state and config backend hashes") } @@ -1694,7 +1841,7 @@ func TestMetaBackend_configToExtra(t *testing.T) { } // Check the state - s := testDataStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename)) + s := testDataStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename)) backendHash := s.Backend.Hash // init again but remove the path option from the config @@ -1715,7 +1862,7 @@ func TestMetaBackend_configToExtra(t *testing.T) { t.Fatal(diags.Err()) } - s = testDataStateRead(t, filepath.Join(DefaultDataDir, backendlocal.DefaultStateFilename)) + s = testDataStateRead(t, filepath.Join(DefaultDataDir, backendLocal.DefaultStateFilename)) if s.Backend.Hash == backendHash { t.Fatal("state.Backend.Hash was not updated") diff --git a/command/plan.go b/command/plan.go index 2f3411286..1e1664609 100644 --- a/command/plan.go +++ b/command/plan.go @@ -95,17 +95,19 @@ func (c *PlanCommand) Run(args []string) int { // Build the operation opReq := c.Operation(b) - opReq.Destroy = destroy opReq.ConfigDir = configPath + opReq.Destroy = destroy opReq.PlanRefresh = refresh opReq.PlanOutPath = outPath opReq.PlanRefresh = refresh opReq.Type = backend.OperationTypePlan + opReq.ConfigLoader, err = c.initConfigLoader() if err != nil { c.showDiagnostics(err) return 1 } + { var moreDiags tfdiags.Diagnostics opReq.Variables, moreDiags = c.collectVariableValues() diff --git a/command/state_meta.go b/command/state_meta.go index f823de880..02a49934a 100644 --- a/command/state_meta.go +++ b/command/state_meta.go @@ -6,10 +6,11 @@ import ( "time" "github.com/hashicorp/terraform/addrs" - backendlocal "github.com/hashicorp/terraform/backend/local" "github.com/hashicorp/terraform/state" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statemgr" + + backendLocal "github.com/hashicorp/terraform/backend/local" ) // StateMeta is the meta struct that should be embedded in state subcommands. @@ -49,7 +50,7 @@ func (c *StateMeta) State() (state.State, error) { // This should never fail panic(backendDiags.Err()) } - localB := localRaw.(*backendlocal.Local) + localB := localRaw.(*backendLocal.Local) _, stateOutPath, _ = localB.StatePaths(workspace) if err != nil { return nil, err diff --git a/command/test-fixtures/backend-change-multi-to-no-default-with-default/main.tf b/command/test-fixtures/backend-change-multi-to-no-default-with-default/main.tf index 93c5bced0..6328a4fb9 100644 --- a/command/test-fixtures/backend-change-multi-to-no-default-with-default/main.tf +++ b/command/test-fixtures/backend-change-multi-to-no-default-with-default/main.tf @@ -1,5 +1,5 @@ terraform { backend "local-no-default" { - environment_dir = "envdir-new" + workspace_dir = "envdir-new" } } diff --git a/command/test-fixtures/backend-change-multi-to-no-default-without-default/main.tf b/command/test-fixtures/backend-change-multi-to-no-default-without-default/main.tf index 93c5bced0..6328a4fb9 100644 --- a/command/test-fixtures/backend-change-multi-to-no-default-without-default/main.tf +++ b/command/test-fixtures/backend-change-multi-to-no-default-without-default/main.tf @@ -1,5 +1,5 @@ terraform { backend "local-no-default" { - environment_dir = "envdir-new" + workspace_dir = "envdir-new" } } diff --git a/go.mod b/go.mod index e22e340e0..3a7705311 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/golang/protobuf v1.2.0 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect github.com/google/go-cmp v0.2.0 + github.com/google/go-querystring v1.0.0 // indirect github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e // indirect github.com/gophercloud/gophercloud v0.0.0-20170524130959-3027adb1ce72 github.com/gopherjs/gopherjs v0.0.0-20181004151105-1babbf986f6f // indirect @@ -65,7 +66,9 @@ require ( github.com/hashicorp/go-retryablehttp v0.0.0-20160930035102-6e85be8fee1d github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90 github.com/hashicorp/go-safetemp v0.0.0-20180326211150-b1a1dbde6fdc // indirect + github.com/hashicorp/go-slug v0.1.0 // indirect github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 // indirect + github.com/hashicorp/go-tfe v0.2.6 github.com/hashicorp/go-uuid v1.0.0 github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577 github.com/hashicorp/golang-lru v0.5.0 // indirect @@ -118,6 +121,7 @@ require ( github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a // indirect github.com/soheilhy/cmux v0.1.4 // indirect github.com/spf13/afero v1.0.2 + github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d // indirect github.com/terraform-providers/terraform-provider-aws v1.41.0 github.com/terraform-providers/terraform-provider-openstack v0.0.0-20170616075611-4080a521c6ea github.com/terraform-providers/terraform-provider-template v1.0.0 // indirect diff --git a/go.sum b/go.sum index 4d5a3d3c3..576fed37a 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCy github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e h1:CYRpN206UTHUinz3VJoLaBdy1gEGeJNsqT0mvswDcMw= github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= github.com/gophercloud/gophercloud v0.0.0-20170524130959-3027adb1ce72 h1:I0ssFkBxJw27fhEVIBVjGQVMqKj5HyzfvfIhdr5Tx2E= @@ -141,8 +143,12 @@ github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90 h1:9HVkPxOp github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90/go.mod h1:o4zcYY1e0GEZI6eSEr+43QDYmuGglw1qSO6qdHUHCgg= github.com/hashicorp/go-safetemp v0.0.0-20180326211150-b1a1dbde6fdc h1:wAa9fGALVHfjYxZuXRnmuJG2CnwRpJYOTvY6YdErAh0= github.com/hashicorp/go-safetemp v0.0.0-20180326211150-b1a1dbde6fdc/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= +github.com/hashicorp/go-slug v0.1.0 h1:MJGEiOwRGrQCBmMMZABHqIESySFJ4ajrsjgDI4/aFI0= +github.com/hashicorp/go-slug v0.1.0/go.mod h1:+zDycQOzGqOqMW7Kn2fp9vz/NtqpMLQlgb9JUF+0km4= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 h1:7YOlAIO2YWnJZkQp7B5eFykaIY7C9JndqAFQyVV5BhM= github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-tfe v0.2.6 h1:o2ryV7ZS0BgaLfNvzWz+A/6J70UETMy+wFL+DQlUy/M= +github.com/hashicorp/go-tfe v0.2.6/go.mod h1:nJs7lSMcNPGQQtjyPG6en099CQ/f83+hfeeSqehl2Fg= github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577 h1:at4+18LrM8myamuV7/vT6x2s1JNXp2k4PsSbt4I02X4= @@ -277,6 +283,8 @@ github.com/spf13/afero v1.0.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d h1:Z4EH+5EffvBEhh37F0C0DnpklTMh00JOkjW5zK3ofBI= +github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d/go.mod h1:BSTlc8jOjh0niykqEGVXOLXdi9o0r0kR8tCYiMvjFgw= github.com/terraform-providers/terraform-provider-aws v1.41.0 h1:ZOuxMXREOtJ+SHMX5SnbZbiqYhl9GNfZDl4f0H6CaOM= github.com/terraform-providers/terraform-provider-aws v1.41.0/go.mod h1:uvqaeKnm2ydZ2LuKuW1NDNBu6heC/7IDGXWm36/6oKs= github.com/terraform-providers/terraform-provider-openstack v0.0.0-20170616075611-4080a521c6ea h1:IfuzHOI3XwwYZS2Xw8SQbxOtGXlIUrKtXtuDCTNxmsQ= diff --git a/main.go b/main.go index 2333bbc33..8caf7414b 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,6 @@ import ( "sync" "github.com/hashicorp/go-plugin" - backendInit "github.com/hashicorp/terraform/backend/init" "github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/helper/logging" "github.com/hashicorp/terraform/svchost/disco" @@ -22,6 +21,8 @@ import ( "github.com/mitchellh/colorstring" "github.com/mitchellh/panicwrap" "github.com/mitchellh/prefixedio" + + backendInit "github.com/hashicorp/terraform/backend/init" ) const ( diff --git a/registry/client_test.go b/registry/client_test.go index ea5d1e5ef..0796f5f33 100644 --- a/registry/client_test.go +++ b/registry/client_test.go @@ -85,6 +85,18 @@ func TestRegistryAuth(t *testing.T) { t.Fatal(err) } + _, err = client.ModuleVersions(mod) + if err != nil { + t.Fatal(err) + } + _, err = client.ModuleLocation(mod, "1.0.0") + if err != nil { + t.Fatal(err) + } + + // Also test without a credentials source + client.services.SetCredentialsSource(nil) + // both should fail without auth _, err = client.ModuleVersions(mod) if err == nil { @@ -94,18 +106,6 @@ func TestRegistryAuth(t *testing.T) { if err == nil { t.Fatal("expected error") } - - // Also test without a credentials source - client.services.SetCredentialsSource(nil) - - _, err = client.ModuleVersions(mod) - if err != nil { - t.Fatal(err) - } - _, err = client.ModuleLocation(mod, "1.0.0") - if err != nil { - t.Fatal(err) - } } func TestLookupModuleLocationRelative(t *testing.T) { diff --git a/website/docs/backends/types/manta.html.md b/website/docs/backends/types/manta.html.md index 018009422..583e7c108 100644 --- a/website/docs/backends/types/manta.html.md +++ b/website/docs/backends/types/manta.html.md @@ -49,5 +49,4 @@ The following configuration options are supported: * `key_id` - (Required) This is the fingerprint of the public key matching the key specified in key_path. It can be obtained via the command ssh-keygen -l -E md5 -f /path/to/key. Can be set via the `SDC_KEY_ID` or `TRITON_KEY_ID` environment variables. * `insecure_skip_tls_verify` - (Optional) This allows skipping TLS verification of the Triton endpoint. It is useful when connecting to a temporary Triton installation such as Cloud-On-A-Laptop which does not generally use a certificate signed by a trusted root CA. Defaults to `false`. * `path` - (Required) The path relative to your private storage directory (`/$MANTA_USER/stor`) where the state file will be stored. **Please Note:** If this path does not exist, then the backend will create this folder location as part of backend creation. - * `objectName` - (Optional, Deprecated) Use `object_name` instead. * `object_name` - (Optional) The name of the state file (defaults to `terraform.tfstate`) diff --git a/website/docs/backends/types/terraform-enterprise.html.md b/website/docs/backends/types/terraform-enterprise.html.md index 2351d3a62..96de20416 100644 --- a/website/docs/backends/types/terraform-enterprise.html.md +++ b/website/docs/backends/types/terraform-enterprise.html.md @@ -8,6 +8,9 @@ description: |- # terraform enterprise +-> **Deprecated** Please use the new enhanced [remote](/docs/backends/types/remote.html) +backend for storing state and running remote operations in Terraform Enterprise. + **Kind: Standard (with no locking)** Reads and writes state from a [Terraform Enterprise](/docs/enterprise/index.html)