diff --git a/internal/command/show.go b/internal/command/show.go index 3b83a3cc77..42f23ae0fa 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -4,11 +4,14 @@ package command import ( + "context" "fmt" "os" "strings" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/cloud" + "github.com/hashicorp/terraform/internal/cloud/cloudplan" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/configs" @@ -24,6 +27,7 @@ import ( // contents of a Terraform plan or state file. type ShowCommand struct { Meta + viewType arguments.ViewType } func (c *ShowCommand) Run(rawArgs []string) int { @@ -38,6 +42,7 @@ func (c *ShowCommand) Run(rawArgs []string) int { c.View.HelpPrompt("show") return 1 } + c.viewType = args.ViewType // Set up view view := views.NewShow(args.ViewType, c.View) @@ -51,7 +56,7 @@ func (c *ShowCommand) Run(rawArgs []string) int { } // Get the data we need to display - plan, stateFile, config, schemas, showDiags := c.show(args.Path) + plan, jsonPlan, stateFile, config, schemas, showDiags := c.show(args.Path) diags = diags.Append(showDiags) if showDiags.HasErrors() { view.Diagnostics(diags) @@ -59,7 +64,7 @@ func (c *ShowCommand) Run(rawArgs []string) int { } // Display the data - return view.Display(config, plan, stateFile, schemas) + return view.Display(config, plan, jsonPlan, stateFile, schemas) } func (c *ShowCommand) Help() string { @@ -83,9 +88,10 @@ func (c *ShowCommand) Synopsis() string { return "Show the current state or a saved plan" } -func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs.Config, *terraform.Schemas, tfdiags.Diagnostics) { +func (c *ShowCommand) show(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, *terraform.Schemas, tfdiags.Diagnostics) { var diags, showDiags tfdiags.Diagnostics var plan *plans.Plan + var jsonPlan *cloudplan.RemotePlanJSON var stateFile *statefile.File var config *configs.Config var schemas *terraform.Schemas @@ -96,7 +102,7 @@ func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs. stateFile, showDiags = c.showFromLatestStateSnapshot() diags = diags.Append(showDiags) if showDiags.HasErrors() { - return plan, stateFile, config, schemas, diags + return plan, jsonPlan, stateFile, config, schemas, diags } } @@ -104,10 +110,10 @@ func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs. // so try to load the argument as a plan file first. // If that fails, try to load it as a statefile. if path != "" { - plan, stateFile, config, showDiags = c.showFromPath(path) + plan, jsonPlan, stateFile, config, showDiags = c.showFromPath(path) diags = diags.Append(showDiags) if showDiags.HasErrors() { - return plan, stateFile, config, schemas, diags + return plan, jsonPlan, stateFile, config, schemas, diags } } @@ -115,11 +121,11 @@ func (c *ShowCommand) show(path string) (*plans.Plan, *statefile.File, *configs. if config != nil || stateFile != nil { schemas, diags = c.MaybeGetSchemas(stateFile.State, config) if diags.HasErrors() { - return plan, stateFile, config, schemas, diags + return plan, jsonPlan, stateFile, config, schemas, diags } } - return plan, stateFile, config, schemas, diags + return plan, jsonPlan, stateFile, config, schemas, diags } func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics @@ -149,17 +155,19 @@ func (c *ShowCommand) showFromLatestStateSnapshot() (*statefile.File, tfdiags.Di return stateFile, diags } -func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config, tfdiags.Diagnostics) { +func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics var planErr, stateErr error var plan *plans.Plan + var jsonPlan *cloudplan.RemotePlanJSON var stateFile *statefile.File var config *configs.Config - // Try to get the plan file and associated data from - // the path argument. If that fails, try to get the - // statefile from the path argument. - plan, stateFile, config, planErr = getPlanFromPath(path) + // Path might be a local plan file, a bookmark to a saved cloud plan, or a + // state file. First, try to get a plan and associated data from a local + // plan file. If that fails, try to get a json plan from the path argument. + // If that fails, try to get the statefile from the path argument. + plan, jsonPlan, stateFile, config, planErr = c.getPlanFromPath(path) if planErr != nil { stateFile, stateErr = getStateFromPath(path) if stateErr != nil { @@ -170,21 +178,57 @@ func (c *ShowCommand) showFromPath(path string) (*plans.Plan, *statefile.File, * fmt.Sprintf("State read error: %s\n\nPlan read error: %s", stateErr, planErr), ), ) - return nil, nil, nil, diags + return nil, nil, nil, nil, diags } } - return plan, stateFile, config, diags + return plan, jsonPlan, stateFile, config, diags } -// getPlanFromPath returns a plan, statefile, and config if the user-supplied -// path points to a plan file. If both plan and error are nil, the path is likely -// a directory. An error could suggest that the given path points to a statefile. -func getPlanFromPath(path string) (*plans.Plan, *statefile.File, *configs.Config, error) { - planReader, err := planfile.Open(path) +// getPlanFromPath returns a plan, json plan, statefile, and config if the +// user-supplied path points to either a local or cloud plan file. Note that +// some of the return values will be nil no matter what; local plan files do not +// yield a json plan, and cloud plans do not yield real plan/state/config +// structs. An error generally suggests that the given path is either a +// directory or a statefile. +func (c *ShowCommand) getPlanFromPath(path string) (*plans.Plan, *cloudplan.RemotePlanJSON, *statefile.File, *configs.Config, error) { + var err error + var plan *plans.Plan + var jsonPlan *cloudplan.RemotePlanJSON + var stateFile *statefile.File + var config *configs.Config + + pf, err := planfile.OpenWrapped(path) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err + } + + if lp, ok := pf.Local(); ok { + plan, stateFile, config, err = getDataFromPlanfileReader(lp) + } else if cp, ok := pf.Cloud(); ok { + redacted := c.viewType != arguments.ViewJSON + jsonPlan, err = c.getDataFromCloudPlan(cp, redacted) } + return plan, jsonPlan, stateFile, config, err +} + +func (c *ShowCommand) getDataFromCloudPlan(plan *cloudplan.SavedPlanBookmark, redacted bool) (*cloudplan.RemotePlanJSON, error) { + // Set up the backend + b, backendDiags := c.Backend(nil) + if backendDiags.HasErrors() { + return nil, backendDiags.Err() + } + // Cloud plans only work if we're cloud. + cl, ok := b.(*cloud.Cloud) + if !ok { + return nil, fmt.Errorf("can't show a saved cloud plan unless the current root module is connected to Terraform Cloud") + } + + return cl.ShowPlanForRun(context.Background(), plan.RunID, plan.Hostname, redacted) +} + +// getDataFromPlanfileReader returns a plan, statefile, and config, extracted from a local plan file. +func getDataFromPlanfileReader(planReader *planfile.Reader) (*plans.Plan, *statefile.File, *configs.Config, error) { // Get plan plan, err := planReader.ReadPlan() if err != nil { diff --git a/internal/command/views/show.go b/internal/command/views/show.go index add3115798..4c9f1bdae3 100644 --- a/internal/command/views/show.go +++ b/internal/command/views/show.go @@ -4,8 +4,11 @@ package views import ( + "bytes" + "encoding/json" "fmt" + "github.com/hashicorp/terraform/internal/cloud/cloudplan" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/jsonformat" "github.com/hashicorp/terraform/internal/command/jsonplan" @@ -20,7 +23,7 @@ import ( type Show interface { // Display renders the plan, if it is available. If plan is nil, it renders the statefile. - Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int + Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int // Diagnostics renders early diagnostics, resulting from argument parsing. Diagnostics(diags tfdiags.Diagnostics) @@ -43,14 +46,31 @@ type ShowHuman struct { var _ Show = (*ShowHuman)(nil) -func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int { +func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int { renderer := jsonformat.Renderer{ Colorize: v.view.colorize, Streams: v.view.streams, RunningInAutomation: v.view.runningInAutomation, } - if plan != nil { + // Prefer to display a pre-built JSON plan, if we got one; then, fall back + // to building one ourselves. + if planJSON != nil { + if !planJSON.Redacted { + v.view.streams.Eprintf("Didn't get renderable JSON plan format for human display") + return 1 + } + // The redacted json plan format can be decoded into a jsonformat.Plan + p := jsonformat.Plan{} + r := bytes.NewReader(planJSON.JSONBytes) + if err := json.NewDecoder(r).Decode(&p); err != nil { + v.view.streams.Eprintf("Couldn't decode renderable JSON plan format: %s", err) + } + + v.view.streams.Print(v.view.colorize.Color(planJSON.RunHeader + "\n")) + renderer.RenderHumanPlan(p, planJSON.Mode, planJSON.Qualities...) + v.view.streams.Print(v.view.colorize.Color("\n" + planJSON.RunFooter + "\n")) + } else if plan != nil { outputs, changed, drift, attrs, err := jsonplan.MarshalForRenderer(plan, schemas) if err != nil { v.view.streams.Eprintf("Failed to marshal plan to json: %s", err) @@ -111,15 +131,23 @@ type ShowJSON struct { var _ Show = (*ShowJSON)(nil) -func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int { - if plan != nil { - jsonPlan, err := jsonplan.Marshal(config, plan, stateFile, schemas) +func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int { + // Prefer to display a pre-built JSON plan, if we got one; then, fall back + // to building one ourselves. + if planJSON != nil { + if planJSON.Redacted { + v.view.streams.Eprintf("Didn't get external JSON plan format") + return 1 + } + v.view.streams.Println(string(planJSON.JSONBytes)) + } else if plan != nil { + planJSON, err := jsonplan.Marshal(config, plan, stateFile, schemas) if err != nil { v.view.streams.Eprintf("Failed to marshal plan to json: %s", err) return 1 } - v.view.streams.Println(string(jsonPlan)) + v.view.streams.Println(string(planJSON)) } else { // It is possible that there is neither state nor a plan. // That's ok, we'll just return an empty object.