tofu show: new explicit/extensible usage style

The "tofu show" command has historically been difficult to extend to meet
new use-cases, such as showing the current configuration without creating
a plan, because it was designed to take zero or one arguments and then try
to guess what the one specified argument was intended to mean.

This commit introduces a new style where the type of object to inspect is
specified using command line option syntax, using one of two
mutually-exclusive options:

    -state      Show the latest state snapshot.
    -plan=FILE  Show the plan from the given saved plan file.

We expect that a future commit will extend this with a new "-config" option
to inspect the configuration rooted in the current working directory, and
possibly with "-module=DIR" to shallowly inspect a single module without
necessarily having to fully initialize it with all of its dependencies
first. However, both of those use-cases (and any others) are not in scope
for this commit, which is focused only on refactoring to make those future
use-cases possible.

The old mode of specifying neither option and providing zero or one
positional arguments is still supported for backward compatibility.
Notably, the legacy style is the only way to access the legacy behavior of
inspecting a specific state snapshot file from the local filesystem, which
has not often been used since Terraform v0.9 as we've moved away
from manual management of state files to the structure of state backends.
Those who _do_ still need that old behavior can still access it in the
old way, but there will be no new-style equivalent of it unless we learn
of a compelling use case for it.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
Martin Atkins
2025-04-23 15:47:00 -07:00
parent d2b0259819
commit f42dfbc497
9 changed files with 709 additions and 296 deletions

View File

@@ -24,8 +24,18 @@ 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, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *tofu.Schemas) int
// DisplayState renders the given state snapshot, returning a status code for "tofu show" to return.
DisplayState(stateFile *statefile.File, schemas *tofu.Schemas) int
// DisplayPlan renders the given plan, returning a status code for "tofu show" to return.
//
// Unfortunately there are two possible ways to represent a plan:
// - Locally-generated plans are loaded as *plans.Plan.
// - Remotely-generated plans (using remote operations) are loaded as *cloudplan.RemotePlanJSON.
//
// Therefore the implementation of this method must handle both cases,
// preferring planJSON if it is not nil and using plan otherwise.
DisplayPlan(plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, config *configs.Config, priorStateFile *statefile.File, schemas *tofu.Schemas) int
// Diagnostics renders early diagnostics, resulting from argument parsing.
Diagnostics(diags tfdiags.Diagnostics)
@@ -48,7 +58,38 @@ type ShowHuman struct {
var _ Show = (*ShowHuman)(nil)
func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *tofu.Schemas) int {
func (v *ShowHuman) DisplayState(stateFile *statefile.File, schemas *tofu.Schemas) int {
renderer := jsonformat.Renderer{
Colorize: v.view.colorize,
Streams: v.view.streams,
RunningInAutomation: v.view.runningInAutomation,
ShowSensitive: v.view.showSensitive,
}
if stateFile == nil {
v.view.streams.Println("No state.")
return 0
}
root, outputs, err := jsonstate.MarshalForRenderer(stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal state to json: %s", err)
return 1
}
jstate := jsonformat.State{
StateFormatVersion: jsonstate.FormatVersion,
ProviderFormatVersion: jsonprovider.FormatVersion,
RootModule: root,
RootModuleOutputs: outputs,
ProviderSchemas: jsonprovider.MarshalForRenderer(schemas),
}
renderer.RenderHumanState(jstate)
return 0
}
func (v *ShowHuman) DisplayPlan(plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, config *configs.Config, priorStateFile *statefile.File, schemas *tofu.Schemas) int {
renderer := jsonformat.Renderer{
Colorize: v.view.colorize,
Streams: v.view.streams,
@@ -100,26 +141,7 @@ func (v *ShowHuman) Display(config *configs.Config, plan *plans.Plan, planJSON *
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
} else {
if stateFile == nil {
v.view.streams.Println("No state.")
return 0
}
root, outputs, err := jsonstate.MarshalForRenderer(stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal state to json: %s", err)
return 1
}
jstate := jsonformat.State{
StateFormatVersion: jsonstate.FormatVersion,
ProviderFormatVersion: jsonprovider.FormatVersion,
RootModule: root,
RootModuleOutputs: outputs,
ProviderSchemas: jsonprovider.MarshalForRenderer(schemas),
}
renderer.RenderHumanState(jstate)
v.view.streams.Println("No plan.")
}
return 0
}
@@ -134,7 +156,17 @@ type ShowJSON struct {
var _ Show = (*ShowJSON)(nil)
func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, stateFile *statefile.File, schemas *tofu.Schemas) int {
func (v *ShowJSON) DisplayState(stateFile *statefile.File, schemas *tofu.Schemas) int {
jsonState, err := jsonstate.Marshal(stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal state to json: %s", err)
return 1
}
v.view.streams.Println(string(jsonState))
return 0
}
func (v *ShowJSON) DisplayPlan(plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, config *configs.Config, priorStateFile *statefile.File, schemas *tofu.Schemas) int {
// Prefer to display a pre-built JSON plan, if we got one; then, fall back
// to building one ourselves.
if planJSON != nil {
@@ -144,7 +176,7 @@ func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, planJSON *c
}
v.view.streams.Println(string(planJSON.JSONBytes))
} else if plan != nil {
planJSON, err := jsonplan.Marshal(config, plan, stateFile, schemas)
planJSON, err := jsonplan.Marshal(config, plan, priorStateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
@@ -152,14 +184,10 @@ func (v *ShowJSON) Display(config *configs.Config, plan *plans.Plan, planJSON *c
}
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.
jsonState, err := jsonstate.Marshal(stateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal state to json: %s", err)
return 1
}
v.view.streams.Println(string(jsonState))
// Should not get here because at least one of the two plan arguments
// should be present, but we'll tolerate this by just returning an
// empty JSON object.
v.view.streams.Println("{}")
}
return 0
}