terraform/internal/configs/experiments.go

246 lines
8.5 KiB
Go

package configs
import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/internal/experiments"
"github.com/hashicorp/terraform/version"
"github.com/zclconf/go-cty/cty"
)
// sniffActiveExperiments does minimal parsing of the given body for
// "terraform" blocks with "experiments" attributes, returning the
// experiments found.
//
// This is separate from other processing so that we can be sure that all of
// the experiments are known before we process the result of the module config,
// and thus we can take into account which experiments are active when deciding
// how to decode.
func sniffActiveExperiments(body hcl.Body) (experiments.Set, hcl.Diagnostics) {
rootContent, _, diags := body.PartialContent(configFileTerraformBlockSniffRootSchema)
ret := experiments.NewSet()
for _, block := range rootContent.Blocks {
content, _, blockDiags := block.Body.PartialContent(configFileExperimentsSniffBlockSchema)
diags = append(diags, blockDiags...)
if attr, exists := content.Attributes["language"]; exists {
// We don't yet have a sense of selecting an edition of the
// language, but we're reserving this syntax for now so that
// if and when we do this later older versions of Terraform
// will emit a more helpful error message than just saying
// this attribute doesn't exist. Handling this as part of
// experiments is a bit odd for now but justified by the
// fact that a future fuller implementation of switchable
// languages would be likely use a similar implementation
// strategy as experiments, and thus would lead to this
// function being refactored to deal with both concerns at
// once. We'll see, though!
kw := hcl.ExprAsKeyword(attr.Expr)
currentVersion := version.SemVer.String()
const firstEdition = "TF2021"
switch {
case kw == "": // (the expression wasn't a keyword at all)
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid language edition",
Detail: fmt.Sprintf(
"The language argument expects a bare language edition keyword. Terraform %s supports only language edition %s, which is the default.",
currentVersion, firstEdition,
),
Subject: attr.Expr.Range().Ptr(),
})
case kw != firstEdition:
rel := "different"
if kw > firstEdition { // would be weird for this not to be true, but it's user input so anything goes
rel = "newer"
}
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unsupported language edition",
Detail: fmt.Sprintf(
"Terraform v%s only supports language edition %s. This module requires a %s version of Terraform CLI.",
currentVersion, firstEdition, rel,
),
Subject: attr.Expr.Range().Ptr(),
})
}
}
attr, exists := content.Attributes["experiments"]
if !exists {
continue
}
exps, expDiags := decodeExperimentsAttr(attr)
diags = append(diags, expDiags...)
if !expDiags.HasErrors() {
ret = experiments.SetUnion(ret, exps)
}
}
return ret, diags
}
func decodeExperimentsAttr(attr *hcl.Attribute) (experiments.Set, hcl.Diagnostics) {
var diags hcl.Diagnostics
exprs, moreDiags := hcl.ExprList(attr.Expr)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
return nil, diags
}
var ret = experiments.NewSet()
for _, expr := range exprs {
kw := hcl.ExprAsKeyword(expr)
if kw == "" {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid experiment keyword",
Detail: "Elements of \"experiments\" must all be keywords representing active experiments.",
Subject: expr.Range().Ptr(),
})
continue
}
exp, err := experiments.GetCurrent(kw)
switch err := err.(type) {
case experiments.UnavailableError:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unknown experiment keyword",
Detail: fmt.Sprintf("There is no current experiment with the keyword %q.", kw),
Subject: expr.Range().Ptr(),
})
case experiments.ConcludedError:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Experiment has concluded",
Detail: fmt.Sprintf("Experiment %q is no longer available. %s", kw, err.Message),
Subject: expr.Range().Ptr(),
})
case nil:
// No error at all means it's valid and current.
ret.Add(exp)
// However, experimental features are subject to breaking changes
// in future releases, so we'll warn about them to help make sure
// folks aren't inadvertently using them in places where that'd be
// inappropriate, particularly if the experiment is active in a
// shared module they depend on.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: fmt.Sprintf("Experimental feature %q is active", exp.Keyword()),
Detail: "Experimental features are subject to breaking changes in future minor or patch releases, based on feedback.\n\nIf you have feedback on the design of this feature, please open a GitHub issue to discuss it.",
Subject: expr.Range().Ptr(),
})
default:
// This should never happen, because GetCurrent is not documented
// to return any other error type, but we'll handle it to be robust.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid experiment keyword",
Detail: fmt.Sprintf("Could not parse %q as an experiment keyword: %s.", kw, err.Error()),
Subject: expr.Range().Ptr(),
})
}
}
return ret, diags
}
func checkModuleExperiments(m *Module) hcl.Diagnostics {
var diags hcl.Diagnostics
// When we have current experiments, this is a good place to check that
// the features in question can only be used when the experiments are
// active. Return error diagnostics if a feature is being used without
// opting in to the feature. For example:
/*
if !m.ActiveExperiments.Has(experiments.ResourceForEach) {
for _, rc := range m.ManagedResources {
if rc.ForEach != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Resource for_each is experimental",
Detail: "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding resource_for_each to the list of active experiments.",
Subject: rc.ForEach.Range().Ptr(),
})
}
}
for _, rc := range m.DataResources {
if rc.ForEach != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Resource for_each is experimental",
Detail: "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding resource_for_each to the list of active experiments.",
Subject: rc.ForEach.Range().Ptr(),
})
}
}
}
*/
if !m.ActiveExperiments.Has(experiments.ModuleVariableOptionalAttrs) {
for _, v := range m.Variables {
if typeConstraintHasOptionalAttrs(v.Type) {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Optional object type attributes are experimental",
Detail: "This feature is currently an opt-in experiment, subject to change in future releases based on feedback.\n\nActivate the feature for this module by adding module_variable_optional_attrs to the list of active experiments.",
Subject: v.DeclRange.Ptr(),
})
}
}
}
if !m.ActiveExperiments.Has(experiments.ConfigDrivenMove) {
for _, mc := range m.Moved {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Config-driven move is experimental",
Detail: "This feature is currently under development and is not yet fully-functional.\n\nIf you'd like to try the partial implementation that exists so far, add config_driven_move to the set of active experiments for this module.",
Subject: mc.DeclRange.Ptr(),
})
}
}
return diags
}
func typeConstraintHasOptionalAttrs(ty cty.Type) bool {
if ty == cty.NilType {
// Weird, but we'll just ignore it to avoid crashing.
return false
}
switch {
case ty.IsPrimitiveType():
return false
case ty.IsCollectionType():
return typeConstraintHasOptionalAttrs(ty.ElementType())
case ty.IsObjectType():
if len(ty.OptionalAttributes()) != 0 {
return true
}
for _, aty := range ty.AttributeTypes() {
if typeConstraintHasOptionalAttrs(aty) {
return true
}
}
return false
case ty.IsTupleType():
for _, ety := range ty.TupleElementTypes() {
if typeConstraintHasOptionalAttrs(ety) {
return true
}
}
return false
default:
return false
}
}