Implement the remote enhanced backend

This is a refactored version of the `remote` backend that was initially added to Terraform v0.11.8 which should now be compatible with v0.12.0.
This commit is contained in:
Sander van Harmelen 2018-10-31 16:45:03 +01:00
parent c795302ab2
commit 52a1b22f7a
94 changed files with 5813 additions and 305 deletions

View File

@ -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

View File

@ -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),

View File

@ -29,7 +29,7 @@ func testStateClient(t *testing.T, c map[string]string) remote.Client {
}
synthBody := configs.SynthBody("<test>", 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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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(),
)
}
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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 {

View File

@ -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,

View File

@ -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}

View File

@ -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{

View File

@ -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
}

View File

@ -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",
},
},
}

View File

@ -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)

View File

@ -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)

View File

@ -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{

677
backend/remote/backend.go Normal file
View File

@ -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 <HOST>" 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 <HOST>" 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\"",
}

View File

@ -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]
`

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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]
`

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

14
backend/remote/cli.go Normal file
View File

@ -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
}

View File

@ -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,
}}
}

View File

@ -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.

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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.

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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.

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -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"

View File

@ -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.

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -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"

View File

@ -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.

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -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"

View File

@ -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.

View File

@ -0,0 +1,4 @@
variable "foo" {}
variable "bar" {}
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -0,0 +1,5 @@
resource "null_resource" "foo" {
triggers {
random = "${guid()}"
}
}

View File

@ -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()}

View File

@ -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.

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -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"

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -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"

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -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"

View File

@ -0,0 +1,4 @@
variable "foo" {}
variable "bar" {}
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -0,0 +1,5 @@
resource "null_resource" "foo" {
triggers {
random = "${guid()}"
}
}

View File

@ -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()}

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

View File

@ -0,0 +1 @@
resource "null_resource" "foo" {}

View File

@ -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: <computed>
Plan: 1 to add, 0 to change, 0 to destroy.

182
backend/remote/testing.go Normal file
View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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,

View File

@ -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) {

View File

@ -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()

View File

@ -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())
}

View File

@ -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
}

View File

@ -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.
//

View File

@ -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

View File

@ -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
`

View File

@ -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")

View File

@ -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()

View File

@ -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

View File

@ -1,5 +1,5 @@
terraform {
backend "local-no-default" {
environment_dir = "envdir-new"
workspace_dir = "envdir-new"
}
}

View File

@ -1,5 +1,5 @@
terraform {
backend "local-no-default" {
environment_dir = "envdir-new"
workspace_dir = "envdir-new"
}
}

4
go.mod
View File

@ -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

8
go.sum
View File

@ -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=

View File

@ -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 (

View File

@ -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) {

View File

@ -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`)

View File

@ -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)