From 4b0b1540cc74a19b877d1270bedcb58176e176be Mon Sep 17 00:00:00 2001 From: James Pogran Date: Fri, 27 Aug 2021 16:36:06 -0400 Subject: [PATCH] Module View Command Handler Adds a handler to return a list of modules used by a given module path. Each module returned shows the module name, version, documentation link and what type of module it is (local, github, or terraform registry). It also detects if a module has nested modules, and adds them in the `DependentModules` property. This is meant to be used in tandem with https://github.com/hashicorp/vscode-terraform/pull/746 TODO: It is technically incorrect to use the package hashicorp/terraform-registry-address here as it is written to parse Terraform provider addresses and may not work correctly on Terraform module addresses. The proper approach is to create a new parsing library that is dedicated to parsing these kinds of addresses correctly, by re-using the logic defined in the authorative source: hashicorp/terraform/internal/addrs/module_source.go. However this works enough for now to identify module types for display in vscode-terraform. This also increases the CI timeout for the build process. We think the introduction of go-getter inflated the dependency tree and adds more time. At the moment it is more economical to eat the build time than take the engineering time to copy the methods needed to detect the correct module sources. --- .github/workflows/ci.yml | 2 +- go.mod | 3 +- go.sum | 6 +- .../handlers/command/module_calls.go | 139 ++++++++++++++++++ .../handlers/command/module_calls_test.go | 89 +++++++++++ .../langserver/handlers/execute_command.go | 1 + internal/terraform/datadir/module_types.go | 49 ++++++ 7 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 internal/langserver/handlers/command/module_calls.go create mode 100644 internal/langserver/handlers/command/module_calls_test.go create mode 100644 internal/terraform/datadir/module_types.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d692865..99d5838e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ env: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Checkout diff --git a/go.mod b/go.mod index 3331cae7..5b5e0374 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/fsnotify/fsnotify v1.5.1 github.com/google/go-cmp v0.5.6 github.com/google/uuid v1.2.0 // indirect + github.com/hashicorp/go-getter v1.5.8 github.com/hashicorp/go-memdb v1.3.2 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.3.0 @@ -16,7 +17,7 @@ require ( github.com/hashicorp/hcl/v2 v2.10.1 github.com/hashicorp/terraform-exec v0.14.0 github.com/hashicorp/terraform-json v0.12.0 - github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896 + github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045 github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1 github.com/kylelemons/godebug v1.1.0 // indirect github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5 diff --git a/go.sum b/go.sum index 431fd966..5a7b6e9e 100644 --- a/go.sum +++ b/go.sum @@ -159,8 +159,9 @@ github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtng github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-getter v1.5.3 h1:NF5+zOlQegim+w/EUhSLh6QhXHmZMEeHLQzllkQ3ROU= github.com/hashicorp/go-getter v1.5.3/go.mod h1:BrrV/1clo8cCYu6mxvboYg+KutTiFnXjMEgDD8+i7ZI= +github.com/hashicorp/go-getter v1.5.8 h1:qx5CZXxXz5YFpALPkbf/F1iZZoRE+f6T1i/AWw/Zkic= +github.com/hashicorp/go-getter v1.5.8/go.mod h1:BrrV/1clo8cCYu6mxvboYg+KutTiFnXjMEgDD8+i7ZI= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.0 h1:8exGP7ego3OmkfksihtSouGMZ+hQrhxx+FVELeXpVPE= github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -202,8 +203,9 @@ github.com/hashicorp/terraform-exec v0.14.0 h1:UQoUcxKTZZXhyyK68Cwn4mApT4mnFPmEX github.com/hashicorp/terraform-exec v0.14.0/go.mod h1:qrAASDq28KZiMPDnQ02sFS9udcqEkRly002EA2izXTA= github.com/hashicorp/terraform-json v0.12.0 h1:8czPgEEWWPROStjkWPUnTQDXmpmZPlkQAwYYLETaTvw= github.com/hashicorp/terraform-json v0.12.0/go.mod h1:pmbq9o4EuL43db5+0ogX10Yofv1nozM+wskr/bGFJpI= -github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896 h1:1FGtlkJw87UsTMg5s8jrekrHmUPUJaMcu6ELiVhQrNw= github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896/go.mod h1:bzBPnUIkI0RxauU8Dqo+2KrZZ28Cf48s8V6IHt3p4co= +github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045 h1:R/I8ofvXuPcTNoc//N4ruvaHGZcShI/VuU2iXo875Lo= +github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045/go.mod h1:anRyJbe12BZscpFgaeGu9gH12qfdBP094LYFtuAFzd4= github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1 h1:K6wkKTi4+aSYXDFGbGWgd3sP+gWTTM9VYOVeCXjJJm8= github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1/go.mod h1:wG+IttAk2LqgHE76fD0wt2kucaKzV7pzm7OTqCJJC3M= github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0= diff --git a/internal/langserver/handlers/command/module_calls.go b/internal/langserver/handlers/command/module_calls.go new file mode 100644 index 00000000..9d7be602 --- /dev/null +++ b/internal/langserver/handlers/command/module_calls.go @@ -0,0 +1,139 @@ +package command + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/creachadair/jrpc2/code" + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/langserver/cmd" + "github.com/hashicorp/terraform-ls/internal/terraform/datadir" + "github.com/hashicorp/terraform-ls/internal/uri" +) + +const moduleCallsVersion = 0 + +type moduleCallsResponse struct { + FormatVersion int `json:"v"` + ModuleCalls []moduleCall `json:"module_calls"` +} + +type moduleCall struct { + Name string `json:"name"` + SourceAddr string `json:"source_addr"` + Version string `json:"version,omitempty"` + SourceType datadir.ModuleType `json:"source_type,omitempty"` + DocsLink string `json:"docs_link,omitempty"` + DependentModules []moduleCall `json:"dependent_modules"` +} + +func ModuleCallsHandler(ctx context.Context, args cmd.CommandArgs) (interface{}, error) { + response := moduleCallsResponse{ + FormatVersion: moduleCallsVersion, + ModuleCalls: make([]moduleCall, 0), + } + + modUri, ok := args.GetString("uri") + if !ok || modUri == "" { + return response, fmt.Errorf("%w: expected module uri argument to be set", code.InvalidParams.Err()) + } + + if !uri.IsURIValid(modUri) { + return response, fmt.Errorf("URI %q is not valid", modUri) + } + + modPath, err := uri.PathFromURI(modUri) + if err != nil { + return response, err + } + + mm, err := lsctx.ModuleFinder(ctx) + if err != nil { + return response, err + } + + found, _ := mm.ModuleByPath(modPath) + if found == nil { + return response, nil + } + + if found.ModManifest == nil { + return response, nil + } + + response.ModuleCalls = parseModuleRecords(found.ModManifest.Records) + + return response, nil +} + +func parseModuleRecords(records []datadir.ModuleRecord) []moduleCall { + // sort all records by key so that dependent modules are found + // after primary modules + sort.SliceStable(records, func(i, j int) bool { + return records[i].Key < records[j].Key + }) + + modules := make(map[string]moduleCall) + for _, manifest := range records { + if manifest.IsRoot() { + // this is the current directory, which is technically a module + // skipping as it's not relevant in the activity bar (yet?) + continue + } + + moduleName := manifest.Key + subModuleName := "" + + // determine if this module is nested in another module + // in the currecnt workspace by finding a period in the moduleName + // is it better to look at SourceAddr and compare? + if strings.Contains(manifest.Key, ".") { + v := strings.Split(manifest.Key, ".") + moduleName = v[0] + subModuleName = v[1] + } + + // build what we know + moduleInfo := moduleCall{ + Name: moduleName, + SourceAddr: manifest.SourceAddr, + DocsLink: getModuleDocumentationLink(manifest), + Version: manifest.VersionStr, + SourceType: manifest.GetModuleType(), + DependentModules: make([]moduleCall, 0), + } + + m, present := modules[moduleName] + if present { + // this module is located inside another so append + moduleInfo.Name = subModuleName + m.DependentModules = append(m.DependentModules, moduleInfo) + modules[moduleName] = m + } else { + // this is the first we've seen module + modules[moduleName] = moduleInfo + } + } + + // don't need the map anymore, return a list of modules found + list := make([]moduleCall, 0) + for _, mo := range modules { + list = append(list, mo) + } + + sort.SliceStable(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) + + return list +} + +func getModuleDocumentationLink(record datadir.ModuleRecord) string { + if record.GetModuleType() != datadir.TFREGISTRY { + return "" + } + + return fmt.Sprintf(`https://registry.terraform.io/modules/%s/%s`, record.SourceAddr, record.VersionStr) +} diff --git a/internal/langserver/handlers/command/module_calls_test.go b/internal/langserver/handlers/command/module_calls_test.go new file mode 100644 index 00000000..05ced561 --- /dev/null +++ b/internal/langserver/handlers/command/module_calls_test.go @@ -0,0 +1,89 @@ +package command + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-ls/internal/terraform/datadir" +) + +func Test_parseModuleRecords(t *testing.T) { + tests := []struct { + name string + records []datadir.ModuleRecord + want []moduleCall + }{ + { + name: "detects terraform module types", + records: []datadir.ModuleRecord{ + { + Key: "ec2_instances", + SourceAddr: "terraform-aws-modules/ec2-instance/aws", + VersionStr: "2.12.0", + Dir: ".terraform\\modules\\ec2_instances", + }, + { + Key: "web_server_sg", + SourceAddr: "github.com/terraform-aws-modules/terraform-aws-security-group", + VersionStr: "", + Dir: ".terraform\\modules\\web_server_sg", + }, + { + Key: "eks", + SourceAddr: "terraform-aws-modules/eks/aws", + VersionStr: "17.20.0", + Dir: ".terraform\\modules\\eks", + }, + { + Key: "eks.fargate", + SourceAddr: "./modules/fargate", + VersionStr: "", + Dir: ".terraform\\modules\\eks\\modules\\fargate", + }, + }, + want: []moduleCall{ + { + Name: "ec2_instances", + SourceAddr: "terraform-aws-modules/ec2-instance/aws", + Version: "2.12.0", + SourceType: "tfregistry", + DocsLink: "https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws/2.12.0", + DependentModules: []moduleCall{}, + }, + { + Name: "eks", + SourceAddr: "terraform-aws-modules/eks/aws", + Version: "17.20.0", + SourceType: "tfregistry", + DocsLink: "https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/17.20.0", + DependentModules: []moduleCall{ + { + Name: "fargate", + SourceAddr: "./modules/fargate", + Version: "", + SourceType: "local", + DocsLink: "", + DependentModules: []moduleCall{}, + }, + }, + }, + { + Name: "web_server_sg", + SourceAddr: "github.com/terraform-aws-modules/terraform-aws-security-group", + Version: "", + SourceType: "github", + DocsLink: "", + DependentModules: []moduleCall{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseModuleRecords(tt.records) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("module mismatch: %s", diff) + } + }) + } +} diff --git a/internal/langserver/handlers/execute_command.go b/internal/langserver/handlers/execute_command.go index 3c33f063..0d543ec9 100644 --- a/internal/langserver/handlers/execute_command.go +++ b/internal/langserver/handlers/execute_command.go @@ -16,6 +16,7 @@ var handlers = cmd.Handlers{ cmd.Name("module.callers"): command.ModuleCallersHandler, cmd.Name("terraform.init"): command.TerraformInitHandler, cmd.Name("terraform.validate"): command.TerraformValidateHandler, + cmd.Name("module.calls"): command.ModuleCallsHandler, } func (lh *logHandler) WorkspaceExecuteCommand(ctx context.Context, params lsp.ExecuteCommandParams) (interface{}, error) { diff --git a/internal/terraform/datadir/module_types.go b/internal/terraform/datadir/module_types.go new file mode 100644 index 00000000..fac82bd4 --- /dev/null +++ b/internal/terraform/datadir/module_types.go @@ -0,0 +1,49 @@ +package datadir + +import ( + "github.com/hashicorp/go-getter" + tfregistry "github.com/hashicorp/terraform-registry-address" +) + +type ModuleType string + +const ( + UNKNOWN ModuleType = "unknown" + TFREGISTRY ModuleType = "tfregistry" + LOCAL ModuleType = "local" + GITHUB ModuleType = "github" + GIT ModuleType = "git" +) + +// GetModuleType parses source addresses to determine what kind of source the Terraform module comes +// from. It currently supports detecting Terraform Registry modules, GitHub modules, Git modules, and +// local file paths +func (r *ModuleRecord) GetModuleType() ModuleType { + // TODO: It is technically incorrect to use the package hashicorp/terraform-registry-address + // here as it is written to parse Terraform provider addresses and may not work correctly on + // Terraform module addresses. The proper approach is to create a new parsing library that is + // dedicated to parsing these kinds of addresses correctly, by re-using the logic defined in + // the authorative source: hashicorp/terraform/internal/addrs/module_source.go. + // However this works enough for now to identify module types for display in vscode-terraform. + // Example: terraform-aws-modules/ec2-instance/aws + if _, err := tfregistry.ParseRawProviderSourceString(r.SourceAddr); err == nil { + return TFREGISTRY + } + + // Example: github.com/terraform-aws-modules/terraform-aws-security-group + if _, ok, _ := new(getter.GitHubDetector).Detect(r.SourceAddr, ""); ok { + return GITHUB + } + + // Example: git::https://example.com/vpc.git + if _, ok, _ := new(getter.GitDetector).Detect(r.SourceAddr, ""); ok { + return GIT + } + + // Local, non relative, file paths + if _, ok, _ := new(getter.FileDetector).Detect(r.SourceAddr, ""); ok { + return LOCAL + } + + return UNKNOWN +}