Change how to fall back from remote to local backend

In order to support free organizations, we need a way to load the `remote` backend and then, depending on the used offering/plan, enable or disable remote operations.

In other words, we should be able to dynamically fall back to the `local` backend if needed, after first configuring the `remote` backend.

To make this works we need to change the way this was done previously when the env var `TF_FORCE_LOCAL_BACKEND` was set. The clear difference of course being that the env var would be available on startup, while the used offering/plan is only known after being able to connect to TFE.
This commit is contained in:
Sander van Harmelen 2018-11-15 20:26:46 +01:00
parent 77844852e6
commit a17f317025
20 changed files with 587 additions and 245 deletions

View File

@ -3,7 +3,6 @@
package init
import (
"os"
"sync"
"github.com/hashicorp/terraform/backend"
@ -49,13 +48,7 @@ func Init(services *disco.Disco) {
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": func() backend.Backend { return backendRemote.New(services) },
// Remote State backends.
"artifactory": func() backend.Backend { return backendArtifactory.New() },

View File

@ -1,11 +1,8 @@
package init
import (
"os"
"reflect"
"testing"
backendLocal "github.com/hashicorp/terraform/backend/local"
)
func TestInit_backend(t *testing.T) {
@ -44,42 +41,3 @@ func TestInit_backend(t *testing.T) {
})
}
}
func TestInit_forceLocalBackend(t *testing.T) {
// Initialize the backends map
Init(nil)
enhancedBackends := []struct {
Name string
Type string
}{
{"local", "nil"},
{"remote", "*remote.Remote"},
}
// Set the TF_FORCE_LOCAL_BACKEND flag so all enhanced backends will
// return a local.Local backend with themselves as embedded backend.
if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil {
t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err)
}
defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND")
// Make sure we always get the local backend.
for _, b := range enhancedBackends {
f := Backend(b.Name)
local, ok := f().(*backendLocal.Local)
if !ok {
t.Fatalf("expected backend %q to be \"*local.Local\", got: %T", b.Name, f())
}
bType := "nil"
if local.Backend != nil {
bType = reflect.TypeOf(local.Backend).String()
}
if bType != b.Type {
t.Fatalf("expected local.Backend to be %s, got: %s", b.Type, bType)
}
}
}

View File

@ -258,9 +258,6 @@ func (b *Local) DeleteWorkspace(name string) error {
}
func (b *Local) StateMgr(name string) (statemgr.Full, error) {
statePath, stateOutPath, backupPath := b.StatePaths(name)
log.Printf("[TRACE] backend/local: state manager for workspace %q will:\n - read initial snapshot from %s\n - write new snapshots to %s\n - create any backup at %s", name, statePath, stateOutPath, backupPath)
// If we have a backend handling state, delegate to that.
if b.Backend != nil {
return b.Backend.StateMgr(name)
@ -274,6 +271,9 @@ func (b *Local) StateMgr(name string) (statemgr.Full, error) {
return nil, err
}
statePath, stateOutPath, backupPath := b.StatePaths(name)
log.Printf("[TRACE] backend/local: state manager for workspace %q will:\n - read initial snapshot from %s\n - write new snapshots to %s\n - create any backup at %s", name, statePath, stateOutPath, backupPath)
s := statemgr.NewFilesystemBetweenPaths(statePath, stateOutPath)
if backupPath != "" {
s.SetBackupPath(backupPath)

View File

@ -25,8 +25,8 @@ import (
func TestLocal_applyBasic(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
p := TestLocalProvider(t, b, "test", applyFixtureSchema())
p := TestLocalProvider(t, b, "test", applyFixtureSchema())
p.ApplyResourceChangeResponse = providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("yes"),
"ami": cty.StringVal("bar"),
@ -95,8 +95,8 @@ func TestLocal_applyEmptyDir(t *testing.T) {
func TestLocal_applyEmptyDirDestroy(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{})
p := TestLocalProvider(t, b, "test", &terraform.ProviderSchema{})
p.ApplyResourceChangeResponse = providers.ApplyResourceChangeResponse{}
op, configCleanup := testOperationApply(t, "./test-fixtures/empty")
@ -122,6 +122,7 @@ func TestLocal_applyEmptyDirDestroy(t *testing.T) {
func TestLocal_applyError(t *testing.T) {
b, cleanup := TestLocal(t)
defer cleanup()
p := TestLocalProvider(t, b, "test", nil)
p.GetSchemaReturn = &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{

View File

@ -188,7 +188,6 @@ func (b *Local) opPlan(
}
func (b *Local) renderPlan(plan *plans.Plan, schemas *terraform.Schemas) {
counts := map[plans.Action]int{}
for _, change := range plan.Changes.Resources {
counts[change.Action]++

View File

@ -25,6 +25,8 @@ import (
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty"
backendLocal "github.com/hashicorp/terraform/backend/local"
)
const (
@ -49,28 +51,36 @@ type Remote struct {
// Operation. See Operation for more details.
ContextOpts *terraform.ContextOpts
// client is the remote backend API client
// client is the remote backend API client.
client *tfe.Client
// hostname of the remote backend server
// hostname of the remote backend server.
hostname string
// organization is the organization that contains the target workspaces
// organization is the organization that contains the target workspaces.
organization string
// workspace is used to map the default workspace to a remote workspace
// 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
// 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 defines the configuration for the backend.
schema *schema.Backend
// services is used for service discovery
services *disco.Disco
// local, if non-nil, will be used for all enhanced behavior. This
// allows local behavior with the remote backend functioning as remote
// state storage backend.
local backend.Enhanced
// forceLocal, if true, will force the use of the local backend.
forceLocal bool
// opLock locks operations
opLock sync.Mutex
}
@ -84,6 +94,7 @@ func New(services *disco.Disco) *Remote {
}
}
// ConfigSchema implements backend.Enhanced.
func (b *Remote) ConfigSchema() *configschema.Block {
return &configschema.Block{
Attributes: map[string]*configschema.Attribute{
@ -126,6 +137,7 @@ func (b *Remote) ConfigSchema() *configschema.Block {
}
}
// ValidateConfig implements backend.Enhanced.
func (b *Remote) ValidateConfig(obj cty.Value) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
@ -173,6 +185,7 @@ func (b *Remote) ValidateConfig(obj cty.Value) tfdiags.Diagnostics {
return diags
}
// Configure implements backend.Enhanced.
func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
@ -255,8 +268,31 @@ func (b *Remote) Configure(obj cty.Value) tfdiags.Diagnostics {
`Terraform Enterprise client: %s.`, err,
),
))
return diags
}
// Check if the organization exists.
_, err = b.client.Organizations.Read(context.Background(), b.organization)
if err != nil {
if err == tfe.ErrResourceNotFound {
err = fmt.Errorf("organization %s does not exist", b.organization)
}
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Failed to read organization settings",
fmt.Sprintf(
`The "remote" backend encountered an unexpected error while reading the `+
`organization settings: %s.`, err,
),
cty.Path{cty.GetAttrStep{Name: "organization"}},
))
return diags
}
// Configure a local backend for when we need to run operations locally.
b.local = backendLocal.NewWithBackend(b)
b.forceLocal = os.Getenv("TF_FORCE_LOCAL_BACKEND") != ""
return diags
}
@ -292,103 +328,7 @@ func (b *Remote) token(hostname string) (string, error) {
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.
// StateMgr implements backend.Enhanced.
func (b *Remote) StateMgr(name string) (state.State, error) {
if b.workspace == "" && name == backend.DefaultStateName {
return nil, backend.ErrDefaultWorkspaceNotSupported
@ -447,18 +387,111 @@ func (b *Remote) StateMgr(name string) (state.State, error) {
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
// DeleteWorkspace implements backend.Enhanced.
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
}
client := &remoteClient{
client: b.client,
organization: b.organization,
workspace: name,
}
return client.Delete()
}
// Workspaces implements backend.Enhanced.
func (b *Remote) Workspaces() ([]string, error) {
if b.prefix == "" {
return nil, backend.ErrWorkspacesNotSupported
}
return b.workspaces()
}
// workspaces returns a filtered list of remote workspace names.
func (b *Remote) workspaces() ([]string, error) {
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
}
// Operation implements backend.Enhanced.
func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend.RunningOperation, error) {
// Get the remote workspace name.
workspace := op.Workspace
switch {
case op.Workspace == backend.DefaultStateName:
workspace = b.workspace
case b.prefix != "" && !strings.HasPrefix(op.Workspace, b.prefix):
workspace = b.prefix + op.Workspace
}
// Retrieve the workspace for this operation.
w, err := b.client.Workspaces.Read(ctx, b.organization, workspace)
if err != nil {
return nil, generalError("Failed to retrieve workspace", err)
}
// Check if we need to use the local backend to run the operation.
if b.forceLocal || !w.Operations {
return b.local.Operation(ctx, op)
}
// Set the remote workspace name.
op.Workspace = w.Name
// Determine the function to call for our operation
var f func(context.Context, context.Context, *backend.Operation) (*tfe.Run, error)
var f func(context.Context, context.Context, *backend.Operation, *tfe.Workspace) (*tfe.Run, error)
switch op.Type {
case backend.OperationTypePlan:
f = b.opPlan
@ -499,7 +532,7 @@ func (b *Remote) Operation(ctx context.Context, op *backend.Operation) (*backend
defer b.opLock.Unlock()
r, opErr := f(stopCtx, cancelCtx, op)
r, opErr := f(stopCtx, cancelCtx, op, w)
if opErr != nil && opErr != context.Canceled {
b.ReportResult(runningOp, opErr)
return

View File

@ -12,15 +12,9 @@ import (
"github.com/hashicorp/terraform/tfdiags"
)
func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation) (*tfe.Run, error) {
func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*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 {

View File

@ -64,11 +64,14 @@ func TestRemote_applyBasic(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected 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)
t.Fatalf("expected apply summery in output: %s", output)
}
}
@ -407,11 +410,14 @@ func TestRemote_applyAutoApprove(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected 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)
t.Fatalf("expected apply summery in output: %s", output)
}
}
@ -460,11 +466,120 @@ func TestRemote_applyWithAutoApply(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected 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)
t.Fatalf("expected apply summery in output: %s", output)
}
}
func TestRemote_applyForceLocal(t *testing.T) {
// Set TF_FORCE_LOCAL_BACKEND so the remote backend will use
// the local backend with itself as embedded backend.
if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil {
t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err)
}
defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND")
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, "Running apply in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summery in output: %s", output)
}
}
func TestRemote_applyWorkspaceWithoutOperations(t *testing.T) {
b := testBackendNoDefault(t)
ctx := context.Background()
// Create a named workspace that doesn't allow operations.
_, err := b.client.Workspaces.Create(
ctx,
b.organization,
tfe.WorkspaceCreateOptions{
Name: tfe.String(b.prefix + "no-operations"),
},
)
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 = "no-operations"
run, err := b.Operation(ctx, 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, "Running apply in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summery in output: %s", output)
}
}
@ -526,8 +641,11 @@ func TestRemote_applyLockTimeout(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "Lock timeout exceeded") {
t.Fatalf("missing lock timout error in output: %s", output)
t.Fatalf("expected 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)
@ -570,11 +688,14 @@ func TestRemote_applyDestroy(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "0 to add, 0 to change, 1 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected 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)
t.Fatalf("expected apply summery in output: %s", output)
}
}
@ -643,14 +764,17 @@ func TestRemote_applyPolicyPass(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: true") {
t.Fatalf("missing polic check result in output: %s", output)
t.Fatalf("expected 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)
t.Fatalf("expected apply summery in output: %s", output)
}
}
@ -691,11 +815,14 @@ func TestRemote_applyPolicyHardFail(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: false") {
t.Fatalf("missing policy check result in output: %s", output)
t.Fatalf("expected 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)
@ -735,14 +862,17 @@ func TestRemote_applyPolicySoftFail(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: false") {
t.Fatalf("missing policy check result in output: %s", output)
t.Fatalf("expected 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)
t.Fatalf("expected apply summery in output: %s", output)
}
}
@ -784,11 +914,14 @@ func TestRemote_applyPolicySoftFailAutoApprove(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: false") {
t.Fatalf("missing policy check result in output: %s", output)
t.Fatalf("expected 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)
@ -841,14 +974,17 @@ func TestRemote_applyPolicySoftFailAutoApply(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: false") {
t.Fatalf("missing policy check result in output: %s", output)
t.Fatalf("expected 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)
t.Fatalf("expected apply summery in output: %s", output)
}
}
@ -875,6 +1011,6 @@ func TestRemote_applyWithRemoteError(t *testing.T) {
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)
t.Fatalf("expected apply error in output: %s", output)
}
}

View File

@ -907,6 +907,7 @@ func (m *mockWorkspaces) Create(ctx context.Context, organization string, option
w := &tfe.Workspace{
ID: generateID("ws-"),
Name: *options.Name,
Operations: !strings.HasSuffix(*options.Name, "no-operations"),
Permissions: &tfe.WorkspacePermissions{
CanQueueRun: true,
CanUpdate: true,

View File

@ -18,15 +18,9 @@ import (
"github.com/hashicorp/terraform/tfdiags"
)
func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation) (*tfe.Run, error) {
func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*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 {

View File

@ -54,8 +54,11 @@ func TestRemote_planBasic(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
}
@ -284,6 +287,86 @@ func TestRemote_planNoConfig(t *testing.T) {
}
}
func TestRemote_planForceLocal(t *testing.T) {
// Set TF_FORCE_LOCAL_BACKEND so the remote backend will use
// the local backend with itself as embedded backend.
if err := os.Setenv("TF_FORCE_LOCAL_BACKEND", "1"); err != nil {
t.Fatalf("error setting environment variable TF_FORCE_LOCAL_BACKEND: %v", err)
}
defer os.Unsetenv("TF_FORCE_LOCAL_BACKEND")
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.Fatalf("expected a non-empty plan")
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summery in output: %s", output)
}
}
func TestRemote_planWorkspaceWithoutOperations(t *testing.T) {
b := testBackendNoDefault(t)
ctx := context.Background()
// Create a named workspace that doesn't allow operations.
_, err := b.client.Workspaces.Create(
ctx,
b.organization,
tfe.WorkspaceCreateOptions{
Name: tfe.String(b.prefix + "no-operations"),
},
)
if err != nil {
t.Fatalf("error creating named workspace: %v", err)
}
op, configCleanup := testOperationPlan(t, "./test-fixtures/plan")
defer configCleanup()
op.Workspace = "no-operations"
run, err := b.Operation(ctx, 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, "Running plan in the remote backend") {
t.Fatalf("unexpected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summery in output: %s", output)
}
}
func TestRemote_planLockTimeout(t *testing.T) {
b := testBackendDefault(t)
ctx := context.Background()
@ -342,8 +425,11 @@ func TestRemote_planLockTimeout(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "Lock timeout exceeded") {
t.Fatalf("missing lock timout error in output: %s", output)
t.Fatalf("expected 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)
@ -428,8 +514,11 @@ func TestRemote_planWithWorkingDirectory(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
}
@ -455,11 +544,14 @@ func TestRemote_planPolicyPass(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: true") {
t.Fatalf("missing polic check result in output: %s", output)
t.Fatalf("expected polic check result in output: %s", output)
}
}
@ -490,11 +582,14 @@ func TestRemote_planPolicyHardFail(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: false") {
t.Fatalf("missing policy check result in output: %s", output)
t.Fatalf("expected policy check result in output: %s", output)
}
}
@ -525,11 +620,14 @@ func TestRemote_planPolicySoftFail(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("missing plan summery in output: %s", output)
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "Sentinel Result: false") {
t.Fatalf("missing policy check result in output: %s", output)
t.Fatalf("expected policy check result in output: %s", output)
}
}
@ -555,7 +653,10 @@ func TestRemote_planWithRemoteError(t *testing.T) {
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in the remote backend") {
t.Fatalf("expected remote backend header in output: %s", output)
}
if !strings.Contains(output, "null_resource.foo: 1 error") {
t.Fatalf("missing plan error in output: %s", output)
t.Fatalf("expected plan error in output: %s", output)
}
}

View File

@ -7,6 +7,8 @@ import (
"github.com/hashicorp/terraform/backend"
"github.com/zclconf/go-cty/cty"
backendLocal "github.com/hashicorp/terraform/backend/local"
)
func TestRemote(t *testing.T) {
@ -32,6 +34,30 @@ func TestRemote_config(t *testing.T) {
confErr string
valErr string
}{
"with_a_nonexisting_organization": {
config: cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"organization": cty.StringVal("nonexisting"),
"token": cty.NullVal(cty.String),
"workspaces": cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("prod"),
"prefix": cty.NullVal(cty.String),
}),
}),
confErr: "organization nonexisting does not exist",
},
"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",
},
"with_a_name": {
config: cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
@ -78,18 +104,6 @@ func TestRemote_config(t *testing.T) {
}),
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 {
@ -107,27 +121,22 @@ func TestRemote_config(t *testing.T) {
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())
t.Fatalf("%s: unexpected configure result: %v", name, confDiags.Err())
}
}
}
func TestRemote_nonexistingOrganization(t *testing.T) {
msg := "does not exist"
func TestRemote_localBackend(t *testing.T) {
b := testBackendDefault(t)
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)
local, ok := b.local.(*backendLocal.Local)
if !ok {
t.Fatalf("expected b.local to be \"*local.Local\", got: %T", b.local)
}
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)
remote, ok := local.Backend.(*Remote)
if !ok {
t.Fatalf("expected local.Backend to be *remote.Remote, got: %T", remote)
}
}

View File

@ -6,9 +6,16 @@ import (
// CLIInit implements backend.CLI
func (b *Remote) CLIInit(opts *backend.CLIOpts) error {
if cli, ok := b.local.(backend.CLI); ok {
if err := cli.CLIInit(opts); err != nil {
return err
}
}
b.CLI = opts.CLI
b.CLIColor = opts.CLIColor
b.ShowDiagnostics = opts.ShowDiagnostics
b.ContextOpts = opts.ContextOpts
return nil
}

View File

@ -0,0 +1,28 @@
package remote
import (
"flag"
"io/ioutil"
"log"
"os"
"testing"
"github.com/hashicorp/terraform/helper/logging"
)
func TestMain(m *testing.M) {
flag.Parse()
if testing.Verbose() {
// if we're verbose, use the logging requested by TF_LOG
logging.SetOutput()
} else {
// otherwise silence all logs
log.SetOutput(ioutil.Discard)
}
// Make sure TF_FORCE_LOCAL_BACKEND is unset
os.Unsetenv("TF_FORCE_LOCAL_BACKEND")
os.Exit(m.Run())
}

View File

@ -1,3 +1,6 @@
Terraform v0.11.10
Initializing plugins and modules...
null_resource.hello: Destroying... (ID: 8657651096157629581)
null_resource.hello: Destruction complete after 0s

View File

@ -1,3 +1,6 @@
Terraform v0.11.10
Initializing plugins and modules...
null_resource.hello: Creating...
null_resource.hello: Creation complete after 0s (ID: 8657651096157629581)

View File

@ -1,3 +1,6 @@
Terraform v0.11.10
Initializing plugins and modules...
null_resource.hello: Creating...
null_resource.hello: Creation complete after 0s (ID: 8657651096157629581)

View File

@ -1,3 +1,6 @@
Terraform v0.11.10
Initializing plugins and modules...
null_resource.hello: Creating...
null_resource.hello: Creation complete after 0s (ID: 8657651096157629581)

View File

@ -1,3 +1,6 @@
Terraform v0.11.10
Initializing plugins and modules...
null_resource.hello: Creating...
null_resource.hello: Creation complete after 0s (ID: 8657651096157629581)

View File

@ -11,6 +11,8 @@ import (
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/configs"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/providers"
"github.com/hashicorp/terraform/state/remote"
"github.com/hashicorp/terraform/svchost"
"github.com/hashicorp/terraform/svchost/auth"
@ -19,6 +21,8 @@ import (
"github.com/hashicorp/terraform/tfdiags"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty"
backendLocal "github.com/hashicorp/terraform/backend/local"
)
const (
@ -108,6 +112,9 @@ func testBackend(t *testing.T, obj cty.Value) *Remote {
}
}
// Set local to a local test backend.
b.local = testLocalBackend(t, b)
ctx := context.Background()
// Create the organization.
@ -131,6 +138,29 @@ func testBackend(t *testing.T, obj cty.Value) *Remote {
return b
}
func testLocalBackend(t *testing.T, remote *Remote) backend.Enhanced {
b := backendLocal.NewWithBackend(remote)
b.CLI = remote.CLI
b.ShowDiagnostics = remote.ShowDiagnostics
// Add a test provider to the local backend.
p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{
ResourceTypes: map[string]*configschema.Block{
"null_resource": {
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
},
},
},
})
p.ApplyResourceChangeResponse = providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("yes"),
})}
return b
}
// testServer returns a *httptest.Server used for local testing.
func testServer(t *testing.T) *httptest.Server {
mux := http.NewServeMux()
@ -141,6 +171,49 @@ func testServer(t *testing.T) *httptest.Server {
io.WriteString(w, `{"tfe.v2":"/api/v2/"}`)
})
// Respond to the initial query to read the organization settings.
mux.HandleFunc("/api/v2/organizations/hashicorp", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/vnd.api+json")
io.WriteString(w, `{
"data": {
"id": "hashicorp",
"type": "organizations",
"attributes": {
"name": "hashicorp",
"created-at": "2017-09-07T14:34:40.492Z",
"email": "user@example.com",
"collaborator-auth-policy": "password",
"enterprise-plan": "premium",
"permissions": {
"can-update": true,
"can-destroy": true,
"can-create-team": true,
"can-create-workspace": true,
"can-update-oauth": true,
"can-update-api-token": true,
"can-update-sentinel": true,
"can-traverse": true,
"can-create-workspace-migration": true
}
}
}
}`)
})
// All tests that are assumed to pass will use the hashicorp organization,
// so for all other organization requests we will return a 404.
mux.HandleFunc("/api/v2/organizations/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
io.WriteString(w, `{
"errors": [
{
"status": "404",
"title": "not found"
}
]
}`)
})
return httptest.NewServer(mux)
}