Skip to content

Commit

Permalink
Merge pull request #35884 from hashicorp/sebasslash/modules-command
Browse files Browse the repository at this point in the history
`terraform modules` command
  • Loading branch information
sebasslash authored Oct 28, 2024
2 parents 6a3a933 + 1133d0e commit 8144156
Show file tree
Hide file tree
Showing 21 changed files with 906 additions and 0 deletions.
6 changes: 6 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,12 @@ func initCommands(
}, nil
},

"modules": func() (cli.Command, error) {
return &command.ModulesCommand{
Meta: meta,
}, nil
},

"output": func() (cli.Command, error) {
return &command.OutputCommand{
Meta: meta,
Expand Down
50 changes: 50 additions & 0 deletions internal/command/arguments/modules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package arguments

import "github.com/hashicorp/terraform/internal/tfdiags"

// Modules represents the command-line arguments for the modules command
type Modules struct {
// ViewType specifies which output format to use: human, JSON, or "raw"
ViewType ViewType
}

// ParseModules processes CLI arguments, returning a Modules value and error
// diagnostics. If there are any diagnostics present, a Modules value is still
// returned representing the best effort interpretation of the arguments.
func ParseModules(args []string) (*Modules, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var jsonOutput bool

modules := &Modules{}
cmdFlags := defaultFlagSet("modules")
cmdFlags.BoolVar(&jsonOutput, "json", false, "json")

if err := cmdFlags.Parse(args); err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to parse command-line flags",
err.Error(),
))
}

args = cmdFlags.Args()
if len(args) > 0 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Too many command line arguments",
"Expected no positional arguments",
))
}

switch {
case jsonOutput:
modules.ViewType = ViewJSON
default:
modules.ViewType = ViewHuman
}

return modules, diags
}
91 changes: 91 additions & 0 deletions internal/command/arguments/modules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package arguments

import (
"reflect"
"testing"

"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/terraform/internal/tfdiags"
)

func TestParseModules_valid(t *testing.T) {
testCases := map[string]struct {
args []string
want *Modules
}{
"default": {
nil,
&Modules{
ViewType: ViewHuman,
},
},
"json": {
[]string{"-json"},
&Modules{
ViewType: ViewJSON,
},
},
}

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, diags := ParseModules(tc.args)
if len(diags) > 0 {
t.Fatalf("unexpected diags: %v", diags)
}
if *got != *tc.want {
t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
})
}
}

func TestParseModules_invalid(t *testing.T) {
testCases := map[string]struct {
args []string
want *Modules
wantDiags tfdiags.Diagnostics
}{
"invalid flag": {
[]string{"-sauron"},
&Modules{
ViewType: ViewHuman,
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Failed to parse command-line flags",
"flag provided but not defined: -sauron",
),
},
},
"too many arguments": {
[]string{"-json", "frodo"},
&Modules{
ViewType: ViewJSON,
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Too many command line arguments",
"Expected no positional arguments",
),
},
},
}

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, gotDiags := ParseModules(tc.args)
if *got != *tc.want {
t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
if !reflect.DeepEqual(gotDiags, tc.wantDiags) {
t.Fatalf("wrong result\ngot: %s\nwant: %s", spew.Sdump(gotDiags), spew.Sdump(tc.wantDiags))
}
})
}
}
136 changes: 136 additions & 0 deletions internal/command/modules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package command

import (
"errors"
"fmt"

"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/modsdir"
"github.com/hashicorp/terraform/internal/moduleref"
"github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags"
)

// ModulesCommand is a Command implementation that prints out information
// about the modules declared by the current configuration.
type ModulesCommand struct {
Meta
viewType arguments.ViewType
}

func (c *ModulesCommand) Help() string {
return modulesCommandHelp
}

func (c *ModulesCommand) Synopsis() string {
return "Show all declared modules in a working directory"
}

func (c *ModulesCommand) Run(rawArgs []string) int {
// Parse global view arguments
rawArgs = c.Meta.process(rawArgs)
common, rawArgs := arguments.ParseView(rawArgs)
c.View.Configure(common)

// Parse command specific flags
args, diags := arguments.ParseModules(rawArgs)
if diags.HasErrors() {
c.View.Diagnostics(diags)
c.View.HelpPrompt("modules")
return 1
}
c.viewType = args.ViewType

// Set up the command's view
view := views.NewModules(c.viewType, c.View)

// TODO: Remove this check once a human readable view is supported
// for this command
if c.viewType != arguments.ViewJSON {
c.Ui.Error(
"The `terraform modules` command requires the `-json` flag.\n")
c.Ui.Error(modulesCommandHelp)
return 1
}

rootModPath, err := ModulePath([]string{})
if err != nil {
diags = diags.Append(err)
view.Diagnostics(diags)
return 1
}

// Read the root module path so we can then traverse the tree
rootModEarly, earlyConfDiags := c.loadSingleModule(rootModPath)
if rootModEarly == nil {
diags = diags.Append(errors.New("root module not found. Please run terraform init"), earlyConfDiags)
view.Diagnostics(diags)
return 1
}

config, confDiags := c.loadConfig(rootModPath)
// Here we check if there are any uninstalled dependencies
versionDiags := terraform.CheckCoreVersionRequirements(config)
if versionDiags.HasErrors() {
view.Diagnostics(versionDiags)
return 1
}

diags = diags.Append(earlyConfDiags)
if earlyConfDiags.HasErrors() {
view.Diagnostics(diags)
return 1
}

diags = diags.Append(confDiags)
if confDiags.HasErrors() {
view.Diagnostics(diags)
return 1
}

// Fetch the module manifest
internalManifest, diags := c.internalManifest()
if diags.HasErrors() {
view.Diagnostics(diags)
return 1
}

// Create a module reference resolver
resolver := moduleref.NewResolver(internalManifest)

// Crawl the Terraform config and find entries with references
manifestWithRef := resolver.Resolve(config)

// Render the new manifest with references
return view.Display(*manifestWithRef)
}

// internalManifest will use the configuration loader to refresh and load the
// internal manifest.
func (c *ModulesCommand) internalManifest() (modsdir.Manifest, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics

loader, err := c.initConfigLoader()
if err != nil {
diags = diags.Append(fmt.Errorf("Failed to initialize config loader: %w", err))
return nil, diags
}

if err = loader.RefreshModules(); err != nil {
diags = diags.Append(fmt.Errorf("Failed to refresh module manifest: %w", err))
return nil, diags
}

return loader.ModuleManifest(), diags
}

const modulesCommandHelp = `
Usage: terraform [global options] modules -json
Prints out a list of all declared Terraform modules and their resolved versions
in a Terraform working directory.
`
Loading

0 comments on commit 8144156

Please sign in to comment.