Files
opentf/internal/cloud/backend_apply_test.go
Martin Atkins 67a5cd0911 statemgr+remote: context.Context parameters
This extends statemgr.Persistent, statemgr.Locker and remote.Client to
all expect context.Context parameters, and then updates all of the existing
implementations of those interfaces to support them.

All of the calls to statemgr.Persistent and statemgr.Locker methods outside
of tests are consistently context.TODO() for now, because the caller
landscape of these interfaces has some complications:

1. statemgr.Locker is also used by the clistate package for its state
   implementation that was derived from statemgr.Filesystem's predecessor,
   even though what clistate manages is not actually "state" in the sense
   of package statemgr. The callers of that are not yet ready to provide
   real contexts.

   In a future commit we'll either need to plumb context through to all of
   the clistate callers, or continue the effort to separate statemgr from
   clistate by introducing a clistate-specific "locker" API for it
   to use instead.

2. We call statemgr.Persistent and statemgr.Locker methods in situations
   where the active context might have already been cancelled, and so we'll
   need to make sure to ignore cancellation when calling those.

   This is mainly limited to PersistState and Unlock, since both need to
   be able to complete after a cancellation, but there are various
   codepaths that perform a Lock, Refresh, Persist, Unlock sequence and so
   it isn't yet clear where is the best place to enforce the invariant that
   Persist and Unlock must not be called with a cancelable context. We'll
   deal with that more in subsequent commits.

Within the various state manager and remote client implementations the
contexts _are_ wired together as best as possible with how these subsystems
are already laid out, and so once we deal with the problems above and make
callers provide suitable contexts they should be able to reach all of the
leaf API clients that might want to generate OpenTelemetry traces.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-07-10 08:11:39 -07:00

1975 lines
54 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cloud
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"testing"
"time"
"github.com/google/go-cmp/cmp"
tfe "github.com/hashicorp/go-tfe"
mocks "github.com/hashicorp/go-tfe/mocks"
version "github.com/hashicorp/go-version"
"github.com/mitchellh/cli"
gomock "go.uber.org/mock/gomock"
"github.com/opentofu/opentofu/internal/addrs"
"github.com/opentofu/opentofu/internal/backend"
"github.com/opentofu/opentofu/internal/cloud/cloudplan"
"github.com/opentofu/opentofu/internal/command/arguments"
"github.com/opentofu/opentofu/internal/command/clistate"
"github.com/opentofu/opentofu/internal/command/jsonformat"
"github.com/opentofu/opentofu/internal/command/views"
"github.com/opentofu/opentofu/internal/depsfile"
"github.com/opentofu/opentofu/internal/initwd"
"github.com/opentofu/opentofu/internal/plans"
"github.com/opentofu/opentofu/internal/plans/planfile"
"github.com/opentofu/opentofu/internal/states/statemgr"
"github.com/opentofu/opentofu/internal/terminal"
"github.com/opentofu/opentofu/internal/tofu"
tfversion "github.com/opentofu/opentofu/version"
)
func testOperationApply(t *testing.T, configDir string) (*backend.Operation, func(*testing.T) *terminal.TestOutput) {
t.Helper()
return testOperationApplyWithTimeout(t, configDir, 0)
}
func testOperationApplyWithTimeout(t *testing.T, configDir string, timeout time.Duration) (*backend.Operation, func(*testing.T) *terminal.TestOutput) {
t.Helper()
_, configLoader := initwd.MustLoadConfigForTests(t, configDir, "tests")
streams, done := terminal.StreamsForTesting(t)
view := views.NewView(streams)
stateLockerView := views.NewStateLocker(arguments.ViewHuman, view)
operationView := views.NewOperation(arguments.ViewHuman, false, view)
// Many of our tests use an overridden "null" provider that's just in-memory
// inside the test process, not a separate plugin on disk.
depLocks := depsfile.NewLocks()
depLocks.SetProviderOverridden(addrs.MustParseProviderSourceString("registry.opentofu.org/hashicorp/null"))
return &backend.Operation{
ConfigDir: configDir,
ConfigLoader: configLoader,
PlanRefresh: true,
StateLocker: clistate.NewLocker(timeout, stateLockerView),
Type: backend.OperationTypeApply,
View: operationView,
DependencyLocks: depLocks,
}, done
}
func TestCloud_applyBasic(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
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 cloud backend") {
t.Fatalf("expected TFC 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)
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after apply: %s", err.Error())
}
}
func TestCloud_applyJSONBasic(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, done := testOperationApply(t, "./testdata/apply-json")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
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)
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", gotOut)
}
if !strings.Contains(gotOut, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summary in output: %s", gotOut)
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after apply: %s", err.Error())
}
}
func TestCloud_applyJSONWithOutputs(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, done := testOperationApply(t, "./testdata/apply-json-with-outputs")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
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)
}
outp := close(t)
gotOut := outp.Stdout()
expectedSimpleOutput := `simple = [
"some",
"list",
]`
expectedSensitiveOutput := `secret = (sensitive value)`
expectedComplexOutput := `complex = {
keyA = {
someList = [
1,
2,
3,
]
}
keyB = {
someBool = true
someStr = "hello"
}
}`
if !strings.Contains(gotOut, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", gotOut)
}
if !strings.Contains(gotOut, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summary in output: %s", gotOut)
}
if !strings.Contains(gotOut, "Outputs:") {
t.Fatalf("expected output header: %s", gotOut)
}
if !strings.Contains(gotOut, expectedSimpleOutput) {
t.Fatalf("expected output: %s, got: %s", expectedSimpleOutput, gotOut)
}
if !strings.Contains(gotOut, expectedSensitiveOutput) {
t.Fatalf("expected output: %s, got: %s", expectedSensitiveOutput, gotOut)
}
if !strings.Contains(gotOut, expectedComplexOutput) {
t.Fatalf("expected output: %s, got: %s", expectedComplexOutput, gotOut)
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after apply: %s", err.Error())
}
}
func TestCloud_applyCanceled(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
// Stop the run to simulate a Ctrl-C.
run.Stop()
<-run.Done()
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after cancelling apply: %s", err.Error())
}
}
func TestCloud_applyWithoutPermissions(t *testing.T) {
b, bCleanup := testBackendWithTags(t)
defer bCleanup()
// Create a named workspace without permissions.
w, err := b.client.Workspaces.Create(
context.Background(),
b.organization,
tfe.WorkspaceCreateOptions{
Name: tfe.String("prod"),
},
)
if err != nil {
t.Fatalf("error creating named workspace: %v", err)
}
w.Permissions.CanQueueApply = false
op, done := testOperationApply(t, "./testdata/apply")
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()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "Insufficient rights to apply changes") {
t.Fatalf("expected a permissions error, got: %v", errOutput)
}
}
func TestCloud_applyWithVCS(t *testing.T) {
b, bCleanup := testBackendWithTags(t)
defer bCleanup()
// Create a named workspace with a VCS.
_, err := b.client.Workspaces.Create(
context.Background(),
b.organization,
tfe.WorkspaceCreateOptions{
Name: tfe.String("prod"),
VCSRepo: &tfe.VCSRepoOptions{},
},
)
if err != nil {
t.Fatalf("error creating named workspace: %v", err)
}
op, done := testOperationApply(t, "./testdata/apply")
op.Workspace = "prod"
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "not allowed for workspaces with a VCS") {
t.Fatalf("expected a VCS error, got: %v", errOutput)
}
}
func TestCloud_applyWithParallelism(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
if b.ContextOpts == nil {
b.ContextOpts = &tofu.ContextOpts{}
}
b.ContextOpts.Parallelism = 3
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "parallelism values are currently not supported") {
t.Fatalf("expected a parallelism error, got: %v", errOutput)
}
}
// Apply with local plan file should fail.
func TestCloud_applyWithLocalPlan(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "saved local plan is not supported") {
t.Fatalf("expected a saved plan error, got: %v", errOutput)
}
}
// Apply with bookmark to an existing cloud plan that's in a confirmable state
// should work.
func TestCloud_applyWithCloudPlan(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-json")
defer done(t)
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
// Perform the plan before trying to apply it
ws, err := b.client.Workspaces.Read(context.Background(), b.organization, b.WorkspaceMapping.Name)
if err != nil {
t.Fatalf("Couldn't read workspace: %s", err)
}
planRun, err := b.plan(context.Background(), context.Background(), context.Background(), op, ws)
if err != nil {
t.Fatalf("Couldn't perform plan: %s", err)
}
// Synthesize a cloud plan file with the plan's run ID
pf := &cloudplan.SavedPlanBookmark{
RemotePlanFormat: 1,
RunID: planRun.ID,
Hostname: b.hostname,
}
op.PlanFile = planfile.NewWrappedCloud(pf)
// Start spying on the apply output (now that the plan's done)
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
// Try apply
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := close(t)
if run.Result != backend.OperationSuccess {
t.Fatal("expected apply operation to succeed")
}
if run.PlanEmpty {
t.Fatalf("expected plan to not be empty")
}
gotOut := output.Stdout()
if !strings.Contains(gotOut, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summary in output: %s", gotOut)
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after apply: %s", err.Error())
}
}
func TestCloud_applyWithoutRefresh(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
op.PlanRefresh = false
op.Workspace = testBackendSingleWorkspaceName
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 non-empty")
}
// We should find a run inside the mock client that has refresh set
// to false.
runsAPI := b.client.Runs.(*MockRuns)
if got, want := len(runsAPI.Runs), 1; got != want {
t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want)
}
for _, run := range runsAPI.Runs {
if diff := cmp.Diff(false, run.Refresh); diff != "" {
t.Errorf("wrong Refresh setting in the created run\n%s", diff)
}
}
}
func TestCloud_applyWithRefreshOnly(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
op.PlanMode = plans.RefreshOnlyMode
op.Workspace = testBackendSingleWorkspaceName
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 non-empty")
}
// We should find a run inside the mock client that has refresh-only set
// to true.
runsAPI := b.client.Runs.(*MockRuns)
if got, want := len(runsAPI.Runs), 1; got != want {
t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want)
}
for _, run := range runsAPI.Runs {
if diff := cmp.Diff(true, run.RefreshOnly); diff != "" {
t.Errorf("wrong RefreshOnly setting in the created run\n%s", diff)
}
}
}
func TestCloud_applyWithTarget(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
addr, _ := addrs.ParseAbsResourceStr("null_resource.foo")
op.Targets = []addrs.Targetable{addr}
op.Workspace = testBackendSingleWorkspaceName
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 succeed")
}
if run.PlanEmpty {
t.Fatalf("expected plan to be non-empty")
}
// We should find a run inside the mock client that has the same
// target address we requested above.
runsAPI := b.client.Runs.(*MockRuns)
if got, want := len(runsAPI.Runs), 1; got != want {
t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want)
}
for _, run := range runsAPI.Runs {
if diff := cmp.Diff([]string{"null_resource.foo"}, run.TargetAddrs); diff != "" {
t.Errorf("wrong TargetAddrs in the created run\n%s", diff)
}
}
}
// Applying with an exclude flag should error
func TestCloud_applyWithExclude(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
addr, _ := addrs.ParseAbsResourceStr("null_resource.foo")
op.Workspace = testBackendSingleWorkspaceName
op.Excludes = []addrs.Targetable{addr}
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "-exclude option is not supported") {
t.Fatalf("expected -exclude option is not supported error, got: %v", errOutput)
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after failed apply: %s", err.Error())
}
}
func TestCloud_applyWithReplace(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
addr, _ := addrs.ParseAbsResourceInstanceStr("null_resource.foo")
op.ForceReplace = []addrs.AbsResourceInstance{addr}
op.Workspace = testBackendSingleWorkspaceName
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 succeed")
}
if run.PlanEmpty {
t.Fatalf("expected plan to be non-empty")
}
// We should find a run inside the mock client that has the same
// refresh address we requested above.
runsAPI := b.client.Runs.(*MockRuns)
if got, want := len(runsAPI.Runs), 1; got != want {
t.Fatalf("wrong number of runs in the mock client %d; want %d", got, want)
}
for _, run := range runsAPI.Runs {
if diff := cmp.Diff([]string{"null_resource.foo"}, run.ReplaceAddrs); diff != "" {
t.Errorf("wrong ReplaceAddrs in the created run\n%s", diff)
}
}
}
func TestCloud_applyWithRequiredVariables(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-variables")
defer done(t)
op.Variables = testVariables(tofu.ValueFromNamedFile, "foo") // "bar" variable value missing
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
// The usual error of a required variable being missing is deferred and the operation
// is successful
if run.Result != backend.OperationSuccess {
t.Fatal("expected plan operation to succeed")
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in cloud backend") {
t.Fatalf("unexpected TFC header in output: %s", output)
}
}
func TestCloud_applyNoConfig(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/empty")
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected apply operation to fail")
}
if !run.PlanEmpty {
t.Fatalf("expected plan to be empty")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "configuration files found") {
t.Fatalf("expected configuration files error, got: %v", errOutput)
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after failed apply: %s", err.Error())
}
}
func TestCloud_applyNoChanges(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-no-changes")
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
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)
}
if !strings.Contains(output, "Sentinel Result: true") {
t.Fatalf("expected policy check result in output: %s", output)
}
}
func TestCloud_applyNoApprove(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
input := testInput(t, map[string]string{
"approve": "no",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
output := done(t)
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 := output.Stderr()
if !strings.Contains(errOutput, "Apply discarded") {
t.Fatalf("expected an apply discarded error, got: %v", errOutput)
}
}
func TestCloud_applyAutoApprove(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
ctrl := gomock.NewController(t)
applyMock := mocks.NewMockApplies(ctrl)
// This needs three new lines because we check for a minimum of three lines
// in the parsing of logs in `opApply` function.
logs := strings.NewReader(applySuccessOneResourceAdded)
applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil)
b.client.Applies = applyMock
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
input := testInput(t, map[string]string{
"approve": "no",
})
op.AutoApprove = true
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
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, "Running apply in cloud backend") {
t.Fatalf("expected TFC 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 TestCloud_applyApprovedExternally(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
input := testInput(t, map[string]string{
"approve": "wait-for-external-update",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
ctx := context.Background()
run, err := b.Operation(ctx, op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
// Wait 50 milliseconds to make sure the run started.
time.Sleep(50 * time.Millisecond)
wl, err := b.client.Workspaces.List(
ctx,
b.organization,
nil,
)
if err != nil {
t.Fatalf("unexpected error listing workspaces: %v", err)
}
if len(wl.Items) != 1 {
t.Fatalf("expected 1 workspace, got %d workspaces", len(wl.Items))
}
rl, err := b.client.Runs.List(ctx, wl.Items[0].ID, nil)
if err != nil {
t.Fatalf("unexpected error listing runs: %v", err)
}
if len(rl.Items) != 1 {
t.Fatalf("expected 1 run, got %d runs", len(rl.Items))
}
err = b.client.Runs.Apply(context.Background(), rl.Items[0].ID, tfe.RunApplyOptions{})
if err != nil {
t.Fatalf("unexpected error approving run: %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 apply in cloud backend") {
t.Fatalf("expected TFC 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, "approved using the UI or API") {
t.Fatalf("expected external approval in output: %s", output)
}
if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summery in output: %s", output)
}
}
func TestCloud_applyDiscardedExternally(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
input := testInput(t, map[string]string{
"approve": "wait-for-external-update",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
ctx := context.Background()
run, err := b.Operation(ctx, op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
// Wait 50 milliseconds to make sure the run started.
time.Sleep(50 * time.Millisecond)
wl, err := b.client.Workspaces.List(
ctx,
b.organization,
nil,
)
if err != nil {
t.Fatalf("unexpected error listing workspaces: %v", err)
}
if len(wl.Items) != 1 {
t.Fatalf("expected 1 workspace, got %d workspaces", len(wl.Items))
}
rl, err := b.client.Runs.List(ctx, wl.Items[0].ID, nil)
if err != nil {
t.Fatalf("unexpected error listing runs: %v", err)
}
if len(rl.Items) != 1 {
t.Fatalf("expected 1 run, got %d runs", len(rl.Items))
}
err = b.client.Runs.Discard(context.Background(), rl.Items[0].ID, tfe.RunDiscardOptions{})
if err != nil {
t.Fatalf("unexpected error discarding run: %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")
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in cloud backend") {
t.Fatalf("expected TFC 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, "discarded using the UI or API") {
t.Fatalf("expected external discard output: %s", output)
}
if strings.Contains(output, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("unexpected apply summery in output: %s", output)
}
}
func TestCloud_applyWithAutoApprove(t *testing.T) {
b, bCleanup := testBackendWithTags(t)
defer bCleanup()
ctrl := gomock.NewController(t)
applyMock := mocks.NewMockApplies(ctrl)
// This needs three new lines because we check for a minimum of three lines
// in the parsing of logs in `opApply` function.
logs := strings.NewReader(applySuccessOneResourceAdded)
applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil)
b.client.Applies = applyMock
// Create a named workspace that auto applies.
_, err := b.client.Workspaces.Create(
context.Background(),
b.organization,
tfe.WorkspaceCreateOptions{
Name: tfe.String("prod"),
},
)
if err != nil {
t.Fatalf("error creating named workspace: %v", err)
}
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = "prod"
op.AutoApprove = true
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, "Running apply in cloud backend") {
t.Fatalf("expected TFC 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 TestCloud_applyForceLocal(t *testing.T) {
// Set TF_FORCE_LOCAL_BACKEND so the cloud backend will use
// the local backend with itself as embedded backend.
t.Setenv("TF_FORCE_LOCAL_BACKEND", "1")
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
streams, done := terminal.StreamsForTesting(t)
view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams))
op.View = view
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 cloud backend") {
t.Fatalf("unexpected TFC header in output: %s", output)
}
if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", output)
}
if !run.State.HasManagedResourceInstanceObjects() {
t.Fatalf("expected resources in state")
}
}
func TestCloud_applyWorkspaceWithoutOperations(t *testing.T) {
b, bCleanup := testBackendWithTags(t)
defer bCleanup()
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("no-operations"),
},
)
if err != nil {
t.Fatalf("error creating named workspace: %v", err)
}
op, done := testOperationApply(t, "./testdata/apply")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = "no-operations"
streams, done := terminal.StreamsForTesting(t)
view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams))
op.View = view
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 cloud backend") {
t.Fatalf("unexpected TFC header in output: %s", output)
}
if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") {
t.Fatalf("expected plan summary in output: %s", output)
}
if !run.State.HasManagedResourceInstanceObjects() {
t.Fatalf("expected resources in state")
}
}
func TestCloud_applyLockTimeout(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
ctx := context.Background()
// Retrieve the workspace used to run this operation in.
w, err := b.client.Workspaces.Read(ctx, b.organization, b.WorkspaceMapping.Name)
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, done := testOperationApplyWithTimeout(t, "./testdata/apply", 50*time.Millisecond)
defer done(t)
input := testInput(t, map[string]string{
"cancel": "yes",
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
_, 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(200 * time.Millisecond):
t.Fatalf("expected lock timeout after 50 milliseconds, waited 200 milliseconds")
}
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, "Running apply in cloud backend") {
t.Fatalf("expected TFC header in output: %s", output)
}
if !strings.Contains(output, "Lock timeout exceeded") {
t.Fatalf("expected lock timeout 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 TestCloud_applyDestroy(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-destroy")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.PlanMode = plans.DestroyMode
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
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 cloud backend") {
t.Fatalf("expected TFC header in output: %s", output)
}
if !strings.Contains(output, "0 to add, 0 to change, 1 to destroy") {
t.Fatalf("expected plan summery in output: %s", output)
}
if !strings.Contains(output, "0 added, 0 changed, 1 destroyed") {
t.Fatalf("expected apply summery in output: %s", output)
}
}
func TestCloud_applyDestroyNoConfig(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
input := testInput(t, map[string]string{
"approve": "yes",
})
op, done := testOperationApply(t, "./testdata/empty")
defer done(t)
op.PlanMode = plans.DestroyMode
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
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 TestCloud_applyJSONWithProvisioner(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
input := testInput(t, map[string]string{
"approve": "yes",
})
op, done := testOperationApply(t, "./testdata/apply-json-with-provisioner")
defer done(t)
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
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)
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "null_resource.foo: Provisioning with 'local-exec'") {
t.Fatalf("expected provisioner local-exec start in logs: %s", gotOut)
}
if !strings.Contains(gotOut, "null_resource.foo: (local-exec):") {
t.Fatalf("expected provisioner local-exec progress in logs: %s", gotOut)
}
if !strings.Contains(gotOut, "Hello World!") {
t.Fatalf("expected provisioner local-exec output in logs: %s", gotOut)
}
stateMgr, _ := b.StateMgr(t.Context(), testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(t.Context(), statemgr.NewLockInfo()); err != nil {
t.Fatalf("unexpected error locking state after apply: %s", err.Error())
}
}
func TestCloud_applyJSONWithProvisionerError(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, done := testOperationApply(t, "./testdata/apply-json-with-provisioner-error")
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "local-exec provisioner error") {
t.Fatalf("unexpected error in apply logs: %s", gotOut)
}
}
func TestCloud_applyPolicyPass(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-policy-passed")
defer done(t)
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
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 cloud backend") {
t.Fatalf("expected TFC 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, "Sentinel Result: true") {
t.Fatalf("expected policy check result in output: %s", output)
}
if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summery in output: %s", output)
}
}
func TestCloud_applyPolicyHardFail(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-policy-hard-failed")
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
viewOutput := done(t)
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 := viewOutput.Stderr()
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, "Running apply in cloud backend") {
t.Fatalf("expected TFC 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, "Sentinel Result: false") {
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)
}
}
func TestCloud_applyPolicySoftFail(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-policy-soft-failed")
defer done(t)
input := testInput(t, map[string]string{
"override": "override",
"approve": "yes",
})
op.AutoApprove = false
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
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 cloud backend") {
t.Fatalf("expected TFC 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, "Sentinel Result: false") {
t.Fatalf("expected policy check result in output: %s", output)
}
if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summery in output: %s", output)
}
}
func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
ctrl := gomock.NewController(t)
policyCheckMock := mocks.NewMockPolicyChecks(ctrl)
// This needs three new lines because we check for a minimum of three lines
// in the parsing of logs in `opApply` function.
logs := strings.NewReader(fmt.Sprintf("%s\n%s", sentinelSoftFail, applySuccessOneResourceAdded))
pc := &tfe.PolicyCheck{
ID: "pc-1",
Actions: &tfe.PolicyActions{
IsOverridable: true,
},
Permissions: &tfe.PolicyPermissions{
CanOverride: true,
},
Scope: tfe.PolicyScopeOrganization,
Status: tfe.PolicySoftFailed,
}
policyCheckMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(pc, nil)
policyCheckMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil)
policyCheckMock.EXPECT().Override(gomock.Any(), gomock.Any()).Return(nil, nil)
b.client.PolicyChecks = policyCheckMock
applyMock := mocks.NewMockApplies(ctrl)
// This needs three new lines because we check for a minimum of three lines
// in the parsing of logs in `opApply` function.
logs = strings.NewReader("\n\n\n1 added, 0 changed, 0 destroyed")
applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil)
b.client.Applies = applyMock
op, done := testOperationApply(t, "./testdata/apply-policy-soft-failed")
input := testInput(t, map[string]string{})
op.AutoApprove = true
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
<-run.Done()
viewOutput := done(t)
if run.Result != backend.OperationSuccess {
t.Fatal("expected apply operation to success due to auto-approve")
}
if run.PlanEmpty {
t.Fatalf("expected plan to not be empty, plan operation completed without error")
}
if len(input.answers) != 0 {
t.Fatalf("expected no answers, got: %v", input.answers)
}
errOutput := viewOutput.Stderr()
if strings.Contains(errOutput, "soft failed") {
t.Fatalf("expected no policy check errors, instead got: %v", errOutput)
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Sentinel Result: false") {
t.Fatalf("expected policy check to be false, instead got: %s", output)
}
if !strings.Contains(output, "Apply complete!") {
t.Fatalf("expected apply to be complete, instead got: %s", output)
}
if !strings.Contains(output, "Resources: 1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected resources, instead got: %s", output)
}
}
func TestCloud_applyPolicySoftFailAutoApprove(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
ctrl := gomock.NewController(t)
applyMock := mocks.NewMockApplies(ctrl)
// This needs three new lines because we check for a minimum of three lines
// in the parsing of logs in `opApply` function.
logs := strings.NewReader(applySuccessOneResourceAdded)
applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil)
b.client.Applies = applyMock
// Create a named workspace that auto applies.
_, err := b.client.Workspaces.Create(
context.Background(),
b.organization,
tfe.WorkspaceCreateOptions{
Name: tfe.String("prod"),
},
)
if err != nil {
t.Fatalf("error creating named workspace: %v", err)
}
op, done := testOperationApply(t, "./testdata/apply-policy-soft-failed")
defer done(t)
input := testInput(t, map[string]string{
"override": "override",
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = "prod"
op.AutoApprove = true
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) != 2 {
t.Fatalf("expected an unused answer, got: %v", input.answers)
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running apply in cloud backend") {
t.Fatalf("expected TFC 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, "Sentinel Result: false") {
t.Fatalf("expected policy check result in output: %s", output)
}
if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") {
t.Fatalf("expected apply summery in output: %s", output)
}
}
func TestCloud_applyWithRemoteError(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, done := testOperationApply(t, "./testdata/apply-with-error")
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
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("expected apply error in output: %s", output)
}
}
func TestCloud_applyJSONWithRemoteError(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
stream, close := terminal.StreamsForTesting(t)
b.renderer = &jsonformat.Renderer{
Streams: stream,
Colorize: mockColorize(),
}
op, done := testOperationApply(t, "./testdata/apply-json-with-error")
defer done(t)
op.Workspace = testBackendSingleWorkspaceName
mockSROWorkspace(t, b, op.Workspace)
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())
}
outp := close(t)
gotOut := outp.Stdout()
if !strings.Contains(gotOut, "Unsupported block type") {
t.Fatalf("unexpected plan error in output: %s", gotOut)
}
}
func TestCloud_applyVersionCheck(t *testing.T) {
testCases := map[string]struct {
localVersion string
remoteVersion string
forceLocal bool
executionMode string
wantErr string
}{
"versions can be different for remote apply": {
localVersion: "0.14.0",
remoteVersion: "0.13.5",
executionMode: "remote",
},
"versions can be different for local apply": {
localVersion: "0.14.0",
remoteVersion: "0.13.5",
executionMode: "local",
},
"force local with remote operations and different versions is acceptable": {
localVersion: "0.14.0",
remoteVersion: "0.14.0-acme-provider-bundle",
forceLocal: true,
executionMode: "remote",
},
"no error if versions are identical": {
localVersion: "0.14.0",
remoteVersion: "0.14.0",
forceLocal: true,
executionMode: "remote",
},
"no error if force local but workspace has remote operations disabled": {
localVersion: "0.14.0",
remoteVersion: "0.13.5",
forceLocal: true,
executionMode: "local",
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
// SETUP: Save original local version state and restore afterwards
p := tfversion.Prerelease
v := tfversion.Version
s := tfversion.SemVer
defer func() {
tfversion.Prerelease = p
tfversion.Version = v
tfversion.SemVer = s
}()
// SETUP: Set local version for the test case
tfversion.Prerelease = ""
tfversion.Version = tc.localVersion
tfversion.SemVer = version.Must(version.NewSemver(tc.localVersion))
// SETUP: Set force local for the test case
b.forceLocal = tc.forceLocal
ctx := context.Background()
// SETUP: set the operations and Terraform Version fields on the
// remote workspace
_, err := b.client.Workspaces.Update(
ctx,
b.organization,
b.WorkspaceMapping.Name,
tfe.WorkspaceUpdateOptions{
ExecutionMode: tfe.String(tc.executionMode),
TerraformVersion: tfe.String(tc.remoteVersion),
},
)
if err != nil {
t.Fatalf("error creating named workspace: %v", err)
}
// RUN: prepare the apply operation and run it
op, opDone := testOperationApply(t, "./testdata/apply")
defer opDone(t)
streams, done := terminal.StreamsForTesting(t)
view := views.NewOperation(arguments.ViewHuman, false, views.NewView(streams))
op.View = view
input := testInput(t, map[string]string{
"approve": "yes",
})
op.UIIn = input
op.UIOut = b.CLI
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(ctx, op)
if err != nil {
t.Fatalf("error starting operation: %v", err)
}
// RUN: wait for completion
<-run.Done()
output := done(t)
if tc.wantErr != "" {
// ASSERT: if the test case wants an error, check for failure
// and the error message
if run.Result != backend.OperationFailure {
t.Fatalf("expected run to fail, but result was %#v", run.Result)
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, tc.wantErr) {
t.Fatalf("missing error %q\noutput: %s", tc.wantErr, errOutput)
}
} else {
// ASSERT: otherwise, check for success and appropriate output
// based on whether the run should be local or remote
if run.Result != backend.OperationSuccess {
t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String())
}
output := b.CLI.(*cli.MockUi).OutputWriter.String()
hasRemote := strings.Contains(output, "Running apply in cloud backend")
hasSummary := strings.Contains(output, "1 added, 0 changed, 0 destroyed")
hasResources := run.State.HasManagedResourceInstanceObjects()
if !tc.forceLocal && !isLocalExecutionMode(tc.executionMode) {
if !hasRemote {
t.Errorf("missing TFC header in output: %s", output)
}
if !hasSummary {
t.Errorf("expected apply summary in output: %s", output)
}
} else {
if hasRemote {
t.Errorf("unexpected TFC header in output: %s", output)
}
if !hasResources {
t.Errorf("expected resources in state")
}
}
}
})
}
}
const applySuccessOneResourceAdded = `
OpenTofu v0.11.10
Initializing plugins and modules...
null_resource.hello: Creating...
null_resource.hello: Creation complete after 0s (ID: 8657651096157629581)
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
`
const sentinelSoftFail = `
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"
`