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-multierror v1.1.1
|
||||||
github.com/hashicorp/go-plugin v1.4.3
|
github.com/hashicorp/go-plugin v1.4.3
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.4
|
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-uuid v1.0.3
|
||||||
github.com/hashicorp/go-version v1.6.0
|
github.com/hashicorp/go-version v1.6.0
|
||||||
github.com/hashicorp/hcl v1.0.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 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
|
||||||
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
|
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-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||||
github.com/hashicorp/go-tfe v1.28.0 h1:YQNfHz5UPMiOD2idad4GCjzG3R2ExPww741PBPqMOIU=
|
github.com/hashicorp/go-tfe v1.29.0 h1:hVvgoKtLAWTkXl9p/8WnItCaW65VJwqpjLZkXe8R2AM=
|
||||||
github.com/hashicorp/go-tfe v1.28.0/go.mod h1:z0182DGE/63AKUaWblUVBIrt+xdSmsuuXg5AoxGqDF4=
|
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.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.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=
|
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 is a plan that was passed as an argument. This is valid for
|
||||||
// plan and apply arguments but may not work for all backends.
|
// 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
|
// The options below are more self-explanatory and affect the runtime
|
||||||
// behavior of the operation.
|
// behavior of the operation.
|
||||||
|
@ -77,7 +77,12 @@ func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.
|
|||||||
|
|
||||||
var ctxDiags tfdiags.Diagnostics
|
var ctxDiags tfdiags.Diagnostics
|
||||||
var configSnap *configload.Snapshot
|
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
|
var stateMeta *statemgr.SnapshotMeta
|
||||||
// If the statemgr implements our optional PersistentMeta interface then we'll
|
// If the statemgr implements our optional PersistentMeta interface then we'll
|
||||||
// additionally verify that the state snapshot in the plan file has
|
// 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
|
stateMeta = &m
|
||||||
}
|
}
|
||||||
log.Printf("[TRACE] backend/local: populating backend.LocalRun from plan file")
|
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() {
|
if ctxDiags.HasErrors() {
|
||||||
diags = diags.Append(ctxDiags)
|
diags = diags.Append(ctxDiags)
|
||||||
return nil, nil, nil, diags
|
return nil, nil, nil, diags
|
||||||
|
@ -86,6 +86,41 @@ func TestLocalRun_error(t *testing.T) {
|
|||||||
assertBackendStateUnlocked(t, b)
|
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) {
|
func TestLocalRun_stalePlan(t *testing.T) {
|
||||||
configDir := "./testdata/apply"
|
configDir := "./testdata/apply"
|
||||||
b := TestLocal(t)
|
b := TestLocal(t)
|
||||||
@ -146,7 +181,7 @@ func TestLocalRun_stalePlan(t *testing.T) {
|
|||||||
if err := planfile.Create(planPath, planfileArgs); err != nil {
|
if err := planfile.Create(planPath, planfileArgs); err != nil {
|
||||||
t.Fatalf("unexpected error writing planfile: %s", err)
|
t.Fatalf("unexpected error writing planfile: %s", err)
|
||||||
}
|
}
|
||||||
planFile, err := planfile.Open(planPath)
|
planFile, err := planfile.OpenWrapped(planPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error reading planfile: %s", err)
|
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")
|
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
|
||||||
defer configCleanup()
|
defer configCleanup()
|
||||||
|
|
||||||
op.PlanFile = &planfile.Reader{}
|
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
|
||||||
op.Workspace = backend.DefaultStateName
|
op.Workspace = backend.DefaultStateName
|
||||||
|
|
||||||
run, err := b.Operation(context.Background(), op)
|
run, err := b.Operation(context.Background(), op)
|
||||||
|
@ -239,7 +239,7 @@ func TestRemote_planWithPlan(t *testing.T) {
|
|||||||
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
|
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
|
||||||
defer configCleanup()
|
defer configCleanup()
|
||||||
|
|
||||||
op.PlanFile = &planfile.Reader{}
|
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
|
||||||
op.Workspace = backend.DefaultStateName
|
op.Workspace = backend.DefaultStateName
|
||||||
|
|
||||||
run, err := b.Operation(context.Background(), op)
|
run, err := b.Operation(context.Background(), op)
|
||||||
|
@ -7,8 +7,10 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
tfe "github.com/hashicorp/go-tfe"
|
tfe "github.com/hashicorp/go-tfe"
|
||||||
"github.com/hashicorp/terraform/internal/backend"
|
"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(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Error,
|
||||||
"Applying a saved plan is currently not supported",
|
"Applying a saved local plan is not supported",
|
||||||
`Terraform Cloud currently requires configuration to be present and `+
|
`Terraform Cloud can apply a saved cloud plan, or create a new plan when `+
|
||||||
`does not accept an existing saved plan as an argument at this time.`,
|
`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()
|
return nil, diags.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the plan phase.
|
var r *tfe.Run
|
||||||
r, err := b.plan(stopCtx, cancelCtx, op, w)
|
var err error
|
||||||
if err != nil {
|
|
||||||
return r, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// This check is also performed in the plan method to determine if
|
if cp, ok := op.PlanFile.Cloud(); ok {
|
||||||
// the policies should be checked, but we need to check the values
|
log.Printf("[TRACE] Loading saved cloud plan for apply")
|
||||||
// here again to determine if we are done and should return.
|
// Check hostname first, for a more actionable error than a generic 404 later
|
||||||
if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
|
if cp.Hostname != b.hostname {
|
||||||
return r, nil
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
}
|
tfdiags.Error,
|
||||||
|
"Saved plan is for a different hostname",
|
||||||
// Retrieve the run to get its current status.
|
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),
|
||||||
r, err = b.client.Runs.Read(stopCtx, r.ID)
|
))
|
||||||
if err != nil {
|
return r, diags.Err()
|
||||||
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."
|
|
||||||
}
|
}
|
||||||
|
// 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 {
|
||||||
if err != nil && err != errRunApproved {
|
|
||||||
return r, err
|
return r, err
|
||||||
}
|
}
|
||||||
} else if mustConfirm && !b.input {
|
|
||||||
return r, errApplyNeedsUIConfirmation
|
if r.Workspace.ID != w.ID {
|
||||||
} else {
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
// If we don't need to ask for confirmation, insert a blank
|
tfdiags.Error,
|
||||||
// line to separate the ouputs.
|
"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 {
|
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 !op.AutoApprove && err != errRunApproved {
|
||||||
if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil {
|
if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil {
|
||||||
return r, generalError("Failed to approve the apply command", err)
|
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
|
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 = `
|
const applyDefaultHeader = `
|
||||||
[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C
|
[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
|
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...
|
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/addrs"
|
||||||
"github.com/hashicorp/terraform/internal/backend"
|
"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/arguments"
|
||||||
"github.com/hashicorp/terraform/internal/command/clistate"
|
"github.com/hashicorp/terraform/internal/command/clistate"
|
||||||
"github.com/hashicorp/terraform/internal/command/jsonformat"
|
"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)
|
b, bCleanup := testBackendWithName(t)
|
||||||
defer bCleanup()
|
defer bCleanup()
|
||||||
|
|
||||||
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
|
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
|
||||||
defer configCleanup()
|
defer configCleanup()
|
||||||
|
|
||||||
op.PlanFile = &planfile.Reader{}
|
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
|
||||||
op.Workspace = testBackendSingleWorkspaceName
|
op.Workspace = testBackendSingleWorkspaceName
|
||||||
|
|
||||||
run, err := b.Operation(context.Background(), op)
|
run, err := b.Operation(context.Background(), op)
|
||||||
@ -432,11 +434,80 @@ func TestCloud_applyWithPlan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
errOutput := output.Stderr()
|
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)
|
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) {
|
func TestCloud_applyWithoutRefresh(t *testing.T) {
|
||||||
b, bCleanup := testBackendWithName(t)
|
b, bCleanup := testBackendWithName(t)
|
||||||
defer bCleanup()
|
defer bCleanup()
|
||||||
|
@ -5,6 +5,7 @@ package cloud
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@ -550,11 +551,12 @@ func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *te
|
|||||||
return <-result
|
return <-result
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method will fetch the redacted plan output and marshal the response into
|
// This method will fetch the redacted plan output as a byte slice, mirroring
|
||||||
// a struct the jsonformat.Renderer expects.
|
// 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
|
// Note: Apologies for the lengthy definition, this is a result of not being
|
||||||
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) {
|
// 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 := retryablehttp.NewClient()
|
||||||
client.RetryMax = 10
|
client.RetryMax = 10
|
||||||
client.RetryWaitMin = 100 * time.Millisecond
|
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("Authorization", "Bearer "+token)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
p := &jsonformat.Plan{}
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -586,10 +587,17 @@ var readRedactedPlan func(context.Context, url.URL, string, string) (*jsonformat
|
|||||||
return nil, err
|
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 nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ import (
|
|||||||
version "github.com/hashicorp/go-version"
|
version "github.com/hashicorp/go-version"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/internal/backend"
|
"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/command/jsonformat"
|
||||||
"github.com/hashicorp/terraform/internal/configs"
|
"github.com/hashicorp/terraform/internal/configs"
|
||||||
"github.com/hashicorp/terraform/internal/genconfig"
|
"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 {
|
if !op.HasConfig() && op.PlanMode != plans.DestroyMode {
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Error,
|
||||||
@ -95,7 +87,25 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation
|
|||||||
return nil, diags.Err()
|
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) {
|
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"))
|
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{
|
configOptions := tfe.ConfigurationVersionCreateOptions{
|
||||||
AutoQueueRuns: tfe.Bool(false),
|
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)
|
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),
|
Refresh: tfe.Bool(op.PlanRefresh),
|
||||||
Workspace: w,
|
Workspace: w,
|
||||||
AutoApply: tfe.Bool(op.AutoApprove),
|
AutoApply: tfe.Bool(op.AutoApprove),
|
||||||
|
SavePlan: tfe.Bool(op.PlanOutPath != ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch op.PlanMode {
|
switch op.PlanMode {
|
||||||
@ -495,10 +509,14 @@ func (b *Cloud) renderPlanLogs(ctx context.Context, op *backend.Operation, run *
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if renderSRO || shouldGenerateConfig {
|
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 {
|
if err != nil {
|
||||||
return generalError("Failed to read JSON plan", err)
|
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
|
// 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/addrs"
|
||||||
"github.com/hashicorp/terraform/internal/backend"
|
"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/arguments"
|
||||||
"github.com/hashicorp/terraform/internal/command/clistate"
|
"github.com/hashicorp/terraform/internal/command/clistate"
|
||||||
"github.com/hashicorp/terraform/internal/command/jsonformat"
|
"github.com/hashicorp/terraform/internal/command/jsonformat"
|
||||||
@ -337,7 +338,7 @@ func TestCloud_planWithPlan(t *testing.T) {
|
|||||||
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
|
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
|
||||||
defer configCleanup()
|
defer configCleanup()
|
||||||
|
|
||||||
op.PlanFile = &planfile.Reader{}
|
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
|
||||||
op.Workspace = testBackendSingleWorkspaceName
|
op.Workspace = testBackendSingleWorkspaceName
|
||||||
|
|
||||||
run, err := b.Operation(context.Background(), op)
|
run, err := b.Operation(context.Background(), op)
|
||||||
@ -366,8 +367,11 @@ func TestCloud_planWithPath(t *testing.T) {
|
|||||||
|
|
||||||
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
|
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
|
||||||
defer configCleanup()
|
defer configCleanup()
|
||||||
|
defer done(t)
|
||||||
|
|
||||||
op.PlanOutPath = "./testdata/plan"
|
tmpDir := t.TempDir()
|
||||||
|
pfPath := tmpDir + "/plan.tfplan"
|
||||||
|
op.PlanOutPath = pfPath
|
||||||
op.Workspace = testBackendSingleWorkspaceName
|
op.Workspace = testBackendSingleWorkspaceName
|
||||||
|
|
||||||
run, err := b.Operation(context.Background(), op)
|
run, err := b.Operation(context.Background(), op)
|
||||||
@ -376,17 +380,27 @@ func TestCloud_planWithPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<-run.Done()
|
<-run.Done()
|
||||||
output := done(t)
|
if run.Result != backend.OperationSuccess {
|
||||||
if run.Result == backend.OperationSuccess {
|
t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String())
|
||||||
t.Fatal("expected plan operation to fail")
|
|
||||||
}
|
}
|
||||||
if !run.PlanEmpty {
|
if run.PlanEmpty {
|
||||||
t.Fatalf("expected plan to be empty")
|
t.Fatal("expected a non-empty plan")
|
||||||
}
|
}
|
||||||
|
|
||||||
errOutput := output.Stderr()
|
output := b.CLI.(*cli.MockUi).OutputWriter.String()
|
||||||
if !strings.Contains(errOutput, "generated plan is currently not supported") {
|
if !strings.Contains(output, "Running plan in Terraform Cloud") {
|
||||||
t.Fatalf("expected a generated plan error, got: %v", errOutput)
|
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()
|
defer bCleanup()
|
||||||
|
|
||||||
// Make sure the workspace doesn't exist yet -- otherwise, we can't test what
|
// 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/zclconf/go-cty/cty"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/internal/backend"
|
"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"
|
||||||
"github.com/hashicorp/terraform/internal/configs/configschema"
|
"github.com/hashicorp/terraform/internal/configs/configschema"
|
||||||
"github.com/hashicorp/terraform/internal/httpclient"
|
"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()) {
|
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{
|
obj := cty.ObjectVal(map[string]cty.Value{
|
||||||
"hostname": cty.NullVal(cty.String),
|
"hostname": cty.NullVal(cty.String),
|
||||||
"organization": cty.StringVal("hashicorp"),
|
"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()) {
|
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)),
|
"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()) {
|
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)),
|
"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 {
|
func testCloudState(t *testing.T) *State {
|
||||||
@ -213,7 +220,7 @@ func testBackendWithOutputs(t *testing.T) (*Cloud, func()) {
|
|||||||
return b, cleanup
|
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
|
var s *httptest.Server
|
||||||
if handlers != nil {
|
if handlers != nil {
|
||||||
s = testServerWithHandlers(handlers)
|
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/"
|
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)
|
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
|
// 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.Runs = mc.Runs
|
||||||
b.client.RunEvents = mc.RunEvents
|
b.client.RunEvents = mc.RunEvents
|
||||||
b.client.StateVersions = mc.StateVersions
|
b.client.StateVersions = mc.StateVersions
|
||||||
|
b.client.StateVersionOutputs = mc.StateVersionOutputs
|
||||||
b.client.Variables = mc.Variables
|
b.client.Variables = mc.Variables
|
||||||
b.client.Workspaces = mc.Workspaces
|
b.client.Workspaces = mc.Workspaces
|
||||||
|
|
||||||
@ -331,7 +339,7 @@ func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) {
|
|||||||
}
|
}
|
||||||
baseURL.Path = "/api/v2/"
|
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)
|
return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -22,7 +21,6 @@ import (
|
|||||||
tfe "github.com/hashicorp/go-tfe"
|
tfe "github.com/hashicorp/go-tfe"
|
||||||
"github.com/mitchellh/copystructure"
|
"github.com/mitchellh/copystructure"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/internal/command/jsonformat"
|
|
||||||
tfversion "github.com/hashicorp/terraform/version"
|
tfversion "github.com/hashicorp/terraform/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -468,13 +466,13 @@ func (m *MockOrganizations) ReadRunQueue(ctx context.Context, name string, optio
|
|||||||
|
|
||||||
type MockRedactedPlans struct {
|
type MockRedactedPlans struct {
|
||||||
client *MockClient
|
client *MockClient
|
||||||
redactedPlans map[string]*jsonformat.Plan
|
redactedPlans map[string][]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMockRedactedPlans(client *MockClient) *MockRedactedPlans {
|
func newMockRedactedPlans(client *MockClient) *MockRedactedPlans {
|
||||||
return &MockRedactedPlans{
|
return &MockRedactedPlans{
|
||||||
client: client,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := ioutil.ReadAll(redactedPlanFile)
|
raw, err := io.ReadAll(redactedPlanFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
redactedPlan := &jsonformat.Plan{}
|
m.redactedPlans[planID] = raw
|
||||||
err = json.Unmarshal(raw, redactedPlan)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.redactedPlans[planID] = redactedPlan
|
|
||||||
|
|
||||||
return nil
|
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 {
|
if p, ok := m.redactedPlans[planID]; ok {
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
@ -521,7 +513,7 @@ func (m *MockRedactedPlans) Read(ctx context.Context, hostname, token, planID st
|
|||||||
type MockPlans struct {
|
type MockPlans struct {
|
||||||
client *MockClient
|
client *MockClient
|
||||||
logs map[string]string
|
logs map[string]string
|
||||||
planOutputs map[string]string
|
planOutputs map[string][]byte
|
||||||
plans map[string]*tfe.Plan
|
plans map[string]*tfe.Plan
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -529,7 +521,7 @@ func newMockPlans(client *MockClient) *MockPlans {
|
|||||||
return &MockPlans{
|
return &MockPlans{
|
||||||
client: client,
|
client: client,
|
||||||
logs: make(map[string]string),
|
logs: make(map[string]string),
|
||||||
planOutputs: make(map[string]string),
|
planOutputs: make(map[string][]byte),
|
||||||
plans: make(map[string]*tfe.Plan),
|
plans: make(map[string]*tfe.Plan),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -556,6 +548,17 @@ func (m *MockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) {
|
|||||||
w.WorkingDirectory,
|
w.WorkingDirectory,
|
||||||
"plan.log",
|
"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
|
m.plans[p.ID] = p
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
@ -616,7 +619,7 @@ func (m *MockPlans) ReadJSONOutput(ctx context.Context, planID string) ([]byte,
|
|||||||
return nil, tfe.ErrResourceNotFound
|
return nil, tfe.ErrResourceNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return []byte(planOutput), nil
|
return planOutput, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockTaskStages struct {
|
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)
|
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()
|
m.Lock()
|
||||||
defer m.Unlock()
|
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])
|
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 ||
|
hasChanges := r.IsDestroy ||
|
||||||
bytes.Contains(logs, []byte("1 to add")) ||
|
bytes.Contains(logs, []byte("1 to add")) ||
|
||||||
bytes.Contains(logs, []byte("1 to change")) ||
|
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.IsCancelable = false
|
||||||
r.Actions.IsConfirmable = true
|
r.Actions.IsConfirmable = true
|
||||||
r.HasChanges = true
|
r.HasChanges = true
|
||||||
|
r.Plan.HasChanges = true
|
||||||
r.Permissions.CanApply = true
|
r.Permissions.CanApply = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1136,8 +1140,22 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
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 {
|
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,
|
CanQueueRun: true,
|
||||||
CanForceDelete: tfe.Bool(true),
|
CanForceDelete: tfe.Bool(true),
|
||||||
},
|
},
|
||||||
|
Organization: &tfe.Organization{
|
||||||
|
Name: organization,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if options.AutoApply != nil {
|
if options.AutoApply != nil {
|
||||||
w.AutoApply = *options.AutoApply
|
w.AutoApply = *options.AutoApply
|
||||||
|
@ -150,8 +150,8 @@ func (c *ApplyCommand) Run(rawArgs []string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ApplyCommand) LoadPlanFile(path string) (*planfile.Reader, tfdiags.Diagnostics) {
|
func (c *ApplyCommand) LoadPlanFile(path string) (*planfile.WrappedPlanFile, tfdiags.Diagnostics) {
|
||||||
var planFile *planfile.Reader
|
var planFile *planfile.WrappedPlanFile
|
||||||
var diags tfdiags.Diagnostics
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
// Try to load plan if path is specified
|
// 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
|
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
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
// FIXME: we need to apply the state arguments to the meta object here
|
// 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
|
// Load the backend
|
||||||
var be backend.Enhanced
|
var be backend.Enhanced
|
||||||
var beDiags tfdiags.Diagnostics
|
var beDiags tfdiags.Diagnostics
|
||||||
if planFile == nil {
|
if lp, ok := planFile.Local(); ok {
|
||||||
backendConfig, configDiags := c.loadBackendConfig(".")
|
plan, err := lp.ReadPlan()
|
||||||
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 err != nil {
|
if err != nil {
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
tfdiags.Error,
|
||||||
@ -236,7 +225,19 @@ func (c *ApplyCommand) PrepareBackend(planFile *planfile.Reader, args *arguments
|
|||||||
))
|
))
|
||||||
return nil, diags
|
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)
|
diags = diags.Append(beDiags)
|
||||||
@ -250,7 +251,7 @@ func (c *ApplyCommand) OperationRequest(
|
|||||||
be backend.Enhanced,
|
be backend.Enhanced,
|
||||||
view views.Apply,
|
view views.Apply,
|
||||||
viewType arguments.ViewType,
|
viewType arguments.ViewType,
|
||||||
planFile *planfile.Reader,
|
planFile *planfile.WrappedPlanFile,
|
||||||
args *arguments.Operation,
|
args *arguments.Operation,
|
||||||
autoApprove bool,
|
autoApprove bool,
|
||||||
) (*backend.Operation, tfdiags.Diagnostics) {
|
) (*backend.Operation, tfdiags.Diagnostics) {
|
||||||
|
@ -55,7 +55,7 @@ func (c *GraphCommand) Run(args []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to load plan if path is specified
|
// Try to load plan if path is specified
|
||||||
var planFile *planfile.Reader
|
var planFile *planfile.WrappedPlanFile
|
||||||
if planPath != "" {
|
if planPath != "" {
|
||||||
planFile, err = c.PlanFile(planPath)
|
planFile, err = c.PlanFile(planPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -19,14 +19,9 @@ import (
|
|||||||
"github.com/hashicorp/terraform/internal/plans"
|
"github.com/hashicorp/terraform/internal/plans"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PlanRendererOpt int
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
detectedDrift string = "drift"
|
detectedDrift string = "drift"
|
||||||
proposedChange string = "change"
|
proposedChange string = "change"
|
||||||
|
|
||||||
Errored PlanRendererOpt = iota
|
|
||||||
CanNotApply
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Plan struct {
|
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) {
|
func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Quality) {
|
||||||
checkOpts := func(target PlanRendererOpt) bool {
|
checkOpts := func(target plans.Quality) bool {
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
if opt == target {
|
if opt == target {
|
||||||
return true
|
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
|
// the plan is "applyable" and, if so, whether it had refresh changes
|
||||||
// that we already would've presented above.
|
// that we already would've presented above.
|
||||||
|
|
||||||
if checkOpts(Errored) {
|
if checkOpts(plans.Errored) {
|
||||||
if haveRefreshChanges {
|
if haveRefreshChanges {
|
||||||
renderer.Streams.Print(format.HorizontalRule(renderer.Colorize, renderer.Streams.Stdout.Columns()))
|
renderer.Streams.Print(format.HorizontalRule(renderer.Colorize, renderer.Streams.Stdout.Columns()))
|
||||||
renderer.Streams.Println()
|
renderer.Streams.Println()
|
||||||
@ -143,7 +138,7 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...PlanRen
|
|||||||
)
|
)
|
||||||
|
|
||||||
if haveRefreshChanges {
|
if haveRefreshChanges {
|
||||||
if !checkOpts(CanNotApply) {
|
if !checkOpts(plans.NoChanges) {
|
||||||
// In this case, applying this plan will not change any
|
// In this case, applying this plan will not change any
|
||||||
// remote objects but _will_ update the state to match what
|
// remote objects but _will_ update the state to match what
|
||||||
// we detected during refresh, so we'll reassure the user
|
// 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 len(changes) > 0 {
|
||||||
if checkOpts(Errored) {
|
if checkOpts(plans.Errored) {
|
||||||
renderer.Streams.Printf("\nTerraform planned the following actions, but then encountered a problem:\n")
|
renderer.Streams.Printf("\nTerraform planned the following actions, but then encountered a problem:\n")
|
||||||
} else {
|
} else {
|
||||||
renderer.Streams.Printf("\nTerraform will perform the following actions:\n")
|
renderer.Streams.Printf("\nTerraform will perform the following actions:\n")
|
||||||
|
@ -82,7 +82,7 @@ type Renderer struct {
|
|||||||
RunningInAutomation bool
|
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) {
|
if incompatibleVersions(jsonplan.FormatVersion, plan.PlanFormatVersion) || incompatibleVersions(jsonprovider.FormatVersion, plan.ProviderFormatVersion) {
|
||||||
renderer.Streams.Println(format.WordWrap(
|
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."),
|
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)
|
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.
|
// stored in a plan.
|
||||||
//
|
//
|
||||||
// The current workspace name is also stored as part of the plan, and so this
|
// 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
|
// method will check that it matches the currently-selected workspace name
|
||||||
// and produce error diagnostics if not.
|
// 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
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
f := backendInit.Backend(settings.Type)
|
f := backendInit.Backend(settings.Type)
|
||||||
@ -310,7 +310,7 @@ func (m *Meta) BackendForPlan(settings plans.Backend) (backend.Enhanced, tfdiags
|
|||||||
return nil, diags
|
return nil, diags
|
||||||
}
|
}
|
||||||
b := f()
|
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()
|
schema := b.ConfigSchema()
|
||||||
configVal, err := settings.Config.Decode(schema.ImpliedType())
|
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
|
// Otherwise, we'll wrap our state-only remote backend in the local backend
|
||||||
// to cause any operations to be run locally.
|
// 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()
|
cliOpts, err := m.backendCLIOpts()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diags = diags.Append(err)
|
diags = diags.Append(err)
|
||||||
|
@ -1550,7 +1550,7 @@ func TestMetaBackend_planLocal(t *testing.T) {
|
|||||||
m := testMetaBackend(t, nil)
|
m := testMetaBackend(t, nil)
|
||||||
|
|
||||||
// Get the backend
|
// Get the backend
|
||||||
b, diags := m.BackendForPlan(backendConfig)
|
b, diags := m.BackendForLocalPlan(backendConfig)
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
t.Fatal(diags.Err())
|
t.Fatal(diags.Err())
|
||||||
}
|
}
|
||||||
@ -1651,7 +1651,7 @@ func TestMetaBackend_planLocalStatePath(t *testing.T) {
|
|||||||
m.stateOutPath = statePath
|
m.stateOutPath = statePath
|
||||||
|
|
||||||
// Get the backend
|
// Get the backend
|
||||||
b, diags := m.BackendForPlan(plannedBackend)
|
b, diags := m.BackendForLocalPlan(plannedBackend)
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
t.Fatal(diags.Err())
|
t.Fatal(diags.Err())
|
||||||
}
|
}
|
||||||
@ -1740,7 +1740,7 @@ func TestMetaBackend_planLocalMatch(t *testing.T) {
|
|||||||
m := testMetaBackend(t, nil)
|
m := testMetaBackend(t, nil)
|
||||||
|
|
||||||
// Get the backend
|
// Get the backend
|
||||||
b, diags := m.BackendForPlan(backendConfig)
|
b, diags := m.BackendForLocalPlan(backendConfig)
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
t.Fatal(diags.Err())
|
t.Fatal(diags.Err())
|
||||||
}
|
}
|
||||||
|
@ -27,14 +27,15 @@ func (m *Meta) Input() bool {
|
|||||||
return true
|
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
|
// If the return value and error are both nil, the given path exists but seems
|
||||||
// to be a configuration directory instead.
|
// to be a configuration directory instead.
|
||||||
//
|
//
|
||||||
// Error will be non-nil if path refers to something which looks like a plan
|
// Error will be non-nil if path refers to something which looks like a plan
|
||||||
// file and loading the file fails.
|
// 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)
|
fi, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -45,5 +46,5 @@ func (m *Meta) PlanFile(path string) (*planfile.Reader, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return planfile.Open(path)
|
return planfile.OpenWrapped(path)
|
||||||
}
|
}
|
||||||
|
@ -4,11 +4,15 @@
|
|||||||
package command
|
package command
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/internal/backend"
|
"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/arguments"
|
||||||
"github.com/hashicorp/terraform/internal/command/views"
|
"github.com/hashicorp/terraform/internal/command/views"
|
||||||
"github.com/hashicorp/terraform/internal/configs"
|
"github.com/hashicorp/terraform/internal/configs"
|
||||||
@ -20,10 +24,29 @@ import (
|
|||||||
"github.com/hashicorp/terraform/internal/tfdiags"
|
"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
|
// ShowCommand is a Command implementation that reads and outputs the
|
||||||
// contents of a Terraform plan or state file.
|
// contents of a Terraform plan or state file.
|
||||||
type ShowCommand struct {
|
type ShowCommand struct {
|
||||||
Meta
|
Meta
|
||||||
|
viewType arguments.ViewType
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ShowCommand) Run(rawArgs []string) int {
|
func (c *ShowCommand) Run(rawArgs []string) int {
|
||||||
@ -38,6 +61,7 @@ func (c *ShowCommand) Run(rawArgs []string) int {
|
|||||||
c.View.HelpPrompt("show")
|
c.View.HelpPrompt("show")
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
c.viewType = args.ViewType
|
||||||
|
|
||||||
// Set up view
|
// Set up view
|
||||||
view := views.NewShow(args.ViewType, c.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
|
// 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)
|
diags = diags.Append(showDiags)
|
||||||
if showDiags.HasErrors() {
|
if showDiags.HasErrors() {
|
||||||
view.Diagnostics(diags)
|
view.Diagnostics(diags)
|
||||||
@ -59,7 +83,7 @@ func (c *ShowCommand) Run(rawArgs []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Display the data
|
// Display the data
|
||||||
return view.Display(config, plan, stateFile, schemas)
|
return view.Display(config, plan, jsonPlan, stateFile, schemas)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ShowCommand) Help() string {
|
func (c *ShowCommand) Help() string {
|
||||||
@ -83,9 +107,10 @@ func (c *ShowCommand) Synopsis() string {
|
|||||||
return "Show the current state or a saved plan"
|
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 diags, showDiags tfdiags.Diagnostics
|
||||||
var plan *plans.Plan
|
var plan *plans.Plan
|
||||||
|
var jsonPlan *cloudplan.RemotePlanJSON
|
||||||
var stateFile *statefile.File
|
var stateFile *statefile.File
|
||||||
var config *configs.Config
|
var config *configs.Config
|
||||||
var schemas *terraform.Schemas
|
var schemas *terraform.Schemas
|
||||||
@ -96,7 +121,7 @@ func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs.
|
|||||||
stateFile, showDiags = c.showFromLatestStateSnapshot()
|
stateFile, showDiags = c.showFromLatestStateSnapshot()
|
||||||
diags = diags.Append(showDiags)
|
diags = diags.Append(showDiags)
|
||||||
if showDiags.HasErrors() {
|
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.
|
// so try to load the argument as a plan file first.
|
||||||
// If that fails, try to load it as a statefile.
|
// If that fails, try to load it as a statefile.
|
||||||
if path != "" {
|
if path != "" {
|
||||||
plan, stateFile, config, showDiags = c.showFromPath(path)
|
plan, jsonPlan, stateFile, config, showDiags = c.showFromPath(path)
|
||||||
diags = diags.Append(showDiags)
|
diags = diags.Append(showDiags)
|
||||||
if showDiags.HasErrors() {
|
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 {
|
if config != nil || stateFile != nil {
|
||||||
schemas, diags = c.MaybeGetSchemas(stateFile.State, config)
|
schemas, diags = c.MaybeGetSchemas(stateFile.State, config)
|
||||||
if diags.HasErrors() {
|
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) {
|
func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Diagnostics) {
|
||||||
var diags tfdiags.Diagnostics
|
var diags tfdiags.Diagnostics
|
||||||
@ -149,42 +174,129 @@ func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Di
|
|||||||
return stateFile, diags
|
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 diags tfdiags.Diagnostics
|
||||||
var planErr, stateErr error
|
var planErr, stateErr error
|
||||||
var plan *plans.Plan
|
var plan *plans.Plan
|
||||||
|
var jsonPlan *cloudplan.RemotePlanJSON
|
||||||
var stateFile *statefile.File
|
var stateFile *statefile.File
|
||||||
var config *configs.Config
|
var config *configs.Config
|
||||||
|
|
||||||
// Try to get the plan file and associated data from
|
// Path might be a local plan file, a bookmark to a saved cloud plan, or a
|
||||||
// the path argument. If that fails, try to get the
|
// state file. First, try to get a plan and associated data from a local
|
||||||
// statefile from the path argument.
|
// plan file. If that fails, try to get a json plan from the path argument.
|
||||||
plan, stateFile, config, planErr = getPlanFromPath(path)
|
// If that fails, try to get the statefile from the path argument.
|
||||||
|
plan, jsonPlan, stateFile, config, planErr = c.getPlanFromPath(path)
|
||||||
if planErr != nil {
|
if planErr != nil {
|
||||||
stateFile, stateErr = getStateFromPath(path)
|
stateFile, stateErr = getStateFromPath(path)
|
||||||
if stateErr != nil {
|
if stateErr != nil {
|
||||||
diags = diags.Append(
|
// To avoid spamming the user with irrelevant errors, first check to
|
||||||
tfdiags.Sourceless(
|
// see if one of our errors happens to know for a fact what file
|
||||||
tfdiags.Error,
|
// type we were dealing with. If so, then we can ignore the other
|
||||||
"Failed to read the given file as a state or plan file",
|
// ones (which are likely to be something unhelpful like "not a
|
||||||
fmt.Sprintf("State read error: %s\n\nPlan read error: %s", stateErr, planErr),
|
// valid zip file"). If not, we can fall back to dumping whatever
|
||||||
),
|
// we've got.
|
||||||
)
|
var unLocal *planfile.ErrUnusableLocalPlan
|
||||||
return nil, nil, nil, diags
|
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
|
// getPlanFromPath returns a plan, json plan, statefile, and config if the
|
||||||
// path points to a plan file. If both plan and error are nil, the path is likely
|
// user-supplied path points to either a local or cloud plan file. Note that
|
||||||
// a directory. An error could suggest that the given path points to a statefile.
|
// some of the return values will be nil no matter what; local plan files do not
|
||||||
func getPlanFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config, error) {
|
// yield a json plan, and cloud plans do not yield real plan/state/config
|
||||||
planReader, err := planfile.Open(path)
|
// 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 {
|
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
|
// Get plan
|
||||||
plan, err := planReader.ReadPlan()
|
plan, err := planReader.ReadPlan()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -200,7 +312,7 @@ func getPlanFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config
|
|||||||
// Get config
|
// Get config
|
||||||
config, diags := planReader.ReadConfig()
|
config, diags := planReader.ReadConfig()
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
return nil, nil, nil, diags.Err()
|
return nil, nil, nil, errUnusable(diags.Err(), "local plan")
|
||||||
}
|
}
|
||||||
|
|
||||||
return plan, stateFile, config, err
|
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) {
|
func getStateFromPath(path string) (*statefile.File, error) {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Error loading statefile: %s", err)
|
return nil, fmt.Errorf("Error loading statefile: %w", err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var stateFile *statefile.File
|
var stateFile *statefile.File
|
||||||
stateFile, err = statefile.Read(file)
|
stateFile, err = statefile.Read(file)
|
||||||
if err != nil {
|
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
|
return stateFile, nil
|
||||||
}
|
}
|
||||||
@ -227,12 +339,12 @@ func getStateFromBackend(b backend.Backend, workspace string) (*statefile.File,
|
|||||||
// Get the state store for the given workspace
|
// Get the state store for the given workspace
|
||||||
stateStore, err := b.StateMgr(workspace)
|
stateStore, err := b.StateMgr(workspace)
|
||||||
if err != nil {
|
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
|
// Refresh the state store with the latest state snapshot from persistent storage
|
||||||
if err := stateStore.RefreshState(); err != nil {
|
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
|
// Get the latest state snapshot and return it
|
||||||
|
@ -201,9 +201,13 @@ func TestShow_argsPlanFileDoesNotExist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := output.Stderr()
|
got := output.Stderr()
|
||||||
want := `Plan read error: open doesNotExist.tfplan:`
|
want1 := `Plan read error: couldn't load the provided path`
|
||||||
if !strings.Contains(got, want) {
|
want2 := `open doesNotExist.tfplan: no such file or directory`
|
||||||
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
|
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()
|
got := output.Stderr()
|
||||||
want := `Plan read error: open doesNotExist.tfplan:`
|
want1 := `Plan read error: couldn't load the provided path`
|
||||||
if !strings.Contains(got, want) {
|
want2 := `open doesNotExist.tfplan: no such file or directory`
|
||||||
t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want)
|
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.
|
// Side load some data that we can't extract from the JSON plan.
|
||||||
var opts []jsonformat.PlanRendererOpt
|
var opts []plans.Quality
|
||||||
if !plan.CanApply() {
|
if !plan.CanApply() {
|
||||||
opts = append(opts, jsonformat.CanNotApply)
|
opts = append(opts, plans.NoChanges)
|
||||||
}
|
}
|
||||||
if plan.Errored {
|
if plan.Errored {
|
||||||
opts = append(opts, jsonformat.Errored)
|
opts = append(opts, plans.Errored)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
|
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
|
||||||
|
@ -4,8 +4,11 @@
|
|||||||
package views
|
package views
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
|
||||||
"github.com/hashicorp/terraform/internal/command/arguments"
|
"github.com/hashicorp/terraform/internal/command/arguments"
|
||||||
"github.com/hashicorp/terraform/internal/command/jsonformat"
|
"github.com/hashicorp/terraform/internal/command/jsonformat"
|
||||||
"github.com/hashicorp/terraform/internal/command/jsonplan"
|
"github.com/hashicorp/terraform/internal/command/jsonplan"
|
||||||
@ -20,7 +23,7 @@ import (
|
|||||||
|
|
||||||
type Show interface {
|
type Show interface {
|
||||||
// Display renders the plan, if it is available. If plan is nil, it renders the statefile.
|
// 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 renders early diagnostics, resulting from argument parsing.
|
||||||
Diagnostics(diags tfdiags.Diagnostics)
|
Diagnostics(diags tfdiags.Diagnostics)
|
||||||
@ -43,14 +46,31 @@ type ShowHuman struct {
|
|||||||
|
|
||||||
var _ Show = (*ShowHuman)(nil)
|
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{
|
renderer := jsonformat.Renderer{
|
||||||
Colorize: v.view.colorize,
|
Colorize: v.view.colorize,
|
||||||
Streams: v.view.streams,
|
Streams: v.view.streams,
|
||||||
RunningInAutomation: v.view.runningInAutomation,
|
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)
|
outputs, changed, drift, attrs, err := jsonplan.MarshalForRenderer(plan, schemas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
|
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,
|
RelevantAttributes: attrs,
|
||||||
}
|
}
|
||||||
|
|
||||||
var opts []jsonformat.PlanRendererOpt
|
var opts []plans.Quality
|
||||||
if !plan.CanApply() {
|
if !plan.CanApply() {
|
||||||
opts = append(opts, jsonformat.CanNotApply)
|
opts = append(opts, plans.NoChanges)
|
||||||
}
|
}
|
||||||
if plan.Errored {
|
if plan.Errored {
|
||||||
opts = append(opts, jsonformat.Errored)
|
opts = append(opts, plans.Errored)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
|
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
|
||||||
@ -111,15 +131,23 @@ type ShowJSON struct {
|
|||||||
|
|
||||||
var _ Show = (*ShowJSON)(nil)
|
var _ Show = (*ShowJSON)(nil)
|
||||||
|
|
||||||
func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
|
func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int {
|
||||||
if plan != nil {
|
// Prefer to display a pre-built JSON plan, if we got one; then, fall back
|
||||||
jsonPlan, err := jsonplan.Marshal(config, plan, stateFile, schemas)
|
// 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 {
|
if err != nil {
|
||||||
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
|
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
v.view.streams.Println(string(jsonPlan))
|
v.view.streams.Println(string(planJSON))
|
||||||
} else {
|
} else {
|
||||||
// It is possible that there is neither state nor a plan.
|
// It is possible that there is neither state nor a plan.
|
||||||
// That's ok, we'll just return an empty object.
|
// That's ok, we'll just return an empty object.
|
||||||
|
@ -5,10 +5,12 @@ package views
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/internal/addrs"
|
"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/command/arguments"
|
||||||
"github.com/hashicorp/terraform/internal/configs/configschema"
|
"github.com/hashicorp/terraform/internal/configs/configschema"
|
||||||
"github.com/hashicorp/terraform/internal/initwd"
|
"github.com/hashicorp/terraform/internal/initwd"
|
||||||
@ -23,8 +25,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestShowHuman(t *testing.T) {
|
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 {
|
testCases := map[string]struct {
|
||||||
plan *plans.Plan
|
plan *plans.Plan
|
||||||
|
jsonPlan *cloudplan.RemotePlanJSON
|
||||||
stateFile *statefile.File
|
stateFile *statefile.File
|
||||||
schemas *terraform.Schemas
|
schemas *terraform.Schemas
|
||||||
wantExact bool
|
wantExact bool
|
||||||
@ -33,11 +41,28 @@ func TestShowHuman(t *testing.T) {
|
|||||||
"plan file": {
|
"plan file": {
|
||||||
testPlan(t),
|
testPlan(t),
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
testSchemas(),
|
testSchemas(),
|
||||||
false,
|
false,
|
||||||
"# test_resource.foo will be created",
|
"# 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": {
|
"statefile": {
|
||||||
|
nil,
|
||||||
nil,
|
nil,
|
||||||
&statefile.File{
|
&statefile.File{
|
||||||
Serial: 0,
|
Serial: 0,
|
||||||
@ -49,6 +74,7 @@ func TestShowHuman(t *testing.T) {
|
|||||||
"# test_resource.foo:",
|
"# test_resource.foo:",
|
||||||
},
|
},
|
||||||
"empty statefile": {
|
"empty statefile": {
|
||||||
|
nil,
|
||||||
nil,
|
nil,
|
||||||
&statefile.File{
|
&statefile.File{
|
||||||
Serial: 0,
|
Serial: 0,
|
||||||
@ -63,6 +89,7 @@ func TestShowHuman(t *testing.T) {
|
|||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
true,
|
true,
|
||||||
"No state.\n",
|
"No state.\n",
|
||||||
},
|
},
|
||||||
@ -74,7 +101,7 @@ func TestShowHuman(t *testing.T) {
|
|||||||
view.Configure(&arguments.View{NoColor: true})
|
view.Configure(&arguments.View{NoColor: true})
|
||||||
v := NewShow(arguments.ViewHuman, view)
|
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 {
|
if code != 0 {
|
||||||
t.Errorf("expected 0 return code, got %d", code)
|
t.Errorf("expected 0 return code, got %d", code)
|
||||||
}
|
}
|
||||||
@ -90,15 +117,35 @@ func TestShowHuman(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestShowJSON(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 {
|
testCases := map[string]struct {
|
||||||
plan *plans.Plan
|
plan *plans.Plan
|
||||||
|
jsonPlan *cloudplan.RemotePlanJSON
|
||||||
stateFile *statefile.File
|
stateFile *statefile.File
|
||||||
}{
|
}{
|
||||||
"plan file": {
|
"plan file": {
|
||||||
testPlan(t),
|
testPlan(t),
|
||||||
nil,
|
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": {
|
"statefile": {
|
||||||
|
nil,
|
||||||
nil,
|
nil,
|
||||||
&statefile.File{
|
&statefile.File{
|
||||||
Serial: 0,
|
Serial: 0,
|
||||||
@ -107,6 +154,7 @@ func TestShowJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"empty statefile": {
|
"empty statefile": {
|
||||||
|
nil,
|
||||||
nil,
|
nil,
|
||||||
&statefile.File{
|
&statefile.File{
|
||||||
Serial: 0,
|
Serial: 0,
|
||||||
@ -117,6 +165,7 @@ func TestShowJSON(t *testing.T) {
|
|||||||
"nothing": {
|
"nothing": {
|
||||||
nil,
|
nil,
|
||||||
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 {
|
if code != 0 {
|
||||||
t.Errorf("expected 0 return code, got %d", code)
|
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,
|
RelevantAttributes: attrs,
|
||||||
}
|
}
|
||||||
|
|
||||||
var opts []jsonformat.PlanRendererOpt
|
var opts []plans.Quality
|
||||||
if !run.Verbose.Plan.CanApply() {
|
if !run.Verbose.Plan.CanApply() {
|
||||||
opts = append(opts, jsonformat.CanNotApply)
|
opts = append(opts, plans.NoChanges)
|
||||||
}
|
}
|
||||||
if run.Verbose.Plan.Errored {
|
if run.Verbose.Plan.Errored {
|
||||||
opts = append(opts, jsonformat.Errored)
|
opts = append(opts, plans.Errored)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.RenderHumanPlan(plan, run.Verbose.Plan.UIMode, opts...)
|
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 (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
@ -100,10 +101,17 @@ func TestRoundtrip(t *testing.T) {
|
|||||||
t.Fatalf("failed to create plan file: %s", err)
|
t.Fatalf("failed to create plan file: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pr, err := Open(planFn)
|
wpf, err := OpenWrapped(planFn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to open plan file for reading: %s", err)
|
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) {
|
t.Run("ReadPlan", func(t *testing.T) {
|
||||||
planOut, err := pr.ReadPlan()
|
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 tfstatePreviousFilename = "tfstate-prev"
|
||||||
const dependencyLocksFilename = ".terraform.lock.hcl" // matches the conventional name in an input configuration
|
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
|
// Reader is the main type used to read plan files. Create a Reader by calling
|
||||||
// Open.
|
// Open.
|
||||||
//
|
//
|
||||||
@ -31,8 +50,10 @@ type Reader struct {
|
|||||||
zip *zip.ReadCloser
|
zip *zip.ReadCloser
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open creates a Reader for the file at the given filename, or returns an
|
// Open creates a Reader for the file at the given filename, or returns an error
|
||||||
// error if the file doesn't seem to be a planfile.
|
// 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) {
|
func Open(filename string) (*Reader, error) {
|
||||||
r, err := zip.OpenReader(filename)
|
r, err := zip.OpenReader(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -40,7 +61,7 @@ func Open(filename string) (*Reader, error) {
|
|||||||
// like our old plan format from versions prior to 0.12.
|
// like our old plan format from versions prior to 0.12.
|
||||||
if b, sErr := ioutil.ReadFile(filename); sErr == nil {
|
if b, sErr := ioutil.ReadFile(filename); sErr == nil {
|
||||||
if bytes.HasPrefix(b, []byte("tfplan")) {
|
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
|
return nil, err
|
||||||
@ -84,12 +105,12 @@ func (r *Reader) ReadPlan() (*plans.Plan, error) {
|
|||||||
if planFile == nil {
|
if planFile == nil {
|
||||||
// This should never happen because we checked for this file during
|
// This should never happen because we checked for this file during
|
||||||
// Open, but we'll check anyway to be safe.
|
// 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()
|
pr, err := planFile.Open()
|
||||||
if err != nil {
|
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()
|
defer pr.Close()
|
||||||
|
|
||||||
@ -106,16 +127,16 @@ func (r *Reader) ReadPlan() (*plans.Plan, error) {
|
|||||||
// access the prior state (this and the ReadStateFile method).
|
// access the prior state (this and the ReadStateFile method).
|
||||||
ret, err := readTfplan(pr)
|
ret, err := readTfplan(pr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errUnusable(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prevRunStateFile, err := r.ReadPrevStateFile()
|
prevRunStateFile, err := r.ReadPrevStateFile()
|
||||||
if err != nil {
|
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()
|
priorStateFile, err := r.ReadStateFile()
|
||||||
if err != nil {
|
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
|
ret.PrevRunState = prevRunStateFile.State
|
||||||
@ -134,12 +155,12 @@ func (r *Reader) ReadStateFile() (*statefile.File, error) {
|
|||||||
if file.Name == tfstateFilename {
|
if file.Name == tfstateFilename {
|
||||||
r, err := file.Open()
|
r, err := file.Open()
|
||||||
if err != nil {
|
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 statefile.Read(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, statefile.ErrNoState
|
return nil, errUnusable(statefile.ErrNoState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadPrevStateFile reads the previous state file embedded in the plan file, which
|
// 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 {
|
if file.Name == tfstatePreviousFilename {
|
||||||
r, err := file.Open()
|
r, err := file.Open()
|
||||||
if err != nil {
|
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 statefile.Read(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, statefile.ErrNoState
|
return nil, errUnusable(statefile.ErrNoState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadConfigSnapshot reads the configuration snapshot embedded in the plan
|
// 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.
|
// ErrNoState is returned by ReadState when the state file is empty.
|
||||||
var ErrNoState = errors.New("no state")
|
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.
|
// Read reads a state from the given reader.
|
||||||
//
|
//
|
||||||
// Legacy state format versions 1 through 3 are supported, but the result will
|
// 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
|
return nil, ErrNoState
|
||||||
}
|
}
|
||||||
|
|
||||||
state, diags := readState(src)
|
state, err := readState(src)
|
||||||
if diags.HasErrors() {
|
if err != nil {
|
||||||
return nil, diags.Err()
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if state == nil {
|
if state == nil {
|
||||||
@ -68,7 +87,7 @@ func Read(r io.Reader) (*File, error) {
|
|||||||
return state, diags.Err()
|
return state, diags.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func readState(src []byte) (*File, tfdiags.Diagnostics) {
|
func readState(src []byte) (*File, error) {
|
||||||
var diags tfdiags.Diagnostics
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
if looksLikeVersion0(src) {
|
if looksLikeVersion0(src) {
|
||||||
@ -77,15 +96,20 @@ func readState(src []byte) (*File, tfdiags.Diagnostics) {
|
|||||||
unsupportedFormat,
|
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.",
|
"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)
|
version, versionDiags := sniffJSONStateVersion(src)
|
||||||
diags = diags.Append(versionDiags)
|
diags = diags.Append(versionDiags)
|
||||||
if versionDiags.HasErrors() {
|
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 {
|
switch version {
|
||||||
case 0:
|
case 0:
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
@ -93,15 +117,14 @@ func readState(src []byte) (*File, tfdiags.Diagnostics) {
|
|||||||
unsupportedFormat,
|
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.",
|
"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:
|
case 1:
|
||||||
return readStateV1(src)
|
result, diags = readStateV1(src)
|
||||||
case 2:
|
case 2:
|
||||||
return readStateV2(src)
|
result, diags = readStateV2(src)
|
||||||
case 3:
|
case 3:
|
||||||
return readStateV3(src)
|
result, diags = readStateV3(src)
|
||||||
case 4:
|
case 4:
|
||||||
return readStateV4(src)
|
result, diags = readStateV4(src)
|
||||||
default:
|
default:
|
||||||
thisVersion := tfversion.SemVer.String()
|
thisVersion := tfversion.SemVer.String()
|
||||||
creatingVersion := sniffJSONStateTerraformVersion(src)
|
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),
|
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) {
|
func sniffJSONStateVersion(src []byte) (uint64, tfdiags.Diagnostics) {
|
||||||
|
Loading…
Reference in New Issue
Block a user