diff --git a/earlydecoder/search/decoder.go b/earlydecoder/search/decoder.go new file mode 100644 index 00000000..7dad8112 --- /dev/null +++ b/earlydecoder/search/decoder.go @@ -0,0 +1,66 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package earlydecoder + +import ( + "github.com/hashicorp/hcl/v2" + tfaddr "github.com/hashicorp/terraform-registry-address" + "github.com/hashicorp/terraform-schema/search" + "sort" + "strings" +) + +func LoadSearch(path string, files map[string]*hcl.File) (*search.Meta, map[string]hcl.Diagnostics) { + filenames := make([]string, 0) + diags := make(map[string]hcl.Diagnostics, 0) + + mod := newDecodedSearch() + for filename, f := range files { + filenames = append(filenames, filename) + if isSearchFile(filename) { + diags[filename] = loadSearchFromFile(f, mod) + } + } + + sort.Strings(filenames) + + variables := make(map[string]search.Variable) + for key, variable := range mod.Variables { + variables[key] = *variable + } + + lists := make(map[string]search.List) + for key, list := range mod.List { + lists[key] = *list + } + + refs := make(map[search.ProviderRef]tfaddr.Provider, 0) + requirements := make(search.ProviderRequirements, 0) + + for _, cfg := range mod.ProviderConfigs { + src := refs[search.ProviderRef{ + LocalName: cfg.Name, + }] + if cfg.Alias != "" { + refs[search.ProviderRef{ + LocalName: cfg.Name, + Alias: cfg.Alias, + }] = src + } + } + + return &search.Meta{ + Path: path, + Filenames: filenames, + Variables: variables, + Lists: lists, + ProviderReferences: refs, + ProviderRequirements: requirements, + }, diags +} + +func isSearchFile(name string) bool { + return strings.HasSuffix(name, ".tfquery.hcl") || + strings.HasSuffix(name, ".tfquery.hcl.json") +} diff --git a/earlydecoder/search/decoder_test.go b/earlydecoder/search/decoder_test.go new file mode 100644 index 00000000..ef4f6a14 --- /dev/null +++ b/earlydecoder/search/decoder_test.go @@ -0,0 +1,162 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package earlydecoder + +import ( + "fmt" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + tfaddr "github.com/hashicorp/terraform-registry-address" + "github.com/hashicorp/terraform-schema/search" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" + "testing" +) + +type testCase struct { + name string + cfg string + fileName string + expectedMeta *search.Meta + expectedError map[string]hcl.Diagnostics +} + +var customComparer = []cmp.Option{ + cmp.Comparer(compareVersionConstraint), + ctydebug.CmpOptions, +} + +var fileName = "test.tfquery.hcl" + +func TestLoadSearch(t *testing.T) { + path := t.TempDir() + + testCases := []testCase{ + { + "empty config", + ``, + fileName, + &search.Meta{ + Path: path, + Filenames: []string{fileName}, + Variables: map[string]search.Variable{}, + Lists: map[string]search.List{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + ProviderReferences: map[search.ProviderRef]tfaddr.Provider{}, + }, + map[string]hcl.Diagnostics{fileName: nil}, + }, + { + "variables", + `variable "example" { + type = string + default = "default_value" + } + + variable "example2" { + description = "description" + sensitive = true + }`, + fileName, + &search.Meta{ + + Path: path, + Filenames: []string{fileName}, + Variables: map[string]search.Variable{ + "example": { + Type: cty.String, + DefaultValue: cty.StringVal("default_value"), + }, + "example2": { + Type: cty.DynamicPseudoType, + Description: "description", + IsSensitive: true, + }, + }, + Lists: map[string]search.List{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + ProviderReferences: map[search.ProviderRef]tfaddr.Provider{}, + }, + map[string]hcl.Diagnostics{fileName: nil}, + }, + } + + runTestCases(testCases, t, path) + +} + +func runTestCases(testCases []testCase, t *testing.T, path string) { + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.name), func(t *testing.T) { + f, diags := hclsyntax.ParseConfig([]byte(tc.cfg), tc.fileName, hcl.InitialPos) + if len(diags) > 0 { + t.Fatal(diags) + } + files := map[string]*hcl.File{ + tc.fileName: f, + } + + var fdiags map[string]hcl.Diagnostics + meta, fdiags := LoadSearch(path, files) + + if diff := cmp.Diff(tc.expectedError, fdiags, customComparer...); diff != "" { + t.Fatalf("expected errors doesn't match: %s", diff) + } + + if diff := cmp.Diff(tc.expectedMeta, meta, customComparer...); diff != "" { + t.Fatalf("search meta doesn't match: %s", diff) + } + }) + } +} + +func TestLoadSearchDiagnostics(t *testing.T) { + path := t.TempDir() + + testCases := []testCase{ + { + "invalid variable default value", + `variable "example" { + type = string + default = [1] +}`, + fileName, + &search.Meta{ + Path: path, + Filenames: []string{fileName}, + Variables: map[string]search.Variable{ + "example": { + Type: cty.String, + DefaultValue: cty.DynamicVal, + }, + }, + Lists: map[string]search.List{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + ProviderReferences: map[search.ProviderRef]tfaddr.Provider{}, + }, + map[string]hcl.Diagnostics{ + fileName: { + { + Severity: hcl.DiagError, + Summary: `Invalid default value for variable`, + Detail: `This default value is not compatible with the variable's type constraint: string required, but have tuple.`, + Subject: &hcl.Range{ + Filename: fileName, + Start: hcl.Pos{Line: 3, Column: 13, Byte: 49}, + End: hcl.Pos{Line: 3, Column: 16, Byte: 52}, + }, + }, + }, + }, + }, + } + + runTestCases(testCases, t, path) +} + +func compareVersionConstraint(x, y *version.Constraint) bool { + return x.Equals(y) +} diff --git a/earlydecoder/search/load_search.go b/earlydecoder/search/load_search.go new file mode 100644 index 00000000..70f4f6d3 --- /dev/null +++ b/earlydecoder/search/load_search.go @@ -0,0 +1,139 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package earlydecoder + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/typeexpr" + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/terraform-schema/search" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" +) + +type providerConfig struct { + Name string + Alias string +} + +type decodedSearch struct { + List map[string]*search.List + Variables map[string]*search.Variable + ProviderConfigs map[string]*providerConfig +} + +var providerConfigSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "version", + }, + { + Name: "alias", + }, + }, +} + +func newDecodedSearch() *decodedSearch { + return &decodedSearch{ + List: make(map[string]*search.List), + Variables: make(map[string]*search.Variable), + ProviderConfigs: make(map[string]*providerConfig), + } +} + +func loadSearchFromFile(file *hcl.File, ds *decodedSearch) hcl.Diagnostics { + var diags hcl.Diagnostics + + content, _, contentDiags := file.Body.PartialContent(rootSchema) + diags = append(diags, contentDiags...) + + for _, block := range content.Blocks { + switch block.Type { + case "variable": + content, _, contentDiags := block.Body.PartialContent(variableSchema) + diags = append(diags, contentDiags...) + + if len(block.Labels) != 1 || block.Labels[0] == "" { + continue + } + + name := block.Labels[0] + description := "" + isSensitive := false + var valDiags hcl.Diagnostics + + if attr, defined := content.Attributes["description"]; defined { + valDiags = gohcl.DecodeExpression(attr.Expr, nil, &description) + diags = append(diags, valDiags...) + } + + if attr, defined := content.Attributes["sensitive"]; defined { + valDiags = gohcl.DecodeExpression(attr.Expr, nil, &isSensitive) + diags = append(diags, valDiags...) + } + + varType := cty.DynamicPseudoType + var defaults *typeexpr.Defaults + if attr, defined := content.Attributes["type"]; defined { + varType, defaults, valDiags = typeexpr.TypeConstraintWithDefaults(attr.Expr) + diags = append(diags, valDiags...) + } + + defaultValue := cty.NilVal + if attr, defined := content.Attributes["default"]; defined { + val, vDiags := attr.Expr.Value(nil) + diags = append(diags, vDiags...) + if !vDiags.HasErrors() { + if varType != cty.NilType { + var err error + val, err = convert.Convert(val, varType) + if err != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid default value for variable", + Detail: fmt.Sprintf("This default value is not compatible with the variable's type constraint: %s.", err), + Subject: attr.Expr.Range().Ptr(), + }) + val = cty.DynamicVal + } + } + + defaultValue = val + } + } + + ds.Variables[name] = &search.Variable{ + Type: varType, + Description: description, + DefaultValue: defaultValue, + TypeDefaults: defaults, + IsSensitive: isSensitive, + } + + case "provider": + content, _, contentDiags := block.Body.PartialContent(providerConfigSchema) + diags = append(diags, contentDiags...) + name := block.Labels[0] + providerKey := name + var alias string + if attr, defined := content.Attributes["alias"]; defined { + valDiags := gohcl.DecodeExpression(attr.Expr, nil, &alias) + diags = append(diags, valDiags...) + if !valDiags.HasErrors() && alias != "" { + providerKey = fmt.Sprintf("%s.%s", name, alias) + } + } + + ds.ProviderConfigs[providerKey] = &providerConfig{ + Name: name, + Alias: alias, + } + } + + } + + return diags +} diff --git a/earlydecoder/search/schema.go b/earlydecoder/search/schema.go new file mode 100644 index 00000000..3b75032e --- /dev/null +++ b/earlydecoder/search/schema.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package earlydecoder + +import ( + "github.com/hashicorp/hcl/v2" +) + +var rootSchema = &hcl.BodySchema{ + Blocks: []hcl.BlockHeaderSchema{ + { + Type: "list", + LabelNames: []string{"type", "name"}, + }, + { + Type: "variable", + LabelNames: []string{"name"}, + }, + { + Type: "provider", + LabelNames: []string{"name"}, + }, + }, +} + +var variableSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "description", + }, + { + Name: "type", + }, + { + Name: "default", + }, + { + Name: "sensitive", + }, + }, +} diff --git a/earlydecoder/stacks/decoder_test.go b/earlydecoder/stacks/decoder_test.go index 719b46ac..9245832c 100644 --- a/earlydecoder/stacks/decoder_test.go +++ b/earlydecoder/stacks/decoder_test.go @@ -264,7 +264,7 @@ func TestLoadStackDiagnostics(t *testing.T) { { Severity: hcl.DiagError, Summary: `Invalid default value for variable`, - Detail: `This default value is not compatible with the variable's type constraint: string required.`, + Detail: `This default value is not compatible with the variable's type constraint: string required, but have tuple.`, Subject: &hcl.Range{ Filename: fileName, Start: hcl.Pos{Line: 3, Column: 13, Byte: 49}, diff --git a/go.mod b/go.mod index 21cb2d16..7067f61d 100644 --- a/go.mod +++ b/go.mod @@ -10,10 +10,10 @@ require ( github.com/hashicorp/hcl-lang v0.0.0-20250613065305-ef4e1a57cead github.com/hashicorp/hcl/v2 v2.23.0 github.com/hashicorp/terraform-exec v0.23.0 - github.com/hashicorp/terraform-json v0.25.0 + github.com/hashicorp/terraform-json v0.26.0 github.com/hashicorp/terraform-registry-address v0.3.0 github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5 - github.com/zclconf/go-cty v1.16.2 + github.com/zclconf/go-cty v1.16.3 github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 ) diff --git a/go.sum b/go.sum index a55bfc8c..07bba1c2 100644 --- a/go.sum +++ b/go.sum @@ -50,8 +50,8 @@ github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3q github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I= github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY= -github.com/hashicorp/terraform-json v0.25.0 h1:rmNqc/CIfcWawGiwXmRuiXJKEiJu1ntGoxseG1hLhoQ= -github.com/hashicorp/terraform-json v0.25.0/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc= +github.com/hashicorp/terraform-json v0.26.0 h1:+BnJavhRH+oyNWPnfzrfQwVWCZBFMvjdiH2Vi38Udz4= +github.com/hashicorp/terraform-json v0.26.0/go.mod h1:eyWCeC3nrZamyrKLFnrvwpc3LQPIJsx8hWHQ/nu2/v4= github.com/hashicorp/terraform-registry-address v0.3.0 h1:HMpK3nqaGFPS9VmgRXrJL/dzHNdheGVKk5k7VlFxzCo= github.com/hashicorp/terraform-registry-address v0.3.0/go.mod h1:jRGCMiLaY9zii3GLC7hqpSnwhfnCN5yzvY0hh4iCGbM= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= @@ -76,8 +76,8 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= -github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= +github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= diff --git a/internal/schema/refscope/scopes.go b/internal/schema/refscope/scopes.go index 7fbbdc3e..9d9a88ba 100644 --- a/internal/schema/refscope/scopes.go +++ b/internal/schema/refscope/scopes.go @@ -22,4 +22,6 @@ var ( IdentityTokenScope = lang.ScopeId("identity_token") StoreScope = lang.ScopeId("store") OrchestrateContext = lang.ScopeId("orchestrate_context") + + ListScope = lang.ScopeId("list") ) diff --git a/internal/schema/search/1.14/list_block.go b/internal/schema/search/1.14/list_block.go new file mode 100644 index 00000000..b4f61b09 --- /dev/null +++ b/internal/schema/search/1.14/list_block.go @@ -0,0 +1,90 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/terraform-schema/internal/schema/refscope" + "github.com/hashicorp/terraform-schema/internal/schema/tokmod" + "github.com/zclconf/go-cty/cty" +) + +func listBlockSchema() *schema.BlockSchema { + return &schema.BlockSchema{ + Address: &schema.BlockAddrSchema{ + Steps: []schema.AddrStep{ + schema.StaticStep{Name: "list"}, + schema.LabelStep{Index: 0}, + schema.LabelStep{Index: 1}, + }, + FriendlyName: "list", + ScopeId: refscope.ListScope, + AsReference: true, + DependentBodyAsData: true, + InferDependentBody: true, + DependentBodySelfRef: true, + }, + Labels: []*schema.LabelSchema{ + { + Name: "type", + Description: lang.PlainText("List Type"), + SemanticTokenModifiers: lang.SemanticTokenModifiers{tokmod.Type, lang.TokenModifierDependent}, + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + Description: lang.PlainText("Reference Name"), + SemanticTokenModifiers: lang.SemanticTokenModifiers{tokmod.Name}, + }, + }, + Description: lang.PlainText("A list block defines a mechanism to retrieve collections of resources. " + + "It specifies the type of resource to be listed and is uniquely identified by the name."), + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + Count: true, + ForEach: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "provider": { + Constraint: schema.Reference{OfScopeId: refscope.ProviderScope}, + IsRequired: true, + Description: lang.Markdown("Reference to a `provider` configuration block, e.g. `mycloud.west` or `mycloud`"), + IsDepKey: true, + SemanticTokenModifiers: lang.SemanticTokenModifiers{lang.TokenModifierDependent}, + }, + "include_resource": { + Constraint: schema.AnyExpression{OfType: cty.Bool}, + DefaultValue: schema.DefaultValue{Value: cty.False}, + IsOptional: true, + Description: lang.Markdown("By default, the results of a list resource only include the identities of the discovered resources. " + + "If it is marked true then the provider should include the resource data in the result."), + }, + "limit": { + Constraint: schema.AnyExpression{OfType: cty.Number}, + IsOptional: true, + Description: lang.Markdown("Limit is an optional value that can be used to limit the " + + "number of results returned by the list resource."), + }, + "depends_on": { + Constraint: schema.Set{ + Elem: schema.OneOf{ + schema.Reference{OfScopeId: refscope.ListScope}, + }, + }, + IsOptional: true, + Description: lang.Markdown("Set of references to hidden dependencies, e.g. other list"), + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "config": { + Description: lang.Markdown("Filters specific to the list type"), + MaxItems: 1, + MinItems: 1, + }, + }, + }, + } +} diff --git a/internal/schema/search/1.14/locals_block.go b/internal/schema/search/1.14/locals_block.go new file mode 100644 index 00000000..eed2ee1e --- /dev/null +++ b/internal/schema/search/1.14/locals_block.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/terraform-schema/internal/schema/refscope" + "github.com/hashicorp/terraform-schema/internal/schema/tokmod" + "github.com/zclconf/go-cty/cty" +) + +func localsBlockSchema() *schema.BlockSchema { + return &schema.BlockSchema{ + SemanticTokenModifiers: lang.SemanticTokenModifiers{tokmod.Locals}, + Description: lang.Markdown("Local values assigning names to expressions, so you can use these multiple times without repetition\n" + + "e.g. `service_name = \"forum\"`"), + Body: &schema.BodySchema{ + AnyAttribute: &schema.AttributeSchema{ + Address: &schema.AttributeAddrSchema{ + Steps: []schema.AddrStep{ + schema.StaticStep{Name: "local"}, + schema.AttrNameStep{}, + }, + ScopeId: refscope.LocalScope, + AsExprType: true, + AsReference: true, + }, + Constraint: schema.AnyExpression{OfType: cty.DynamicPseudoType}, + }, + }, + } +} diff --git a/internal/schema/search/1.14/provider_block.go b/internal/schema/search/1.14/provider_block.go new file mode 100644 index 00000000..2d9b2ce6 --- /dev/null +++ b/internal/schema/search/1.14/provider_block.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/terraform-schema/internal/schema/refscope" + "github.com/hashicorp/terraform-schema/internal/schema/tokmod" + "github.com/zclconf/go-cty/cty" +) + +func providerBlockSchema() *schema.BlockSchema { + return &schema.BlockSchema{ + Address: &schema.BlockAddrSchema{ + Steps: []schema.AddrStep{ + schema.LabelStep{Index: 0}, + schema.AttrValueStep{Name: "alias", IsOptional: true}, + }, + FriendlyName: "provider", + ScopeId: refscope.ProviderScope, + AsReference: true, + }, + SemanticTokenModifiers: lang.SemanticTokenModifiers{tokmod.Provider}, + Labels: []*schema.LabelSchema{ + { + Name: "name", + SemanticTokenModifiers: lang.SemanticTokenModifiers{tokmod.Name, lang.TokenModifierDependent}, + Description: lang.PlainText("Provider Name"), + IsDepKey: true, + Completable: true, + }, + }, + Description: lang.PlainText("A provider block is used to specify a provider configuration"), + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + DynamicBlocks: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "alias": { + Constraint: schema.LiteralType{Type: cty.String}, + IsOptional: true, + Description: lang.Markdown("Alias for using the same provider with different configurations for different resources, e.g. `eu-west`"), + }, + "version": { + Constraint: schema.LiteralType{Type: cty.String}, + IsOptional: true, + IsDeprecated: true, + Description: lang.Markdown("Specifies a version constraint for the provider. e.g. `~> 1.0`.\n" + + "**DEPRECATED:** Use `required_providers` block to manage provider version instead."), + }, + }, + }, + } +} diff --git a/internal/schema/search/1.14/root.go b/internal/schema/search/1.14/root.go new file mode 100644 index 00000000..8b349524 --- /dev/null +++ b/internal/schema/search/1.14/root.go @@ -0,0 +1,22 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/schema" +) + +// SearchSchema returns the static schema for a search +// configuration (*.tfsearch.hcl) file. +func SearchSchema(_ *version.Version) *schema.BodySchema { + return &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "list": listBlockSchema(), + "locals": localsBlockSchema(), + "provider": providerBlockSchema(), + "variable": variableBlockSchema(), + }, + } +} diff --git a/internal/schema/search/1.14/variable_block.go b/internal/schema/search/1.14/variable_block.go new file mode 100644 index 00000000..43ba5cf1 --- /dev/null +++ b/internal/schema/search/1.14/variable_block.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform-schema/internal/schema/refscope" + "github.com/hashicorp/terraform-schema/internal/schema/tokmod" +) + +func variableBlockSchema() *schema.BlockSchema { + return &schema.BlockSchema{ + Address: &schema.BlockAddrSchema{ + Steps: []schema.AddrStep{ + schema.StaticStep{Name: "var"}, + schema.LabelStep{Index: 0}, + }, + FriendlyName: "variable", + ScopeId: refscope.VariableScope, + AsReference: true, + AsTypeOf: &schema.BlockAsTypeOf{ + AttributeExpr: "type", + }, + }, + SemanticTokenModifiers: lang.SemanticTokenModifiers{tokmod.Variable}, + Labels: []*schema.LabelSchema{ + { + Name: "name", + SemanticTokenModifiers: lang.SemanticTokenModifiers{tokmod.Name}, + Description: lang.PlainText("Variable Name"), + }, + }, + Description: lang.Markdown("Input variable allowing users to customize aspects of the configuration when used directly " + + "(e.g. via CLI, `tfvars` file or via environment variables), or as a module (via `module` arguments)"), + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "description": { + Constraint: schema.LiteralType{Type: cty.String}, + IsOptional: true, + Description: lang.Markdown("Description to document the purpose of the variable and what value is expected"), + }, + "type": { + Constraint: schema.TypeDeclaration{}, + IsOptional: true, + Description: lang.Markdown("Type constraint restricting the type of value to accept, e.g. `string` or `list(string)`"), + }, + "sensitive": { + Constraint: schema.LiteralType{Type: cty.Bool}, + DefaultValue: schema.DefaultValue{Value: cty.False}, + IsOptional: true, + Description: lang.Markdown("Whether the variable contains sensitive material and should be hidden in the UI"), + }, + "nullable": { + Constraint: schema.LiteralType{Type: cty.Bool}, + DefaultValue: schema.DefaultValue{Value: cty.False}, + IsOptional: true, + Description: lang.Markdown("Specifies whether `null` is a valid value for this variable"), + }, + "ephemeral": { + IsOptional: true, + Constraint: schema.LiteralType{Type: cty.Bool}, + Description: lang.PlainText("Whether the value is ephemeral and should not be persisted in the state"), + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "validation": { + Description: lang.Markdown("Custom validation rule to restrict what value is expected for the variable"), + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "condition": { + Constraint: schema.LiteralType{Type: cty.Bool}, + IsRequired: true, + Description: lang.Markdown("Condition under which a variable value is valid, " + + "e.g. `length(var.example) >= 4` enforces minimum of 4 characters"), + }, + "error_message": { + Constraint: schema.LiteralType{Type: cty.String}, + IsRequired: true, + Description: lang.Markdown("Error message to present when the variable is considered invalid, " + + "i.e. when `condition` evaluates to `false`"), + }, + }, + }, + }, + }, + }, + } +} diff --git a/schema/convert_json.go b/schema/convert_json.go index 73d7936c..b7f690d0 100644 --- a/schema/convert_json.go +++ b/schema/convert_json.go @@ -21,6 +21,7 @@ func ProviderSchemaFromJson(jsonSchema *tfjson.ProviderSchema, pAddr tfaddr.Prov EphemeralResources: map[string]*schema.BodySchema{}, DataSources: map[string]*schema.BodySchema{}, Functions: map[string]*schema.FunctionSignature{}, + ListResources: map[string]*schema.BodySchema{}, } if jsonSchema.ConfigSchema != nil { @@ -50,6 +51,11 @@ func ProviderSchemaFromJson(jsonSchema *tfjson.ProviderSchema, pAddr tfaddr.Prov ps.Functions[fnName].Detail = detailForSrcAddr(pAddr, nil) } + for lrName, lrSchema := range jsonSchema.ListResourceSchemas { + ps.ListResources[lrName] = bodySchemaFromJson(lrSchema.Block) + ps.ListResources[lrName].Detail = detailForSrcAddr(pAddr, nil) + } + return ps } diff --git a/schema/convert_json_test.go b/schema/convert_json_test.go index fc54cbcc..736e17fd 100644 --- a/schema/convert_json_test.go +++ b/schema/convert_json_test.go @@ -27,6 +27,7 @@ func TestProviderSchemaFromJson_empty(t *testing.T) { EphemeralResources: map[string]*schema.BodySchema{}, DataSources: map[string]*schema.BodySchema{}, Functions: map[string]*schema.FunctionSignature{}, + ListResources: map[string]*schema.BodySchema{}, } if diff := cmp.Diff(expectedPs, ps, ctydebug.CmpOptions); diff != "" { @@ -297,6 +298,7 @@ func TestProviderSchemaFromJson_basic(t *testing.T) { EphemeralResources: map[string]*schema.BodySchema{}, DataSources: map[string]*schema.BodySchema{}, Functions: map[string]*schema.FunctionSignature{}, + ListResources: map[string]*schema.BodySchema{}, } if diff := cmp.Diff(expectedPs, ps, ctydebug.CmpOptions); diff != "" { @@ -506,6 +508,7 @@ func TestProviderSchemaFromJson_nested_set_list(t *testing.T) { EphemeralResources: map[string]*schema.BodySchema{}, DataSources: map[string]*schema.BodySchema{}, Functions: map[string]*schema.FunctionSignature{}, + ListResources: map[string]*schema.BodySchema{}, } if diff := cmp.Diff(expectedPs, ps, ctydebug.CmpOptions); diff != "" { @@ -556,6 +559,7 @@ func TestProviderSchemaFromJson_function(t *testing.T) { VarParam: nil, }, }, + ListResources: map[string]*schema.BodySchema{}, }, }, { @@ -583,6 +587,7 @@ func TestProviderSchemaFromJson_function(t *testing.T) { VarParam: nil, }, }, + ListResources: map[string]*schema.BodySchema{}, }, }, { @@ -631,6 +636,7 @@ func TestProviderSchemaFromJson_function(t *testing.T) { }, }, }, + ListResources: map[string]*schema.BodySchema{}, }, }, } diff --git a/schema/provider_schema.go b/schema/provider_schema.go index a5499bb8..94dc8970 100644 --- a/schema/provider_schema.go +++ b/schema/provider_schema.go @@ -15,6 +15,7 @@ type ProviderSchema struct { EphemeralResources map[string]*schema.BodySchema DataSources map[string]*schema.BodySchema Functions map[string]*schema.FunctionSignature + ListResources map[string]*schema.BodySchema } func (ps *ProviderSchema) Copy() *ProviderSchema { @@ -54,6 +55,13 @@ func (ps *ProviderSchema) Copy() *ProviderSchema { } } + if ps.ListResources != nil { + newPs.ListResources = make(map[string]*schema.BodySchema, len(ps.ListResources)) + for name, lsSchema := range ps.ListResources { + newPs.ListResources[name] = lsSchema.Copy() + } + } + return newPs } @@ -75,4 +83,7 @@ func (ps *ProviderSchema) SetProviderVersion(pAddr tfaddr.Provider, v *version.V for _, fSig := range ps.Functions { fSig.Detail = detailForSrcAddr(pAddr, v) } + for _, lsSchema := range ps.ListResources { + lsSchema.Detail = detailForSrcAddr(pAddr, v) + } } diff --git a/schema/search/search_schema.go b/schema/search/search_schema.go new file mode 100644 index 00000000..42e49e0f --- /dev/null +++ b/schema/search/search_schema.go @@ -0,0 +1,17 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/schema" + search_1_14 "github.com/hashicorp/terraform-schema/internal/schema/search/1.14" +) + +// CoreSearchSchemaForVersion finds a schema for search configuration files +// that is relevant for the given Terraform version. +// It will return an error if such schema cannot be found. +func CoreSearchSchemaForVersion(v *version.Version) (*schema.BodySchema, error) { + return search_1_14.SearchSchema(v), nil +} diff --git a/schema/search/search_schema_merge.go b/schema/search/search_schema_merge.go new file mode 100644 index 00000000..bf866990 --- /dev/null +++ b/schema/search/search_schema_merge.go @@ -0,0 +1,167 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "strings" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + tfaddr "github.com/hashicorp/terraform-registry-address" + tfschema "github.com/hashicorp/terraform-schema/schema" + tfsearch "github.com/hashicorp/terraform-schema/search" +) + +type SearchSchemaMerger struct { + coreSchema *schema.BodySchema + stateReader StateReader +} + +// StateReader exposes a set of methods to read data from the internal language server state +type StateReader interface { + // ProviderSchema returns the schema for a provider we have stored in memory. The can come + // from different sources. + ProviderSchema(modPath string, addr tfaddr.Provider, vc version.Constraints) (*tfschema.ProviderSchema, error) +} + +func NewSearchSchemaMerger(coreSchema *schema.BodySchema) *SearchSchemaMerger { + return &SearchSchemaMerger{ + coreSchema: coreSchema, + } +} + +func (m *SearchSchemaMerger) SetStateReader(mr StateReader) { + m.stateReader = mr +} + +func (m *SearchSchemaMerger) SchemaForSearch(meta *tfsearch.Meta) (*schema.BodySchema, error) { + if m.coreSchema == nil { + return nil, tfschema.CoreSchemaRequiredErr{} + } + + if meta == nil { + return m.coreSchema, nil + } + + if m.stateReader == nil { + return m.coreSchema, nil + } + + mergedSchema := m.coreSchema.Copy() + + if mergedSchema.Blocks["provider"].DependentBody == nil { + mergedSchema.Blocks["provider"].DependentBody = make(map[schema.SchemaKey]*schema.BodySchema) + } + + if mergedSchema.Blocks["list"].DependentBody == nil { + mergedSchema.Blocks["list"].DependentBody = make(map[schema.SchemaKey]*schema.BodySchema) + } + + if _, ok := mergedSchema.Blocks["variable"]; ok { + mergedSchema.Blocks["variable"].Labels = []*schema.LabelSchema{ + { + Name: "name", + IsDepKey: true, + Description: lang.PlainText("Variable name"), + }, + } + mergedSchema.Blocks["variable"].DependentBody = variableDependentBody(meta.Variables) + } + + providerRefs := ProviderReferences(meta.ProviderReferences) + + for pAddr, pVersionCons := range meta.ProviderRequirements { + pSchema, err := m.stateReader.ProviderSchema(meta.Path, pAddr, pVersionCons) + if err != nil { + continue + } + refs := providerRefs.ReferencesOfProvider(pAddr) + + for _, localRef := range refs { + if pSchema.Provider != nil { + mergedSchema.Blocks["provider"].DependentBody[schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: localRef.LocalName}, + }, + })] = pSchema.Provider + } + + providerAddr := lang.Address{ + lang.RootStep{Name: localRef.LocalName}, + } + if localRef.Alias != "" { + providerAddr = append(providerAddr, lang.AttrStep{Name: localRef.Alias}) + } + for lrName, lrSchema := range pSchema.ListResources { + depKeys := schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: lrName}, + }, + Attributes: []schema.AttributeDependent{ + { + Name: "provider", + Expr: schema.ExpressionValue{ + Address: providerAddr, + }, + }, + }, + } + mergedSchema.Blocks["list"].DependentBody[schema.NewSchemaKey(depKeys)] = lrSchema + + // No explicit association is required + // if the resource prefix matches provider name + if TypeBelongsToProvider(lrName, localRef) { + depKeys := schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: lrName}, + }, + } + mergedSchema.Blocks["list"].DependentBody[schema.NewSchemaKey(depKeys)] = lrSchema + } + } + } + } + return mergedSchema, nil +} + +func variableDependentBody(vars map[string]tfsearch.Variable) map[schema.SchemaKey]*schema.BodySchema { + depBodies := make(map[schema.SchemaKey]*schema.BodySchema) + + for name, mVar := range vars { + depKeys := schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: name}, + }, + } + depBodies[schema.NewSchemaKey(depKeys)] = &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "default": { + Constraint: schema.LiteralType{Type: mVar.Type}, + IsOptional: true, + Description: lang.Markdown("Default value to use when variable is not explicitly set"), + }, + }, + } + } + + return depBodies +} + +type ProviderReferences map[tfsearch.ProviderRef]tfaddr.Provider + +func (pr ProviderReferences) ReferencesOfProvider(addr tfaddr.Provider) []tfsearch.ProviderRef { + refs := make([]tfsearch.ProviderRef, 0) + + for ref, pAddr := range pr { + if pAddr.Equals(addr) { + refs = append(refs, ref) + } + } + return refs +} + +func TypeBelongsToProvider(typeName string, pRef tfsearch.ProviderRef) bool { + return typeName == pRef.LocalName || strings.HasPrefix(typeName, pRef.LocalName+"_") +} diff --git a/schema/search/search_schema_merge_test.go b/schema/search/search_schema_merge_test.go new file mode 100644 index 00000000..adbf5129 --- /dev/null +++ b/schema/search/search_schema_merge_test.go @@ -0,0 +1,509 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "errors" + "fmt" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + tfjson "github.com/hashicorp/terraform-json" + tfaddr "github.com/hashicorp/terraform-registry-address" + "github.com/hashicorp/terraform-schema/internal/addr" + tfmod "github.com/hashicorp/terraform-schema/module" + tfschema "github.com/hashicorp/terraform-schema/schema" + tfsearch "github.com/hashicorp/terraform-schema/search" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestSearchSchemaMerger_SchemaForSearch_noCoreSchema(t *testing.T) { + sm := NewSearchSchemaMerger(nil) + + _, err := sm.SchemaForSearch(nil) + if err == nil { + t.Fatal("expected error for nil core schema") + } + + if !errors.Is(err, tfschema.CoreSchemaRequiredErr{}) { + t.Fatalf("unexpected error: %#v", err) + } +} + +func TestSearchSchemaMerger_SchemaForSearch_noProviderSchema(t *testing.T) { + testCoreSchema := &schema.BodySchema{} + + sm := NewSearchSchemaMerger(testCoreSchema) + + _, err := sm.SchemaForSearch(&tfsearch.Meta{}) + if err != nil { + t.Fatal(err) + } +} + +func TestSearchSchemaMerger_SchemaForSearch_providerNameMatch(t *testing.T) { + testCoreSchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "alias": {Constraint: schema.LiteralType{Type: cty.String}, IsOptional: true}, + }, + }, + }, + "list": {}, + }, + } + sm := NewSearchSchemaMerger(testCoreSchema) + sm.SetStateReader(&testSearchSchemaReader{ + ps: &tfjson.ProviderSchemas{ + FormatVersion: "1.0", + Schemas: map[string]*tfjson.ProviderSchema{ + "registry.terraform.io/hashicorp/data": { + ConfigSchema: &tfjson.Schema{ + Block: &tfjson.SchemaBlock{ + Attributes: map[string]*tfjson.SchemaAttribute{ + "foobar": { + AttributeType: cty.Bool, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + }) + + givenBodySchema, err := sm.SchemaForSearch(&tfsearch.Meta{ + ProviderReferences: map[tfsearch.ProviderRef]tfaddr.Provider{ + {LocalName: "data"}: addr.NewDefaultProvider("data"), + }, + ProviderRequirements: tfsearch.ProviderRequirements{ + addr.NewDefaultProvider("data"): version.MustConstraints(version.NewConstraint("1.0")), + }, + }) + + if err != nil { + t.Fatal(err) + } + expectedBodySchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "alias": {Constraint: schema.LiteralType{Type: cty.String}, IsOptional: true}, + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + `{"labels":[{"index":0,"value":"data"}]}`: { + Blocks: map[string]*schema.BlockSchema{}, + Attributes: map[string]*schema.AttributeSchema{ + "foobar": { + IsOptional: true, + Constraint: schema.AnyExpression{OfType: cty.Bool}, + }, + }, + Detail: "hashicorp/data", + DocsLink: &schema.DocsLink{ + URL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + Tooltip: "hashicorp/data Documentation", + }, + HoverURL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + }, + }, + }, + "list": { + DependentBody: map[schema.SchemaKey]*schema.BodySchema{}, + }, + }, + } + + if diff := cmp.Diff(expectedBodySchema, givenBodySchema, ctydebug.CmpOptions); diff != "" { + t.Fatalf("schema mismatch: %s", diff) + } +} + +func TestSchemaMerger_SchemaForModule_twiceMerged(t *testing.T) { + testCoreSchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "alias": {Constraint: schema.LiteralType{Type: cty.String}, IsOptional: true}, + }, + }, + }, + "list": {}, + }, + } + sm := NewSearchSchemaMerger(testCoreSchema) + sm.SetStateReader(&testSearchSchemaReader{ + ps: &tfjson.ProviderSchemas{ + FormatVersion: "1.0", + Schemas: map[string]*tfjson.ProviderSchema{ + "registry.terraform.io/hashicorp/data": { + ConfigSchema: &tfjson.Schema{ + Block: &tfjson.SchemaBlock{ + Attributes: map[string]*tfjson.SchemaAttribute{ + "foobar": { + AttributeType: cty.Bool, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + }) + + givenBodySchema, err := sm.SchemaForSearch(&tfsearch.Meta{ + ProviderReferences: map[tfsearch.ProviderRef]tfaddr.Provider{ + {LocalName: "data"}: addr.NewDefaultProvider("data"), + }, + ProviderRequirements: tfsearch.ProviderRequirements{ + addr.NewDefaultProvider("data"): version.MustConstraints(version.NewConstraint("1.0")), + }, + }) + + if err != nil { + t.Fatal(err) + } + expectedBodySchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "alias": {Constraint: schema.LiteralType{Type: cty.String}, IsOptional: true}, + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + `{"labels":[{"index":0,"value":"data"}]}`: { + Blocks: map[string]*schema.BlockSchema{}, + Attributes: map[string]*schema.AttributeSchema{ + "foobar": { + IsOptional: true, + Constraint: schema.AnyExpression{OfType: cty.Bool}, + }, + }, + Detail: "hashicorp/data", + DocsLink: &schema.DocsLink{ + URL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + Tooltip: "hashicorp/data Documentation", + }, + HoverURL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + }, + }, + }, + "list": { + DependentBody: map[schema.SchemaKey]*schema.BodySchema{}, + }, + }, + } + + if diff := cmp.Diff(expectedBodySchema, givenBodySchema, ctydebug.CmpOptions); diff != "" { + t.Fatalf("schema mismatch: %s", diff) + } + + // now merge again with different local name + givenBodySchema, err = sm.SchemaForSearch(&tfsearch.Meta{ + ProviderReferences: map[tfsearch.ProviderRef]tfaddr.Provider{ + {LocalName: "test_data"}: addr.NewDefaultProvider("data"), + }, + ProviderRequirements: tfsearch.ProviderRequirements{ + addr.NewDefaultProvider("data"): version.MustConstraints(version.NewConstraint("1.0")), + }, + }) + + if err != nil { + t.Fatal(err) + } + expectedBodySchema = &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "alias": {Constraint: schema.LiteralType{Type: cty.String}, IsOptional: true}, + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + `{"labels":[{"index":0,"value":"test_data"}]}`: { + Blocks: map[string]*schema.BlockSchema{}, + Attributes: map[string]*schema.AttributeSchema{ + "foobar": { + IsOptional: true, + Constraint: schema.AnyExpression{OfType: cty.Bool}, + }, + }, + Detail: "hashicorp/data", + DocsLink: &schema.DocsLink{ + URL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + Tooltip: "hashicorp/data Documentation", + }, + HoverURL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + }, + }, + }, + "list": { + DependentBody: map[schema.SchemaKey]*schema.BodySchema{}, + }, + }, + } + + if diff := cmp.Diff(expectedBodySchema, givenBodySchema, ctydebug.CmpOptions); diff != "" { + t.Fatalf("schema mismatch: %s", diff) + } +} + +func TestStackSchemaMerger_SchemaForStack_variables(t *testing.T) { + testCoreSchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": {}, + "list": {}, + "variable": {}, + }, + } + sm := NewSearchSchemaMerger(testCoreSchema) + sm.SetStateReader(&testSearchSchemaReader{}) + + givenBodySchema, err := sm.SchemaForSearch(&tfsearch.Meta{ + Variables: map[string]tfsearch.Variable{ + "foo": {Type: cty.String, Description: "A foo variable", IsSensitive: true, DefaultValue: cty.StringVal("bar")}, + }, + }) + if err != nil { + t.Fatal(err) + } + expectedBodySchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + DependentBody: map[schema.SchemaKey]*schema.BodySchema{}, + }, + "list": { + DependentBody: map[schema.SchemaKey]*schema.BodySchema{}, + }, + "variable": { + Labels: []*schema.LabelSchema{{Name: "name", IsDepKey: true, Description: lang.MarkupContent{Value: "Variable name", Kind: lang.PlainTextKind}}}, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + `{"labels":[{"index":0,"value":"foo"}]}`: { + Attributes: map[string]*schema.AttributeSchema{ + "default": { + Constraint: schema.LiteralType{Type: cty.String}, + Description: lang.MarkupContent{Value: "Default value to use when variable is not explicitly set", Kind: lang.MarkdownKind}, + IsOptional: true, + }, + }, + }, + }, + }, + }, + } + + if diff := cmp.Diff(expectedBodySchema, givenBodySchema, ctydebug.CmpOptions); diff != "" { + t.Fatalf("schema mismatch: %s", diff) + } +} + +func TestStackSchemaMerger_SchemaForSearch_lists(t *testing.T) { + testCoreSchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "alias": {Constraint: schema.LiteralType{Type: cty.String}, IsOptional: true}, + }, + }, + }, + "list": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + }, + }, + } + sm := NewSearchSchemaMerger(testCoreSchema) + sm.SetStateReader(&testSearchSchemaReader{ + ps: &tfjson.ProviderSchemas{ + FormatVersion: "1.0", + Schemas: map[string]*tfjson.ProviderSchema{ + "registry.terraform.io/hashicorp/data": { + ConfigSchema: &tfjson.Schema{ + Block: &tfjson.SchemaBlock{ + Attributes: map[string]*tfjson.SchemaAttribute{ + "foobar": { + AttributeType: cty.Bool, + Optional: true, + }, + }, + }, + }, + ListResourceSchemas: map[string]*tfjson.Schema{ + "dummy_resource": { + Block: &tfjson.SchemaBlock{ + NestedBlocks: map[string]*tfjson.SchemaBlockType{ + "config": { + Block: &tfjson.SchemaBlock{ + Attributes: map[string]*tfjson.SchemaAttribute{ + "count": { + AttributeType: cty.Number, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + givenBodySchema, err := sm.SchemaForSearch(&tfsearch.Meta{ + ProviderReferences: map[tfsearch.ProviderRef]tfaddr.Provider{ + {LocalName: "data"}: addr.NewDefaultProvider("data"), + }, + ProviderRequirements: tfsearch.ProviderRequirements{ + addr.NewDefaultProvider("data"): version.MustConstraints(version.NewConstraint("1.0")), + }, + }) + if err != nil { + t.Fatal(err) + } + expectedBodySchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "provider": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "alias": {Constraint: schema.LiteralType{Type: cty.String}, IsOptional: true}, + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + `{"labels":[{"index":0,"value":"data"}]}`: { + Blocks: map[string]*schema.BlockSchema{}, + Attributes: map[string]*schema.AttributeSchema{ + "foobar": { + IsOptional: true, + Constraint: schema.AnyExpression{OfType: cty.Bool}, + }, + }, + Detail: "hashicorp/data", + DocsLink: &schema.DocsLink{ + URL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + Tooltip: "hashicorp/data Documentation", + }, + HoverURL: "https://registry.terraform.io/providers/hashicorp/data/latest/docs", + }, + }, + }, + "list": { + Labels: []*schema.LabelSchema{ + {Name: "name"}, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + `{"labels":[{"index":0,"value":"dummy_resource"}],"attrs":[{"name":"provider","expr":{"addr":"data"}}]}`: { + Detail: "hashicorp/data", + Blocks: map[string]*schema.BlockSchema{ + "config": { + Labels: []*schema.LabelSchema{}, + Body: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{}, + Attributes: map[string]*schema.AttributeSchema{ + "count": { + IsOptional: true, + Constraint: schema.AnyExpression{OfType: cty.Number}, + }, + }, + }, + }, + }, + Attributes: map[string]*schema.AttributeSchema{}, + }, + }, + }, + }, + } + + if diff := cmp.Diff(expectedBodySchema, givenBodySchema, ctydebug.CmpOptions); diff != "" { + t.Fatalf("schema mismatch: %s", diff) + } +} + +type testSearchSchemaReader struct { + ps *tfjson.ProviderSchemas +} + +func (r *testSearchSchemaReader) InstalledModulePath(rootPath string, normalizedSource string) (string, bool) { + if normalizedSource == "git::https://example.com/vpc.git" { + return "fake/git/path", true + } + if normalizedSource == "registry.terraform.io/registry/source/test" { + return "fake/registry/path", true + } + + return "", false +} + +func (r *testSearchSchemaReader) ProviderSchema(modPath string, addr tfaddr.Provider, vc version.Constraints) (*tfschema.ProviderSchema, error) { + jsonSchema, ok := r.ps.Schemas[addr.String()] + if !ok { + return nil, fmt.Errorf("%s: schema not found", addr.String()) + } + + return tfschema.ProviderSchemaFromJson(jsonSchema, addr), nil +} + +func (r *testSearchSchemaReader) LocalModuleMeta(modPath string) (*tfmod.Meta, error) { + switch filepath.ToSlash(modPath) { + case "fake/git/path": + return &tfmod.Meta{ + Variables: map[string]tfmod.Variable{ + "foo": {Type: cty.String}, + }, + }, nil + case "fake/registry/path": + return &tfmod.Meta{ + Outputs: map[string]tfmod.Output{ + "bar": {Value: cty.DynamicVal}, + }, + }, nil + case "local/path": + return &tfmod.Meta{ + ProviderReferences: map[tfmod.ProviderRef]tfaddr.Provider{ + {LocalName: "test"}: tfaddr.NewProvider("registry.terraform.io", "hashicorp", "test"), + }, + Filenames: []string{"main.tf"}, + }, nil + } + + return nil, fmt.Errorf("invalid source") +} diff --git a/schema/versions_gen.go b/schema/versions_gen.go index a5a4770c..62215ada 100755 --- a/schema/versions_gen.go +++ b/schema/versions_gen.go @@ -10,7 +10,10 @@ var ( LatestAvailableVersion = version.Must(version.NewVersion("1.12.2")) terraformVersions = version.Collection{ + version.Must(version.NewVersion("1.14.0-alpha20250724")), version.Must(version.NewVersion("1.14.0-alpha20250716")), + version.Must(version.NewVersion("1.13.0-beta3")), + version.Must(version.NewVersion("1.13.0-beta2")), version.Must(version.NewVersion("1.13.0-beta1")), version.Must(version.NewVersion("1.13.0-alpha20250708")), version.Must(version.NewVersion("1.13.0-alpha20250702")), diff --git a/search/list.go b/search/list.go new file mode 100644 index 00000000..bd78015e --- /dev/null +++ b/search/list.go @@ -0,0 +1,7 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package search + +type List struct { +} diff --git a/search/meta.go b/search/meta.go new file mode 100644 index 00000000..b6afa014 --- /dev/null +++ b/search/meta.go @@ -0,0 +1,48 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package search + +import ( + "github.com/hashicorp/go-version" + tfaddr "github.com/hashicorp/terraform-registry-address" +) + +type Meta struct { + Path string + Filenames []string + + CoreRequirements version.Constraints + Variables map[string]Variable + Lists map[string]List + ProviderRequirements ProviderRequirements + ProviderReferences map[ProviderRef]tfaddr.Provider +} + +type ProviderRef struct { + LocalName string + + // If not empty, Alias identifies which non-default (aliased) provider + // configuration this address refers to. + Alias string +} + +type ProviderRequirements map[tfaddr.Provider]version.Constraints + +func (pr ProviderRequirements) Equals(reqs ProviderRequirements) bool { + if len(pr) != len(reqs) { + return false + } + + for pAddr, vCons := range pr { + c, ok := reqs[pAddr] + if !ok { + return false + } + if !vCons.Equals(c) { + return false + } + } + + return true +} diff --git a/search/variable.go b/search/variable.go new file mode 100644 index 00000000..57023800 --- /dev/null +++ b/search/variable.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package search + +import ( + "github.com/hashicorp/hcl/v2/ext/typeexpr" + "github.com/zclconf/go-cty/cty" +) + +type Variable struct { + Description string + Type cty.Type + + IsSensitive bool + + // DefaultValue represents default value if one is defined + // and is decodable without errors, else cty.NilVal + DefaultValue cty.Value + + // TypeDefaults represents any default values for optional object + // attributes assuming Type is of cty.Object and has defaults. + // + // Any relationships between DefaultValue & TypeDefaults are left + // for downstream to deal with using e.g. TypeDefaults.Apply(). + TypeDefaults *typeexpr.Defaults +}