From 6a27c82bb4dc9c9fb9274a18739ccb24cdb49382 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 8 Jul 2025 14:08:23 -0700 Subject: [PATCH] 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 --- internal/command/arguments/show.go | 28 +++- internal/command/arguments/show_test.go | 72 ++++++++- .../arguments/showtargettype_string.go | 5 +- internal/command/jsonconfig/config.go | 144 +++++++++++------- internal/command/jsonconfig/expression.go | 11 ++ .../command/jsonconfig/expression_test.go | 22 ++- internal/command/jsonconfig/single_module.go | 80 ++++++++++ internal/command/show.go | 31 ++++ internal/command/show_test.go | 110 +++++++++++++ .../show-config-single-module/main.tf | 42 +++++ internal/command/views/show.go | 24 ++- website/docs/cli/commands/show.mdx | 21 ++- 12 files changed, 519 insertions(+), 71 deletions(-) create mode 100644 internal/command/jsonconfig/single_module.go create mode 100644 internal/command/testdata/show-config-single-module/main.tf diff --git a/internal/command/arguments/show.go b/internal/command/arguments/show.go index 745eeed520..214b3aaec6 100644 --- a/internal/command/arguments/show.go +++ b/internal/command/arguments/show.go @@ -54,6 +54,13 @@ const ( // // This target type does not use [Show.TargetArg]. ShowConfig + + // ShowModule represents a request to show just one module in isolation, + // without requiring any of its dependencies to be installed. + // + // For this target type, [Show.TargetArg] is a path to the directory + // containing the module. + ShowModule ) // ParseShow processes CLI arguments, returning a Show value and errors. @@ -69,12 +76,14 @@ func ParseShow(args []string) (*Show, tfdiags.Diagnostics) { var stateTarget bool var planTarget string var configTarget bool + var moduleTarget string cmdFlags := extendedFlagSet("show", nil, nil, show.Vars) cmdFlags.BoolVar(&jsonOutput, "json", false, "json") cmdFlags.BoolVar(&show.ShowSensitive, "show-sensitive", false, "displays sensitive values") cmdFlags.BoolVar(&stateTarget, "state", false, "show the latest state snapshot") cmdFlags.StringVar(&planTarget, "plan", "", "show the plan from a saved plan file") cmdFlags.BoolVar(&configTarget, "config", false, "show the current configuration") + cmdFlags.StringVar(&moduleTarget, "module", "", "show metadata about one module") if err := cmdFlags.Parse(args); err != nil { diags = diags.Append(tfdiags.Sourceless( @@ -84,7 +93,7 @@ func ParseShow(args []string) (*Show, tfdiags.Diagnostics) { )) } - // If -config is specified, -json is required + // If -config or -module=... is selected, -json is required if configTarget && !jsonOutput { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -93,6 +102,14 @@ func ParseShow(args []string) (*Show, tfdiags.Diagnostics) { )) return show, diags } + if moduleTarget != "" && !jsonOutput { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "JSON output required for module", + "The -module=DIR option requires -json to be specified.", + )) + return show, diags + } switch { case jsonOutput: @@ -101,7 +118,7 @@ func ParseShow(args []string) (*Show, tfdiags.Diagnostics) { show.ViewType = ViewHuman } - if planTarget == "" && !stateTarget && !configTarget { + if planTarget == "" && moduleTarget == "" && !stateTarget && !configTarget { // If none of the target type options was provided then we're // in the legacy mode where the target type is implied by // the number of arguments. @@ -153,11 +170,16 @@ func ParseShow(args []string) (*Show, tfdiags.Diagnostics) { show.TargetType = ShowConfig show.TargetArg = "" } + if moduleTarget != "" { + targetTypes++ + show.TargetType = ShowModule + show.TargetArg = moduleTarget + } if targetTypes != 1 { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Conflicting object types to show", - "The -state, -plan=FILENAME, and -config options are mutually-exclusive, to specify which kind of object to show.", + "The -state, -plan=FILENAME, -config, and -module=DIR options are mutually-exclusive, to specify which kind of object to show.", )) } return show, diags diff --git a/internal/command/arguments/show_test.go b/internal/command/arguments/show_test.go index 4f71770318..d475b11a37 100644 --- a/internal/command/arguments/show_test.go +++ b/internal/command/arguments/show_test.go @@ -90,6 +90,14 @@ func TestParseShow_valid(t *testing.T) { ViewType: ViewJSON, }, }, + "module with json": { + []string{"-module=foo", "-json"}, + &Show{ + TargetType: ShowModule, + TargetArg: "foo", + ViewType: ViewJSON, + }, + }, } for name, tc := range testCases { @@ -163,7 +171,7 @@ func TestParseShow_invalid(t *testing.T) { tfdiags.Sourceless( tfdiags.Error, "Conflicting object types to show", - "The -state, -plan=FILENAME, and -config options are mutually-exclusive, to specify which kind of object to show.", + "The -state, -plan=FILENAME, -config, and -module=DIR options are mutually-exclusive, to specify which kind of object to show.", ), }, }, @@ -217,7 +225,7 @@ func TestParseShow_invalid(t *testing.T) { tfdiags.Sourceless( tfdiags.Error, "Conflicting object types to show", - "The -state, -plan=FILENAME, and -config options are mutually-exclusive, to specify which kind of object to show.", + "The -state, -plan=FILENAME, -config, and -module=DIR options are mutually-exclusive, to specify which kind of object to show.", ), }, }, @@ -232,7 +240,65 @@ func TestParseShow_invalid(t *testing.T) { tfdiags.Sourceless( tfdiags.Error, "Conflicting object types to show", - "The -state, -plan=FILENAME, and -config options are mutually-exclusive, to specify which kind of object to show.", + "The -state, -plan=FILENAME, -config, and -module=DIR options are mutually-exclusive, to specify which kind of object to show.", + ), + }, + }, + "module without json": { + []string{"-module=foo"}, + &Show{ + ViewType: ViewNone, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "JSON output required for module", + "The -module=DIR option requires -json to be specified.", + ), + }, + }, + "module with state": { + []string{"-module=foo", "-state", "-json"}, + &Show{ + TargetType: ShowModule, + TargetArg: "foo", + ViewType: ViewJSON, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Conflicting object types to show", + "The -state, -plan=FILENAME, -config, and -module=DIR options are mutually-exclusive, to specify which kind of object to show.", + ), + }, + }, + "module with plan": { + []string{"-module=foo", "-plan=tfplan", "-json"}, + &Show{ + TargetType: ShowModule, + TargetArg: "foo", + ViewType: ViewJSON, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Conflicting object types to show", + "The -state, -plan=FILENAME, -config, and -module=DIR options are mutually-exclusive, to specify which kind of object to show.", + ), + }, + }, + "module with config": { + []string{"-module=foo", "-config", "-json"}, + &Show{ + TargetType: ShowModule, + TargetArg: "foo", + ViewType: ViewJSON, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Conflicting object types to show", + "The -state, -plan=FILENAME, -config, and -module=DIR options are mutually-exclusive, to specify which kind of object to show.", ), }, }, diff --git a/internal/command/arguments/showtargettype_string.go b/internal/command/arguments/showtargettype_string.go index afb1ab6b43..2cd10ce698 100644 --- a/internal/command/arguments/showtargettype_string.go +++ b/internal/command/arguments/showtargettype_string.go @@ -12,11 +12,12 @@ func _() { _ = x[ShowState-1] _ = x[ShowPlan-2] _ = x[ShowConfig-3] + _ = x[ShowModule-4] } -const _ShowTargetType_name = "ShowUnknownTypeShowStateShowPlanShowConfig" +const _ShowTargetType_name = "ShowUnknownTypeShowStateShowPlanShowConfigShowModule" -var _ShowTargetType_index = [...]uint8{0, 15, 24, 32, 42} +var _ShowTargetType_index = [...]uint8{0, 15, 24, 32, 42, 52} func (i ShowTargetType) String() string { if i < 0 || i >= ShowTargetType(len(_ShowTargetType_index)-1) { diff --git a/internal/command/jsonconfig/config.go b/internal/command/jsonconfig/config.go index 0f893dd50a..f659577a31 100644 --- a/internal/command/jsonconfig/config.go +++ b/internal/command/jsonconfig/config.go @@ -54,7 +54,7 @@ type moduleCall struct { Expressions map[string]interface{} `json:"expressions,omitempty"` CountExpression *expression `json:"count_expression,omitempty"` ForEachExpression *expression `json:"for_each_expression,omitempty"` - Module module `json:"module,omitempty"` + Module *module `json:"module,omitempty"` VersionConstraint string `json:"version_constraint,omitempty"` DependsOn []string `json:"depends_on,omitempty"` } @@ -99,7 +99,7 @@ type resource struct { // SchemaVersion indicates which version of the resource type schema the // "values" property conforms to. - SchemaVersion uint64 `json:"schema_version"` + SchemaVersion *uint64 `json:"schema_version,omitempty"` // CountExpression and ForEachExpression describe the expressions given for // the corresponding meta-arguments in the resource configuration block. @@ -111,11 +111,11 @@ type resource struct { } type output struct { - Sensitive bool `json:"sensitive,omitempty"` - Deprecated string `json:"deprecated,omitempty"` - Expression expression `json:"expression,omitempty"` - DependsOn []string `json:"depends_on,omitempty"` - Description string `json:"description,omitempty"` + Sensitive bool `json:"sensitive,omitempty"` + Deprecated string `json:"deprecated,omitempty"` + Expression *expression `json:"expression,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` + Description string `json:"description,omitempty"` } type provisioner struct { @@ -125,6 +125,19 @@ type provisioner struct { // Marshal returns the json encoding of tofu configuration. func Marshal(c *configs.Config, schemas *tofu.Schemas) ([]byte, error) { + return marshal(c, schemas) +} + +// marshal is the shared implementation of both [Marshal] and +// [MarshalSingleModule]. +// +// [MarshalSingleModule] calls this with a synthetic [configs.Config] that +// has only a root module, and with schemas set to nil. Downstream codepaths +// should test for single module mode only by passing the schemas value to +// [inSingleModuleMode], and not by directly testing if schemas are nil, +// so that it's easier for future maintainers to learn about this special +// treatment through the centralized doc comment. +func marshal(c *configs.Config, schemas *tofu.Schemas) ([]byte, error) { var output config pcs := make(map[string]providerConfig) @@ -167,7 +180,9 @@ func marshalProviderConfigs( // Add an entry for each provider configuration block in the module. for k, pc := range c.Module.ProviderConfigs { providerFqn := c.ProviderForConfigAddr(addrs.LocalProviderConfig{LocalName: pc.Name}) - schema := schemas.ProviderConfig(providerFqn) + schema := mapSchema(schemas, func(schemas *tofu.Schemas) *configschema.Block { + return schemas.ProviderConfig(providerFqn) + }) p := providerConfig{ Name: pc.Name, @@ -304,7 +319,11 @@ func marshalProviderConfigs( // Finally, marshal any other provider configs within the called module. // It is safe to do this last because it is invalid to configure a // provider which has passed provider configs in the module call. - marshalProviderConfigs(cc, schemas, m) + // We don't recurse in single-module mode, because cc will be nil in + // that case. + if !inSingleModuleMode(schemas) { + marshalProviderConfigs(cc, schemas, m) + } } } @@ -329,7 +348,10 @@ func marshalModule(c *configs.Config, schemas *tofu.Schemas, addr string) (modul o := output{ Sensitive: v.Sensitive, Deprecated: v.Deprecated, - Expression: marshalExpression(v.Expr), + } + if !inSingleModuleMode(schemas) { + expr := marshalExpression(v.Expr) + o.Expression = &expr } if v.Description != "" { o.Description = v.Description @@ -391,10 +413,9 @@ func marshalModuleCalls(c *configs.Config, schemas *tofu.Schemas) map[string]mod } func marshalModuleCall(c *configs.Config, mc *configs.ModuleCall, schemas *tofu.Schemas) moduleCall { - // It is possible to have a module call with a nil config. - if c == nil { - return moduleCall{} - } + // Note that "c" is always nil when in single module mode! + // Refer to the docs on [inSingleModuleMode] to learn about how that + // special situation works. ret := moduleCall{ // We're intentionally echoing back exactly what the user entered @@ -406,30 +427,34 @@ func marshalModuleCall(c *configs.Config, mc *configs.ModuleCall, schemas *tofu. Source: mc.SourceAddrRaw, VersionConstraint: mc.Version.Required.String(), } - cExp := marshalExpression(mc.Count) - if !cExp.Empty() { - ret.CountExpression = &cExp - } else { - fExp := marshalExpression(mc.ForEach) - if !fExp.Empty() { - ret.ForEachExpression = &fExp + + if !inSingleModuleMode(schemas) { + // The expression-related properties are not available in single-module + // mode. + cExp := marshalExpression(mc.Count) + if !cExp.Empty() { + ret.CountExpression = &cExp + } else { + fExp := marshalExpression(mc.ForEach) + if !fExp.Empty() { + ret.ForEachExpression = &fExp + } } - } - - schema := &configschema.Block{} - schema.Attributes = make(map[string]*configschema.Attribute) - for _, variable := range c.Module.Variables { - schema.Attributes[variable.Name] = &configschema.Attribute{ - Required: variable.Default == cty.NilVal, + schema := &configschema.Block{} + schema.Attributes = make(map[string]*configschema.Attribute) + for _, variable := range c.Module.Variables { + schema.Attributes[variable.Name] = &configschema.Attribute{ + Required: variable.Default == cty.NilVal, + } } + ret.Expressions = marshalExpressions(mc.Config, schema) + + // The "module" property, describing the content of the child module, + // is not available in single-module mode. + module, _ := marshalModule(c, schemas, c.Path.String()) + ret.Module = &module } - ret.Expressions = marshalExpressions(mc.Config, schema) - - module, _ := marshalModule(c, schemas, c.Path.String()) - - ret.Module = module - if len(mc.DependsOn) > 0 { dependencies := make([]string, len(mc.DependsOn)) for i, d := range mc.DependsOn { @@ -467,33 +492,38 @@ func marshalResources(resources map[string]*configs.Resource, schemas *tofu.Sche return rs, fmt.Errorf("resource %s has an unsupported mode %s", r.Address, v.Mode.String()) } - cExp := marshalExpression(v.Count) - if !cExp.Empty() { - r.CountExpression = &cExp - } else { - fExp := marshalExpression(v.ForEach) - if !fExp.Empty() { - r.ForEachExpression = &fExp + if !inSingleModuleMode(schemas) { + // We don't populate the expression and schema-related properties + // when we are in single-module mode. + cExp := marshalExpression(v.Count) + if !cExp.Empty() { + r.CountExpression = &cExp + } else { + fExp := marshalExpression(v.ForEach) + if !fExp.Empty() { + r.ForEachExpression = &fExp + } } - } - schema, schemaVer := schemas.ResourceTypeConfig( - v.Provider, - v.Mode, - v.Type, - ) - if schema == nil { - return nil, fmt.Errorf("no schema found for %s (in provider %s)", v.Addr().String(), v.Provider) + schema, schemaVer := schemas.ResourceTypeConfig( + v.Provider, + v.Mode, + v.Type, + ) + if schema == nil { + return nil, fmt.Errorf("no schema found for %s (in provider %s)", v.Addr().String(), v.Provider) + } + r.SchemaVersion = &schemaVer + r.Expressions = marshalExpressions(v.Config, schema) } - r.SchemaVersion = schemaVer - - r.Expressions = marshalExpressions(v.Config, schema) // Managed is populated only for Mode = addrs.ManagedResourceMode if v.Managed != nil && len(v.Managed.Provisioners) > 0 { var provisioners []provisioner for _, p := range v.Managed.Provisioners { - schema := schemas.ProvisionerConfig(p.Type) + schema := mapSchema(schemas, func(schema *tofu.Schemas) *configschema.Block { + return schemas.ProvisionerConfig(p.Type) + }) prov := provisioner{ Type: p.Type, Expressions: marshalExpressions(p.Config, schema), @@ -538,7 +568,13 @@ func normalizeModuleProviderKeys(m *module, pcs map[string]providerConfig) { } for _, mc := range m.ModuleCalls { - normalizeModuleProviderKeys(&mc.Module, pcs) + if mc.Module == nil { + // This field is not populated in single-module mode, but + // that's okay because it means we have no need to recurse + // into it for nested fixups. + continue + } + normalizeModuleProviderKeys(mc.Module, pcs) } } diff --git a/internal/command/jsonconfig/expression.go b/internal/command/jsonconfig/expression.go index 28549ada02..42959a7f91 100644 --- a/internal/command/jsonconfig/expression.go +++ b/internal/command/jsonconfig/expression.go @@ -94,7 +94,18 @@ func (e *expression) Empty() bool { // expression as value. type expressions map[string]interface{} +// marshalExpressions returns a representation of the expressions in the given +// body after analyzing based on the given schema. +// +// If [inSingleModuleMode] returns true when given schema, the result is always +// nil to represent that expression information is not available in +// single-module mode. func marshalExpressions(body hcl.Body, schema *configschema.Block) expressions { + if inSingleModuleMode(schema) { + // We never generate any expressions in single-module mode. + return nil + } + // Since we want the raw, un-evaluated expressions we need to use the // low-level HCL API here, rather than the hcldec decoder API. That means we // need the low-level schema. diff --git a/internal/command/jsonconfig/expression_test.go b/internal/command/jsonconfig/expression_test.go index d4faa8422a..88de043d8c 100644 --- a/internal/command/jsonconfig/expression_test.go +++ b/internal/command/jsonconfig/expression_test.go @@ -10,11 +10,11 @@ import ( "reflect" "testing" - "github.com/zclconf/go-cty/cty" - "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hcltest" + "github.com/zclconf/go-cty/cty" + "github.com/opentofu/opentofu/internal/configs/configschema" ) @@ -132,6 +132,24 @@ func TestMarshalExpressions(t *testing.T) { } } +func TestMarshalExpressions_singleModuleMode(t *testing.T) { + // In single-module mode the given schema is nil, which should + // cause the result to always be nil. Refer to the docs on + // [inSingleExpressionMode] for more information. + input := hcltest.MockBody(&hcl.BodyContent{ + Attributes: hcl.Attributes{ + "foo": { + Name: "foo", + Expr: hcltest.MockExprTraversalSrc(`var.list[1]`), + }, + }, + }) + got := marshalExpressions(input, nil) + if got != nil { + t.Errorf("wrong result:\nGot: %#v\nWant: ", got) + } +} + func TestMarshalExpression(t *testing.T) { tests := []struct { Input hcl.Expression diff --git a/internal/command/jsonconfig/single_module.go b/internal/command/jsonconfig/single_module.go new file mode 100644 index 0000000000..9284f89f44 --- /dev/null +++ b/internal/command/jsonconfig/single_module.go @@ -0,0 +1,80 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jsonconfig + +import ( + "github.com/opentofu/opentofu/internal/configs" + "github.com/opentofu/opentofu/internal/configs/configschema" + "github.com/opentofu/opentofu/internal/tofu" +) + +// MarshalSingleModule is a variant of [Marshal] that describes only a single +// module, without any references to its child modules or associated provider +// schemas. +// +// This uses only a subset of the typical configuration representation, due to +// schema and child module information being unavailable: +// - Module calls omit the "module" property that would normally describe the +// content of the child module. +// - Resource descriptions omit the "schema_version" property because no +// schema-based information is included. +// - Expression-related properties are omitted in all cases. Technically only +// expressions passed to providers _need_ to be omitted, but for now we +// just consistently omit all of them because that's an easier rule to +// explain and avoids exposing what is and is not provider-based so that +// we could potentially change those details in future. +func MarshalSingleModule(m *configs.Module) ([]byte, error) { + // Our shared codepaths are built to work with a full config tree rather + // than a single module, so we'll construct a synthetic [configs.Config] + // that only has a root module and then downstream shared functions will + // use the nil-ness of the schemas argument to handle the special + // treatments required in single-module mode. + cfg := &configs.Config{ + Module: m, + // Everything else intentionally not populated because single module + // mode should not attempt to access anything else. + } + return marshal(cfg, nil) +} + +// inSingleModuleMode returns true if the given schema value indicates that +// we should be rendering in "single module" mode, meaning that we're producing +// a result for [MarshalSingleModule] rather than [Marshal]. +// +// Currently the rule is only that a nil schemas represents single-module mode; +// this simple rule is factored out into this helper function only so we can +// centralize it underneath this doc comment explaining the special convention. +// +// (This rather odd design is a consequence of how this code evolved; we +// retrofitted the single-module mode later while using this strange treatment +// to minimize the risk to the existing working codepaths. Maybe we'll change +// the appoach to this in future; this is only an implementation detail +// within this package so we'll be able to those changes without affecting +// callers.) +func inSingleModuleMode[S schemaObject](schema S) bool { + return schema == nil +} + +// mapSchema is a helper that uses the given function to transform the given +// schema object only if it isn't nil, or immediately returns nil otherwise. +// +// This is part of our strategy to retrofit the single-module mode without +// a risky refactor of the already-working code, intended to be used in +// conjunction with [inSingleModuleMode] to smuggle the flag for whether we're +// in that mode through the nil-ness of the schema objects. +func mapSchema[In, Out schemaObject](schema In, f func(In) Out) Out { + if schema == nil { + return nil + } + return f(schema) +} + +// schemaObject is a helper interface to allow [inSingleModuleMode] to be +// generic over the different nilable schema types used by different parts +// of the implementation in this package. +type schemaObject interface { + *tofu.Schemas | *configschema.Block +} diff --git a/internal/command/show.go b/internal/command/show.go index b8863de30e..b9b5030e6c 100644 --- a/internal/command/show.go +++ b/internal/command/show.go @@ -192,6 +192,8 @@ func (c *ShowCommand) show(ctx context.Context, targetType arguments.ShowTargetT return c.showFromSavedPlanFile(ctx, targetArg, enc) case arguments.ShowConfig: return c.showConfiguration(ctx) + case arguments.ShowModule: + return c.showModule(ctx, targetArg) case arguments.ShowUnknownType: // This is a legacy case where we just have a filename and need to // try treating it as either a saved plan file or a local state @@ -583,3 +585,32 @@ func (c *ShowCommand) showConfiguration(ctx context.Context) (showRenderFunc, tf return view.DisplayConfig(config, schemas) }, diags } + +// showModule returns a function that will display metadata about the module +// in the given directory, in JSON format. +// +// The module representation is a subset of the configuration representation +// produced by [ShowCommand.showConfiguration], including only what can be +// generated without access to dependencies of the module. In particular, it +// does not include information about resource configuration arguments (which +// would require access to provider schemas) or child modules. +// +// This target type requires requires -json to be specified; it has no +// human-oriented rendering. +func (c *ShowCommand) showModule(ctx context.Context, dir string) (showRenderFunc, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + ctx, span := tracing.Tracer().Start(ctx, "Show Module") + defer span.End() + + mod, moreDiags := c.loadSingleModule(ctx, dir, configs.SelectiveLoadAll) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + return nil, diags + } + + return func(view views.Show) int { + // Display the configuration using the view + return view.DisplaySingleModule(mod) + }, diags +} diff --git a/internal/command/show_test.go b/internal/command/show_test.go index ae6aabb817..fb766c3f98 100644 --- a/internal/command/show_test.go +++ b/internal/command/show_test.go @@ -1576,3 +1576,113 @@ func TestShow_config_conflictingOptions(t *testing.T) { t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want) } } + +func TestShow_module(t *testing.T) { + // We intentionally don't cause the effect of a "tofu init" for this one, + // because the single-module mode is required to work without any + // dependencies installed and without a backend initialized so it can + // be used by the OpenTofu module registry indexing process. + + view, done := testView(t) + c := &ShowCommand{ + Meta: Meta{ + View: view, + }, + } + + args := []string{ + "-module=testdata/show-config-single-module", + "-json", + "-no-color", + } + code := c.Run(args) + output := done(t) + + if code != 0 { + t.Fatalf("wrong exit status %d; want 0\ngot: %s", code, output.Stderr()) + } + + var got map[string]any + if err := json.Unmarshal([]byte(output.Stdout()), &got); err != nil { + t.Fatalf("invalid JSON output: %s\n%s", err, output.Stdout()) + } + want := map[string]any{ + "provider_config": map[string]any{ + "test": map[string]any{ + "full_name": "example.com/bar/test", + "name": "test", + "version_constraint": "~> 2.0.0", + // "expressions" intentionally omitted in single-module mode + }, + }, + "root_module": map[string]any{ + "module_calls": map[string]any{ + "child": map[string]any{ + "source": "example.com/not/actually/used", + "version_constraint": "~> 1.0.0", + // "module" intentionally omitted in single-module mode + // "expressions" intentionally omitted in single-module mode + }, + }, + "outputs": map[string]any{ + "foo": map[string]any{ + // "expression" intentionally omitted in single-module mode + "sensitive": true, + }, + }, + "resources": []any{ + map[string]any{ + "address": "test_instance.foo", + "mode": "managed", + "type": "test_instance", + "name": "foo", + "provider_config_key": "test", + // "expressions" intentionally omitted in single-module mode + // "schema_version" intentionally omitted in single-module mode (because we're not including anything that's schema-sensitive) + // "for_each_expression" intentionally omitted in single-module mode + + "provisioners": []any{ + map[string]any{ + "type": "local-exec", + // "expressions" intentionally omitted in single-module mode + }, + }, + }, + }, + "variables": map[string]any{ + "foo": map[string]any{ + "sensitive": true, + }, + }, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Error("wrong result\n" + diff) + } +} + +func TestShow_module_noJson(t *testing.T) { + view, done := testView(t) + c := &ShowCommand{ + Meta: Meta{ + View: view, + }, + } + + args := []string{ + "-module=testdata/show-config-module", + "-no-color", + } + code := c.Run(args) + output := done(t) + + if code != 1 { + t.Fatalf("unexpected exit status %d; want 1\ngot: %s", code, output.Stdout()) + } + + got := output.Stderr() + want := "JSON output required for module" + if !strings.Contains(got, want) { + t.Errorf("unexpected output\ngot: %s\nwant:\n%s", got, want) + } +} diff --git a/internal/command/testdata/show-config-single-module/main.tf b/internal/command/testdata/show-config-single-module/main.tf new file mode 100644 index 0000000000..0ae10ea0dd --- /dev/null +++ b/internal/command/testdata/show-config-single-module/main.tf @@ -0,0 +1,42 @@ +terraform { + required_providers { + test = { + source = "example.com/bar/test" + version = "~> 2.0.0" + } + } +} + +variable "foo" { + type = string + + sensitive = true +} + +provider "test" { + foo = var.foo +} + +resource "test_instance" "foo" { + count = 5 + + foo = var.foo + + provisioner "local-exec" { + command = "echo 'not actually executed'" + } +} + +module "child" { + # The single-module mode of "tofu show" is supposed to work without + # installing any dependencies, so it's okay that this refers to + # a fake location. + source = "example.com/not/actually/used" + version = "~> 1.0.0" +} + +output "foo" { + value = test_instance.foo[0].foo + + sensitive = true +} diff --git a/internal/command/views/show.go b/internal/command/views/show.go index 9e970b5805..bbc80e7945 100644 --- a/internal/command/views/show.go +++ b/internal/command/views/show.go @@ -39,9 +39,14 @@ type Show interface { // 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 in JSON format, returning a status code for "tofu show" to return. + // 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) } @@ -158,6 +163,13 @@ func (v *ShowHuman) DisplayConfig(config *configs.Config, schemas *tofu.Schemas) 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) } @@ -214,6 +226,16 @@ func (v *ShowJSON) DisplayConfig(config *configs.Config, schemas *tofu.Schemas) 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. diff --git a/website/docs/cli/commands/show.mdx b/website/docs/cli/commands/show.mdx index dd937d8c0f..2a1e49ab93 100644 --- a/website/docs/cli/commands/show.mdx +++ b/website/docs/cli/commands/show.mdx @@ -28,7 +28,8 @@ to inspect: - `-state`: Inspect the latest state snapshot, if any. - `-plan=FILENAME`: Inspect the plan stored in the given saved plan file. -- `-config`: Inspect the current configuration (requires `-json`). +- `-config`: Inspect the current full configuration (requires `-json`). +- `-module=DIR`: Inspect the configuration of just a single module in the given directory, without requiring any dependencies to be installed (requires `-json`). The `-state` option is the default if none of these options are used. The target-selection options are mutually-exclusive. @@ -43,11 +44,11 @@ This command also accepts the following additional options: used in module source addresses or backend settings in the current configuration. -This command relies on schema information from provider plugins to fully -understand the provider-specific data structures in state, plan, and -configuration artifacts. If you are currently using different provider -versions than were used when creating the selected artifact then -you may need to use `tofu apply` (or similar) to allow OpenTofu to +Unless using the `-module=DIR` option, this command relies on schema information +from provider plugins to fully understand the provider-specific data structures +in state, plan, and configuration artifacts. If you are currently using +different provider versions than were used when creating the selected artifact +then you may need to use `tofu apply` (or similar) to allow OpenTofu to upgrade the stored data to match the latest provider schemas. ## JSON Output @@ -62,6 +63,14 @@ depends on the selected artifact type: - `-config` returns [the JSON configuration representation](../../internals/json-format.mdx#configuration-representation), providing exactly the same configuration-related information that the plan representation would include, but without requiring a plan to be created first. +- `-module=DIR` returns a subset of [the JSON configuration representation](../../internals/json-format.mdx#configuration-representation), where: + - The `"module"` property of each module call is omitted. + - The `"schema_version"` property of each resource is omitted. + - All expression-related properties are omitted. + + These omissions together allow this particular mode to work without first + executing `tofu init`, and thus without first installing the module's + dependencies. ## Legacy Usage