Merge pull request #33492 from hashicorp/cli-team/saved-cloud-plans

Implement saved cloud plans
This commit is contained in:
Sebastian Rivera 2023-07-25 10:29:19 -04:00 committed by GitHub
commit dceb8453af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1880 additions and 239 deletions

2
go.mod
View File

@ -41,7 +41,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-plugin v1.4.3
github.com/hashicorp/go-retryablehttp v0.7.4
github.com/hashicorp/go-tfe v1.28.0
github.com/hashicorp/go-tfe v1.29.0
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/hcl v1.0.0

4
go.sum
View File

@ -633,8 +633,8 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX
github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-tfe v1.28.0 h1:YQNfHz5UPMiOD2idad4GCjzG3R2ExPww741PBPqMOIU=
github.com/hashicorp/go-tfe v1.28.0/go.mod h1:z0182DGE/63AKUaWblUVBIrt+xdSmsuuXg5AoxGqDF4=
github.com/hashicorp/go-tfe v1.29.0 h1:hVvgoKtLAWTkXl9p/8WnItCaW65VJwqpjLZkXe8R2AM=
github.com/hashicorp/go-tfe v1.29.0/go.mod h1:z0182DGE/63AKUaWblUVBIrt+xdSmsuuXg5AoxGqDF4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=

View File

@ -270,7 +270,7 @@ type Operation struct {
// Plan is a plan that was passed as an argument. This is valid for
// plan and apply arguments but may not work for all backends.
PlanFile *planfile.Reader
PlanFile *planfile.WrappedPlanFile
// The options below are more self-explanatory and affect the runtime
// behavior of the operation.

View File

@ -77,7 +77,12 @@ func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.
var ctxDiags tfdiags.Diagnostics
var configSnap *configload.Snapshot
if op.PlanFile != nil {
if op.PlanFile.IsCloud() {
diags = diags.Append(fmt.Errorf("error: using a saved cloud plan when executing Terraform locally is not supported"))
return nil, nil, nil, diags
}
if lp, ok := op.PlanFile.Local(); ok {
var stateMeta *statemgr.SnapshotMeta
// If the statemgr implements our optional PersistentMeta interface then we'll
// additionally verify that the state snapshot in the plan file has
@ -87,7 +92,7 @@ func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.
stateMeta = &m
}
log.Printf("[TRACE] backend/local: populating backend.LocalRun from plan file")
ret, configSnap, ctxDiags = b.localRunForPlanFile(op, op.PlanFile, ret, &coreOpts, stateMeta)
ret, configSnap, ctxDiags = b.localRunForPlanFile(op, lp, ret, &coreOpts, stateMeta)
if ctxDiags.HasErrors() {
diags = diags.Append(ctxDiags)
return nil, nil, nil, diags

View File

@ -86,6 +86,41 @@ func TestLocalRun_error(t *testing.T) {
assertBackendStateUnlocked(t, b)
}
func TestLocalRun_cloudPlan(t *testing.T) {
configDir := "./testdata/apply"
b := TestLocal(t)
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests")
defer configCleanup()
planPath := "./testdata/plan-bookmark/bookmark.json"
planFile, err := planfile.OpenWrapped(planPath)
if err != nil {
t.Fatalf("unexpected error reading planfile: %s", err)
}
streams, _ := terminal.StreamsForTesting(t)
view := views.NewView(streams)
stateLocker := clistate.NewLocker(0, views.NewStateLocker(arguments.ViewHuman, view))
op := &backend.Operation{
ConfigDir: configDir,
ConfigLoader: configLoader,
PlanFile: planFile,
Workspace: backend.DefaultStateName,
StateLocker: stateLocker,
}
_, _, diags := b.LocalRun(op)
if !diags.HasErrors() {
t.Fatal("unexpected success")
}
// LocalRun() unlocks the state on failure
assertBackendStateUnlocked(t, b)
}
func TestLocalRun_stalePlan(t *testing.T) {
configDir := "./testdata/apply"
b := TestLocal(t)
@ -146,7 +181,7 @@ func TestLocalRun_stalePlan(t *testing.T) {
if err := planfile.Create(planPath, planfileArgs); err != nil {
t.Fatalf("unexpected error writing planfile: %s", err)
}
planFile, err := planfile.Open(planPath)
planFile, err := planfile.OpenWrapped(planPath)
if err != nil {
t.Fatalf("unexpected error reading planfile: %s", err)
}

View File

@ -0,0 +1,5 @@
{
"remote_plan_format": 1,
"run_id": "run-GXfuHMkbyHccAGUg",
"hostname": "app.terraform.io"
}

View File

@ -264,7 +264,7 @@ func TestRemote_applyWithPlan(t *testing.T) {
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup()
op.PlanFile = &planfile.Reader{}
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
op.Workspace = backend.DefaultStateName
run, err := b.Operation(context.Background(), op)

View File

@ -239,7 +239,7 @@ func TestRemote_planWithPlan(t *testing.T) {
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
op.PlanFile = &planfile.Reader{}
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
op.Workspace = backend.DefaultStateName
run, err := b.Operation(context.Background(), op)

View File

@ -7,8 +7,10 @@ import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"strings"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/internal/backend"
@ -54,12 +56,12 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio
))
}
if op.PlanFile != nil {
if op.PlanFile.IsLocal() {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Applying a saved plan is currently not supported",
`Terraform Cloud currently requires configuration to be present and `+
`does not accept an existing saved plan as an argument at this time.`,
"Applying a saved local plan is not supported",
`Terraform Cloud can apply a saved cloud plan, or create a new plan when `+
`configuration is present. It cannot apply a saved local plan.`,
))
}
@ -79,59 +81,107 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio
return nil, diags.Err()
}
// Run the plan phase.
r, err := b.plan(stopCtx, cancelCtx, op, w)
if err != nil {
return r, err
}
var r *tfe.Run
var err error
// This check is also performed in the plan method to determine if
// the policies should be checked, but we need to check the values
// here again to determine if we are done and should return.
if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
return r, nil
}
// Retrieve the run to get its current status.
r, err = b.client.Runs.Read(stopCtx, r.ID)
if err != nil {
return r, generalError("Failed to retrieve run", err)
}
// Return if the run cannot be confirmed.
if !op.AutoApprove && !r.Actions.IsConfirmable {
return r, nil
}
mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove
if mustConfirm && b.input {
opts := &terraform.InputOpts{Id: "approve"}
if op.PlanMode == plans.DestroyMode {
opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" +
"There is no undo. Only 'yes' will be accepted to confirm."
} else {
opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?"
opts.Description = "Terraform will perform the actions described above.\n" +
"Only 'yes' will be accepted to approve."
if cp, ok := op.PlanFile.Cloud(); ok {
log.Printf("[TRACE] Loading saved cloud plan for apply")
// Check hostname first, for a more actionable error than a generic 404 later
if cp.Hostname != b.hostname {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Saved plan is for a different hostname",
fmt.Sprintf("The given saved plan refers to a run on %s, but the currently configured Terraform Cloud or Terraform Enterprise instance is %s.", cp.Hostname, b.hostname),
))
return r, diags.Err()
}
// Fetch the run referenced in the saved plan bookmark.
r, err = b.client.Runs.ReadWithOptions(stopCtx, cp.RunID, &tfe.RunReadOptions{
Include: []tfe.RunIncludeOpt{tfe.RunWorkspace},
})
err = b.confirm(stopCtx, op, opts, r, "yes")
if err != nil && err != errRunApproved {
if err != nil {
return r, err
}
} else if mustConfirm && !b.input {
return r, errApplyNeedsUIConfirmation
} else {
// If we don't need to ask for confirmation, insert a blank
// line to separate the ouputs.
if r.Workspace.ID != w.ID {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Saved plan is for a different workspace",
fmt.Sprintf("The given saved plan does not refer to a run in the current workspace (%s/%s), so it cannot currently be applied. For more details, view this run in a browser at:\n%s", w.Organization.Name, w.Name, runURL(b.hostname, r.Workspace.Organization.Name, r.Workspace.Name, r.ID)),
))
return r, diags.Err()
}
if !r.Actions.IsConfirmable {
url := runURL(b.hostname, b.organization, op.Workspace, r.ID)
return r, unusableSavedPlanError(r.Status, url)
}
// Since we're not calling plan(), we need to print a run header ourselves:
if b.CLI != nil {
b.CLI.Output("")
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(applySavedHeader) + "\n"))
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
runHeader, b.hostname, b.organization, r.Workspace.Name, r.ID)) + "\n"))
}
} else {
log.Printf("[TRACE] Running new cloud plan for apply")
// Run the plan phase.
r, err = b.plan(stopCtx, cancelCtx, op, w)
if err != nil {
return r, err
}
// This check is also performed in the plan method to determine if
// the policies should be checked, but we need to check the values
// here again to determine if we are done and should return.
if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
return r, nil
}
// Retrieve the run to get its current status.
r, err = b.client.Runs.Read(stopCtx, r.ID)
if err != nil {
return r, generalError("Failed to retrieve run", err)
}
// Return if the run cannot be confirmed.
if !op.AutoApprove && !r.Actions.IsConfirmable {
return r, nil
}
mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove
if mustConfirm && b.input {
opts := &terraform.InputOpts{Id: "approve"}
if op.PlanMode == plans.DestroyMode {
opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" +
"There is no undo. Only 'yes' will be accepted to confirm."
} else {
opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?"
opts.Description = "Terraform will perform the actions described above.\n" +
"Only 'yes' will be accepted to approve."
}
err = b.confirm(stopCtx, op, opts, r, "yes")
if err != nil && err != errRunApproved {
return r, err
}
} else if mustConfirm && !b.input {
return r, errApplyNeedsUIConfirmation
} else {
// If we don't need to ask for confirmation, insert a blank
// line to separate the ouputs.
if b.CLI != nil {
b.CLI.Output("")
}
}
}
// Do the apply!
if !op.AutoApprove && err != errRunApproved {
if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil {
return r, generalError("Failed to approve the apply command", err)
@ -222,6 +272,52 @@ func (b *Cloud) renderApplyLogs(ctx context.Context, run *tfe.Run) error {
return nil
}
func runURL(hostname, orgName, wsName, runID string) string {
return fmt.Sprintf("https://%s/app/%s/%s/runs/%s", hostname, orgName, wsName, runID)
}
func unusableSavedPlanError(status tfe.RunStatus, url string) error {
var diags tfdiags.Diagnostics
var summary, reason string
switch status {
case tfe.RunApplied:
summary = "Saved plan is already applied"
reason = "The given plan file was already successfully applied, and cannot be applied again."
case tfe.RunApplying, tfe.RunApplyQueued, tfe.RunConfirmed:
summary = "Saved plan is already confirmed"
reason = "The given plan file is already being applied, and cannot be applied again."
case tfe.RunCanceled:
summary = "Saved plan is canceled"
reason = "The given plan file can no longer be applied because the run was canceled via the Terraform Cloud UI or API."
case tfe.RunDiscarded:
summary = "Saved plan is discarded"
reason = "The given plan file can no longer be applied; either another run was applied first, or a user discarded it via the Terraform Cloud UI or API."
case tfe.RunErrored:
summary = "Saved plan is errored"
reason = "The given plan file refers to a plan that had errors and did not complete successfully. It cannot be applied."
case tfe.RunPlannedAndFinished:
// Note: planned and finished can also indicate a plan-only run, but
// terraform plan can't create a saved plan for a plan-only run, so we
// know it's no-changes in this case.
summary = "Saved plan has no changes"
reason = "The given plan file contains no changes, so it cannot be applied."
case tfe.RunPolicyOverride:
summary = "Saved plan requires policy override"
reason = "The given plan file has soft policy failures, and cannot be applied until a user with appropriate permissions overrides the policy check."
default:
summary = "Saved plan cannot be applied"
reason = "Terraform Cloud cannot apply the given plan file. This may mean the plan and checks have not yet completed, or may indicate another problem."
}
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
summary,
fmt.Sprintf("%s For more details, view this run in a browser at:\n%s", reason, url),
))
return diags.Err()
}
const applyDefaultHeader = `
[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C
will cancel the remote apply if it's still pending. If the apply started it
@ -229,3 +325,10 @@ will stop streaming the logs, but will not stop the apply running remotely.[rese
Preparing the remote apply...
`
const applySavedHeader = `
[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the apply running remotely.[reset]
Preparing the remote apply...
`

View File

@ -22,6 +22,7 @@ import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/jsonformat"
@ -407,14 +408,15 @@ func TestCloud_applyWithParallelism(t *testing.T) {
}
}
func TestCloud_applyWithPlan(t *testing.T) {
// Apply with local plan file should fail.
func TestCloud_applyWithLocalPlan(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup()
op.PlanFile = &planfile.Reader{}
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
@ -432,11 +434,80 @@ func TestCloud_applyWithPlan(t *testing.T) {
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "saved plan is currently not supported") {
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, configCleanup, done := testOperationApply(t, "./testdata/apply-json")
defer configCleanup()
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(), 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(testBackendSingleWorkspaceName)
// An error suggests that the state was not unlocked after apply
if _, err := stateMgr.Lock(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()

View File

@ -5,6 +5,7 @@ package cloud
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
@ -550,11 +551,12 @@ func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *te
return <-result
}
// This method will fetch the redacted plan output and marshal the response into
// a struct the jsonformat.Renderer expects.
// This method will fetch the redacted plan output as a byte slice, mirroring
// the behavior of the similar client.Plans.ReadJSONOutput method.
//
// Note: Apologies for the lengthy definition, this is a result of not being able to mock receiver methods
var readRedactedPlan func(context.Context, url.URL, string, string) (*jsonformat.Plan, error) = func(ctx context.Context, baseURL url.URL, token string, planID string) (*jsonformat.Plan, error) {
// Note: Apologies for the lengthy definition, this is a result of not being
// able to mock receiver methods
var readRedactedPlan func(context.Context, url.URL, string, string) ([]byte, error) = func(ctx context.Context, baseURL url.URL, token string, planID string) ([]byte, error) {
client := retryablehttp.NewClient()
client.RetryMax = 10
client.RetryWaitMin = 100 * time.Millisecond
@ -575,7 +577,6 @@ var readRedactedPlan func(context.Context, url.URL, string, string) (*jsonformat
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
p := &jsonformat.Plan{}
resp, err := client.Do(req)
if err != nil {
return nil, err
@ -586,10 +587,17 @@ var readRedactedPlan func(context.Context, url.URL, string, string) (*jsonformat
return nil, err
}
if err := json.NewDecoder(resp.Body).Decode(p); err != nil {
return io.ReadAll(resp.Body)
}
// decodeRedactedPlan unmarshals a downloaded redacted plan into a struct the
// jsonformat.Renderer expects.
func decodeRedactedPlan(jsonBytes []byte) (*jsonformat.Plan, error) {
r := bytes.NewReader(jsonBytes)
p := &jsonformat.Plan{}
if err := json.NewDecoder(r).Decode(p); err != nil {
return nil, err
}
return p, nil
}

View File

@ -23,6 +23,7 @@ import (
version "github.com/hashicorp/go-version"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/genconfig"
@ -65,15 +66,6 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation
))
}
if op.PlanOutPath != "" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Saving a generated plan is currently not supported",
`Terraform Cloud does not support saving the generated execution `+
`plan locally at this time.`,
))
}
if !op.HasConfig() && op.PlanMode != plans.DestroyMode {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
@ -95,7 +87,25 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation
return nil, diags.Err()
}
return b.plan(stopCtx, cancelCtx, op, w)
// If the run errored, exit before checking whether to save a plan file
run, err := b.plan(stopCtx, cancelCtx, op, w)
if err != nil {
return nil, err
}
// Save plan file if -out <FILE> was specified
if op.PlanOutPath != "" {
bookmark := cloudplan.NewSavedPlanBookmark(run.ID, b.hostname)
err = bookmark.Save(op.PlanOutPath)
if err != nil {
return nil, err
}
}
// Everything succeded, so display next steps
op.View.PlanNextStep(op.PlanOutPath, op.GenerateConfigOut)
return run, nil
}
func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
@ -107,9 +117,12 @@ func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation,
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n"))
}
// Plan-only means they ran terraform plan without -out.
planOnly := op.Type == backend.OperationTypePlan && op.PlanOutPath == ""
configOptions := tfe.ConfigurationVersionCreateOptions{
AutoQueueRuns: tfe.Bool(false),
Speculative: tfe.Bool(op.Type == backend.OperationTypePlan),
Speculative: tfe.Bool(planOnly),
}
cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions)
@ -206,6 +219,7 @@ in order to capture the filesystem context the remote workspace expects:
Refresh: tfe.Bool(op.PlanRefresh),
Workspace: w,
AutoApply: tfe.Bool(op.AutoApprove),
SavePlan: tfe.Bool(op.PlanOutPath != ""),
}
switch op.PlanMode {
@ -495,10 +509,14 @@ func (b *Cloud) renderPlanLogs(ctx context.Context, op *backend.Operation, run *
return err
}
if renderSRO || shouldGenerateConfig {
redactedPlan, err = readRedactedPlan(ctx, b.client.BaseURL(), b.token, run.Plan.ID)
jsonBytes, err := readRedactedPlan(ctx, b.client.BaseURL(), b.token, run.Plan.ID)
if err != nil {
return generalError("Failed to read JSON plan", err)
}
redactedPlan, err = decodeRedactedPlan(jsonBytes)
if err != nil {
return generalError("Failed to decode JSON plan", err)
}
}
// Write any generated config before rendering the plan, so we can stop in case of errors

View File

@ -20,6 +20,7 @@ import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/jsonformat"
@ -337,7 +338,7 @@ func TestCloud_planWithPlan(t *testing.T) {
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
op.PlanFile = &planfile.Reader{}
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
@ -366,8 +367,11 @@ func TestCloud_planWithPath(t *testing.T) {
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
defer done(t)
op.PlanOutPath = "./testdata/plan"
tmpDir := t.TempDir()
pfPath := tmpDir + "/plan.tfplan"
op.PlanOutPath = pfPath
op.Workspace = testBackendSingleWorkspaceName
run, err := b.Operation(context.Background(), op)
@ -376,17 +380,27 @@ func TestCloud_planWithPath(t *testing.T) {
}
<-run.Done()
output := done(t)
if run.Result == backend.OperationSuccess {
t.Fatal("expected plan operation to fail")
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")
if run.PlanEmpty {
t.Fatal("expected a non-empty plan")
}
errOutput := output.Stderr()
if !strings.Contains(errOutput, "generated plan is currently not supported") {
t.Fatalf("expected a generated plan error, got: %v", errOutput)
output := b.CLI.(*cli.MockUi).OutputWriter.String()
if !strings.Contains(output, "Running plan in Terraform Cloud") {
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 summary in output: %s", output)
}
plan, err := cloudplan.LoadSavedPlanBookmark(pfPath)
if err != nil {
t.Fatalf("error loading cloud plan file: %v", err)
}
if !strings.Contains(plan.RunID, "run-") || plan.Hostname != "app.terraform.io" {
t.Fatalf("unexpected contents in saved cloud plan: %v", plan)
}
}

View File

@ -0,0 +1,114 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cloud
import (
"context"
"fmt"
"strings"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/plans"
)
// ShowPlanForRun downloads the JSON plan output for the specified cloud run
// (either the redacted or unredacted format, per the caller's request), and
// returns it in a cloudplan.RemotePlanJSON wrapper struct (along with various
// metadata required by terraform show). It's intended for use by the terraform
// show command, in order to format and display a saved cloud plan.
func (b *Cloud) ShowPlanForRun(ctx context.Context, runID, runHostname string, redacted bool) (*cloudplan.RemotePlanJSON, error) {
var jsonBytes []byte
mode := plans.NormalMode
var opts []plans.Quality
// Bail early if wrong hostname
if runHostname != b.hostname {
return nil, fmt.Errorf("hostname for run (%s) does not match the configured cloud integration (%s)", runHostname, b.hostname)
}
// Get run and plan
r, err := b.client.Runs.ReadWithOptions(ctx, runID, &tfe.RunReadOptions{Include: []tfe.RunIncludeOpt{tfe.RunPlan, tfe.RunWorkspace}})
if err == tfe.ErrResourceNotFound {
return nil, fmt.Errorf("couldn't read information for cloud run %s; make sure you've run `terraform login` and that you have permission to view the run", runID)
} else if err != nil {
return nil, fmt.Errorf("couldn't read information for cloud run %s: %w", runID, err)
}
// Sort out the run mode
if r.IsDestroy {
mode = plans.DestroyMode
} else if r.RefreshOnly {
mode = plans.RefreshOnlyMode
}
// Check that the plan actually finished
switch r.Plan.Status {
case tfe.PlanErrored:
// Errored plans might still be displayable, but we want to mention it to the renderer.
opts = append(opts, plans.Errored)
case tfe.PlanFinished:
// Good to go, but alert the renderer if it has no changes.
if !r.Plan.HasChanges {
opts = append(opts, plans.NoChanges)
}
default:
// Bail, we can't use this.
err = fmt.Errorf("can't display a cloud plan that is currently %s", r.Plan.Status)
return nil, err
}
// Fetch the json plan!
if redacted {
jsonBytes, err = readRedactedPlan(ctx, b.client.BaseURL(), b.token, r.Plan.ID)
} else {
jsonBytes, err = b.client.Plans.ReadJSONOutput(ctx, r.Plan.ID)
}
if err == tfe.ErrResourceNotFound {
if redacted {
return nil, fmt.Errorf("couldn't read plan data for cloud run %s; make sure you've run `terraform login` and that you have permission to view the run", runID)
} else {
return nil, fmt.Errorf("couldn't read unredacted JSON plan data for cloud run %s; make sure you've run `terraform login` and that you have admin permissions on the workspace", runID)
}
} else if err != nil {
return nil, fmt.Errorf("couldn't read plan data for cloud run %s: %w", runID, err)
}
// Format a run header and footer
header := strings.TrimSpace(fmt.Sprintf(runHeader, b.hostname, b.organization, r.Workspace.Name, r.ID))
footer := strings.TrimSpace(statusFooter(r.Status, r.Actions.IsConfirmable, r.Workspace.Locked))
out := &cloudplan.RemotePlanJSON{
JSONBytes: jsonBytes,
Redacted: redacted,
Mode: mode,
Qualities: opts,
RunHeader: header,
RunFooter: footer,
}
return out, nil
}
func statusFooter(status tfe.RunStatus, isConfirmable, locked bool) string {
statusText := strings.ReplaceAll(string(status), "_", " ")
statusColor := "red"
statusNote := "not confirmable"
if isConfirmable {
statusColor = "green"
statusNote = "confirmable"
}
lockedColor := "green"
lockedText := "unlocked"
if locked {
lockedColor = "red"
lockedText = "locked"
}
return fmt.Sprintf(statusFooterText, statusColor, statusText, statusNote, lockedColor, lockedText)
}
const statusFooterText = `
[reset][%s]Run status: %s (%s)[reset]
[%s]Workspace is %s[reset]
`

View File

@ -0,0 +1,217 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cloud
import (
"context"
"path/filepath"
"strings"
"testing"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform/internal/plans"
)
// A brief discourse on the theory of testing for this feature. Doing
// `terraform show cloudplan.tfplan` relies on the correctness of the following
// behaviors:
//
// 1. TFC API returns redacted or unredacted plan JSON on request, if permission
// requirements are met and the run is in a condition where that JSON exists.
// 2. Cloud.ShowPlanForRun() makes correct API calls, calculates metadata
// properly given a tfe.Run, and returns either a cloudplan.RemotePlanJSON or an err.
// 3. The Show command instantiates Cloud backend when given a cloud planfile,
// calls .ShowPlanForRun() on it, and passes result to Display() impls.
// 4. Display() impls yield the correct output when given a cloud plan json biscuit.
//
// 1 is axiomatic and outside our domain. 3 is regrettably totally untestable
// unless we refactor the Meta command to enable stubbing out a backend factory
// or something, which seems inadvisable at this juncture. 4 is exercised over
// in internal/command/views/show_test.go. And thus, this file only cares about
// item 2.
// 404 on run: special error message
func TestCloud_showMissingRun(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()
mockSROWorkspace(t, b, testBackendSingleWorkspaceName)
absentRunID := "run-WwwwXxxxYyyyZzzz"
_, err := b.ShowPlanForRun(context.Background(), absentRunID, "app.terraform.io", true)
if !strings.Contains(err.Error(), "terraform login") {
t.Fatalf("expected error message to suggest checking your login status, instead got: %s", err)
}
}
// If redacted json is available but unredacted is not
func TestCloud_showMissingUnredactedJson(t *testing.T) {
b, mc, bCleanup := testBackendAndMocksWithName(t)
defer bCleanup()
mockSROWorkspace(t, b, testBackendSingleWorkspaceName)
ctx := context.Background()
runID, err := testCloudRunForShow(mc, "./testdata/plan-json-basic-no-unredacted", tfe.RunPlannedAndSaved, tfe.PlanFinished)
if err != nil {
t.Fatalf("failed to init test data: %s", err)
}
// Showing the human-formatted plan should still work as expected!
redacted, err := b.ShowPlanForRun(ctx, runID, "app.terraform.io", true)
if err != nil {
t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err)
}
if !strings.Contains(string(redacted.JSONBytes), `"plan_format_version":`) {
t.Fatalf("show for human doesn't include expected redacted json content")
}
// Should be marked as containing changes and non-errored
canNotApply := false
errored := false
for _, opt := range redacted.Qualities {
if opt == plans.NoChanges {
canNotApply = true
}
if opt == plans.Errored {
errored = true
}
}
if canNotApply || errored {
t.Fatalf("expected neither errored nor can't-apply in opts, instead got: %#v", redacted.Qualities)
}
// But show -json should result in a special error.
_, err = b.ShowPlanForRun(ctx, runID, "app.terraform.io", false)
if err == nil {
t.Fatalf("unexpected success: reading unredacted json without admin permissions should have errored")
}
if !strings.Contains(err.Error(), "admin") {
t.Fatalf("expected error message to suggest your permissions are wrong, instead got: %s", err)
}
}
// If both kinds of json are available, both kinds of show should work
func TestCloud_showIncludesUnredactedJson(t *testing.T) {
b, mc, bCleanup := testBackendAndMocksWithName(t)
defer bCleanup()
mockSROWorkspace(t, b, testBackendSingleWorkspaceName)
ctx := context.Background()
runID, err := testCloudRunForShow(mc, "./testdata/plan-json-basic", tfe.RunPlannedAndSaved, tfe.PlanFinished)
if err != nil {
t.Fatalf("failed to init test data: %s", err)
}
// Showing the human-formatted plan should work as expected:
redacted, err := b.ShowPlanForRun(ctx, runID, "app.terraform.io", true)
if err != nil {
t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err)
}
if !strings.Contains(string(redacted.JSONBytes), `"plan_format_version":`) {
t.Fatalf("show for human doesn't include expected redacted json content")
}
// Showing the external json plan format should work as expected:
unredacted, err := b.ShowPlanForRun(ctx, runID, "app.terraform.io", false)
if err != nil {
t.Fatalf("failed to show plan for robot, even though unredacted json should be present: %s", err)
}
if !strings.Contains(string(unredacted.JSONBytes), `"format_version":`) {
t.Fatalf("show for robot doesn't include expected unredacted json content")
}
}
func TestCloud_showNoChanges(t *testing.T) {
b, mc, bCleanup := testBackendAndMocksWithName(t)
defer bCleanup()
mockSROWorkspace(t, b, testBackendSingleWorkspaceName)
ctx := context.Background()
runID, err := testCloudRunForShow(mc, "./testdata/plan-json-no-changes", tfe.RunPlannedAndSaved, tfe.PlanFinished)
if err != nil {
t.Fatalf("failed to init test data: %s", err)
}
// Showing the human-formatted plan should work as expected:
redacted, err := b.ShowPlanForRun(ctx, runID, "app.terraform.io", true)
if err != nil {
t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err)
}
// Should be marked as no changes
canNotApply := false
for _, opt := range redacted.Qualities {
if opt == plans.NoChanges {
canNotApply = true
}
}
if !canNotApply {
t.Fatalf("expected opts to include CanNotApply, instead got: %#v", redacted.Qualities)
}
}
func TestCloud_showFooterNotConfirmable(t *testing.T) {
b, mc, bCleanup := testBackendAndMocksWithName(t)
defer bCleanup()
mockSROWorkspace(t, b, testBackendSingleWorkspaceName)
ctx := context.Background()
runID, err := testCloudRunForShow(mc, "./testdata/plan-json-full", tfe.RunDiscarded, tfe.PlanFinished)
if err != nil {
t.Fatalf("failed to init test data: %s", err)
}
// A little more custom run tweaking:
mc.Runs.Runs[runID].Actions.IsConfirmable = false
// Showing the human-formatted plan should work as expected:
redacted, err := b.ShowPlanForRun(ctx, runID, "app.terraform.io", true)
if err != nil {
t.Fatalf("failed to show plan for human, even though redacted json should be present: %s", err)
}
// Footer should mention that you can't apply it:
if !strings.Contains(redacted.RunFooter, "not confirmable") {
t.Fatalf("footer should call out that run isn't confirmable, instead got: %s", redacted.RunFooter)
}
}
func testCloudRunForShow(mc *MockClient, configDir string, runStatus tfe.RunStatus, planStatus tfe.PlanStatus) (string, error) {
ctx := context.Background()
// get workspace ID
wsID := mc.Workspaces.workspaceNames[testBackendSingleWorkspaceName].ID
// create and upload config version
cvOpts := tfe.ConfigurationVersionCreateOptions{
AutoQueueRuns: tfe.Bool(false),
Speculative: tfe.Bool(false),
}
cv, err := mc.ConfigurationVersions.Create(ctx, wsID, cvOpts)
if err != nil {
return "", err
}
absDir, err := filepath.Abs(configDir)
if err != nil {
return "", err
}
err = mc.ConfigurationVersions.Upload(ctx, cv.UploadURL, absDir)
if err != nil {
return "", err
}
// create run
rOpts := tfe.RunCreateOptions{
PlanOnly: tfe.Bool(false),
IsDestroy: tfe.Bool(false),
RefreshOnly: tfe.Bool(false),
ConfigurationVersion: cv,
Workspace: &tfe.Workspace{ID: wsID},
}
r, err := mc.Runs.Create(ctx, rOpts)
if err != nil {
return "", err
}
// mess with statuses (this is what requires full access to mock client)
mc.Runs.Runs[r.ID].Status = runStatus
mc.Plans.plans[r.Plan.ID].Status = planStatus
// return the ID
return r.ID, nil
}

View File

@ -650,7 +650,7 @@ func TestCloud_setUnavailableTerraformVersion(t *testing.T) {
}),
})
b, bCleanup := testBackend(t, config, nil)
b, _, bCleanup := testBackend(t, config, nil)
defer bCleanup()
// Make sure the workspace doesn't exist yet -- otherwise, we can't test what

View File

@ -0,0 +1,39 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cloudplan
import (
"github.com/hashicorp/terraform/internal/plans"
)
// RemotePlanJSON is a wrapper struct that associates a pre-baked JSON plan with
// several pieces of metadata that can't be derived directly from the JSON
// contents and must instead be discovered from a tfe.Run or tfe.Plan. The
// wrapper is useful for moving data between the Cloud backend (which is the
// only thing able to fetch the JSON and determine values for the metadata) and
// the command.ShowCommand and views.Show interface (which need to have all of
// this information together).
type RemotePlanJSON struct {
// The raw bytes of json we got from the API.
JSONBytes []byte
// Indicates whether the json bytes are the "redacted json plan" format, or
// the unredacted stable "external json plan" format. These formats are
// actually very different under the hood; the redacted one can be decoded
// directly into a jsonformat.Plan struct and is intended for formatting a
// plan for human consumption, while the unredacted one matches what is
// returned by the jsonplan.Marshal() function, cannot be directly decoded
// into a public type (it's actually a jsonplan.plan struct), and will
// generally be spat back out verbatim.
Redacted bool
// Normal/destroy/refresh. Required by (jsonformat.Renderer).RenderHumanPlan.
Mode plans.Mode
// Unchanged/errored. Required by (jsonformat.Renderer).RenderHumanPlan.
Qualities []plans.Quality
// A human-readable header with a link to view the associated run in the
// Terraform Cloud UI.
RunHeader string
// A human-readable footer with information relevant to the likely next
// actions for this plan.
RunFooter string
}

View File

@ -0,0 +1,75 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cloudplan
import (
"encoding/json"
"errors"
"io"
"os"
"strings"
)
var ErrInvalidRemotePlanFormat = errors.New("invalid remote plan format, must be 1")
var ErrInvalidRunID = errors.New("invalid run ID")
var ErrInvalidHostname = errors.New("invalid hostname")
type SavedPlanBookmark struct {
RemotePlanFormat int `json:"remote_plan_format"`
RunID string `json:"run_id"`
Hostname string `json:"hostname"`
}
func NewSavedPlanBookmark(runID, hostname string) SavedPlanBookmark {
return SavedPlanBookmark{
RemotePlanFormat: 1,
RunID: runID,
Hostname: hostname,
}
}
func LoadSavedPlanBookmark(filepath string) (SavedPlanBookmark, error) {
bookmark := SavedPlanBookmark{}
file, err := os.Open(filepath)
if err != nil {
return bookmark, err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return bookmark, err
}
err = json.Unmarshal(data, &bookmark)
if err != nil {
return bookmark, err
}
// Note that these error cases are somewhat ambiguous, but they *likely*
// mean we're not looking at a saved plan bookmark at all. Since we're not
// certain about the format at this point, it doesn't quite make sense to
// emit a "known file type but bad" error struct the way we do over in the
// planfile and statefile packages.
if bookmark.RemotePlanFormat != 1 {
return bookmark, ErrInvalidRemotePlanFormat
} else if bookmark.Hostname == "" {
return bookmark, ErrInvalidHostname
} else if bookmark.RunID == "" || !strings.HasPrefix(bookmark.RunID, "run-") {
return bookmark, ErrInvalidRunID
}
return bookmark, err
}
func (s *SavedPlanBookmark) Save(filepath string) error {
data, _ := json.Marshal(s)
err := os.WriteFile(filepath, data, 0644)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,99 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package cloudplan
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/zclconf/go-cty/cty"
)
func TestCloud_loadBasic(t *testing.T) {
bookmark := SavedPlanBookmark{
RemotePlanFormat: 1,
RunID: "run-GXfuHMkbyHccAGUg",
Hostname: "app.terraform.io",
}
file := "./testdata/plan-bookmark/bookmark.json"
result, err := LoadSavedPlanBookmark(file)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(bookmark, result, cmp.Comparer(cty.Value.RawEquals)); diff != "" {
t.Errorf("wrong result\n%s", diff)
}
}
func TestCloud_loadCheckRunID(t *testing.T) {
// Run ID must never be empty
file := "./testdata/plan-bookmark/empty_run_id.json"
_, err := LoadSavedPlanBookmark(file)
if !errors.Is(err, ErrInvalidRunID) {
t.Fatalf("expected %s but got %s", ErrInvalidRunID, err)
}
}
func TestCloud_loadCheckHostname(t *testing.T) {
// Hostname must never be empty
file := "./testdata/plan-bookmark/empty_hostname.json"
_, err := LoadSavedPlanBookmark(file)
if !errors.Is(err, ErrInvalidHostname) {
t.Fatalf("expected %s but got %s", ErrInvalidHostname, err)
}
}
func TestCloud_loadCheckVersionNumberBasic(t *testing.T) {
// remote_plan_format must be set to 1
// remote_plan_format and format version number are used interchangeably
file := "./testdata/plan-bookmark/invalid_version.json"
_, err := LoadSavedPlanBookmark(file)
if !errors.Is(err, ErrInvalidRemotePlanFormat) {
t.Fatalf("expected %s but got %s", ErrInvalidRemotePlanFormat, err)
}
}
func TestCloud_saveWhenFileExistsBasic(t *testing.T) {
tmpDir := t.TempDir()
tmpFile, err := os.Create(filepath.Join(tmpDir, "saved-bookmark.json"))
if err != nil {
t.Fatal("File could not be created.", err)
}
defer tmpFile.Close()
// verify the created path exists
// os.Stat() wants path to file
_, error := os.Stat(tmpFile.Name())
if error != nil {
t.Fatal("Path to file does not exist.", error)
} else {
b := &SavedPlanBookmark{
RemotePlanFormat: 1,
RunID: "run-GXfuHMkbyHccAGUg",
Hostname: "app.terraform.io",
}
err := b.Save(tmpFile.Name())
if err != nil {
t.Fatal(err)
}
}
}
func TestCloud_saveWhenFileDoesNotExistBasic(t *testing.T) {
tmpDir := t.TempDir()
b := &SavedPlanBookmark{
RemotePlanFormat: 1,
RunID: "run-GXfuHMkbyHccAGUg",
Hostname: "app.terraform.io",
}
err := b.Save(filepath.Join(tmpDir, "create-new-file.txt"))
if err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,5 @@
{
"remote_plan_format": 1,
"run_id": "run-GXfuHMkbyHccAGUg",
"hostname": "app.terraform.io"
}

View File

@ -0,0 +1,5 @@
{
"remote_plan_format": 1,
"run_id": "run-GXfuHMkbyHccAGUg",
"hostname": ""
}

View File

@ -0,0 +1,5 @@
{
"remote_plan_format": 1,
"run_id": "",
"hostname": "app.terraform.io"
}

View File

@ -0,0 +1,5 @@
{
"remote_plan_format": 11,
"run_id": "run-GXfuHMkbyHccAGUg",
"hostname": "app.terraform.io"
}

View File

@ -0,0 +1,5 @@
{
"remote_plan_format": 1,
"run_id": "run-GXfuHMkbyHccAGUg",
"hostname": "app.terraform.io"
}

View File

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

View File

@ -0,0 +1,116 @@
{
"plan_format_version": "1.1",
"resource_drift": [],
"resource_changes": [
{
"address": "null_resource.foo",
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider_name": "registry.terraform.io/hashicorp/null",
"change": {
"actions": [
"create"
],
"before": null,
"after": {
"triggers": null
},
"after_unknown": {
"id": true
},
"before_sensitive": false,
"after_sensitive": {}
}
}
],
"relevant_attributes": [],
"output_changes": {},
"provider_schemas": {
"registry.terraform.io/hashicorp/null": {
"provider": {
"version": 0,
"block": {
"description_kind": "plain"
}
},
"resource_schemas": {
"null_resource": {
"version": 0,
"block": {
"attributes": {
"id": {
"type": "string",
"description": "This is set to a random value at create time.",
"description_kind": "plain",
"computed": true
},
"triggers": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.",
"description_kind": "plain",
"optional": true
}
},
"description": "The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.",
"description_kind": "plain"
}
}
},
"data_source_schemas": {
"null_data_source": {
"version": 0,
"block": {
"attributes": {
"has_computed_default": {
"type": "string",
"description": "If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.",
"description_kind": "plain",
"optional": true,
"computed": true
},
"id": {
"type": "string",
"description": "This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.",
"description_kind": "plain",
"deprecated": true,
"computed": true
},
"inputs": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.",
"description_kind": "plain",
"optional": true
},
"outputs": {
"type": [
"map",
"string"
],
"description": "After the data source is \"read\", a copy of the `inputs` map.",
"description_kind": "plain",
"computed": true
},
"random": {
"type": "string",
"description": "A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.",
"description_kind": "plain",
"computed": true
}
},
"description": "The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n",
"description_kind": "plain",
"deprecated": true
}
}
}
}
},
"provider_format_version": "1.0"
}

View File

@ -0,0 +1,3 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.409143-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"null_resource.foo: Plan to create","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605841-05:00","change":{"resource":{"addr":"null_resource.foo","module":"","resource":"null_resource.foo","implied_provider":"null","resource_type":"null_resource","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605906-05:00","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}

View File

@ -0,0 +1 @@
{"format_version":"1.1","terraform_version":"1.4.4","planned_values":{"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"triggers":null},"sensitive_values":{}}]}},"resource_changes":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","change":{"actions":["create"],"before":null,"after":{"triggers":null},"after_unknown":{"id":true},"before_sensitive":false,"after_sensitive":{}}}],"configuration":{"provider_config":{"null":{"name":"null","full_name":"registry.terraform.io/hashicorp/null"}},"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_config_key":"null","schema_version":0}]}}}

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1,118 @@
{
"plan_format_version": "1.1",
"resource_drift": [],
"resource_changes": [
{
"address": "null_resource.foo",
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider_name": "registry.terraform.io/hashicorp/null",
"change": {
"actions": [
"no-op"
],
"before": {
"id": "3549869958859575216",
"triggers": null
},
"after": {
"id": "3549869958859575216",
"triggers": null
},
"after_unknown": {},
"before_sensitive": {},
"after_sensitive": {}
}
}
],
"relevant_attributes": [],
"output_changes": {},
"provider_schemas": {
"registry.terraform.io/hashicorp/null": {
"provider": {
"version": 0,
"block": {
"description_kind": "plain"
}
},
"resource_schemas": {
"null_resource": {
"version": 0,
"block": {
"attributes": {
"id": {
"type": "string",
"description": "This is set to a random value at create time.",
"description_kind": "plain",
"computed": true
},
"triggers": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.",
"description_kind": "plain",
"optional": true
}
},
"description": "The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.",
"description_kind": "plain"
}
}
},
"data_source_schemas": {
"null_data_source": {
"version": 0,
"block": {
"attributes": {
"has_computed_default": {
"type": "string",
"description": "If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.",
"description_kind": "plain",
"optional": true,
"computed": true
},
"id": {
"type": "string",
"description": "This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.",
"description_kind": "plain",
"deprecated": true,
"computed": true
},
"inputs": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.",
"description_kind": "plain",
"optional": true
},
"outputs": {
"type": [
"map",
"string"
],
"description": "After the data source is \"read\", a copy of the `inputs` map.",
"description_kind": "plain",
"computed": true
},
"random": {
"type": "string",
"description": "A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.",
"description_kind": "plain",
"computed": true
}
},
"description": "The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n",
"description_kind": "plain",
"deprecated": true
}
}
}
}
},
"provider_format_version": "1.0"
}

View File

@ -0,0 +1 @@
{"format_version":"1.1","terraform_version":"1.4.4","planned_values":{"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"id":"3549869958859575216","triggers":null},"sensitive_values":{}}]}},"resource_changes":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","change":{"actions":["no-op"],"before":{"id":"3549869958859575216","triggers":null},"after":{"id":"3549869958859575216","triggers":null},"after_unknown":{},"before_sensitive":{},"after_sensitive":{}}}],"prior_state":{"format_version":"1.0","terraform_version":"1.4.4","values":{"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"id":"3549869958859575216","triggers":null},"sensitive_values":{}}]}}},"configuration":{"provider_config":{"null":{"name":"null","full_name":"registry.terraform.io/hashicorp/null"}},"root_module":{"resources":[{"address":"null_resource.foo","mode":"managed","type":"null_resource","name":"foo","provider_config_key":"null","schema_version":0}]}}}

View File

@ -0,0 +1,2 @@
{"@level":"info","@message":"Terraform 1.3.7","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.409143-05:00","terraform":"1.3.7","type":"version","ui":"1.0"}
{"@level":"info","@message":"Plan: 0 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-01-19T10:47:27.605906-05:00","changes":{"add":0,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}

View File

@ -26,7 +26,6 @@ import (
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/httpclient"
@ -84,6 +83,11 @@ func testInput(t *testing.T, answers map[string]string) *mockInput {
}
func testBackendWithName(t *testing.T) (*Cloud, func()) {
b, _, c := testBackendAndMocksWithName(t)
return b, c
}
func testBackendAndMocksWithName(t *testing.T) (*Cloud, *MockClient, func()) {
obj := cty.ObjectVal(map[string]cty.Value{
"hostname": cty.NullVal(cty.String),
"organization": cty.StringVal("hashicorp"),
@ -110,7 +114,8 @@ func testBackendWithTags(t *testing.T) (*Cloud, func()) {
),
}),
})
return testBackend(t, obj, nil)
b, _, c := testBackend(t, obj, nil)
return b, c
}
func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
@ -123,7 +128,8 @@ func testBackendNoOperations(t *testing.T) (*Cloud, func()) {
"tags": cty.NullVal(cty.Set(cty.String)),
}),
})
return testBackend(t, obj, nil)
b, _, c := testBackend(t, obj, nil)
return b, c
}
func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, func()) {
@ -136,7 +142,8 @@ func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.Respons
"tags": cty.NullVal(cty.Set(cty.String)),
}),
})
return testBackend(t, obj, handlers)
b, _, c := testBackend(t, obj, handlers)
return b, c
}
func testCloudState(t *testing.T) *State {
@ -213,7 +220,7 @@ func testBackendWithOutputs(t *testing.T) (*Cloud, func()) {
return b, cleanup
}
func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, func()) {
func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, *MockClient, func()) {
var s *httptest.Server
if handlers != nil {
s = testServerWithHandlers(handlers)
@ -264,7 +271,7 @@ func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.Resp
}
baseURL.Path = "/api/v2/"
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) (*jsonformat.Plan, error) {
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) {
return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
}
@ -288,7 +295,7 @@ func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.Resp
}
}
return b, s.Close
return b, mc, s.Close
}
// testUnconfiguredBackend is used for testing the configuration of the backend
@ -322,6 +329,7 @@ func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
b.client.Runs = mc.Runs
b.client.RunEvents = mc.RunEvents
b.client.StateVersions = mc.StateVersions
b.client.StateVersionOutputs = mc.StateVersionOutputs
b.client.Variables = mc.Variables
b.client.Workspaces = mc.Workspaces
@ -331,7 +339,7 @@ func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
}
baseURL.Path = "/api/v2/"
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) (*jsonformat.Plan, error) {
readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) {
return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
}

View File

@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@ -22,7 +21,6 @@ import (
tfe "github.com/hashicorp/go-tfe"
"github.com/mitchellh/copystructure"
"github.com/hashicorp/terraform/internal/command/jsonformat"
tfversion "github.com/hashicorp/terraform/version"
)
@ -468,13 +466,13 @@ func (m *MockOrganizations) ReadRunQueue(ctx context.Context, name string, optio
type MockRedactedPlans struct {
client *MockClient
redactedPlans map[string]*jsonformat.Plan
redactedPlans map[string][]byte
}
func newMockRedactedPlans(client *MockClient) *MockRedactedPlans {
return &MockRedactedPlans{
client: client,
redactedPlans: make(map[string]*jsonformat.Plan),
redactedPlans: make(map[string][]byte),
}
}
@ -495,23 +493,17 @@ func (m *MockRedactedPlans) create(cvID, workspaceID, planID string) error {
return err
}
raw, err := ioutil.ReadAll(redactedPlanFile)
raw, err := io.ReadAll(redactedPlanFile)
if err != nil {
return err
}
redactedPlan := &jsonformat.Plan{}
err = json.Unmarshal(raw, redactedPlan)
if err != nil {
return err
}
m.redactedPlans[planID] = redactedPlan
m.redactedPlans[planID] = raw
return nil
}
func (m *MockRedactedPlans) Read(ctx context.Context, hostname, token, planID string) (*jsonformat.Plan, error) {
func (m *MockRedactedPlans) Read(ctx context.Context, hostname, token, planID string) ([]byte, error) {
if p, ok := m.redactedPlans[planID]; ok {
return p, nil
}
@ -521,7 +513,7 @@ func (m *MockRedactedPlans) Read(ctx context.Context, hostname, token, planID st
type MockPlans struct {
client *MockClient
logs map[string]string
planOutputs map[string]string
planOutputs map[string][]byte
plans map[string]*tfe.Plan
}
@ -529,7 +521,7 @@ func newMockPlans(client *MockClient) *MockPlans {
return &MockPlans{
client: client,
logs: make(map[string]string),
planOutputs: make(map[string]string),
planOutputs: make(map[string][]byte),
plans: make(map[string]*tfe.Plan),
}
}
@ -556,6 +548,17 @@ func (m *MockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) {
w.WorkingDirectory,
"plan.log",
)
// Try to load unredacted json output, if it exists
outputPath := filepath.Join(
m.client.ConfigurationVersions.uploadPaths[cvID],
w.WorkingDirectory,
"plan-unredacted.json",
)
if outBytes, err := os.ReadFile(outputPath); err == nil {
m.planOutputs[p.ID] = outBytes
}
m.plans[p.ID] = p
return p, nil
@ -616,7 +619,7 @@ func (m *MockPlans) ReadJSONOutput(ctx context.Context, planID string) ([]byte,
return nil, tfe.ErrResourceNotFound
}
return []byte(planOutput), nil
return planOutput, nil
}
type MockTaskStages struct {
@ -1085,7 +1088,7 @@ func (m *MockRuns) Read(ctx context.Context, runID string) (*tfe.Run, error) {
return m.ReadWithOptions(ctx, runID, nil)
}
func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.RunReadOptions) (*tfe.Run, error) {
func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, options *tfe.RunReadOptions) (*tfe.Run, error) {
m.Lock()
defer m.Unlock()
@ -1109,7 +1112,7 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run
}
logs, _ := ioutil.ReadFile(m.client.Plans.logs[r.Plan.LogReadURL])
if r.Status == tfe.RunPlanning && r.Plan.Status == tfe.PlanFinished {
if (r.Status == tfe.RunPlanning || r.Status == tfe.RunPlannedAndSaved) && r.Plan.Status == tfe.PlanFinished {
hasChanges := r.IsDestroy ||
bytes.Contains(logs, []byte("1 to add")) ||
bytes.Contains(logs, []byte("1 to change")) ||
@ -1118,6 +1121,7 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run
r.Actions.IsCancelable = false
r.Actions.IsConfirmable = true
r.HasChanges = true
r.Plan.HasChanges = true
r.Permissions.CanApply = true
}
@ -1136,8 +1140,22 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run
if err != nil {
panic(err)
}
r = rc.(*tfe.Run)
return rc.(*tfe.Run), nil
// After copying, handle includes... or at least, any includes we're known to rely on.
if options != nil {
for _, n := range options.Include {
switch n {
case tfe.RunWorkspace:
ws, ok := m.client.Workspaces.workspaceIDs[r.Workspace.ID]
if ok {
r.Workspace = ws
}
}
}
}
return r, nil
}
func (m *MockRuns) Apply(ctx context.Context, runID string, options tfe.RunApplyOptions) error {
@ -1534,6 +1552,9 @@ func (m *MockWorkspaces) Create(ctx context.Context, organization string, option
CanQueueRun: true,
CanForceDelete: tfe.Bool(true),
},
Organization: &tfe.Organization{
Name: organization,
},
}
if options.AutoApply != nil {
w.AutoApply = *options.AutoApply

View File

@ -150,8 +150,8 @@ func (c *ApplyCommand) Run(rawArgs []string) int {
return 0
}
func (c *ApplyCommand) LoadPlanFile(path string) (*planfile.Reader, tfdiags.Diagnostics) {
var planFile *planfile.Reader
func (c *ApplyCommand) LoadPlanFile(path string) (*planfile.WrappedPlanFile, tfdiags.Diagnostics) {
var planFile *planfile.WrappedPlanFile
var diags tfdiags.Diagnostics
// Try to load plan if path is specified
@ -194,7 +194,7 @@ func (c *ApplyCommand) LoadPlanFile(path string) (*planfile.Reader, tfdiags.Diag
return planFile, diags
}
func (c *ApplyCommand) PrepareBackend(planFile *planfile.Reader, args *arguments.State, viewType arguments.ViewType) (backend.Enhanced, tfdiags.Diagnostics) {
func (c *ApplyCommand) PrepareBackend(planFile *planfile.WrappedPlanFile, args *arguments.State, viewType arguments.ViewType) (backend.Enhanced, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// FIXME: we need to apply the state arguments to the meta object here
@ -206,19 +206,8 @@ func (c *ApplyCommand) PrepareBackend(planFile *planfile.Reader, args *arguments
// Load the backend
var be backend.Enhanced
var beDiags tfdiags.Diagnostics
if planFile == nil {
backendConfig, configDiags := c.loadBackendConfig(".")
diags = diags.Append(configDiags)
if configDiags.HasErrors() {
return nil, diags
}
be, beDiags = c.Backend(&BackendOpts{
Config: backendConfig,
ViewType: viewType,
})
} else {
plan, err := planFile.ReadPlan()
if lp, ok := planFile.Local(); ok {
plan, err := lp.ReadPlan()
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
@ -236,7 +225,19 @@ func (c *ApplyCommand) PrepareBackend(planFile *planfile.Reader, args *arguments
))
return nil, diags
}
be, beDiags = c.BackendForPlan(plan.Backend)
be, beDiags = c.BackendForLocalPlan(plan.Backend)
} else {
// Both new plans and saved cloud plans load their backend from config.
backendConfig, configDiags := c.loadBackendConfig(".")
diags = diags.Append(configDiags)
if configDiags.HasErrors() {
return nil, diags
}
be, beDiags = c.Backend(&BackendOpts{
Config: backendConfig,
ViewType: viewType,
})
}
diags = diags.Append(beDiags)
@ -250,7 +251,7 @@ func (c *ApplyCommand) OperationRequest(
be backend.Enhanced,
view views.Apply,
viewType arguments.ViewType,
planFile *planfile.Reader,
planFile *planfile.WrappedPlanFile,
args *arguments.Operation,
autoApprove bool,
) (*backend.Operation, tfdiags.Diagnostics) {

View File

@ -55,7 +55,7 @@ func (c *GraphCommand) Run(args []string) int {
}
// Try to load plan if path is specified
var planFile *planfile.Reader
var planFile *planfile.WrappedPlanFile
if planPath != "" {
planFile, err = c.PlanFile(planPath)
if err != nil {

View File

@ -19,14 +19,9 @@ import (
"github.com/hashicorp/terraform/internal/plans"
)
type PlanRendererOpt int
const (
detectedDrift string = "drift"
proposedChange string = "change"
Errored PlanRendererOpt = iota
CanNotApply
)
type Plan struct {
@ -51,8 +46,8 @@ func (plan Plan) getSchema(change jsonplan.ResourceChange) *jsonprovider.Schema
}
}
func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...PlanRendererOpt) {
checkOpts := func(target PlanRendererOpt) bool {
func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Quality) {
checkOpts := func(target plans.Quality) bool {
for _, opt := range opts {
if opt == target {
return true
@ -102,7 +97,7 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...PlanRen
// the plan is "applyable" and, if so, whether it had refresh changes
// that we already would've presented above.
if checkOpts(Errored) {
if checkOpts(plans.Errored) {
if haveRefreshChanges {
renderer.Streams.Print(format.HorizontalRule(renderer.Colorize, renderer.Streams.Stdout.Columns()))
renderer.Streams.Println()
@ -143,7 +138,7 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...PlanRen
)
if haveRefreshChanges {
if !checkOpts(CanNotApply) {
if !checkOpts(plans.NoChanges) {
// In this case, applying this plan will not change any
// remote objects but _will_ update the state to match what
// we detected during refresh, so we'll reassure the user
@ -210,7 +205,7 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...PlanRen
}
if len(changes) > 0 {
if checkOpts(Errored) {
if checkOpts(plans.Errored) {
renderer.Streams.Printf("\nTerraform planned the following actions, but then encountered a problem:\n")
} else {
renderer.Streams.Printf("\nTerraform will perform the following actions:\n")

View File

@ -82,7 +82,7 @@ type Renderer struct {
RunningInAutomation bool
}
func (renderer Renderer) RenderHumanPlan(plan Plan, mode plans.Mode, opts ...PlanRendererOpt) {
func (renderer Renderer) RenderHumanPlan(plan Plan, mode plans.Mode, opts ...plans.Quality) {
if incompatibleVersions(jsonplan.FormatVersion, plan.PlanFormatVersion) || incompatibleVersions(jsonprovider.FormatVersion, plan.ProviderFormatVersion) {
renderer.Streams.Println(format.WordWrap(
renderer.Colorize.Color("\n[bold][red]Warning:[reset][bold] This plan was generated using a different version of Terraform, the diff presented here may be missing representations of recent features."),

View File

@ -295,13 +295,13 @@ func (m *Meta) selectWorkspace(b backend.Backend) error {
return m.SetWorkspace(workspace)
}
// BackendForPlan is similar to Backend, but uses backend settings that were
// BackendForLocalPlan is similar to Backend, but uses backend settings that were
// stored in a plan.
//
// The current workspace name is also stored as part of the plan, and so this
// method will check that it matches the currently-selected workspace name
// and produce error diagnostics if not.
func (m *Meta) BackendForPlan(settings plans.Backend) (backend.Enhanced, tfdiags.Diagnostics) {
func (m *Meta) BackendForLocalPlan(settings plans.Backend) (backend.Enhanced, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
f := backendInit.Backend(settings.Type)
@ -310,7 +310,7 @@ func (m *Meta) BackendForPlan(settings plans.Backend) (backend.Enhanced, tfdiags
return nil, diags
}
b := f()
log.Printf("[TRACE] Meta.BackendForPlan: instantiated backend of type %T", b)
log.Printf("[TRACE] Meta.BackendForLocalPlan: instantiated backend of type %T", b)
schema := b.ConfigSchema()
configVal, err := settings.Config.Decode(schema.ImpliedType())
@ -361,7 +361,7 @@ func (m *Meta) BackendForPlan(settings plans.Backend) (backend.Enhanced, tfdiags
// Otherwise, we'll wrap our state-only remote backend in the local backend
// to cause any operations to be run locally.
log.Printf("[TRACE] Meta.Backend: backend %T does not support operations, so wrapping it in a local backend", b)
log.Printf("[TRACE] Meta.BackendForLocalPlan: backend %T does not support operations, so wrapping it in a local backend", b)
cliOpts, err := m.backendCLIOpts()
if err != nil {
diags = diags.Append(err)

View File

@ -1550,7 +1550,7 @@ func TestMetaBackend_planLocal(t *testing.T) {
m := testMetaBackend(t, nil)
// Get the backend
b, diags := m.BackendForPlan(backendConfig)
b, diags := m.BackendForLocalPlan(backendConfig)
if diags.HasErrors() {
t.Fatal(diags.Err())
}
@ -1651,7 +1651,7 @@ func TestMetaBackend_planLocalStatePath(t *testing.T) {
m.stateOutPath = statePath
// Get the backend
b, diags := m.BackendForPlan(plannedBackend)
b, diags := m.BackendForLocalPlan(plannedBackend)
if diags.HasErrors() {
t.Fatal(diags.Err())
}
@ -1740,7 +1740,7 @@ func TestMetaBackend_planLocalMatch(t *testing.T) {
m := testMetaBackend(t, nil)
// Get the backend
b, diags := m.BackendForPlan(backendConfig)
b, diags := m.BackendForLocalPlan(backendConfig)
if diags.HasErrors() {
t.Fatal(diags.Err())
}

View File

@ -27,14 +27,15 @@ func (m *Meta) Input() bool {
return true
}
// PlanFile returns a reader for the plan file at the given path.
// PlanFile loads the plan file at the given path, which might be either a local
// or cloud plan.
//
// If the return value and error are both nil, the given path exists but seems
// to be a configuration directory instead.
//
// Error will be non-nil if path refers to something which looks like a plan
// file and loading the file fails.
func (m *Meta) PlanFile(path string) (*planfile.Reader, error) {
func (m *Meta) PlanFile(path string) (*planfile.WrappedPlanFile, error) {
fi, err := os.Stat(path)
if err != nil {
return nil, err
@ -45,5 +46,5 @@ func (m *Meta) PlanFile(path string) (*planfile.Reader, error) {
return nil, nil
}
return planfile.Open(path)
return planfile.OpenWrapped(path)
}

View File

@ -4,11 +4,15 @@
package command
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/cloud"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/configs"
@ -20,10 +24,29 @@ import (
"github.com/hashicorp/terraform/internal/tfdiags"
)
// Many of the methods we get data from can emit special error types if they're
// pretty sure about the file type but still can't use it. But they can't all do
// that! So, we have to do a couple ourselves if we want to preserve that data.
type errUnusableDataMisc struct {
inner error
kind string
}
func errUnusable(err error, kind string) *errUnusableDataMisc {
return &errUnusableDataMisc{inner: err, kind: kind}
}
func (e *errUnusableDataMisc) Error() string {
return e.inner.Error()
}
func (e *errUnusableDataMisc) Unwrap() error {
return e.inner
}
// ShowCommand is a Command implementation that reads and outputs the
// contents of a Terraform plan or state file.
type ShowCommand struct {
Meta
viewType arguments.ViewType
}
func (c *ShowCommand) Run(rawArgs []string) int {
@ -38,6 +61,7 @@ func (c *ShowCommand) Run(rawArgs []string) int {
c.View.HelpPrompt("show")
return 1
}
c.viewType = args.ViewType
// Set up view
view := views.NewShow(args.ViewType, c.View)
@ -51,7 +75,7 @@ func (c *ShowCommand) Run(rawArgs []string) int {
}
// Get the data we need to display
plan, stateFile, config, schemas, showDiags := c.show(args.Path)
plan, jsonPlan, stateFile, config, schemas, showDiags := c.show(args.Path)
diags = diags.Append(showDiags)
if showDiags.HasErrors() {
view.Diagnostics(diags)
@ -59,7 +83,7 @@ func (c *ShowCommand) Run(rawArgs []string) int {
}
// Display the data
return view.Display(config, plan, stateFile, schemas)
return view.Display(config, plan, jsonPlan, stateFile, schemas)
}
func (c *ShowCommand) Help() string {
@ -83,9 +107,10 @@ func (c *ShowCommand) Synopsis() string {
return "Show the current state or a saved plan"
}
func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs.Config, *terraform.Schemas, tfdiags.Diagnostics) {
func (c *ShowCommand) show(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, *terraform.Schemas, tfdiags.Diagnostics) {
var diags, showDiags tfdiags.Diagnostics
var plan *plans.Plan
var jsonPlan *cloudplan.RemotePlanJSON
var stateFile *statefile.File
var config *configs.Config
var schemas *terraform.Schemas
@ -96,7 +121,7 @@ func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs.
stateFile, showDiags = c.showFromLatestStateSnapshot()
diags = diags.Append(showDiags)
if showDiags.HasErrors() {
return plan, stateFile, config, schemas, diags
return plan, jsonPlan, stateFile, config, schemas, diags
}
}
@ -104,10 +129,10 @@ func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs.
// so try to load the argument as a plan file first.
// If that fails, try to load it as a statefile.
if path != "" {
plan, stateFile, config, showDiags = c.showFromPath(path)
plan, jsonPlan, stateFile, config, showDiags = c.showFromPath(path)
diags = diags.Append(showDiags)
if showDiags.HasErrors() {
return plan, stateFile, config, schemas, diags
return plan, jsonPlan, stateFile, config, schemas, diags
}
}
@ -115,11 +140,11 @@ func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs.
if config != nil || stateFile != nil {
schemas, diags = c.MaybeGetSchemas(stateFile.State, config)
if diags.HasErrors() {
return plan, stateFile, config, schemas, diags
return plan, jsonPlan, stateFile, config, schemas, diags
}
}
return plan, stateFile, config, schemas, diags
return plan, jsonPlan, stateFile, config, schemas, diags
}
func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
@ -149,42 +174,129 @@ func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Di
return stateFile, diags
}
func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config, tfdiags.Diagnostics) {
func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var planErr, stateErr error
var plan *plans.Plan
var jsonPlan *cloudplan.RemotePlanJSON
var stateFile *statefile.File
var config *configs.Config
// Try to get the plan file and associated data from
// the path argument. If that fails, try to get the
// statefile from the path argument.
plan, stateFile, config, planErr = getPlanFromPath(path)
// Path might be a local plan file, a bookmark to a saved cloud plan, or a
// state file. First, try to get a plan and associated data from a local
// plan file. If that fails, try to get a json plan from the path argument.
// If that fails, try to get the statefile from the path argument.
plan, jsonPlan, stateFile, config, planErr = c.getPlanFromPath(path)
if planErr != nil {
stateFile, stateErr = getStateFromPath(path)
if stateErr != nil {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
"Failed to read the given file as a state or plan file",
fmt.Sprintf("State read error: %s\n\nPlan read error: %s", stateErr, planErr),
),
)
return nil, nil, nil, diags
// To avoid spamming the user with irrelevant errors, first check to
// see if one of our errors happens to know for a fact what file
// type we were dealing with. If so, then we can ignore the other
// ones (which are likely to be something unhelpful like "not a
// valid zip file"). If not, we can fall back to dumping whatever
// we've got.
var unLocal *planfile.ErrUnusableLocalPlan
var unState *statefile.ErrUnusableState
var unMisc *errUnusableDataMisc
if errors.As(planErr, &unLocal) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
"Couldn't show local plan",
fmt.Sprintf("Plan read error: %s", unLocal),
),
)
} else if errors.As(planErr, &unMisc) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Couldn't show %s", unMisc.kind),
fmt.Sprintf("Plan read error: %s", unMisc),
),
)
} else if errors.As(stateErr, &unState) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
"Couldn't show state file",
fmt.Sprintf("Plan read error: %s", unState),
),
)
} else if errors.As(stateErr, &unMisc) {
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Couldn't show %s", unMisc.kind),
fmt.Sprintf("Plan read error: %s", unMisc),
),
)
} else {
// Ok, give up and show the really big error
diags = diags.Append(
tfdiags.Sourceless(
tfdiags.Error,
"Failed to read the given file as a state or plan file",
fmt.Sprintf("State read error: %s\n\nPlan read error: %s", stateErr, planErr),
),
)
}
return nil, nil, nil, nil, diags
}
}
return plan, stateFile, config, diags
return plan, jsonPlan, stateFile, config, diags
}
// getPlanFromPath returns a plan, statefile, and config if the user-supplied
// path points to a plan file. If both plan and error are nil, the path is likely
// a directory. An error could suggest that the given path points to a statefile.
func getPlanFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config, error) {
planReader, err := planfile.Open(path)
// getPlanFromPath returns a plan, json plan, statefile, and config if the
// user-supplied path points to either a local or cloud plan file. Note that
// some of the return values will be nil no matter what; local plan files do not
// yield a json plan, and cloud plans do not yield real plan/state/config
// structs. An error generally suggests that the given path is either a
// directory or a statefile.
func (c *ShowCommand) getPlanFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, error) {
var err error
var plan *plans.Plan
var jsonPlan *cloudplan.RemotePlanJSON
var stateFile *statefile.File
var config *configs.Config
pf, err := planfile.OpenWrapped(path)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, nil, err
}
if lp, ok := pf.Local(); ok {
plan, stateFile, config, err = getDataFromPlanfileReader(lp)
} else if cp, ok := pf.Cloud(); ok {
redacted := c.viewType != arguments.ViewJSON
jsonPlan, err = c.getDataFromCloudPlan(cp, redacted)
}
return plan, jsonPlan, stateFile, config, err
}
func (c *ShowCommand) getDataFromCloudPlan(plan *cloudplan.SavedPlanBookmark, redacted bool) (*cloudplan.RemotePlanJSON, error) {
// Set up the backend
b, backendDiags := c.Backend(nil)
if backendDiags.HasErrors() {
return nil, errUnusable(backendDiags.Err(), "cloud plan")
}
// Cloud plans only work if we're cloud.
cl, ok := b.(*cloud.Cloud)
if !ok {
return nil, errUnusable(fmt.Errorf("can't show a saved cloud plan unless the current root module is connected to Terraform Cloud"), "cloud plan")
}
result, err := cl.ShowPlanForRun(context.Background(), plan.RunID, plan.Hostname, redacted)
if err != nil {
err = errUnusable(err, "cloud plan")
}
return result, err
}
// getDataFromPlanfileReader returns a plan, statefile, and config, extracted from a local plan file.
func getDataFromPlanfileReader(planReader *planfile.Reader) (*plans.Plan, *statefile.File, *configs.Config, error) {
// Get plan
plan, err := planReader.ReadPlan()
if err != nil {
@ -200,7 +312,7 @@ func getPlanFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config
// Get config
config, diags := planReader.ReadConfig()
if diags.HasErrors() {
return nil, nil, nil, diags.Err()
return nil, nil, nil, errUnusable(diags.Err(), "local plan")
}
return plan, stateFile, config, err
@ -210,14 +322,14 @@ func getPlanFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config
func getStateFromPath(path string) (*statefile.File, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("Error loading statefile: %s", err)
return nil, fmt.Errorf("Error loading statefile: %w", err)
}
defer file.Close()
var stateFile *statefile.File
stateFile, err = statefile.Read(file)
if err != nil {
return nil, fmt.Errorf("Error reading %s as a statefile: %s", path, err)
return nil, fmt.Errorf("Error reading %s as a statefile: %w", path, err)
}
return stateFile, nil
}
@ -227,12 +339,12 @@ func getStateFromBackend(b backend.Backend, workspace string) (*statefile.File,
// Get the state store for the given workspace
stateStore, err := b.StateMgr(workspace)
if err != nil {
return nil, fmt.Errorf("Failed to load state manager: %s", err)
return nil, fmt.Errorf("Failed to load state manager: %w", err)
}
// Refresh the state store with the latest state snapshot from persistent storage
if err := stateStore.RefreshState(); err != nil {
return nil, fmt.Errorf("Failed to load state: %s", err)
return nil, fmt.Errorf("Failed to load state: %w", err)
}
// Get the latest state snapshot and return it

View File

@ -201,9 +201,13 @@ func TestShow_argsPlanFileDoesNotExist(t *testing.T) {
}
got := output.Stderr()
want := `Plan read error: open doesNotExist.tfplan:`
if !strings.Contains(got, want) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
want1 := `Plan read error: couldn't load the provided path`
want2 := `open doesNotExist.tfplan: no such file or directory`
if !strings.Contains(got, want1) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want1)
}
if !strings.Contains(got, want2) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want2)
}
}
@ -256,9 +260,13 @@ func TestShow_json_argsPlanFileDoesNotExist(t *testing.T) {
}
got := output.Stderr()
want := `Plan read error: open doesNotExist.tfplan:`
if !strings.Contains(got, want) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
want1 := `Plan read error: couldn't load the provided path`
want2 := `open doesNotExist.tfplan: no such file or directory`
if !strings.Contains(got, want1) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want1)
}
if !strings.Contains(got, want2) {
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want2)
}
}

View File

@ -115,12 +115,12 @@ func (v *OperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
}
// Side load some data that we can't extract from the JSON plan.
var opts []jsonformat.PlanRendererOpt
var opts []plans.Quality
if !plan.CanApply() {
opts = append(opts, jsonformat.CanNotApply)
opts = append(opts, plans.NoChanges)
}
if plan.Errored {
opts = append(opts, jsonformat.Errored)
opts = append(opts, plans.Errored)
}
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)

View File

@ -4,8 +4,11 @@
package views
import (
"bytes"
"encoding/json"
"fmt"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/command/jsonplan"
@ -20,7 +23,7 @@ import (
type Show interface {
// Display renders the plan, if it is available. If plan is nil, it renders the statefile.
Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int
Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int
// Diagnostics renders early diagnostics, resulting from argument parsing.
Diagnostics(diags tfdiags.Diagnostics)
@ -43,14 +46,31 @@ type ShowHuman struct {
var _ Show = (*ShowHuman)(nil)
func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int {
renderer := jsonformat.Renderer{
Colorize: v.view.colorize,
Streams: v.view.streams,
RunningInAutomation: v.view.runningInAutomation,
}
if plan != nil {
// Prefer to display a pre-built JSON plan, if we got one; then, fall back
// to building one ourselves.
if planJSON != nil {
if !planJSON.Redacted {
v.view.streams.Eprintf("Didn't get renderable JSON plan format for human display")
return 1
}
// The redacted json plan format can be decoded into a jsonformat.Plan
p := jsonformat.Plan{}
r := bytes.NewReader(planJSON.JSONBytes)
if err := json.NewDecoder(r).Decode(&p); err != nil {
v.view.streams.Eprintf("Couldn't decode renderable JSON plan format: %s", err)
}
v.view.streams.Print(v.view.colorize.Color(planJSON.RunHeader + "\n"))
renderer.RenderHumanPlan(p, planJSON.Mode, planJSON.Qualities...)
v.view.streams.Print(v.view.colorize.Color("\n" + planJSON.RunFooter + "\n"))
} else if plan != nil {
outputs, changed, drift, attrs, err := jsonplan.MarshalForRenderer(plan, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
@ -67,12 +87,12 @@ func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, stateFile
RelevantAttributes: attrs,
}
var opts []jsonformat.PlanRendererOpt
var opts []plans.Quality
if !plan.CanApply() {
opts = append(opts, jsonformat.CanNotApply)
opts = append(opts, plans.NoChanges)
}
if plan.Errored {
opts = append(opts, jsonformat.Errored)
opts = append(opts, plans.Errored)
}
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
@ -111,15 +131,23 @@ type ShowJSON struct {
var _ Show = (*ShowJSON)(nil)
func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
if plan != nil {
jsonPlan, err := jsonplan.Marshal(config, plan, stateFile, schemas)
func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int {
// Prefer to display a pre-built JSON plan, if we got one; then, fall back
// to building one ourselves.
if planJSON != nil {
if planJSON.Redacted {
v.view.streams.Eprintf("Didn't get external JSON plan format")
return 1
}
v.view.streams.Println(string(planJSON.JSONBytes))
} else if plan != nil {
planJSON, err := jsonplan.Marshal(config, plan, stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
return 1
}
v.view.streams.Println(string(jsonPlan))
v.view.streams.Println(string(planJSON))
} else {
// It is possible that there is neither state nor a plan.
// That's ok, we'll just return an empty object.

View File

@ -5,10 +5,12 @@ package views
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/initwd"
@ -23,8 +25,14 @@ import (
)
func TestShowHuman(t *testing.T) {
redactedPath := "./testdata/plans/redacted-plan.json"
redactedPlanJson, err := os.ReadFile(redactedPath)
if err != nil {
t.Fatalf("couldn't read json plan test data at %s for showing a cloud plan. Did the file get moved?", redactedPath)
}
testCases := map[string]struct {
plan *plans.Plan
jsonPlan *cloudplan.RemotePlanJSON
stateFile *statefile.File
schemas *terraform.Schemas
wantExact bool
@ -33,11 +41,28 @@ func TestShowHuman(t *testing.T) {
"plan file": {
testPlan(t),
nil,
nil,
testSchemas(),
false,
"# test_resource.foo will be created",
},
"cloud plan file": {
nil,
&cloudplan.RemotePlanJSON{
JSONBytes: redactedPlanJson,
Redacted: true,
Mode: plans.NormalMode,
Qualities: []plans.Quality{},
RunHeader: "[reset][yellow]To view this run in a browser, visit:\nhttps://app.terraform.io/app/example_org/example_workspace/runs/run-run-bugsBUGSbugsBUGS[reset]",
RunFooter: "[reset][green]Run status: planned and saved (confirmable)[reset]\n[green]Workspace is unlocked[reset]",
},
nil,
nil,
false,
"# null_resource.foo will be created",
},
"statefile": {
nil,
nil,
&statefile.File{
Serial: 0,
@ -49,6 +74,7 @@ func TestShowHuman(t *testing.T) {
"# test_resource.foo:",
},
"empty statefile": {
nil,
nil,
&statefile.File{
Serial: 0,
@ -63,6 +89,7 @@ func TestShowHuman(t *testing.T) {
nil,
nil,
nil,
nil,
true,
"No state.\n",
},
@ -74,7 +101,7 @@ func TestShowHuman(t *testing.T) {
view.Configure(&arguments.View{NoColor: true})
v := NewShow(arguments.ViewHuman, view)
code := v.Display(nil, testCase.plan, testCase.stateFile, testCase.schemas)
code := v.Display(nil, testCase.plan, testCase.jsonPlan, testCase.stateFile, testCase.schemas)
if code != 0 {
t.Errorf("expected 0 return code, got %d", code)
}
@ -90,15 +117,35 @@ func TestShowHuman(t *testing.T) {
}
func TestShowJSON(t *testing.T) {
unredactedPath := "../testdata/show-json/basic-create/output.json"
unredactedPlanJson, err := os.ReadFile(unredactedPath)
if err != nil {
t.Fatalf("couldn't read json plan test data at %s for showing a cloud plan. Did the file get moved?", unredactedPath)
}
testCases := map[string]struct {
plan *plans.Plan
jsonPlan *cloudplan.RemotePlanJSON
stateFile *statefile.File
}{
"plan file": {
testPlan(t),
nil,
nil,
},
"cloud plan file": {
nil,
&cloudplan.RemotePlanJSON{
JSONBytes: unredactedPlanJson,
Redacted: false,
Mode: plans.NormalMode,
Qualities: []plans.Quality{},
RunHeader: "[reset][yellow]To view this run in a browser, visit:\nhttps://app.terraform.io/app/example_org/example_workspace/runs/run-run-bugsBUGSbugsBUGS[reset]",
RunFooter: "[reset][green]Run status: planned and saved (confirmable)[reset]\n[green]Workspace is unlocked[reset]",
},
nil,
},
"statefile": {
nil,
nil,
&statefile.File{
Serial: 0,
@ -107,6 +154,7 @@ func TestShowJSON(t *testing.T) {
},
},
"empty statefile": {
nil,
nil,
&statefile.File{
Serial: 0,
@ -117,6 +165,7 @@ func TestShowJSON(t *testing.T) {
"nothing": {
nil,
nil,
nil,
},
}
@ -147,7 +196,7 @@ func TestShowJSON(t *testing.T) {
},
}
code := v.Display(config, testCase.plan, testCase.stateFile, schemas)
code := v.Display(config, testCase.plan, testCase.jsonPlan, testCase.stateFile, schemas)
if code != 0 {
t.Errorf("expected 0 return code, got %d", code)

View File

@ -188,12 +188,12 @@ func (t *TestHuman) Run(run *moduletest.Run, file *moduletest.File) {
RelevantAttributes: attrs,
}
var opts []jsonformat.PlanRendererOpt
var opts []plans.Quality
if !run.Verbose.Plan.CanApply() {
opts = append(opts, jsonformat.CanNotApply)
opts = append(opts, plans.NoChanges)
}
if run.Verbose.Plan.Errored {
opts = append(opts, jsonformat.Errored)
opts = append(opts, plans.Errored)
}
renderer.RenderHumanPlan(plan, run.Verbose.Plan.UIMode, opts...)

View File

@ -0,0 +1,116 @@
{
"plan_format_version": "1.1",
"resource_drift": [],
"resource_changes": [
{
"address": "null_resource.foo",
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider_name": "registry.terraform.io/hashicorp/null",
"change": {
"actions": [
"create"
],
"before": null,
"after": {
"triggers": null
},
"after_unknown": {
"id": true
},
"before_sensitive": false,
"after_sensitive": {}
}
}
],
"relevant_attributes": [],
"output_changes": {},
"provider_schemas": {
"registry.terraform.io/hashicorp/null": {
"provider": {
"version": 0,
"block": {
"description_kind": "plain"
}
},
"resource_schemas": {
"null_resource": {
"version": 0,
"block": {
"attributes": {
"id": {
"type": "string",
"description": "This is set to a random value at create time.",
"description_kind": "plain",
"computed": true
},
"triggers": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that, when changed, will force the null resource to be replaced, re-running any associated provisioners.",
"description_kind": "plain",
"optional": true
}
},
"description": "The `null_resource` resource implements the standard resource lifecycle but takes no further action.\n\nThe `triggers` argument allows specifying an arbitrary set of values that, when changed, will cause the resource to be replaced.",
"description_kind": "plain"
}
}
},
"data_source_schemas": {
"null_data_source": {
"version": 0,
"block": {
"attributes": {
"has_computed_default": {
"type": "string",
"description": "If set, its literal value will be stored and returned. If not, its value defaults to `\"default\"`. This argument exists primarily for testing and has little practical use.",
"description_kind": "plain",
"optional": true,
"computed": true
},
"id": {
"type": "string",
"description": "This attribute is only present for some legacy compatibility issues and should not be used. It will be removed in a future version.",
"description_kind": "plain",
"deprecated": true,
"computed": true
},
"inputs": {
"type": [
"map",
"string"
],
"description": "A map of arbitrary strings that is copied into the `outputs` attribute, and accessible directly for interpolation.",
"description_kind": "plain",
"optional": true
},
"outputs": {
"type": [
"map",
"string"
],
"description": "After the data source is \"read\", a copy of the `inputs` map.",
"description_kind": "plain",
"computed": true
},
"random": {
"type": "string",
"description": "A random value. This is primarily for testing and has little practical use; prefer the [hashicorp/random provider](https://registry.terraform.io/providers/hashicorp/random) for more practical random number use-cases.",
"description_kind": "plain",
"computed": true
}
},
"description": "The `null_data_source` data source implements the standard data source lifecycle but does not\ninteract with any external APIs.\n\nHistorically, the `null_data_source` was typically used to construct intermediate values to re-use elsewhere in configuration. The\nsame can now be achieved using [locals](https://www.terraform.io/docs/language/values/locals.html).\n",
"description_kind": "plain",
"deprecated": true
}
}
}
}
},
"provider_format_version": "1.0"
}

View File

@ -5,6 +5,7 @@ package planfile
import (
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
@ -100,10 +101,17 @@ func TestRoundtrip(t *testing.T) {
t.Fatalf("failed to create plan file: %s", err)
}
pr, err := Open(planFn)
wpf, err := OpenWrapped(planFn)
if err != nil {
t.Fatalf("failed to open plan file for reading: %s", err)
}
pr, ok := wpf.Local()
if !ok {
t.Fatalf("failed to open plan file as a local plan file")
}
if wpf.IsCloud() {
t.Fatalf("wrapped plan claims to be both kinds of plan at once")
}
t.Run("ReadPlan", func(t *testing.T) {
planOut, err := pr.ReadPlan()
@ -167,3 +175,33 @@ func TestRoundtrip(t *testing.T) {
}
})
}
func TestWrappedError(t *testing.T) {
// Open something that isn't a cloud or local planfile: should error
wrongFile := "not a valid zip file"
_, err := OpenWrapped(filepath.Join("testdata", "test-config", "root.tf"))
if !strings.Contains(err.Error(), wrongFile) {
t.Fatalf("expected %q, got %q", wrongFile, err)
}
// Open something that doesn't exist: should error
missingFile := "no such file or directory"
_, err = OpenWrapped(filepath.Join("testdata", "absent.tfplan"))
if !strings.Contains(err.Error(), missingFile) {
t.Fatalf("expected %q, got %q", missingFile, err)
}
}
func TestWrappedCloud(t *testing.T) {
// Loading valid cloud plan results in a wrapped cloud plan
wpf, err := OpenWrapped(filepath.Join("testdata", "cloudplan.json"))
if err != nil {
t.Fatalf("failed to open valid cloud plan: %s", err)
}
if !wpf.IsCloud() {
t.Fatalf("failed to open cloud file as a cloud plan")
}
if wpf.IsLocal() {
t.Fatalf("wrapped plan claims to be both kinds of plan at once")
}
}

View File

@ -21,6 +21,25 @@ const tfstateFilename = "tfstate"
const tfstatePreviousFilename = "tfstate-prev"
const dependencyLocksFilename = ".terraform.lock.hcl" // matches the conventional name in an input configuration
// ErrUnusableLocalPlan is an error wrapper to indicate that we *think* the
// input represents plan file data, but can't use it for some reason (as
// explained in the error text). Callers can check against this type with
// errors.As() if they need to distinguish between corrupt plan files and more
// fundamental problems like an empty file.
type ErrUnusableLocalPlan struct {
inner error
}
func errUnusable(err error) *ErrUnusableLocalPlan {
return &ErrUnusableLocalPlan{inner: err}
}
func (e *ErrUnusableLocalPlan) Error() string {
return e.inner.Error()
}
func (e *ErrUnusableLocalPlan) Unwrap() error {
return e.inner
}
// Reader is the main type used to read plan files. Create a Reader by calling
// Open.
//
@ -31,8 +50,10 @@ type Reader struct {
zip *zip.ReadCloser
}
// Open creates a Reader for the file at the given filename, or returns an
// error if the file doesn't seem to be a planfile.
// Open creates a Reader for the file at the given filename, or returns an error
// if the file doesn't seem to be a planfile. NOTE: Most commands that accept a
// plan file should use OpenWrapped instead, so they can support both local and
// cloud plan files.
func Open(filename string) (*Reader, error) {
r, err := zip.OpenReader(filename)
if err != nil {
@ -40,7 +61,7 @@ func Open(filename string) (*Reader, error) {
// like our old plan format from versions prior to 0.12.
if b, sErr := ioutil.ReadFile(filename); sErr == nil {
if bytes.HasPrefix(b, []byte("tfplan")) {
return nil, fmt.Errorf("the given plan file was created by an earlier version of Terraform; plan files cannot be shared between different Terraform versions")
return nil, errUnusable(fmt.Errorf("the given plan file was created by an earlier version of Terraform; plan files cannot be shared between different Terraform versions"))
}
}
return nil, err
@ -84,12 +105,12 @@ func (r *Reader) ReadPlan() (*plans.Plan, error) {
if planFile == nil {
// This should never happen because we checked for this file during
// Open, but we'll check anyway to be safe.
return nil, fmt.Errorf("the plan file is invalid")
return nil, errUnusable(fmt.Errorf("the plan file is invalid"))
}
pr, err := planFile.Open()
if err != nil {
return nil, fmt.Errorf("failed to retrieve plan from plan file: %s", err)
return nil, errUnusable(fmt.Errorf("failed to retrieve plan from plan file: %s", err))
}
defer pr.Close()
@ -106,16 +127,16 @@ func (r *Reader) ReadPlan() (*plans.Plan, error) {
// access the prior state (this and the ReadStateFile method).
ret, err := readTfplan(pr)
if err != nil {
return nil, err
return nil, errUnusable(err)
}
prevRunStateFile, err := r.ReadPrevStateFile()
if err != nil {
return nil, fmt.Errorf("failed to read previous run state from plan file: %s", err)
return nil, errUnusable(fmt.Errorf("failed to read previous run state from plan file: %s", err))
}
priorStateFile, err := r.ReadStateFile()
if err != nil {
return nil, fmt.Errorf("failed to read prior state from plan file: %s", err)
return nil, errUnusable(fmt.Errorf("failed to read prior state from plan file: %s", err))
}
ret.PrevRunState = prevRunStateFile.State
@ -134,12 +155,12 @@ func (r *Reader) ReadStateFile() (*statefile.File, error) {
if file.Name == tfstateFilename {
r, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to extract state from plan file: %s", err)
return nil, errUnusable(fmt.Errorf("failed to extract state from plan file: %s", err))
}
return statefile.Read(r)
}
}
return nil, statefile.ErrNoState
return nil, errUnusable(statefile.ErrNoState)
}
// ReadPrevStateFile reads the previous state file embedded in the plan file, which
@ -152,12 +173,12 @@ func (r *Reader) ReadPrevStateFile() (*statefile.File, error) {
if file.Name == tfstatePreviousFilename {
r, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to extract previous state from plan file: %s", err)
return nil, errUnusable(fmt.Errorf("failed to extract previous state from plan file: %s", err))
}
return statefile.Read(r)
}
}
return nil, statefile.ErrNoState
return nil, errUnusable(statefile.ErrNoState)
}
// ReadConfigSnapshot reads the configuration snapshot embedded in the plan

View File

@ -0,0 +1,5 @@
{
"remote_plan_format": 1,
"run_id": "run-GXfuHMkbyHccAGUg",
"hostname": "app.terraform.io"
}

View File

@ -0,0 +1,94 @@
package planfile
import (
"errors"
"fmt"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
)
// WrappedPlanFile is a sum type that represents a saved plan, loaded from a
// file path passed on the command line. If the specified file was a thick local
// plan file, the Local field will be populated; if it was a bookmark for a
// remote cloud plan, the Cloud field will be populated. In both cases, the
// other field is expected to be nil. Finally, the outer struct is also expected
// to be used as a pointer, so that a nil value can represent the absence of any
// plan file.
type WrappedPlanFile struct {
local *Reader
cloud *cloudplan.SavedPlanBookmark
}
func (w *WrappedPlanFile) IsLocal() bool {
return w != nil && w.local != nil
}
func (w *WrappedPlanFile) IsCloud() bool {
return w != nil && w.cloud != nil
}
// Local checks whether the wrapped value is a local plan file, and returns it if available.
func (w *WrappedPlanFile) Local() (*Reader, bool) {
if w != nil && w.local != nil {
return w.local, true
} else {
return nil, false
}
}
// Cloud checks whether the wrapped value is a cloud plan file, and returns it if available.
func (w *WrappedPlanFile) Cloud() (*cloudplan.SavedPlanBookmark, bool) {
if w != nil && w.cloud != nil {
return w.cloud, true
} else {
return nil, false
}
}
// NewWrappedLocal constructs a WrappedPlanFile from an already loaded local
// plan file reader. Most cases should use OpenWrapped to load from disk
// instead. If the provided reader is nil, the returned pointer is nil.
func NewWrappedLocal(l *Reader) *WrappedPlanFile {
if l != nil {
return &WrappedPlanFile{local: l}
} else {
return nil
}
}
// NewWrappedCloud constructs a WrappedPlanFile from an already loaded cloud
// plan file. Most cases should use OpenWrapped to load from disk
// instead. If the provided plan file is nil, the returned pointer is nil.
func NewWrappedCloud(c *cloudplan.SavedPlanBookmark) *WrappedPlanFile {
if c != nil {
return &WrappedPlanFile{cloud: c}
} else {
return nil
}
}
// OpenWrapped loads a local or cloud plan file from a specified file path, or
// returns an error if the file doesn't seem to be a plan file of either kind.
// Most consumers should use this and switch behaviors based on the kind of plan
// they expected, rather than directly using Open.
func OpenWrapped(filename string) (*WrappedPlanFile, error) {
// First, try to load it as a local planfile.
local, localErr := Open(filename)
if localErr == nil {
return &WrappedPlanFile{local: local}, nil
}
// Then, try to load it as a cloud plan.
cloud, cloudErr := cloudplan.LoadSavedPlanBookmark(filename)
if cloudErr == nil {
return &WrappedPlanFile{cloud: &cloud}, nil
}
// If neither worked, prioritize definitive "confirmed the format but can't
// use it" errors, then fall back to dumping everything we know.
var ulp *ErrUnusableLocalPlan
if errors.As(localErr, &ulp) {
return nil, ulp
}
combinedErr := fmt.Errorf("couldn't load the provided path as either a local plan file (%s) or a saved cloud plan (%s)", localErr, cloudErr)
return nil, combinedErr
}

20
internal/plans/quality.go Normal file
View File

@ -0,0 +1,20 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package plans
// Quality represents facts about the nature of a plan that might be relevant
// when rendering it, like whether it errored or contains no changes. A plan can
// have multiple qualities.
type Quality int
//go:generate go run golang.org/x/tools/cmd/stringer -type Quality
const (
// Errored plans did not successfully complete, and cannot be applied.
Errored Quality = iota
// NoChanges plans won't result in any actions on infrastructure, or any
// semantically meaningful updates to state. They can sometimes still affect
// the format of state if applied.
NoChanges
)

View File

@ -0,0 +1,24 @@
// Code generated by "stringer -type Quality"; DO NOT EDIT.
package plans
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[Errored-0]
_ = x[NoChanges-1]
}
const _Quality_name = "ErroredNoChanges"
var _Quality_index = [...]uint8{0, 7, 16}
func (i Quality) String() string {
if i < 0 || i >= Quality(len(_Quality_index)-1) {
return "Quality(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Quality_name[_Quality_index[i]:_Quality_index[i+1]]
}

View File

@ -20,6 +20,25 @@ import (
// ErrNoState is returned by ReadState when the state file is empty.
var ErrNoState = errors.New("no state")
// ErrUnusableState is an error wrapper to indicate that we *think* the input
// represents state data, but can't use it for some reason (as explained in the
// error text). Callers can check against this type with errors.As() if they
// need to distinguish between corrupt state and more fundamental problems like
// an empty file.
type ErrUnusableState struct {
inner error
}
func errUnusable(err error) *ErrUnusableState {
return &ErrUnusableState{inner: err}
}
func (e *ErrUnusableState) Error() string {
return e.inner.Error()
}
func (e *ErrUnusableState) Unwrap() error {
return e.inner
}
// Read reads a state from the given reader.
//
// Legacy state format versions 1 through 3 are supported, but the result will
@ -55,9 +74,9 @@ func Read(r io.Reader) (*File, error) {
return nil, ErrNoState
}
state, diags := readState(src)
if diags.HasErrors() {
return nil, diags.Err()
state, err := readState(src)
if err != nil {
return nil, err
}
if state == nil {
@ -68,7 +87,7 @@ func Read(r io.Reader) (*File, error) {
return state, diags.Err()
}
func readState(src []byte) (*File, tfdiags.Diagnostics) {
func readState(src []byte) (*File, error) {
var diags tfdiags.Diagnostics
if looksLikeVersion0(src) {
@ -77,15 +96,20 @@ func readState(src []byte) (*File, tfdiags.Diagnostics) {
unsupportedFormat,
"The state is stored in a legacy binary format that is not supported since Terraform v0.7. To continue, first upgrade the state using Terraform 0.6.16 or earlier.",
))
return nil, diags
return nil, errUnusable(diags.Err())
}
version, versionDiags := sniffJSONStateVersion(src)
diags = diags.Append(versionDiags)
if versionDiags.HasErrors() {
return nil, diags
// This is the last point where there's a really good chance it's not a
// state file at all. Past here, we'll assume errors mean it's state but
// we can't use it.
return nil, diags.Err()
}
var result *File
var err error
switch version {
case 0:
diags = diags.Append(tfdiags.Sourceless(
@ -93,15 +117,14 @@ func readState(src []byte) (*File, tfdiags.Diagnostics) {
unsupportedFormat,
"The state file uses JSON syntax but has a version number of zero. There was never a JSON-based state format zero, so this state file is invalid and cannot be processed.",
))
return nil, diags
case 1:
return readStateV1(src)
result, diags = readStateV1(src)
case 2:
return readStateV2(src)
result, diags = readStateV2(src)
case 3:
return readStateV3(src)
result, diags = readStateV3(src)
case 4:
return readStateV4(src)
result, diags = readStateV4(src)
default:
thisVersion := tfversion.SemVer.String()
creatingVersion := sniffJSONStateTerraformVersion(src)
@ -119,8 +142,13 @@ func readState(src []byte) (*File, tfdiags.Diagnostics) {
fmt.Sprintf("The state file uses format version %d, which is not supported by Terraform %s. This state file may have been created by a newer version of Terraform.", version, thisVersion),
))
}
return nil, diags
}
if diags.HasErrors() {
err = errUnusable(diags.Err())
}
return result, err
}
func sniffJSONStateVersion(src []byte) (uint64, tfdiags.Diagnostics) {