Update show command and view to support inspecting cloud plans

One funny bit: We need to know the ViewType at the point where we ask the Cloud
backend for the plan JSON, because we need to switch between two distinctly
different formats for human show vs. `show -json`. I chose to pass that by
stashing it on the command struct; passing it as an argument would also work,
but one, the argument lists in these nested method calls were getting a little
unwieldy, and two, many of these functions had to be receiver methods anyway in
order to call methods on Meta.
This commit is contained in:
Nick Fagerlund 2023-06-26 17:54:49 -07:00 committed by Sebastian Rivera
parent d5938f6b45
commit 3a9ce2afb1
2 changed files with 100 additions and 28 deletions

View File

@ -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 {

View File

@ -4,8 +4,11 @@
package views
import (
"bytes"
"encoding/json"
"fmt"
"github.com/hashicorp/terraform/internal/cloud/cloudplan"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/jsonformat"
"github.com/hashicorp/terraform/internal/command/jsonplan"
@ -20,7 +23,7 @@ import (
type Show interface {
// Display renders the plan, if it is available. If plan is nil, it renders the statefile.
Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int
Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int
// Diagnostics renders early diagnostics, resulting from argument parsing.
Diagnostics(diags tfdiags.Diagnostics)
@ -43,14 +46,31 @@ type ShowHuman struct {
var _ Show = (*ShowHuman)(nil)
func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, stateFile *statefile.File, schemas *terraform.Schemas) int {
func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *terraform.Schemas) int {
renderer := jsonformat.Renderer{
Colorize: v.view.colorize,
Streams: v.view.streams,
RunningInAutomation: v.view.runningInAutomation,
}
if plan != nil {
// Prefer to display a pre-built JSON plan, if we got one; then, fall back
// to building one ourselves.
if planJSON != nil {
if !planJSON.Redacted {
v.view.streams.Eprintf("Didn't get renderable JSON plan format for human display")
return 1
}
// The redacted json plan format can be decoded into a jsonformat.Plan
p := jsonformat.Plan{}
r := bytes.NewReader(planJSON.JSONBytes)
if err := json.NewDecoder(r).Decode(&p); err != nil {
v.view.streams.Eprintf("Couldn't decode renderable JSON plan format: %s", err)
}
v.view.streams.Print(v.view.colorize.Color(planJSON.RunHeader + "\n"))
renderer.RenderHumanPlan(p, planJSON.Mode, planJSON.Qualities...)
v.view.streams.Print(v.view.colorize.Color("\n" + planJSON.RunFooter + "\n"))
} else if plan != nil {
outputs, changed, drift, attrs, err := jsonplan.MarshalForRenderer(plan, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
@ -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.