Files
opentf/internal/command/views/show.go
Martin Atkins 6a27c82bb4 tofu show: -module=DIR mode, for showing just a single module
We previously added the -config mode for showing the entire assembled
configuration tree, including the content of any descendent modules, but
that mode requires first running "tofu init" to install all of the
provider and module dependencies of the configuration.

This new -module=DIR mode returns a subset of the same JSON representation
for only a single module that can be generated without first installing
any dependencies, making this mode more appropriate for situations like
generating documentation for a single module when importing it into the
OpenTofu Registry. The registry generation process does not want to endure
the overhead of installing other providers and modules when all it actually
needs is metadata about the top-level declarations in the module.

To minimize the risk to the already-working full-config JSON representation
while still reusing most of its code, the implementation details of package
jsonconfig are a little awkward here. Since this code changes relatively
infrequently and is implementing an external interface subject to
compatibility constraints, and since this new behavior is relatively
marginal and intended primarily for our own OpenTofu Registry purposes,
this is a pragmatic tradeoff that is hopefully compensated for well enough
by the code comments that aim to explain what's going on for the benefit
of future maintainers. If we _do_ find ourselves making substantial changes
to this code at a later date then we can consider a more significant
restructure of the code at that point; the weird stuff is intentionally
encapsulated inside package jsonconfig so it can change later without
changing any callers.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-07-10 13:18:26 -07:00

245 lines
8.1 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package views
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/opentofu/opentofu/internal/cloud/cloudplan"
"github.com/opentofu/opentofu/internal/command/arguments"
"github.com/opentofu/opentofu/internal/command/jsonconfig"
"github.com/opentofu/opentofu/internal/command/jsonformat"
"github.com/opentofu/opentofu/internal/command/jsonplan"
"github.com/opentofu/opentofu/internal/command/jsonprovider"
"github.com/opentofu/opentofu/internal/command/jsonstate"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/plans"
"github.com/opentofu/opentofu/internal/states/statefile"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/opentofu/opentofu/internal/tofu"
)
type Show interface {
// DisplayState renders the given state snapshot, returning a status code for "tofu show" to return.
DisplayState(ctx context.Context, 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(ctx context.Context, plan *plans.Plan, planJSON *cloudplan.RemotePlanJSON, config *configs.Config, priorStateFile *statefile.File, schemas *tofu.Schemas) int
// DisplayConfig renders the given configuration, returning a status code for "tofu show" to return.
DisplayConfig(config *configs.Config, schemas *tofu.Schemas) int
// DisplaySingleModule renders just one module, in a format that's a subset
// of that used by [Show.DisplayConfig] which we can produce without
// schema or child module information.
DisplaySingleModule(module *configs.Module) int
// Diagnostics renders early diagnostics, resulting from argument parsing.
Diagnostics(diags tfdiags.Diagnostics)
}
func NewShow(vt arguments.ViewType, view *View) Show {
switch vt {
case arguments.ViewJSON:
return &ShowJSON{view: view}
case arguments.ViewHuman:
return &ShowHuman{view: view}
default:
panic(fmt.Sprintf("unknown view type %v", vt))
}
}
type ShowHuman struct {
view *View
}
var _ Show = (*ShowHuman)(nil)
func (v *ShowHuman) DisplayState(_ context.Context, 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(_ context.Context, 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,
RunningInAutomation: v.view.runningInAutomation,
ShowSensitive: v.view.showSensitive,
}
// 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)
return 1
}
jplan := jsonformat.Plan{
PlanFormatVersion: jsonplan.FormatVersion,
ProviderFormatVersion: jsonprovider.FormatVersion,
OutputChanges: outputs,
ResourceChanges: changed,
ResourceDrift: drift,
ProviderSchemas: jsonprovider.MarshalForRenderer(schemas),
RelevantAttributes: attrs,
}
var opts []plans.Quality
if !plan.CanApply() {
opts = append(opts, plans.NoChanges)
}
if plan.Errored {
opts = append(opts, plans.Errored)
}
renderer.RenderHumanPlan(jplan, plan.UIMode, opts...)
} else {
v.view.streams.Println("No plan.")
}
return 0
}
func (v *ShowHuman) DisplayConfig(config *configs.Config, schemas *tofu.Schemas) int {
// The human view should never be called for configuration display
// since we require -json for -config
v.view.streams.Eprintf("Internal error: human view should not be used for configuration display")
return 1
}
func (v *ShowHuman) DisplaySingleModule(_ *configs.Module) int {
// The human view should never be called for module display
// since we require -json for -module=DIR.
v.view.streams.Eprintf("Internal error: human view should not be used for module display")
return 1
}
func (v *ShowHuman) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}
type ShowJSON struct {
view *View
}
var _ Show = (*ShowJSON)(nil)
func (v *ShowJSON) DisplayState(_ context.Context, 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(_ context.Context, 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 {
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, priorStateFile, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal plan to json: %s", err)
return 1
}
v.view.streams.Println(string(planJSON))
} else {
// 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
}
func (v *ShowJSON) DisplayConfig(config *configs.Config, schemas *tofu.Schemas) int {
configJSON, err := jsonconfig.Marshal(config, schemas)
if err != nil {
v.view.streams.Eprintf("Failed to marshal configuration to JSON: %s", err)
return 1
}
v.view.streams.Println(string(configJSON))
return 0
}
func (v *ShowJSON) DisplaySingleModule(module *configs.Module) int {
moduleJSON, err := jsonconfig.MarshalSingleModule(module)
if err != nil {
v.view.streams.Eprintf("Failed to marshal module contents to JSON: %s", err)
return 1
}
v.view.streams.Println(string(moduleJSON))
return 0
}
// Diagnostics should only be called if show cannot be executed.
// In this case, we choose to render human-readable diagnostic output,
// primarily for backwards compatibility.
func (v *ShowJSON) Diagnostics(diags tfdiags.Diagnostics) {
v.view.Diagnostics(diags)
}