diff --git a/earlydecoder/decoder_test.go b/earlydecoder/decoder_test.go index 97b747f9..f0e0329a 100644 --- a/earlydecoder/decoder_test.go +++ b/earlydecoder/decoder_test.go @@ -1108,7 +1108,7 @@ module "name" { ModuleCalls: map[string]module.DeclaredModuleCall{ "name": { LocalName: "name", - SourceAddr: "registry.terraform.io/terraform-aws-modules/vpc/aws", + SourceAddr: MustParseRawModuleSourceRegistry("registry.terraform.io/terraform-aws-modules/vpc/aws"), }, }, }, @@ -1153,7 +1153,7 @@ module "name" { ModuleCalls: map[string]module.DeclaredModuleCall{ "name": { LocalName: "name", - SourceAddr: "terraform-aws-modules/vpc/aws", + SourceAddr: MustParseRawModuleSourceRegistry("terraform-aws-modules/vpc/aws"), Version: version.MustConstraints(version.NewConstraint("1.0.0")), }, }, @@ -1192,3 +1192,11 @@ func runTestCases(testCases []testCase, t *testing.T, path string) { func compareVersionConstraint(x, y *version.Constraint) bool { return x.Equals(y) } + +func MustParseRawModuleSourceRegistry(source string) tfaddr.ModuleSourceRegistry { + m, err := tfaddr.ParseRawModuleSourceRegistry(source) + if err != nil { + panic(err) + } + return m +} diff --git a/earlydecoder/load_module.go b/earlydecoder/load_module.go index f958e4b3..0bb0c030 100644 --- a/earlydecoder/load_module.go +++ b/earlydecoder/load_module.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" + tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform-schema/backend" "github.com/hashicorp/terraform-schema/internal/typeexpr" "github.com/hashicorp/terraform-schema/module" @@ -305,9 +306,16 @@ func loadModuleFromFile(file *hcl.File, mod *decodedModule) hcl.Diagnostics { } } + var sourceAddr module.ModuleSourceAddr + registryAddr, err := tfaddr.ParseRawModuleSourceRegistry(source) + if err == nil { + sourceAddr = registryAddr + } + // TODO: module.LocalSourceAddr + mod.ModuleCalls[name] = &module.DeclaredModuleCall{ LocalName: name, - SourceAddr: source, + SourceAddr: sourceAddr, Version: versionCons, } } diff --git a/module/module_calls.go b/module/module_calls.go index cda2101d..9fda0eb2 100644 --- a/module/module_calls.go +++ b/module/module_calls.go @@ -1,6 +1,8 @@ package module -import "github.com/hashicorp/go-version" +import ( + "github.com/hashicorp/go-version" +) type ModuleCalls struct { Installed map[string]InstalledModuleCall @@ -16,6 +18,13 @@ type InstalledModuleCall struct { type DeclaredModuleCall struct { LocalName string - SourceAddr string + SourceAddr ModuleSourceAddr Version version.Constraints } + +type ModuleSourceAddr interface { + ForDisplay() string + String() string +} + +// TODO: type LocalSourceAddr diff --git a/registry/registry.go b/registry/registry.go new file mode 100644 index 00000000..cb0c1545 --- /dev/null +++ b/registry/registry.go @@ -0,0 +1,26 @@ +package registry + +import ( + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/lang" + "github.com/zclconf/go-cty/cty" +) + +type ModuleData struct { + Version *version.Version + Inputs []Input + Outputs []Output +} + +type Input struct { + Name string + Type cty.Type + Description lang.MarkupContent + Default cty.Value + Required bool +} + +type Output struct { + Name string + Description lang.MarkupContent +} diff --git a/schema/module_schema.go b/schema/module_schema.go index 3e7a0a08..37f7db1b 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -10,9 +10,100 @@ import ( tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform-schema/internal/schema/refscope" "github.com/hashicorp/terraform-schema/module" + "github.com/hashicorp/terraform-schema/registry" "github.com/zclconf/go-cty/cty" ) +func schemaForDeclaredDependentModuleBlock(module module.DeclaredModuleCall, modMeta *registry.ModuleData) (*schema.BodySchema, error) { + attributes := make(map[string]*schema.AttributeSchema, 0) + + for _, input := range modMeta.Inputs { + aSchema := &schema.AttributeSchema{ + Description: input.Description, + } + if input.Required { + aSchema.IsRequired = true + } else { + aSchema.IsOptional = true + } + + typ := input.Type + defaultType := input.Default.Type() + if typ == cty.DynamicPseudoType && (defaultType != cty.DynamicPseudoType && defaultType != cty.NilType) { + typ = defaultType + } + + aSchema.Expr = convertAttributeTypeToExprConstraints(typ) + + attributes[input.Name] = aSchema + } + + bodySchema := &schema.BodySchema{ + Attributes: attributes, + } + + if module.LocalName == "" { + // avoid creating output refs if we don't have reference name + return bodySchema, nil + } + + modOutputTypes := make(map[string]cty.Type, 0) + targetableOutputs := make(schema.Targetables, 0) + + for _, output := range modMeta.Outputs { + addr := lang.Address{ + lang.RootStep{Name: "module"}, + lang.AttrStep{Name: module.LocalName}, + lang.AttrStep{Name: output.Name}, + } + + targetable := &schema.Targetable{ + Address: addr, + AsType: cty.DynamicPseudoType, + ScopeId: refscope.ModuleScope, + Description: output.Description, + // The Registry API doesn't tell us anything more about output type structure + // so we cannot target nested fields within objects, maps or lists + } + + modOutputTypes[output.Name] = cty.DynamicPseudoType + targetableOutputs = append(targetableOutputs, targetable) + } + + sort.Sort(targetableOutputs) + + addr := lang.Address{ + lang.RootStep{Name: "module"}, + lang.AttrStep{Name: module.LocalName}, + } + bodySchema.TargetableAs = append(bodySchema.TargetableAs, &schema.Targetable{ + Address: addr, + ScopeId: refscope.ModuleScope, + AsType: cty.Object(modOutputTypes), + NestedTargetables: targetableOutputs, + }) + + sourceAddr, ok := module.SourceAddr.(tfaddr.ModuleSourceRegistry) + if ok && sourceAddr.PackageAddr.Host == "registry.terraform.io" { + versionStr := "" + if modMeta.Version == nil { + versionStr = "latest" + } else { + versionStr = modMeta.Version.String() + } + + bodySchema.DocsLink = &schema.DocsLink{ + URL: fmt.Sprintf( + `https://registry.terraform.io/modules/%s/%s`, + sourceAddr.PackageAddr.ForRegistryProtocol(), + versionStr, + ), + } + } + + return bodySchema, nil +} + func schemaForDependentModuleBlock(module module.InstalledModuleCall, modMeta *module.Meta) (*schema.BodySchema, error) { attributes := make(map[string]*schema.AttributeSchema, 0) diff --git a/schema/module_schema_test.go b/schema/module_schema_test.go index c998cd63..c5933f6e 100644 --- a/schema/module_schema_test.go +++ b/schema/module_schema_test.go @@ -8,8 +8,10 @@ import ( "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" + tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform-schema/internal/schema/refscope" "github.com/hashicorp/terraform-schema/module" + "github.com/hashicorp/terraform-schema/registry" "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" ) @@ -423,3 +425,119 @@ func TestSchemaForDependentModuleBlock_DocsLink(t *testing.T) { } } } + +func TestSchemaForDeclaredDependentModuleBlock_basic(t *testing.T) { + meta := ®istry.ModuleData{ + Version: version.Must(version.NewVersion("1.0.0")), + Inputs: []registry.Input{ + { + Name: "example_var", + Type: cty.String, + Description: lang.PlainText("Test var"), + Required: true, + }, + { + Name: "foo_var", + Type: cty.DynamicPseudoType, + Default: cty.NumberIntVal(42), + }, + { + Name: "another_var", + Type: cty.DynamicPseudoType, + }, + }, + Outputs: []registry.Output{ + { + Name: "first", + Description: lang.PlainText("first output"), + }, + { + Name: "second", + Description: lang.PlainText("second output"), + }, + }, + } + module := module.DeclaredModuleCall{ + LocalName: "refname", + SourceAddr: MustParseModuleSource("terraform-aws-modules/eks/aws"), + } + depSchema, err := schemaForDeclaredDependentModuleBlock(module, meta) + if err != nil { + t.Fatal(err) + } + expectedDepSchema := &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "example_var": { + Expr: schema.ExprConstraints{ + schema.TraversalExpr{OfType: cty.String}, + schema.LiteralTypeExpr{Type: cty.String}, + }, + Description: lang.PlainText("Test var"), + IsRequired: true, + }, + "foo_var": { + Expr: schema.ExprConstraints{ + schema.TraversalExpr{OfType: cty.Number}, + schema.LiteralTypeExpr{Type: cty.Number}, + }, + IsOptional: true, + }, + "another_var": { + Expr: schema.ExprConstraints{ + schema.TraversalExpr{OfType: cty.DynamicPseudoType}, + schema.LiteralTypeExpr{Type: cty.DynamicPseudoType}, + }, + IsOptional: true, + }, + }, + TargetableAs: []*schema.Targetable{ + { + Address: lang.Address{ + lang.RootStep{Name: "module"}, + lang.AttrStep{Name: "refname"}, + }, + ScopeId: refscope.ModuleScope, + AsType: cty.Object(map[string]cty.Type{ + "first": cty.DynamicPseudoType, + "second": cty.DynamicPseudoType, + }), + NestedTargetables: []*schema.Targetable{ + { + Address: lang.Address{ + lang.RootStep{Name: "module"}, + lang.AttrStep{Name: "refname"}, + lang.AttrStep{Name: "first"}, + }, + ScopeId: refscope.ModuleScope, + AsType: cty.DynamicPseudoType, + Description: lang.PlainText("first output"), + }, + { + Address: lang.Address{ + lang.RootStep{Name: "module"}, + lang.AttrStep{Name: "refname"}, + lang.AttrStep{Name: "second"}, + }, + ScopeId: refscope.ModuleScope, + AsType: cty.DynamicPseudoType, + Description: lang.PlainText("second output"), + }, + }, + }, + }, + DocsLink: &schema.DocsLink{ + URL: "https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/1.0.0", + }, + } + if diff := cmp.Diff(expectedDepSchema, depSchema, ctydebug.CmpOptions); diff != "" { + t.Fatalf("schema mismatch: %s", diff) + } +} + +func MustParseModuleSource(raw string) tfaddr.ModuleSourceRegistry { + addr, err := tfaddr.ParseRawModuleSourceRegistry(raw) + if err != nil { + panic(err) + } + return addr +} diff --git a/schema/schema_merge.go b/schema/schema_merge.go index 5430fea0..07ef4561 100644 --- a/schema/schema_merge.go +++ b/schema/schema_merge.go @@ -9,6 +9,7 @@ import ( tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform-schema/internal/schema/backends" "github.com/hashicorp/terraform-schema/module" + "github.com/hashicorp/terraform-schema/registry" "github.com/zclconf/go-cty/cty" ) @@ -21,7 +22,8 @@ type SchemaMerger struct { type ModuleReader interface { ModuleCalls(modPath string) (module.ModuleCalls, error) - ModuleMeta(modPath string) (*module.Meta, error) + LocalModuleMeta(modPath string) (*module.Meta, error) + RegistryModuleMeta(addr tfaddr.ModuleSourceRegistry, cons version.Constraints) (*registry.ModuleData, error) } type SchemaReader interface { @@ -187,9 +189,62 @@ func (m *SchemaMerger) SchemaForModule(meta *module.Meta) (*schema.BodySchema, e return mergedSchema, nil } - // TODO: mc.Declared + for _, module := range mc.Declared { + sourceAddr, ok := module.SourceAddr.(tfaddr.ModuleSourceRegistry) + if !ok { + // TODO: local sources (See https://github.com/hashicorp/terraform-ls/issues/598) + continue + } + + modMeta, err := reader.RegistryModuleMeta(sourceAddr, module.Version) + if err != nil { + continue + } + + depKeys := schema.DependencyKeys{ + // Fetching based only on the source can cause conflicts for multiple versions of the same module + // specially if they have different versions or the source of those modules have been modified + // inside the .terraform folder. This is a compromise that we made in this moment since it would impact only auto completion + Attributes: []schema.AttributeDependent{ + { + Name: "source", + Expr: schema.ExpressionValue{ + Static: cty.StringVal(sourceAddr.String()), + }, + }, + }, + } + + depSchema, err := schemaForDeclaredDependentModuleBlock(module, modMeta) + if err == nil { + mergedSchema.Blocks["module"].DependentBody[schema.NewSchemaKey(depKeys)] = depSchema + } + + // There's likely more edge cases with how source address can be represented in config + // vs in module manifest, but for now we at least account for the common case of TF Registry + if err == nil && strings.HasPrefix(sourceAddr.String(), "registry.terraform.io/") { + shortName := strings.TrimPrefix(sourceAddr.String(), "registry.terraform.io/") + + depKeys := schema.DependencyKeys{ + // Fetching based only on the source can cause conflicts for multiple versions of the same module + // specially if they have different versions or the source of those modules have been modified + // inside the .terraform folder. This is a compromise that we made in this moment since it would impact only auto completion + Attributes: []schema.AttributeDependent{ + { + Name: "source", + Expr: schema.ExpressionValue{ + Static: cty.StringVal(shortName), + }, + }, + }, + } + + mergedSchema.Blocks["module"].DependentBody[schema.NewSchemaKey(depKeys)] = depSchema + } + } + for _, module := range mc.Installed { - modMeta, err := reader.ModuleMeta(module.Path) + modMeta, err := reader.LocalModuleMeta(module.Path) if err != nil { continue } diff --git a/schema/schema_merge_test.go b/schema/schema_merge_test.go index 2c5b15cf..9f95273e 100644 --- a/schema/schema_merge_test.go +++ b/schema/schema_merge_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform-schema/earlydecoder" "github.com/hashicorp/terraform-schema/internal/schema/tokmod" "github.com/hashicorp/terraform-schema/module" + "github.com/hashicorp/terraform-schema/registry" "github.com/zclconf/go-cty-debug/ctydebug" "github.com/zclconf/go-cty/cty" ) @@ -392,6 +393,10 @@ func testModuleReader() ModuleReader { type testModuleReaderStruct struct { } +func (m *testModuleReaderStruct) RegistryModuleMeta(addr tfaddr.ModuleSourceRegistry, cons version.Constraints) (*registry.ModuleData, error) { + return nil, nil +} + func (m *testModuleReaderStruct) ModuleCalls(modPath string) (module.ModuleCalls, error) { return module.ModuleCalls{ Installed: map[string]module.InstalledModuleCall{ @@ -404,7 +409,7 @@ func (m *testModuleReaderStruct) ModuleCalls(modPath string) (module.ModuleCalls }, nil } -func (m *testModuleReaderStruct) ModuleMeta(modPath string) (*module.Meta, error) { +func (m *testModuleReaderStruct) LocalModuleMeta(modPath string) (*module.Meta, error) { if modPath == "path" { return &module.Meta{ Path: "path", @@ -426,6 +431,10 @@ func testRegistryModuleReader() ModuleReader { type testRegistryModuleReaderStruct struct { } +func (m *testRegistryModuleReaderStruct) RegistryModuleMeta(addr tfaddr.ModuleSourceRegistry, cons version.Constraints) (*registry.ModuleData, error) { + return nil, nil +} + func (m *testRegistryModuleReaderStruct) ModuleCalls(modPath string) (module.ModuleCalls, error) { return module.ModuleCalls{ Installed: map[string]module.InstalledModuleCall{ @@ -438,7 +447,7 @@ func (m *testRegistryModuleReaderStruct) ModuleCalls(modPath string) (module.Mod }, nil } -func (m *testRegistryModuleReaderStruct) ModuleMeta(modPath string) (*module.Meta, error) { +func (m *testRegistryModuleReaderStruct) LocalModuleMeta(modPath string) (*module.Meta, error) { if modPath == ".terraform/modules/remote-example" { return &module.Meta{ Path: ".terraform/modules/remote-example",