Merge pull request #19299 from hashicorp/f-remote-backend
Implement the remote enhanced backend
This commit is contained in:
commit
8bf9ab8291
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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{
|
||||
b := NewWithBackend(&testDelegateBackend{
|
||||
stateErr: true,
|
||||
statesErr: true,
|
||||
deleteErr: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if _, err := b.StateMgr("test"); err != errTestDelegateState {
|
||||
t.Fatal("expected errTestDelegateState, got:", err)
|
||||
|
|
|
@ -21,16 +21,16 @@ 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{}) {
|
||||
|
||||
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 {
|
||||
|
@ -43,8 +43,8 @@ func TestLocal(t *testing.T) (*Local, func()) {
|
|||
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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ func TestBackend(t *testing.T) {
|
|||
|
||||
b := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{
|
||||
"path": directory,
|
||||
"objectName": keyName,
|
||||
"object_name": keyName,
|
||||
})).(*Backend)
|
||||
|
||||
createMantaFolder(t, b.storageClient, directory)
|
||||
|
@ -49,12 +49,12 @@ func TestBackendLocked(t *testing.T) {
|
|||
|
||||
b1 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{
|
||||
"path": directory,
|
||||
"objectName": keyName,
|
||||
"object_name": keyName,
|
||||
})).(*Backend)
|
||||
|
||||
b2 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{
|
||||
"path": directory,
|
||||
"objectName": keyName,
|
||||
"object_name": keyName,
|
||||
})).(*Backend)
|
||||
|
||||
createMantaFolder(t, b1.storageClient, directory)
|
||||
|
|
|
@ -22,7 +22,7 @@ func TestRemoteClient(t *testing.T) {
|
|||
|
||||
b := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{
|
||||
"path": directory,
|
||||
"objectName": keyName,
|
||||
"object_name": keyName,
|
||||
})).(*Backend)
|
||||
|
||||
createMantaFolder(t, b.storageClient, directory)
|
||||
|
@ -43,12 +43,12 @@ func TestRemoteClientLocks(t *testing.T) {
|
|||
|
||||
b1 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{
|
||||
"path": directory,
|
||||
"objectName": keyName,
|
||||
"object_name": keyName,
|
||||
})).(*Backend)
|
||||
|
||||
b2 := backend.TestBackendConfig(t, New(), backend.TestWrapConfig(map[string]interface{}{
|
||||
"path": directory,
|
||||
"objectName": keyName,
|
||||
"object_name": keyName,
|
||||
})).(*Backend)
|
||||
|
||||
createMantaFolder(t, b1.storageClient, directory)
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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\"",
|
||||
}
|
|
@ -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]
|
||||
`
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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]
|
||||
`
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
}}
|
||||
}
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -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"
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -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"
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -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"
|
|
@ -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.
|
|
@ -0,0 +1,4 @@
|
|||
variable "foo" {}
|
||||
variable "bar" {}
|
||||
|
||||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -0,0 +1,5 @@
|
|||
resource "null_resource" "foo" {
|
||||
triggers {
|
||||
random = "${guid()}"
|
||||
}
|
||||
}
|
|
@ -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()}
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -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"
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -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"
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -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"
|
|
@ -0,0 +1,4 @@
|
|||
variable "foo" {}
|
||||
variable "bar" {}
|
||||
|
||||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -0,0 +1,5 @@
|
|||
resource "null_resource" "foo" {
|
||||
triggers {
|
||||
random = "${guid()}"
|
||||
}
|
||||
}
|
|
@ -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()}
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
resource "null_resource" "foo" {}
|
|
@ -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.
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
||||
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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
//
|
||||
|
|
|
@ -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,8 +600,14 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta
|
|||
return nil, diags
|
||||
}
|
||||
|
||||
workspace := m.Workspace()
|
||||
workspaces, err := localB.Workspaces()
|
||||
if 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))
|
||||
|
@ -610,10 +618,13 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta
|
|||
return nil, diags
|
||||
}
|
||||
|
||||
// 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"
|
||||
// We only care about non-empty states.
|
||||
if localS := localState.State(); !localS.Empty() {
|
||||
localStates = append(localStates, localState)
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +651,7 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta
|
|||
}
|
||||
|
||||
if erase {
|
||||
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))
|
||||
|
@ -651,6 +663,7 @@ func (m *Meta) backend_C_r_s(c *configs.Backend, cHash int, sMgr *state.LocalSta
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.stateLock {
|
||||
stateLocker := clistate.NewLocker(context.Background(), m.stateLockTimeout, m.Ui, m.Colorize())
|
||||
|
@ -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
|
||||
|
|
|
@ -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
|
||||
`
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
terraform {
|
||||
backend "local-no-default" {
|
||||
environment_dir = "envdir-new"
|
||||
workspace_dir = "envdir-new"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
terraform {
|
||||
backend "local-no-default" {
|
||||
environment_dir = "envdir-new"
|
||||
workspace_dir = "envdir-new"
|
||||
}
|
||||
}
|
||||
|
|
4
go.mod
4
go.mod
|
@ -45,6 +45,7 @@ require (
|
|||
github.com/golang/protobuf v1.2.0
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c // indirect
|
||||
github.com/google/go-cmp v0.2.0
|
||||
github.com/google/go-querystring v1.0.0 // indirect
|
||||
github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e // indirect
|
||||
github.com/gophercloud/gophercloud v0.0.0-20170524130959-3027adb1ce72
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181004151105-1babbf986f6f // indirect
|
||||
|
@ -65,7 +66,9 @@ require (
|
|||
github.com/hashicorp/go-retryablehttp v0.0.0-20160930035102-6e85be8fee1d
|
||||
github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90
|
||||
github.com/hashicorp/go-safetemp v0.0.0-20180326211150-b1a1dbde6fdc // indirect
|
||||
github.com/hashicorp/go-slug v0.1.0 // indirect
|
||||
github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 // indirect
|
||||
github.com/hashicorp/go-tfe v0.2.6
|
||||
github.com/hashicorp/go-uuid v1.0.0
|
||||
github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577
|
||||
github.com/hashicorp/golang-lru v0.5.0 // indirect
|
||||
|
@ -118,6 +121,7 @@ require (
|
|||
github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a // indirect
|
||||
github.com/soheilhy/cmux v0.1.4 // indirect
|
||||
github.com/spf13/afero v1.0.2
|
||||
github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d // indirect
|
||||
github.com/terraform-providers/terraform-provider-aws v1.41.0
|
||||
github.com/terraform-providers/terraform-provider-openstack v0.0.0-20170616075611-4080a521c6ea
|
||||
github.com/terraform-providers/terraform-provider-template v1.0.0 // indirect
|
||||
|
|
8
go.sum
8
go.sum
|
@ -97,6 +97,8 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCy
|
|||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e h1:CYRpN206UTHUinz3VJoLaBdy1gEGeJNsqT0mvswDcMw=
|
||||
github.com/googleapis/gax-go v0.0.0-20161107002406-da06d194a00e/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
|
||||
github.com/gophercloud/gophercloud v0.0.0-20170524130959-3027adb1ce72 h1:I0ssFkBxJw27fhEVIBVjGQVMqKj5HyzfvfIhdr5Tx2E=
|
||||
|
@ -141,8 +143,12 @@ github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90 h1:9HVkPxOp
|
|||
github.com/hashicorp/go-rootcerts v0.0.0-20160503143440-6bb64b370b90/go.mod h1:o4zcYY1e0GEZI6eSEr+43QDYmuGglw1qSO6qdHUHCgg=
|
||||
github.com/hashicorp/go-safetemp v0.0.0-20180326211150-b1a1dbde6fdc h1:wAa9fGALVHfjYxZuXRnmuJG2CnwRpJYOTvY6YdErAh0=
|
||||
github.com/hashicorp/go-safetemp v0.0.0-20180326211150-b1a1dbde6fdc/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I=
|
||||
github.com/hashicorp/go-slug v0.1.0 h1:MJGEiOwRGrQCBmMMZABHqIESySFJ4ajrsjgDI4/aFI0=
|
||||
github.com/hashicorp/go-slug v0.1.0/go.mod h1:+zDycQOzGqOqMW7Kn2fp9vz/NtqpMLQlgb9JUF+0km4=
|
||||
github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86 h1:7YOlAIO2YWnJZkQp7B5eFykaIY7C9JndqAFQyVV5BhM=
|
||||
github.com/hashicorp/go-sockaddr v0.0.0-20180320115054-6d291a969b86/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-tfe v0.2.6 h1:o2ryV7ZS0BgaLfNvzWz+A/6J70UETMy+wFL+DQlUy/M=
|
||||
github.com/hashicorp/go-tfe v0.2.6/go.mod h1:nJs7lSMcNPGQQtjyPG6en099CQ/f83+hfeeSqehl2Fg=
|
||||
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v0.0.0-20180322230233-23480c066577 h1:at4+18LrM8myamuV7/vT6x2s1JNXp2k4PsSbt4I02X4=
|
||||
|
@ -277,6 +283,8 @@ github.com/spf13/afero v1.0.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B
|
|||
github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d h1:Z4EH+5EffvBEhh37F0C0DnpklTMh00JOkjW5zK3ofBI=
|
||||
github.com/svanharmelen/jsonapi v0.0.0-20180618144545-0c0828c3f16d/go.mod h1:BSTlc8jOjh0niykqEGVXOLXdi9o0r0kR8tCYiMvjFgw=
|
||||
github.com/terraform-providers/terraform-provider-aws v1.41.0 h1:ZOuxMXREOtJ+SHMX5SnbZbiqYhl9GNfZDl4f0H6CaOM=
|
||||
github.com/terraform-providers/terraform-provider-aws v1.41.0/go.mod h1:uvqaeKnm2ydZ2LuKuW1NDNBu6heC/7IDGXWm36/6oKs=
|
||||
github.com/terraform-providers/terraform-provider-openstack v0.0.0-20170616075611-4080a521c6ea h1:IfuzHOI3XwwYZS2Xw8SQbxOtGXlIUrKtXtuDCTNxmsQ=
|
||||
|
|
3
main.go
3
main.go
|
@ -12,7 +12,6 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-plugin"
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
"github.com/hashicorp/terraform/command/format"
|
||||
"github.com/hashicorp/terraform/helper/logging"
|
||||
"github.com/hashicorp/terraform/svchost/disco"
|
||||
|
@ -22,6 +21,8 @@ import (
|
|||
"github.com/mitchellh/colorstring"
|
||||
"github.com/mitchellh/panicwrap"
|
||||
"github.com/mitchellh/prefixedio"
|
||||
|
||||
backendInit "github.com/hashicorp/terraform/backend/init"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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`)
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue