mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
Merge pull request #33492 from hashicorp/cli-team/saved-cloud-plans
Implement saved cloud plans
This commit is contained in:
commit
dceb8453af
2
go.mod
2
go.mod
@ -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
4
go.sum
@ -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=
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
5
internal/backend/local/testdata/plan-bookmark/bookmark.json
vendored
Normal file
5
internal/backend/local/testdata/plan-bookmark/bookmark.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_plan_format": 1,
|
||||
"run_id": "run-GXfuHMkbyHccAGUg",
|
||||
"hostname": "app.terraform.io"
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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...
|
||||
`
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
114
internal/cloud/backend_show.go
Normal file
114
internal/cloud/backend_show.go
Normal 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]
|
||||
`
|
217
internal/cloud/backend_show_test.go
Normal file
217
internal/cloud/backend_show_test.go
Normal 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
|
||||
}
|
@ -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
|
||||
|
39
internal/cloud/cloudplan/remote_plan_json.go
Normal file
39
internal/cloud/cloudplan/remote_plan_json.go
Normal 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
|
||||
}
|
75
internal/cloud/cloudplan/saved_plan.go
Normal file
75
internal/cloud/cloudplan/saved_plan.go
Normal 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
|
||||
}
|
99
internal/cloud/cloudplan/saved_plan_test.go
Normal file
99
internal/cloud/cloudplan/saved_plan_test.go
Normal 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)
|
||||
}
|
||||
}
|
5
internal/cloud/cloudplan/testdata/plan-bookmark/bookmark.json
vendored
Normal file
5
internal/cloud/cloudplan/testdata/plan-bookmark/bookmark.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_plan_format": 1,
|
||||
"run_id": "run-GXfuHMkbyHccAGUg",
|
||||
"hostname": "app.terraform.io"
|
||||
}
|
5
internal/cloud/cloudplan/testdata/plan-bookmark/empty_hostname.json
vendored
Normal file
5
internal/cloud/cloudplan/testdata/plan-bookmark/empty_hostname.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_plan_format": 1,
|
||||
"run_id": "run-GXfuHMkbyHccAGUg",
|
||||
"hostname": ""
|
||||
}
|
5
internal/cloud/cloudplan/testdata/plan-bookmark/empty_run_id.json
vendored
Normal file
5
internal/cloud/cloudplan/testdata/plan-bookmark/empty_run_id.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_plan_format": 1,
|
||||
"run_id": "",
|
||||
"hostname": "app.terraform.io"
|
||||
}
|
5
internal/cloud/cloudplan/testdata/plan-bookmark/invalid_version.json
vendored
Normal file
5
internal/cloud/cloudplan/testdata/plan-bookmark/invalid_version.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_plan_format": 11,
|
||||
"run_id": "run-GXfuHMkbyHccAGUg",
|
||||
"hostname": "app.terraform.io"
|
||||
}
|
5
internal/cloud/testdata/plan-bookmark/bookmark.json
vendored
Normal file
5
internal/cloud/testdata/plan-bookmark/bookmark.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_plan_format": 1,
|
||||
"run_id": "run-GXfuHMkbyHccAGUg",
|
||||
"hostname": "app.terraform.io"
|
||||
}
|
1
internal/cloud/testdata/plan-json-basic-no-unredacted/main.tf
vendored
Normal file
1
internal/cloud/testdata/plan-json-basic-no-unredacted/main.tf
vendored
Normal file
@ -0,0 +1 @@
|
||||
resource "null_resource" "foo" {}
|
116
internal/cloud/testdata/plan-json-basic-no-unredacted/plan-redacted.json
vendored
Normal file
116
internal/cloud/testdata/plan-json-basic-no-unredacted/plan-redacted.json
vendored
Normal 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"
|
||||
}
|
3
internal/cloud/testdata/plan-json-basic-no-unredacted/plan.log
vendored
Normal file
3
internal/cloud/testdata/plan-json-basic-no-unredacted/plan.log
vendored
Normal 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"}
|
1
internal/cloud/testdata/plan-json-basic/plan-unredacted.json
vendored
Normal file
1
internal/cloud/testdata/plan-json-basic/plan-unredacted.json
vendored
Normal 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}]}}}
|
1
internal/cloud/testdata/plan-json-full/plan-unredacted.json
vendored
Normal file
1
internal/cloud/testdata/plan-json-full/plan-unredacted.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/cloud/testdata/plan-json-no-changes/main.tf
vendored
Normal file
1
internal/cloud/testdata/plan-json-no-changes/main.tf
vendored
Normal file
@ -0,0 +1 @@
|
||||
resource "null_resource" "foo" {}
|
118
internal/cloud/testdata/plan-json-no-changes/plan-redacted.json
vendored
Normal file
118
internal/cloud/testdata/plan-json-no-changes/plan-redacted.json
vendored
Normal 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"
|
||||
}
|
1
internal/cloud/testdata/plan-json-no-changes/plan-unredacted.json
vendored
Normal file
1
internal/cloud/testdata/plan-json-no-changes/plan-unredacted.json
vendored
Normal 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}]}}}
|
2
internal/cloud/testdata/plan-json-no-changes/plan.log
vendored
Normal file
2
internal/cloud/testdata/plan-json-no-changes/plan.log
vendored
Normal 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"}
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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")
|
||||
|
@ -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."),
|
||||
|
@ -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)
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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...)
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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...)
|
||||
|
116
internal/command/views/testdata/plans/redacted-plan.json
vendored
Normal file
116
internal/command/views/testdata/plans/redacted-plan.json
vendored
Normal 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"
|
||||
}
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
5
internal/plans/planfile/testdata/cloudplan.json
vendored
Normal file
5
internal/plans/planfile/testdata/cloudplan.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"remote_plan_format": 1,
|
||||
"run_id": "run-GXfuHMkbyHccAGUg",
|
||||
"hostname": "app.terraform.io"
|
||||
}
|
94
internal/plans/planfile/wrapped.go
Normal file
94
internal/plans/planfile/wrapped.go
Normal 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
20
internal/plans/quality.go
Normal 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
|
||||
)
|
24
internal/plans/quality_string.go
Normal file
24
internal/plans/quality_string.go
Normal 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]]
|
||||
}
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user