mirror of
https://github.com/opentofu/opentofu.git
synced 2026-06-09 08:33:23 -04:00
configs: New-style core version constraints, etc
Previously we interpreted a "required_version" argument in a "terraform" block as if it were specifying an OpenTofu version constraint, when in reality most modules use this to represent a version constraint for OpenTofu's predecessor instead. The primary effect of this commit is to introduce a new top-level block type called "language" which describes language and implementation compatibility metadata in a way that intentionally differs from what's used by OpenTofu's predecessor. This also causes OpenTofu to ignore the required_version argument unless it appears in an OpenTofu-specific file with a ".tofu" suffix, and makes OpenTofu completely ignore the language edition and experimental feature opt-in options from OpenTofu's predecessor on the assumption that those could continue to evolve independently of changes in OpenTofu. We retain support for using required_versions in .tofu files as a bridge solution for modules that need to remain compatible with OpenTofu versions prior to v1.12. Module authors should keep following the strategy of having both a versions.tf and a versions.tofu file for now, and wait until the OpenTofu v1.11 series is end-of-life before adopting the new "language" block type. I also took this opportunity to simplify how we handle these parts of the configuration, since the OpenTofu project has no immediate plans to use either multiple language editions or language experiments and so for now we can reduce our handling of those language features to just enough that we'd return reasonable error messages if today's OpenTofu is exposed to a module that was written for a newer version of OpenTofu that extends these language features. The cross-cutting plumbing for representing the active experiments for a module is still present so that we can reactivate it later if we need to, but for now that set will always be empty. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
parent
340f8d1fdd
commit
551579f5eb
45 changed files with 742 additions and 921 deletions
|
|
@ -33,7 +33,6 @@ import (
|
|||
"github.com/opentofu/opentofu/internal/providercache"
|
||||
"github.com/opentofu/opentofu/internal/states"
|
||||
"github.com/opentofu/opentofu/internal/tfdiags"
|
||||
"github.com/opentofu/opentofu/internal/tofu"
|
||||
"github.com/opentofu/opentofu/internal/tofumigrate"
|
||||
"github.com/opentofu/opentofu/internal/tracing"
|
||||
"github.com/opentofu/opentofu/internal/tracing/traceattrs"
|
||||
|
|
@ -159,16 +158,17 @@ func (c *InitCommand) Run(rawArgs []string) int {
|
|||
|
||||
// Load just the root module to begin backend and module initialization
|
||||
rootModEarly, earlyConfDiags := c.loadSingleModuleWithTests(ctx, path, args.TestsDirectory)
|
||||
|
||||
// There may be parsing errors in config loading but these will be shown later _after_
|
||||
// checking for core version requirement errors. Not meeting the version requirement should
|
||||
// be the first error displayed if that is an issue, but other operations are required
|
||||
// before being able to check core version requirements.
|
||||
if rootModEarly == nil {
|
||||
if earlyConfDiags.HasErrors() {
|
||||
// Historical note: prior to OpenTofu v1.12, we took some extraordinary
|
||||
// effort here to return any backend-related errors in preference to
|
||||
// config loading errors about the root module. We no longer do that
|
||||
// and instead exit early if the root module isn't at least valid enough
|
||||
// to load, because remote operations with the "cloud" backend isn't
|
||||
// such an important use-case for OpenTofu as it presumably is for our
|
||||
// predecessor and so we prefer simpler control flow here.
|
||||
view.ConfigError()
|
||||
diags = diags.Append(earlyConfDiags)
|
||||
view.Diagnostics(diags)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
|
@ -248,22 +248,9 @@ func (c *InitCommand) Run(rawArgs []string) int {
|
|||
// With all of the modules (hopefully) installed, we can now try to load the
|
||||
// whole configuration tree.
|
||||
config, confDiags := c.loadConfigWithTests(ctx, path, args.TestsDirectory)
|
||||
// configDiags will be handled after the version constraint check, since an
|
||||
// incorrect version of tofu may be producing errors for configuration
|
||||
// constructs added in later versions.
|
||||
|
||||
// Before we go further, we'll check to make sure none of the modules in
|
||||
// the configuration declare that they don't support this OpenTofu
|
||||
// version, so we can produce a version-related error message rather than
|
||||
// potentially-confusing downstream errors.
|
||||
versionDiags := tofu.CheckCoreVersionRequirements(config)
|
||||
if versionDiags.HasErrors() {
|
||||
view.Diagnostics(versionDiags)
|
||||
return 1
|
||||
}
|
||||
|
||||
// We've passed the core version check, now we can show errors from the
|
||||
// configuration and backend initialization.
|
||||
// We don't immediately handle confDiags here because we prefer to show
|
||||
// shallow backend-related errors if there are any, before we complain
|
||||
// about anything in nested modules.
|
||||
|
||||
// Now, we can check the diagnostics from the early configuration and the
|
||||
// backend.
|
||||
|
|
|
|||
|
|
@ -2238,7 +2238,7 @@ func TestInit_checkRequiredVersionFirst(t *testing.T) {
|
|||
t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout())
|
||||
}
|
||||
errStr := output.Stderr()
|
||||
if !strings.Contains(errStr, `Unsupported OpenTofu Core version`) {
|
||||
if !strings.Contains(errStr, `This module is not compatible with OpenTofu v`) {
|
||||
t.Fatalf("output should point to unmet version constraint, but is:\n\n%s", errStr)
|
||||
}
|
||||
})
|
||||
|
|
@ -2262,7 +2262,7 @@ func TestInit_checkRequiredVersionFirst(t *testing.T) {
|
|||
t.Fatalf("got exit status %d; want 1\nstderr:\n%s\n\nstdout:\n%s", code, output.Stderr(), output.Stdout())
|
||||
}
|
||||
errStr := output.Stderr()
|
||||
if !strings.Contains(errStr, `Unsupported OpenTofu Core version`) {
|
||||
if !strings.Contains(errStr, `This module is not compatible with OpenTofu v`) {
|
||||
t.Fatalf("output should point to unmet version constraint, but is:\n\n%s", errStr)
|
||||
}
|
||||
})
|
||||
|
|
@ -2899,39 +2899,6 @@ func TestInit_invalidSyntaxWithBackend(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInit_invalidSyntaxInvalidBackend(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("init-syntax-invalid-backend-invalid"), td)
|
||||
t.Chdir(td)
|
||||
|
||||
view, done := testView(t)
|
||||
m := Meta{
|
||||
WorkingDir: workdir.NewDir("."),
|
||||
View: view,
|
||||
}
|
||||
|
||||
c := &InitCommand{
|
||||
Meta: m,
|
||||
}
|
||||
|
||||
code := c.Run([]string{"-no-color"})
|
||||
output := done(t)
|
||||
if code == 0 {
|
||||
t.Fatalf("succeeded, but was expecting error\nstdout:\n%s\nstderr:\n%s", output.Stdout(), output.Stderr())
|
||||
}
|
||||
|
||||
errStr := output.Stderr()
|
||||
if subStr := "OpenTofu encountered problems during initialization, including problems\nwith the configuration, described below."; !strings.Contains(errStr, subStr) {
|
||||
t.Errorf("Error output should include preamble\nwant substr: %s\ngot:\n%s", subStr, errStr)
|
||||
}
|
||||
if subStr := "Error: Unsupported block type"; !strings.Contains(errStr, subStr) {
|
||||
t.Errorf("Error output should mention syntax errors\nwant substr: %s\ngot:\n%s", subStr, errStr)
|
||||
}
|
||||
if subStr := "Error: Unsupported backend type"; !strings.Contains(errStr, subStr) {
|
||||
t.Errorf("Error output should mention the invalid backend\nwant substr: %s\ngot:\n%s", subStr, errStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_invalidSyntaxBackendAttribute(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
testCopyDir(t, testFixturePath("init-syntax-invalid-backend-attribute-invalid"), td)
|
||||
|
|
|
|||
|
|
@ -904,18 +904,14 @@ func (m *Meta) checkRequiredVersion(ctx context.Context) tfdiags.Diagnostics {
|
|||
return diags
|
||||
}
|
||||
|
||||
config, configDiags := loader.LoadConfig(ctx, pwd, call)
|
||||
_, configDiags := loader.LoadConfig(ctx, pwd, call)
|
||||
if configDiags.HasErrors() {
|
||||
diags = diags.Append(configDiags)
|
||||
return diags
|
||||
}
|
||||
|
||||
versionDiags := tofu.CheckCoreVersionRequirements(config)
|
||||
if versionDiags.HasErrors() {
|
||||
diags = diags.Append(versionDiags)
|
||||
return diags
|
||||
}
|
||||
|
||||
// If there were any OpenTofu-version-related errors then they would've
|
||||
// already been detected by loader.LoadConfig above.
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
terraform {
|
||||
backend "nonexistent" {}
|
||||
}
|
||||
|
||||
bad_block {
|
||||
}
|
||||
|
||||
|
|
@ -861,19 +861,6 @@ func (c *Config) ProviderForConfigAddr(addr addrs.LocalProviderConfig) addrs.Pro
|
|||
return c.ResolveAbsProviderAddr(addr, addrs.RootModule).Provider
|
||||
}
|
||||
|
||||
func (c *Config) CheckCoreVersionRequirements() hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
diags = diags.Extend(c.Module.CheckCoreVersionRequirements(c.Path, c.SourceAddr))
|
||||
|
||||
for _, c := range c.Children {
|
||||
childDiags := c.CheckCoreVersionRequirements()
|
||||
diags = diags.Extend(childDiags)
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
type testConfigTransformFunc func(*TestRun, *TestFile) (func(), hcl.Diagnostics)
|
||||
|
||||
// TransformForTest prepares the config to execute the given test.
|
||||
|
|
|
|||
|
|
@ -155,5 +155,7 @@ func (l *Loader) ImportSourcesFromSnapshot(snap *Snapshot) {
|
|||
// is responsible for deciding for itself whether and how to call this
|
||||
// method.
|
||||
func (l *Loader) AllowLanguageExperiments(allowed bool) {
|
||||
l.parser.AllowLanguageExperiments(allowed)
|
||||
// We don't currently have any support for language experiments. We'll
|
||||
// add support here later if we decide to make use of language experiments
|
||||
// in future versions of OpenTofu.
|
||||
}
|
||||
|
|
|
|||
166
internal/configs/diagnostics.go
Normal file
166
internal/configs/diagnostics.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package configs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
hcVersion "github.com/hashicorp/go-version"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
)
|
||||
|
||||
// incompatibleModuleDiagnosticExtra is the type used for a value in the
|
||||
// "ExtraInfo" field of a diagnostic to mark it as representing that a module
|
||||
// is somehow incompatible with the current version of OpenTofu.
|
||||
//
|
||||
// When loading a module, if any of the diagnostics have a value of this type
|
||||
// in their extra info then we discard all other diagnostics because they are
|
||||
// possibly describing attempts to use language features that are not available
|
||||
// in the current version of OpenTofu.
|
||||
//
|
||||
// There is only one value of this type, which is its zero value.
|
||||
type incompatibleModuleDiagnosticExtra struct{}
|
||||
|
||||
// finalizeModuleLoadDiagnostics should be called at the end of loading and
|
||||
// merging together all of the files for a module, to make final adjustments
|
||||
// before returning diagnostics for presentation to the user.
|
||||
//
|
||||
// The return value shares a backing array with the given diagnostics, so
|
||||
// the caller must treat the given slice as invalid after passing it to this
|
||||
// function and must use the return value in place of it.
|
||||
func finalizeModuleLoadDiagnostics(diags hcl.Diagnostics) hcl.Diagnostics {
|
||||
// This is currently focused only on noticing whether there are any
|
||||
// "version mismatch" diagnostics and, if so, discarding any other
|
||||
// diagnostics.
|
||||
|
||||
haveVersionMismatches := slices.ContainsFunc(diags, isIncompatibleModuleDiagnostic)
|
||||
if !haveVersionMismatches {
|
||||
// In the common case where there are no version mismatches, we just
|
||||
// return the given diagnostics back verbatim.
|
||||
return diags
|
||||
}
|
||||
|
||||
// If we get here then we modify the backing array in-place to remove
|
||||
// any diagnostics that are not talking about incompatibility.
|
||||
return slices.DeleteFunc(diags, func(diag *hcl.Diagnostic) bool {
|
||||
return !isIncompatibleModuleDiagnostic(diag)
|
||||
})
|
||||
}
|
||||
|
||||
func isIncompatibleModuleDiagnostic(diag *hcl.Diagnostic) bool {
|
||||
_, ok := diag.Extra.(incompatibleModuleDiagnosticExtra)
|
||||
return ok
|
||||
}
|
||||
|
||||
// checkVersionRequirements does minimal parsing of the given body for
|
||||
// the different ways that module authors are allowed to specify which versions
|
||||
// of OpenTofu a module is compatible with, returning error diagnostics if
|
||||
// any declaration excludes the current version of OpenTofu.
|
||||
//
|
||||
// This is guaranteed to not return any error diagnostics if all of the
|
||||
// declarations it finds allow the current version of OpenTofu.
|
||||
//
|
||||
// This is intended to maximize the chance that we'll be able to read the
|
||||
// requirements (syntax errors notwithstanding) even if the config file contains
|
||||
// constructs that might've been added in future OpenTofu versions
|
||||
//
|
||||
// This is a "best effort" sort of method which will check constraints it is
|
||||
// able to find, but might not succeed if the given body is too invalid to
|
||||
// be processed at all.
|
||||
func checkVersionRequirements(body hcl.Body, expectedVersion *hcVersion.Version) hcl.Diagnostics {
|
||||
rootContent, _, diags := body.PartialContent(configFileVersionConstraintSniffRootSchema)
|
||||
|
||||
incompatibleDiag := func(rng hcl.Range) *hcl.Diagnostic {
|
||||
return &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Incompatible module",
|
||||
Detail: fmt.Sprintf("This module is not compatible with OpenTofu v%s.\n\nTo proceed, either choose another supported OpenTofu version or update this version constraint. Version constraints are normally set for good reason, so updating the constraint may lead to other errors or unexpected behavior.", expectedVersion.String()),
|
||||
Subject: rng.Ptr(),
|
||||
// This "Extra" is used by [finalizeModuleLoadDiagnostics] to
|
||||
// discard all other diagnostics whenever at least one
|
||||
// incompatibility-related diagnostic is present.
|
||||
Extra: incompatibleModuleDiagnosticExtra{},
|
||||
}
|
||||
}
|
||||
|
||||
for _, block := range rootContent.Blocks {
|
||||
switch block.Type {
|
||||
case "language":
|
||||
// New-style language block. In this case we're looking for nested
|
||||
// blocks of type "compatible_with", which may or may not contain
|
||||
// OpenTofu version constraints.
|
||||
content, _, blockDiags := block.Body.PartialContent(configFileModernVersionConstraintSniffSchema)
|
||||
diags = append(diags, blockDiags...)
|
||||
for _, nestedBlock := range content.Blocks {
|
||||
if nestedBlock.Type != "compatible_with" {
|
||||
continue
|
||||
}
|
||||
constraint, constraintDiags := decodeLanguageCompatibleWithOpenTofu(nestedBlock)
|
||||
diags = append(diags, constraintDiags...)
|
||||
if constraint != nil {
|
||||
validDiags := validateOpenTofuCoreVersionConstraint(*constraint)
|
||||
diags = append(diags, validDiags...)
|
||||
if !validDiags.HasErrors() && !constraint.Required.Check(expectedVersion) {
|
||||
diags = diags.Append(incompatibleDiag(constraint.DeclRange))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "terraform":
|
||||
// Legacy style of version constraint, using the required_version
|
||||
// argument in a "terraform" block. We only pay attention to these
|
||||
// in OpenTofu-specific files, because otherwise we assume they
|
||||
// are intended to constrain our predecessor instead.
|
||||
if ext := tofuFileExt(block.DefRange.Filename); ext == "" {
|
||||
continue // not an OpenTofu-specific file
|
||||
}
|
||||
|
||||
content, _, blockDiags := block.Body.PartialContent(configFileLegacyVersionConstraintSniffSchema)
|
||||
diags = append(diags, blockDiags...)
|
||||
|
||||
attr, exists := content.Attributes["required_version"]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
constraint, constraintDiags := decodeVersionConstraint(attr)
|
||||
diags = append(diags, constraintDiags...)
|
||||
if !constraintDiags.HasErrors() {
|
||||
validDiags := validateOpenTofuCoreVersionConstraint(constraint)
|
||||
diags = append(diags, validDiags...)
|
||||
if !validDiags.HasErrors() && !constraint.Required.Check(expectedVersion) {
|
||||
diags = diags.Append(incompatibleDiag(constraint.DeclRange))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
// configFileVersionConstraintSniffRootSchema is a schema for
|
||||
// sniffCoreVersionRequirements and sniffActiveExperiments.
|
||||
var configFileVersionConstraintSniffRootSchema = &hcl.BodySchema{
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{Type: "terraform"},
|
||||
{Type: "language"},
|
||||
},
|
||||
}
|
||||
|
||||
// configFileLegacyVersionConstraintSniffSchema is a schema for checkVersionRequirements
|
||||
var configFileLegacyVersionConstraintSniffSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{Name: "required_version"},
|
||||
},
|
||||
}
|
||||
|
||||
// configFileModernVersionConstraintSniffSchema is a schema for checkVersionRequirements
|
||||
var configFileModernVersionConstraintSniffSchema = &hcl.BodySchema{
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{Type: "compatible_with"},
|
||||
},
|
||||
}
|
||||
131
internal/configs/diagnostics_test.go
Normal file
131
internal/configs/diagnostics_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package configs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
hcVersion "github.com/hashicorp/go-version"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/hashicorp/hcl/v2/hcltest"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
||||
func TestRequiredVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
Name string
|
||||
CurrentVersion string
|
||||
RequiredVersions string
|
||||
Err bool
|
||||
}{
|
||||
{
|
||||
"doesn't match",
|
||||
"0.1.0",
|
||||
"> 0.6.0",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"matches",
|
||||
"0.7.0",
|
||||
"> 0.6.0",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"prerelease doesn't match with inequality",
|
||||
"0.8.0",
|
||||
"> 0.7.0-beta",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"prerelease doesn't match with equality",
|
||||
"0.7.0",
|
||||
"0.7.0-beta",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
fakeSourceRange := hcl.Range{
|
||||
// This must have a .tofu suffix for the required_version
|
||||
// subtest to work, because we only support that legacy form
|
||||
// in OpenTofu-specific files.
|
||||
Filename: "versions.tofu",
|
||||
Start: hcl.InitialPos,
|
||||
End: hcl.InitialPos,
|
||||
}
|
||||
currentVersion := hcVersion.Must(hcVersion.NewVersion(test.CurrentVersion))
|
||||
|
||||
t.Logf("matching constraint %q against current version %q", test.RequiredVersions, test.CurrentVersion)
|
||||
|
||||
t.Run("language block", func(t *testing.T) {
|
||||
body := hcltest.MockBody(&hcl.BodyContent{
|
||||
Blocks: []*hcl.Block{
|
||||
{
|
||||
Type: "language",
|
||||
Body: hcltest.MockBody(&hcl.BodyContent{
|
||||
Blocks: []*hcl.Block{
|
||||
{
|
||||
Type: "compatible_with",
|
||||
Body: hcltest.MockBody(&hcl.BodyContent{
|
||||
Attributes: hcl.Attributes{
|
||||
"opentofu": {
|
||||
Name: "opentofu",
|
||||
Expr: hcl.StaticExpr(cty.StringVal(test.RequiredVersions), fakeSourceRange),
|
||||
Range: fakeSourceRange,
|
||||
NameRange: fakeSourceRange,
|
||||
},
|
||||
},
|
||||
}),
|
||||
DefRange: fakeSourceRange,
|
||||
TypeRange: fakeSourceRange,
|
||||
},
|
||||
},
|
||||
}),
|
||||
DefRange: fakeSourceRange,
|
||||
TypeRange: fakeSourceRange,
|
||||
},
|
||||
},
|
||||
})
|
||||
diags := checkVersionRequirements(body, currentVersion)
|
||||
if test.Err && !diags.HasErrors() {
|
||||
t.Error("unexpected success; want error")
|
||||
} else if !test.Err && diags.HasErrors() {
|
||||
t.Errorf("unexpected error: %s", diags.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy required_version", func(t *testing.T) {
|
||||
body := hcltest.MockBody(&hcl.BodyContent{
|
||||
Blocks: []*hcl.Block{
|
||||
{
|
||||
Type: "terraform",
|
||||
Body: hcltest.MockBody(&hcl.BodyContent{
|
||||
Attributes: hcl.Attributes{
|
||||
"required_version": {
|
||||
Name: "required_version",
|
||||
Expr: hcl.StaticExpr(cty.StringVal(test.RequiredVersions), fakeSourceRange),
|
||||
Range: fakeSourceRange,
|
||||
NameRange: fakeSourceRange,
|
||||
},
|
||||
},
|
||||
}),
|
||||
DefRange: fakeSourceRange,
|
||||
TypeRange: fakeSourceRange,
|
||||
},
|
||||
},
|
||||
})
|
||||
diags := checkVersionRequirements(body, currentVersion)
|
||||
if test.Err && !diags.HasErrors() {
|
||||
t.Error("unexpected success; want error")
|
||||
} else if !test.Err && diags.HasErrors() {
|
||||
t.Errorf("unexpected error: %s", diags.Error())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package configs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/opentofu/opentofu/internal/experiments"
|
||||
"github.com/opentofu/opentofu/version"
|
||||
)
|
||||
|
||||
// When developing UI for experimental features, you can temporarily disable
|
||||
// the experiment warning by setting this package-level variable to a non-empty
|
||||
// value using a link-time flag:
|
||||
//
|
||||
// go install -ldflags="-X 'github.com/opentofu/opentofu/internal/configs.disableExperimentWarnings=yes'"
|
||||
//
|
||||
// This functionality is for development purposes only and is not a feature we
|
||||
// are committing to supporting for end users.
|
||||
var disableExperimentWarnings = ""
|
||||
|
||||
// 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, allowed bool) (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. OpenTofu %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(
|
||||
"OpenTofu v%s only supports language edition %s. This module requires a %s version of OpenTofu CLI.",
|
||||
currentVersion, firstEdition, rel,
|
||||
),
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
attr, exists := content.Attributes["experiments"]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
exps, expDiags := decodeExperimentsAttr(attr)
|
||||
|
||||
// Because we concluded this particular experiment in the same
|
||||
// release as we made experiments alpha-releases-only, we need to
|
||||
// treat it as special to avoid masking the "experiment has concluded"
|
||||
// error with the more general "experiments are not available at all"
|
||||
// error. Note that this experiment is marked as concluded so this
|
||||
// only "allows" showing the different error message that it is
|
||||
// concluded, and does not allow actually using the experiment outside
|
||||
// of an alpha.
|
||||
// NOTE: We should be able to remove this special exception a release
|
||||
// or two after v1.3 when folks have had a chance to notice that the
|
||||
// experiment has concluded and update their modules accordingly.
|
||||
// When we do so, we might also consider changing decodeExperimentsAttr
|
||||
// to _not_ include concluded experiments in the returned set, since
|
||||
// we're doing that right now only to make this condition work.
|
||||
if exps.Has(experiments.ModuleVariableOptionalAttrs) && len(exps) == 1 {
|
||||
allowed = true
|
||||
}
|
||||
|
||||
if allowed {
|
||||
diags = append(diags, expDiags...)
|
||||
if !expDiags.HasErrors() {
|
||||
ret = experiments.SetUnion(ret, exps)
|
||||
}
|
||||
} else {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Module uses experimental features",
|
||||
Detail: "Experimental features are intended only for gathering early feedback on new language designs, and so are available only in alpha releases of OpenTofu.",
|
||||
Subject: attr.NameRange.Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
// As a special case we still include the optional attributes
|
||||
// experiment if it's present, because our caller treats that
|
||||
// as special. See the comment in sniffActiveExperiments for
|
||||
// more information, and remove this special case here one the
|
||||
// special case up there is also removed.
|
||||
if kw == "module_variable_optional_attrs" {
|
||||
ret.Add(experiments.ModuleVariableOptionalAttrs)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if disableExperimentWarnings == "" {
|
||||
// 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 available only in alpha releases of OpenTofu and are subject to breaking changes or total removal in later versions, based on feedback. We recommend against using experimental features in production.\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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
return diags
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package configs
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/experiments"
|
||||
)
|
||||
|
||||
func TestExperimentsConfig(t *testing.T) {
|
||||
// The experiment registrations are global, so we need to do some special
|
||||
// patching in order to get a predictable set for our tests.
|
||||
current := experiments.Experiment("current")
|
||||
concluded := experiments.Experiment("concluded")
|
||||
currentExperiments := experiments.NewSet(current)
|
||||
concludedExperiments := map[experiments.Experiment]string{
|
||||
concluded: "Reticulate your splines.",
|
||||
}
|
||||
defer experiments.OverrideForTesting(t, currentExperiments, concludedExperiments)()
|
||||
|
||||
t.Run("current", func(t *testing.T) {
|
||||
parser := NewParser(nil)
|
||||
parser.AllowLanguageExperiments(true)
|
||||
mod, diags := parser.LoadConfigDir("testdata/experiments/current", RootModuleCallForTesting())
|
||||
if got, want := len(diags), 1; got != want {
|
||||
t.Fatalf("wrong number of diagnostics %d; want %d", got, want)
|
||||
}
|
||||
got := diags[0]
|
||||
want := &hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: `Experimental feature "current" is active`,
|
||||
Detail: "Experimental features are available only in alpha releases of OpenTofu and are subject to breaking changes or total removal in later versions, based on feedback. We recommend against using experimental features in production.\n\nIf you have feedback on the design of this feature, please open a GitHub issue to discuss it.",
|
||||
Subject: &hcl.Range{
|
||||
Filename: filepath.FromSlash("testdata/experiments/current/current_experiment.tf"),
|
||||
Start: hcl.Pos{Line: 2, Column: 18, Byte: 29},
|
||||
End: hcl.Pos{Line: 2, Column: 25, Byte: 36},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("wrong warning\n%s", diff)
|
||||
}
|
||||
if got, want := len(mod.ActiveExperiments), 1; got != want {
|
||||
t.Errorf("wrong number of experiments %d; want %d", got, want)
|
||||
}
|
||||
if !mod.ActiveExperiments.Has(current) {
|
||||
t.Errorf("module does not indicate current experiment as active")
|
||||
}
|
||||
})
|
||||
t.Run("concluded", func(t *testing.T) {
|
||||
parser := NewParser(nil)
|
||||
parser.AllowLanguageExperiments(true)
|
||||
_, diags := parser.LoadConfigDir("testdata/experiments/concluded", RootModuleCallForTesting())
|
||||
if got, want := len(diags), 1; got != want {
|
||||
t.Fatalf("wrong number of diagnostics %d; want %d", got, want)
|
||||
}
|
||||
got := diags[0]
|
||||
want := &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: `Experiment has concluded`,
|
||||
Detail: `Experiment "concluded" is no longer available. Reticulate your splines.`,
|
||||
Subject: &hcl.Range{
|
||||
Filename: filepath.FromSlash("testdata/experiments/concluded/concluded_experiment.tf"),
|
||||
Start: hcl.Pos{Line: 2, Column: 18, Byte: 29},
|
||||
End: hcl.Pos{Line: 2, Column: 27, Byte: 38},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("wrong error\n%s", diff)
|
||||
}
|
||||
})
|
||||
t.Run("concluded", func(t *testing.T) {
|
||||
parser := NewParser(nil)
|
||||
parser.AllowLanguageExperiments(true)
|
||||
_, diags := parser.LoadConfigDir("testdata/experiments/unknown", RootModuleCallForTesting())
|
||||
if got, want := len(diags), 1; got != want {
|
||||
t.Fatalf("wrong number of diagnostics %d; want %d", got, want)
|
||||
}
|
||||
got := diags[0]
|
||||
want := &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: `Unknown experiment keyword`,
|
||||
Detail: `There is no current experiment with the keyword "unknown".`,
|
||||
Subject: &hcl.Range{
|
||||
Filename: filepath.FromSlash("testdata/experiments/unknown/unknown_experiment.tf"),
|
||||
Start: hcl.Pos{Line: 2, Column: 18, Byte: 29},
|
||||
End: hcl.Pos{Line: 2, Column: 25, Byte: 36},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("wrong error\n%s", diff)
|
||||
}
|
||||
})
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
parser := NewParser(nil)
|
||||
parser.AllowLanguageExperiments(true)
|
||||
_, diags := parser.LoadConfigDir("testdata/experiments/invalid", RootModuleCallForTesting())
|
||||
if got, want := len(diags), 1; got != want {
|
||||
t.Fatalf("wrong number of diagnostics %d; want %d", got, want)
|
||||
}
|
||||
got := diags[0]
|
||||
want := &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: `Invalid expression`,
|
||||
Detail: `A static list expression is required.`,
|
||||
Subject: &hcl.Range{
|
||||
Filename: filepath.FromSlash("testdata/experiments/invalid/invalid_experiments.tf"),
|
||||
Start: hcl.Pos{Line: 2, Column: 17, Byte: 28},
|
||||
End: hcl.Pos{Line: 2, Column: 24, Byte: 35},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("wrong error\n%s", diff)
|
||||
}
|
||||
})
|
||||
t.Run("disallowed", func(t *testing.T) {
|
||||
parser := NewParser(nil)
|
||||
parser.AllowLanguageExperiments(false) // The default situation for release builds
|
||||
_, diags := parser.LoadConfigDir("testdata/experiments/current", RootModuleCallForTesting())
|
||||
if got, want := len(diags), 1; got != want {
|
||||
t.Fatalf("wrong number of diagnostics %d; want %d", got, want)
|
||||
}
|
||||
got := diags[0]
|
||||
want := &hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: `Module uses experimental features`,
|
||||
Detail: `Experimental features are intended only for gathering early feedback on new language designs, and so are available only in alpha releases of OpenTofu.`,
|
||||
Subject: &hcl.Range{
|
||||
Filename: filepath.FromSlash("testdata/experiments/current/current_experiment.tf"),
|
||||
Start: hcl.Pos{Line: 2, Column: 3, Byte: 14},
|
||||
End: hcl.Pos{Line: 2, Column: 14, Byte: 25},
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("wrong error\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
274
internal/configs/language.go
Normal file
274
internal/configs/language.go
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package configs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
hcVersion "github.com/hashicorp/go-version"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
|
||||
"github.com/opentofu/opentofu/version"
|
||||
)
|
||||
|
||||
// validateLanguageBlock checks the validity of a "language" block and returns
|
||||
// any diagnostics related to it.
|
||||
//
|
||||
// Note that this DOES NOT check whether the version constraints in the block
|
||||
// match the current version of OpenTofu. Instead that happens as part of
|
||||
// [checkVersionRequirements], which we run separately before other decoding
|
||||
// work to maximize the chance of us being able to report that the module is
|
||||
// declared incompatible instead of complaining about use of a language feature
|
||||
// this version doesn't understand.
|
||||
//
|
||||
// Currently we do not retain any of the information from a language block
|
||||
// after validating it. Instead, we interpret it just enough to generate useful
|
||||
// error messages if we encounter something that seems like how we expect these
|
||||
// language features might be used in future versions of OpenTofu.
|
||||
func validateLanguageBlock(block *hcl.Block, override bool) hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
if override {
|
||||
// Language blocks are not allowed in override files, because we want
|
||||
// each module to have a clear central definition of what it's
|
||||
// compatible with and what language features it intends to use.
|
||||
//
|
||||
// These settings have whole-module scope, so allowing overrides would
|
||||
// have potentially-surprising effects on other declarations elsewhere
|
||||
// in the module.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Language selections in override file",
|
||||
Detail: "Language-related settings in \"language\" blocks are not allowed in override files. Place these settings in a normal configuration file.",
|
||||
Subject: block.DefRange.Ptr(),
|
||||
})
|
||||
return diags
|
||||
}
|
||||
|
||||
content, moreDiags := block.Body.Content(languageBlockSchema)
|
||||
diags = append(diags, moreDiags...)
|
||||
|
||||
if attr, ok := content.Attributes["edition"]; ok {
|
||||
// OpenTofu does not currently make any real use of language editions,
|
||||
// since there is only one "living" edition of the language right now.
|
||||
// This is reserved just so that if we decide to introduce a new edition
|
||||
// later then older versions of OpenTofu will return a more helpful
|
||||
// error message, rather than just returning a generic about the
|
||||
// argument being unrecognized.
|
||||
kw := hcl.ExprAsKeyword(attr.Expr)
|
||||
currentVersion := version.SemVer.String()
|
||||
const firstEdition = "tofu2024"
|
||||
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 \"edition\" argument expects a bare language edition keyword. OpenTofu %s supports only language edition %s, which is the default.",
|
||||
currentVersion, firstEdition,
|
||||
),
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
case strings.HasPrefix(kw, "TF"):
|
||||
// OpenTofu's predecessor was accepting "TF2021" as its single valid
|
||||
// language edition keyword at the time we forked from it, so we'll
|
||||
// use a specialized error message for this just in case someone
|
||||
// found that in their documentation and tried to use it in OpenTofu.
|
||||
// Note that this would appear only if someone tried to use a
|
||||
// keyword like this in the OpenTofu-defined "language" block, so
|
||||
// it seems unlikely that anyone would actually see this in practice,
|
||||
// but if it _does_ come up then it'd be weird to tell the operator
|
||||
// that it requires "a different version of OpenTofu CLI".
|
||||
//
|
||||
// The syntax that our predecessor would've used -- a "language"
|
||||
// argument inside a "terraform" block -- is still accepted by
|
||||
// OpenTofu, but now completely ignored because we can't predict
|
||||
// how future versions of their language would use that.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unsupported language edition",
|
||||
Detail: fmt.Sprintf(
|
||||
"OpenTofu v%s does not support language edition %q. This module may be intended for use with other software.",
|
||||
currentVersion, firstEdition,
|
||||
),
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
case kw != firstEdition:
|
||||
rel := "different"
|
||||
if kw > firstEdition {
|
||||
rel = "newer"
|
||||
}
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unsupported language edition",
|
||||
Detail: fmt.Sprintf(
|
||||
"OpenTofu v%s only supports language edition %s. This module requires a %s version of OpenTofu CLI.",
|
||||
currentVersion, firstEdition, rel,
|
||||
),
|
||||
Subject: attr.Expr.Range().Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if attr, ok := content.Attributes["experiments"]; ok {
|
||||
moreDiags := decodeReservedExperimentsAttr(attr)
|
||||
diags = append(diags, moreDiags...)
|
||||
}
|
||||
|
||||
var compatibleWithOpenTofu *VersionConstraint
|
||||
for _, nestedBlock := range content.Blocks {
|
||||
switch nestedBlock.Type {
|
||||
case "compatible_with":
|
||||
// Note that we don't actually check whether the declared version
|
||||
// constraint matches the current version of OpenTofu here, because
|
||||
// that should have been checked by some earlier call to
|
||||
// [checkVersionRequirements], which extracts the same information
|
||||
// we're reading here in a cautious way that's more likely to
|
||||
// succeed in a module intended for a later OpenTofu version.
|
||||
//
|
||||
// The checks here are just about whether the declarations are
|
||||
// valid regardless of which versions it allows.
|
||||
if compatibleWithOpenTofu != nil {
|
||||
// Each language block should have at most one compatible_with
|
||||
// block referring to OpenTofu, but we'll ignore blocks that
|
||||
// don't mention OpenTofu at all.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Duplicate compatible_with block",
|
||||
Detail: fmt.Sprintf(
|
||||
"Each language block may have at most one compatible_with block referring to OpenTofu. The OpenTofu version constraint was already declared at %s.",
|
||||
compatibleWithOpenTofu.DeclRange,
|
||||
),
|
||||
Subject: block.DefRange.Ptr(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
constraint, moreDiags := decodeLanguageCompatibleWithOpenTofu(nestedBlock)
|
||||
diags = append(diags, moreDiags...)
|
||||
if !moreDiags.HasErrors() {
|
||||
if constraint.Required.Check(hcVersion.Must(hcVersion.NewVersion("1.11.0"))) {
|
||||
// This language feature was added in OpenTofu v1.12, so it
|
||||
// isn't suitable for describing compatibility with earlier
|
||||
// versions of OpenTofu. We'll return a warning to help module
|
||||
// authors notice that even if they are only testing with newer
|
||||
// versions of OpenTofu.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagWarning,
|
||||
Summary: "Ineffective version constraint",
|
||||
Detail: "The compatible_with block was added in OpenTofu v1.12.0, so any constraint specified this way should exclude earlier versions of OpenTofu, such as by including \">= 0.12.0\".\n\nIf your module must be compatible with earlier versions of OpenTofu, use the required_version argument in a \"terraform\" block in a file named with the .tofu suffix, which is an older way to specify OpenTofu version constraints.",
|
||||
Subject: constraint.DeclRange.Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
compatibleWithOpenTofu = constraint
|
||||
default:
|
||||
// It should not be possible to get here because HCL should've
|
||||
// rejected any other block types as not being in the schema.
|
||||
panic(fmt.Sprintf("unexpected block type %q", nestedBlock.Type))
|
||||
}
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
// decodeLanguageCompatibleWithOpenTofu takes a [hcl.Block] representing a
|
||||
// "compatible_with" block inside a "language" block and attempts to recognize
|
||||
// an "opentofu" argument within it, returning its associated version constraint
|
||||
// if present.
|
||||
//
|
||||
// This function intentionally silently ignores anything else appearing in that
|
||||
// block so that additional arguments can be used by other software that works
|
||||
// with OpenTofu modules.
|
||||
func decodeLanguageCompatibleWithOpenTofu(block *hcl.Block) (*VersionConstraint, hcl.Diagnostics) {
|
||||
var ret *VersionConstraint
|
||||
content, _, diags := block.Body.PartialContent(languageCompatibleWithSchema)
|
||||
if attr, ok := content.Attributes["opentofu"]; ok {
|
||||
constraint, moreDiags := decodeVersionConstraint(attr)
|
||||
diags = append(diags, moreDiags...)
|
||||
if !moreDiags.HasErrors() {
|
||||
ret = &constraint
|
||||
}
|
||||
}
|
||||
return ret, diags
|
||||
}
|
||||
|
||||
func validateOpenTofuCoreVersionConstraint(constraint VersionConstraint) hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
// We don't permit writing prerelease versions in the version
|
||||
// constraint arguments. We don't actually know why this rule is
|
||||
// here but it was inherited from our predecessor and preserved
|
||||
// for consistency until we know a reason to allow it.
|
||||
for _, required := range constraint.Required {
|
||||
if required.Prerelease() {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid required_version constraint",
|
||||
Detail: fmt.Sprintf(
|
||||
"Prerelease version constraints are not supported: %s. Remove the prerelease information from the constraint. Prerelease versions of OpenTofu will match constraints using their version core only.",
|
||||
required.String(),
|
||||
),
|
||||
Subject: constraint.DeclRange.Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return diags
|
||||
}
|
||||
|
||||
// decodeReservedExperimentsAttr decodes the "experiments" attribute in a
|
||||
// "language" block just enough to return error messages if it's being used
|
||||
// in ways we expect we might use it in future versions of OpenTofu.
|
||||
func decodeReservedExperimentsAttr(attr *hcl.Attribute) hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
exprs, moreDiags := hcl.ExprList(attr.Expr)
|
||||
diags = append(diags, moreDiags...)
|
||||
if moreDiags.HasErrors() {
|
||||
return diags
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// The current version of OpenTofu does not support any language
|
||||
// experiments, so we'll just reject anything we find in here.
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
var languageBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{Name: "edition"},
|
||||
{Name: "experiments"},
|
||||
},
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{Type: "compatible_with"},
|
||||
},
|
||||
}
|
||||
|
||||
var languageCompatibleWithSchema = &hcl.BodySchema{
|
||||
// This describes only the subset that OpenTofu uses. This block should be
|
||||
// decoded using [hcl.Body.PartialContent] so as to ignore anything that's
|
||||
// not included in this schema.
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{Name: "opentofu"},
|
||||
},
|
||||
}
|
||||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"github.com/opentofu/opentofu/internal/addrs"
|
||||
"github.com/opentofu/opentofu/internal/encryption/config"
|
||||
"github.com/opentofu/opentofu/internal/experiments"
|
||||
tfversion "github.com/opentofu/opentofu/version"
|
||||
)
|
||||
|
||||
// Module is a container for a set of configuration constructs that are
|
||||
|
|
@ -32,10 +31,6 @@ type Module struct {
|
|||
// values.
|
||||
SourceDir string
|
||||
|
||||
CoreVersionConstraints []VersionConstraint
|
||||
|
||||
ActiveExperiments experiments.Set
|
||||
|
||||
Backend *Backend
|
||||
CloudConfig *CloudConfig
|
||||
ProviderConfigs map[string]*Provider
|
||||
|
|
@ -68,6 +63,11 @@ type Module struct {
|
|||
|
||||
// StaticEvaluator is used to evaluate static expressions in the scope of the Module.
|
||||
StaticEvaluator *StaticEvaluator
|
||||
|
||||
// ActiveExperiments is not currently used and so is always nil, but is
|
||||
// reserved to be a place to capture a module's active experiments if we
|
||||
// begin using language experiments in a later release.
|
||||
ActiveExperiments experiments.Set
|
||||
}
|
||||
|
||||
// GetProviderConfig uses name and alias to find the respective Provider configuration.
|
||||
|
|
@ -89,10 +89,6 @@ func (m *Module) GetProviderConfig(name, alias string) (*Provider, bool) {
|
|||
// analysis of individual elements, but must be built into a Module to detect
|
||||
// duplicate declarations.
|
||||
type File struct {
|
||||
CoreVersionConstraints []VersionConstraint
|
||||
|
||||
ActiveExperiments experiments.Set
|
||||
|
||||
Backends []*Backend
|
||||
CloudConfigs []*CloudConfig
|
||||
ProviderConfigs []*Provider
|
||||
|
|
@ -273,8 +269,6 @@ func NewModule(primaryFiles, overrideFiles []*File, call StaticModuleCall, sourc
|
|||
diags = append(diags, pDiags...)
|
||||
}
|
||||
|
||||
diags = append(diags, checkModuleExperiments(mod)...)
|
||||
|
||||
// Generate the FQN -> LocalProviderName map
|
||||
mod.gatherProviderLocalNames()
|
||||
|
||||
|
|
@ -300,12 +294,6 @@ func (m *Module) ResourceByAddr(addr addrs.Resource) *Resource {
|
|||
func (m *Module) appendFile(file *File) hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
// If there are any conflicting requirements then we'll catch them
|
||||
// when we actually check these constraints.
|
||||
m.CoreVersionConstraints = append(m.CoreVersionConstraints, file.CoreVersionConstraints...)
|
||||
|
||||
m.ActiveExperiments = experiments.SetUnion(m.ActiveExperiments, file.ActiveExperiments)
|
||||
|
||||
for _, b := range file.Backends {
|
||||
if m.Backend != nil {
|
||||
diags = append(diags, &hcl.Diagnostic{
|
||||
|
|
@ -602,14 +590,6 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics {
|
|||
func (m *Module) mergeFile(file *File) hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
if len(file.CoreVersionConstraints) != 0 {
|
||||
// This is a bit of a strange case for overriding since we normally
|
||||
// would union together across multiple files anyway, but we'll
|
||||
// allow it and have each override file clobber any existing list.
|
||||
m.CoreVersionConstraints = nil
|
||||
m.CoreVersionConstraints = append(m.CoreVersionConstraints, file.CoreVersionConstraints...)
|
||||
}
|
||||
|
||||
if len(file.Backends) != 0 {
|
||||
switch len(file.Backends) {
|
||||
case 1:
|
||||
|
|
@ -874,62 +854,6 @@ func (m *Module) ImpliedProviderForUnqualifiedType(pType string) addrs.Provider
|
|||
return addrs.ImpliedProviderForUnqualifiedType(pType)
|
||||
}
|
||||
|
||||
func (m *Module) CheckCoreVersionRequirements(path addrs.Module, sourceAddr addrs.ModuleSource) hcl.Diagnostics {
|
||||
var diags hcl.Diagnostics
|
||||
|
||||
for _, constraint := range m.CoreVersionConstraints {
|
||||
// Before checking if the constraints are met, check that we are not using any prerelease fields as these
|
||||
// are not currently supported.
|
||||
var prereleaseDiags hcl.Diagnostics
|
||||
for _, required := range constraint.Required {
|
||||
if required.Prerelease() {
|
||||
prereleaseDiags = prereleaseDiags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid required_version constraint",
|
||||
Detail: fmt.Sprintf(
|
||||
"Prerelease version constraints are not supported: %s. Remove the prerelease information from the constraint. Prerelease versions of OpenTofu will match constraints using their version core only.",
|
||||
required.String()),
|
||||
Subject: constraint.DeclRange.Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(prereleaseDiags) > 0 {
|
||||
// There were some prerelease fields in the constraints. Don't check the constraints as they will
|
||||
// fail, and populate the diagnostics for these constraints with the prerelease diagnostics.
|
||||
diags = diags.Extend(prereleaseDiags)
|
||||
continue
|
||||
}
|
||||
|
||||
if !constraint.Required.Check(tfversion.SemVer) {
|
||||
switch {
|
||||
case len(path) == 0:
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unsupported OpenTofu Core version",
|
||||
Detail: fmt.Sprintf(
|
||||
"This configuration does not support OpenTofu version %s. To proceed, either choose another supported OpenTofu version or update this version constraint. Version constraints are normally set for good reason, so updating the constraint may lead to other errors or unexpected behavior.",
|
||||
tfversion.String(),
|
||||
),
|
||||
Subject: constraint.DeclRange.Ptr(),
|
||||
})
|
||||
default:
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Unsupported OpenTofu Core version",
|
||||
Detail: fmt.Sprintf(
|
||||
"Module %s (from %s) does not support OpenTofu version %s. To proceed, either choose another supported OpenTofu version or update this version constraint. Version constraints are normally set for good reason, so updating the constraint may lead to other errors or unexpected behavior.",
|
||||
path, sourceAddr, tfversion.String(),
|
||||
),
|
||||
Subject: constraint.DeclRange.Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
// EphemeralVariablesHints builds a map that indicates what variable name of the module
|
||||
// is ephemeral and which isn't.
|
||||
// This is used by the plan to know what variables are meant to be stored
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
"github.com/opentofu/opentofu/internal/addrs"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
)
|
||||
|
|
@ -495,6 +497,31 @@ func TestModule_cloud_duplicate_overrides(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestModuleFromTheFuture(t *testing.T) {
|
||||
_, diags := testModuleFromDir("testdata/invalid-modules/unsupported-version-and-other-error")
|
||||
if !diags.HasErrors() {
|
||||
t.Fatal("unexpected success; want 'incompatible module' error")
|
||||
}
|
||||
|
||||
var gotSummaries []string
|
||||
for _, diag := range diags {
|
||||
if diag.Severity == hcl.DiagError {
|
||||
gotSummaries = append(gotSummaries, diag.Summary)
|
||||
}
|
||||
}
|
||||
wantSummaries := []string{
|
||||
// The configuration fixture used here includes both a mismatching version
|
||||
// constraint _and_ an unrecognized block type, but we should've reported
|
||||
// only the mismatching version constraint because we assume the
|
||||
// unrecognized block type became valid in a future version of OpenTofu.
|
||||
"Incompatible module",
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(wantSummaries, gotSummaries); diff != "" {
|
||||
t.Error("wrong error diagnostics\n" + diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceByAddr(t *testing.T) {
|
||||
managedResource := &Resource{Mode: addrs.ManagedResourceMode, Name: "name", Type: "test_resource"}
|
||||
dataResource := &Resource{Mode: addrs.DataResourceMode, Name: "name", Type: "test_data"}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,6 @@ import (
|
|||
type Parser struct {
|
||||
fs afero.Afero
|
||||
p *hclparse.Parser
|
||||
|
||||
// allowExperiments controls whether we will allow modules to opt in to
|
||||
// experimental language features. In main code this will be set only
|
||||
// for alpha releases and some development builds. Test code must decide
|
||||
// for itself whether to enable it so that tests can cover both the
|
||||
// allowed and not-allowed situations.
|
||||
allowExperiments bool
|
||||
}
|
||||
|
||||
// NewParser creates and returns a new Parser that reads files from the given
|
||||
|
|
@ -110,16 +103,3 @@ func (p *Parser) ForceFileSource(filename string, src []byte) {
|
|||
Bytes: src,
|
||||
})
|
||||
}
|
||||
|
||||
// AllowLanguageExperiments specifies whether subsequent LoadConfigFile (and
|
||||
// similar) calls will allow opting in to experimental language features.
|
||||
//
|
||||
// If this method is never called for a particular parser, the default behavior
|
||||
// is to disallow language experiments.
|
||||
//
|
||||
// Main code should set this only for alpha or development builds. Test code
|
||||
// is responsible for deciding for itself whether and how to call this
|
||||
// method.
|
||||
func (p *Parser) AllowLanguageExperiments(allowed bool) {
|
||||
p.allowExperiments = allowed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ package configs
|
|||
|
||||
import (
|
||||
"github.com/hashicorp/hcl/v2"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/encryption/config"
|
||||
"github.com/opentofu/opentofu/version"
|
||||
)
|
||||
|
||||
// LoadConfigFile reads the file at the given path and parses it as a config
|
||||
|
|
@ -56,24 +58,29 @@ func (p *Parser) loadConfigFile(path string, override bool) (*File, hcl.Diagnost
|
|||
if body == nil {
|
||||
return nil, diags
|
||||
}
|
||||
ret, moreDiags := loadConfigFileBody(body, path, override, p.allowExperiments)
|
||||
ret, moreDiags := loadConfigFileBody(body, path, override)
|
||||
diags = append(diags, moreDiags...)
|
||||
return ret, diags
|
||||
}
|
||||
|
||||
func loadConfigFileBody(body hcl.Body, filename string, override bool, allowExperiments bool) (*File, hcl.Diagnostics) {
|
||||
func loadConfigFileBody(body hcl.Body, _ string, override bool) (*File, hcl.Diagnostics) {
|
||||
var diags hcl.Diagnostics
|
||||
file := &File{}
|
||||
|
||||
var reqDiags hcl.Diagnostics
|
||||
file.CoreVersionConstraints, reqDiags = sniffCoreVersionRequirements(body)
|
||||
// We check for version compatibility constraints in the module first, using
|
||||
// some code designed to be as resilient as possible to unpredictable
|
||||
// future extensions to the language, so that we have the best possible
|
||||
// chance of returning a version compatibility error if someone intentionally
|
||||
// excluded the current version due to the module using newer features.
|
||||
reqDiags := checkVersionRequirements(body, version.SemVer)
|
||||
diags = append(diags, reqDiags...)
|
||||
|
||||
// We'll load the experiments first because other decoding logic in the
|
||||
// loop below might depend on these experiments.
|
||||
var expDiags hcl.Diagnostics
|
||||
file.ActiveExperiments, expDiags = sniffActiveExperiments(body, allowExperiments)
|
||||
diags = append(diags, expDiags...)
|
||||
// We still continue here even if there was a version compatibility problem
|
||||
// because we want to gather as complete as possible a map of the content
|
||||
// of the valid parts of the module in case a caller wants to use that
|
||||
// for careful partial analysis. Note though that if we have at least one
|
||||
// version compatibility diagnostic in diags then any other diagnostics
|
||||
// added later will eventually be discarded by [finalizeModuleLoadDiagnostics].
|
||||
|
||||
content, contentDiags := body.Content(configFileSchema)
|
||||
diags = append(diags, contentDiags...)
|
||||
|
|
@ -81,13 +88,18 @@ func loadConfigFileBody(body hcl.Body, filename string, override bool, allowExpe
|
|||
for _, block := range content.Blocks {
|
||||
switch block.Type {
|
||||
|
||||
case "language":
|
||||
cfgDiags := validateLanguageBlock(block, override)
|
||||
diags = append(diags, cfgDiags...)
|
||||
|
||||
case "terraform":
|
||||
content, contentDiags := block.Body.Content(terraformBlockSchema)
|
||||
diags = append(diags, contentDiags...)
|
||||
|
||||
// We ignore the "terraform_version", "language" and "experiments"
|
||||
// attributes here because sniffCoreVersionRequirements and
|
||||
// sniffActiveExperiments already dealt with those above.
|
||||
// We ignore the "required_version", "language" and "experiments"
|
||||
// attributes here because checkVersionRequirements above deals
|
||||
// with "required_version" and the other two are not relevant
|
||||
// to OpenTofu. ("language" blocks contain OpenTofu's equivalents.)
|
||||
|
||||
for _, innerBlock := range content.Blocks {
|
||||
switch innerBlock.Type {
|
||||
|
|
@ -126,8 +138,8 @@ func loadConfigFileBody(body hcl.Body, filename string, override bool, allowExpe
|
|||
}
|
||||
|
||||
default:
|
||||
// Should never happen because the above cases should be exhaustive
|
||||
// for all block type names in our schema.
|
||||
// Should never happen because the above cases should be
|
||||
// exhaustive for all block type names in our schema.
|
||||
continue
|
||||
|
||||
}
|
||||
|
|
@ -235,48 +247,13 @@ func loadConfigFileBody(body hcl.Body, filename string, override bool, allowExpe
|
|||
return file, diags
|
||||
}
|
||||
|
||||
// sniffCoreVersionRequirements does minimal parsing of the given body for
|
||||
// "terraform" blocks with "required_version" attributes, returning the
|
||||
// requirements found.
|
||||
//
|
||||
// This is intended to maximize the chance that we'll be able to read the
|
||||
// requirements (syntax errors notwithstanding) even if the config file contains
|
||||
// constructs that might've been added in future OpenTofu versions
|
||||
//
|
||||
// This is a "best effort" sort of method which will return constraints it is
|
||||
// able to find, but may return no constraints at all if the given body is
|
||||
// so invalid that it cannot be decoded at all.
|
||||
func sniffCoreVersionRequirements(body hcl.Body) ([]VersionConstraint, hcl.Diagnostics) {
|
||||
rootContent, _, diags := body.PartialContent(configFileTerraformBlockSniffRootSchema)
|
||||
|
||||
var constraints []VersionConstraint
|
||||
|
||||
for _, block := range rootContent.Blocks {
|
||||
content, _, blockDiags := block.Body.PartialContent(configFileVersionSniffBlockSchema)
|
||||
diags = append(diags, blockDiags...)
|
||||
|
||||
attr, exists := content.Attributes["required_version"]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
constraint, constraintDiags := decodeVersionConstraint(attr)
|
||||
diags = append(diags, constraintDiags...)
|
||||
if !constraintDiags.HasErrors() {
|
||||
constraints = append(constraints, constraint)
|
||||
}
|
||||
}
|
||||
|
||||
return constraints, diags
|
||||
}
|
||||
|
||||
// configFileSchema is the schema for the top-level of a config file. We use
|
||||
// the low-level HCL API for this level so we can easily deal with each
|
||||
// block type separately with its own decoding logic.
|
||||
var configFileSchema = &hcl.BodySchema{
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{
|
||||
Type: "terraform",
|
||||
Type: "language",
|
||||
},
|
||||
{
|
||||
// This one is not really valid, but we include it here so we
|
||||
|
|
@ -328,6 +305,9 @@ var configFileSchema = &hcl.BodySchema{
|
|||
{
|
||||
Type: "removed",
|
||||
},
|
||||
{
|
||||
Type: "terraform",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -335,7 +315,18 @@ var configFileSchema = &hcl.BodySchema{
|
|||
// a configuration file.
|
||||
var terraformBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
// This argument is accepted in any file, but ignored unless it appears
|
||||
// in a file named with a ".tofu" or similar suffix that indicates
|
||||
// it's intended for OpenTofu rather than its predecessor.
|
||||
{Name: "required_version"},
|
||||
|
||||
// The following two are included for compatibility with modules
|
||||
// written by OpenTofu's predecessor, but are ignored when present
|
||||
// because we cannot predict what any future experiment or language
|
||||
// edition keywords in our predecessor might represent.
|
||||
//
|
||||
// The equivalents of these arguments for OpenTofu are inside top-level
|
||||
// "language" blocks, which are handled elsewhere in this package.
|
||||
{Name: "experiments"},
|
||||
{Name: "language"},
|
||||
},
|
||||
|
|
@ -359,31 +350,3 @@ var terraformBlockSchema = &hcl.BodySchema{
|
|||
},
|
||||
},
|
||||
}
|
||||
|
||||
// configFileTerraformBlockSniffRootSchema is a schema for
|
||||
// sniffCoreVersionRequirements and sniffActiveExperiments.
|
||||
var configFileTerraformBlockSniffRootSchema = &hcl.BodySchema{
|
||||
Blocks: []hcl.BlockHeaderSchema{
|
||||
{
|
||||
Type: "terraform",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// configFileVersionSniffBlockSchema is a schema for sniffCoreVersionRequirements
|
||||
var configFileVersionSniffBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{
|
||||
Name: "required_version",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// configFileExperimentsSniffBlockSchema is a schema for sniffActiveExperiments,
|
||||
// to decode a single attribute from inside a "terraform" block.
|
||||
var configFileExperimentsSniffBlockSchema = &hcl.BodySchema{
|
||||
Attributes: []hcl.AttributeSchema{
|
||||
{Name: "experiments"},
|
||||
{Name: "language"},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ func (p *Parser) LoadConfigDirSelective(path string, call StaticModuleCall, load
|
|||
mod, modDiags := NewModule(primary, override, call, path, load)
|
||||
diags = append(diags, modDiags...)
|
||||
|
||||
diags = finalizeModuleLoadDiagnostics(diags)
|
||||
return mod, diags
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +93,7 @@ func (p *Parser) LoadConfigDirUneval(path string, load SelectiveLoader) (*Module
|
|||
mod, modDiags := NewModuleUneval(primary, override, path, load)
|
||||
diags = append(diags, modDiags...)
|
||||
|
||||
diags = finalizeModuleLoadDiagnostics(diags)
|
||||
return mod, diags
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +115,7 @@ func (p *Parser) LoadConfigDirWithTests(path string, testDirectory string, call
|
|||
mod, modDiags := NewModuleWithTests(primary, override, tests, call, path)
|
||||
diags = append(diags, modDiags...)
|
||||
|
||||
diags = finalizeModuleLoadDiagnostics(diags)
|
||||
return mod, diags
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,33 +40,6 @@ func TestParserLoadConfigDirSuccess(t *testing.T) {
|
|||
path := filepath.Join("testdata/valid-modules", name)
|
||||
|
||||
mod, diags := parser.LoadConfigDir(path, RootModuleCallForTesting())
|
||||
if len(diags) != 0 && len(mod.ActiveExperiments) != 0 {
|
||||
// As a special case to reduce churn while we're working
|
||||
// through experimental features, we'll ignore the warning
|
||||
// that an experimental feature is active if the module
|
||||
// intentionally opted in to that feature.
|
||||
// If you want to explicitly test for the feature warning
|
||||
// to be generated, consider using testdata/warning-files
|
||||
// instead.
|
||||
filterDiags := make(hcl.Diagnostics, 0, len(diags))
|
||||
for _, diag := range diags {
|
||||
if diag.Severity != hcl.DiagWarning {
|
||||
continue
|
||||
}
|
||||
match := false
|
||||
for exp := range mod.ActiveExperiments {
|
||||
allowedSummary := fmt.Sprintf("Experimental feature %q is active", exp.Keyword())
|
||||
if diag.Summary == allowedSummary {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
filterDiags = append(filterDiags, diag)
|
||||
}
|
||||
}
|
||||
diags = filterDiags
|
||||
}
|
||||
if len(diags) != 0 {
|
||||
t.Errorf("unexpected diagnostics")
|
||||
for _, diag := range diags {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
terraform {
|
||||
# The language argument expects a bare keyword, not a string.
|
||||
language = "TF2021" # ERROR: Invalid language edition
|
||||
language {
|
||||
# The edition argument expects a bare keyword, not a string.
|
||||
edition = "tofu2024" # ERROR: Invalid language edition
|
||||
}
|
||||
|
|
|
|||
3
internal/configs/testdata/error-files/unsupported_experiment.tf
vendored
Normal file
3
internal/configs/testdata/error-files/unsupported_experiment.tf
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
language {
|
||||
experiments = [unsupported] # ERROR: Unknown experiment keyword
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
terraform {
|
||||
# If a future change in this repository happens to make TF2038 a valid
|
||||
language {
|
||||
# If a future change in this repository happens to make tofu2038 a valid
|
||||
# edition then this will start failing; in that case, change this file to
|
||||
# select a different edition that isn't supported.
|
||||
language = TF2038 # ERROR: Unsupported language edition
|
||||
edition = tofu2038 # ERROR: Unsupported language edition
|
||||
}
|
||||
|
|
|
|||
5
internal/configs/testdata/error-files/unsupported_version.tf
vendored
Normal file
5
internal/configs/testdata/error-files/unsupported_version.tf
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
language {
|
||||
compatible_with {
|
||||
opentofu = "999.9" # ERROR: Incompatible module
|
||||
}
|
||||
}
|
||||
5
internal/configs/testdata/error-files/unsupported_version_legacy.tofu
vendored
Normal file
5
internal/configs/testdata/error-files/unsupported_version_legacy.tofu
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
terraform {
|
||||
# This is the old way to declare OpenTofu version constraints for a module,
|
||||
# from before OpenTofu v1.12. This form is accepted only in .tofu-suffixed files.
|
||||
required_version = "999.9" # ERROR: Incompatible module
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
terraform {
|
||||
experiments = [concluded]
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
terraform {
|
||||
experiments = [current]
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
terraform {
|
||||
experiments = invalid
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
terraform {
|
||||
experiments = [unknown]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# experiments.EverythingIsAPlan exists but is not registered as an active (or
|
||||
# concluded) experiment, so this should fail until the experiment "gate" is
|
||||
# removed.
|
||||
terraform {
|
||||
experiments = [everything_is_a_plan]
|
||||
}
|
||||
|
||||
moved {
|
||||
from = test_instance.foo
|
||||
to = test_instance.bar
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
language {
|
||||
compatible_with {
|
||||
opentofu = "999.9" # ERROR: Incompatible module
|
||||
}
|
||||
}
|
||||
|
||||
# There should not be any error about the following block, even though its
|
||||
# block type is not recognized by the current version of OpenTofu, because
|
||||
# we assume it was added in some future version OpenTofu v999.9 based on
|
||||
# the version constraint above.
|
||||
unrecognized {}
|
||||
18
internal/configs/testdata/valid-files/language.tf
vendored
Normal file
18
internal/configs/testdata/valid-files/language.tf
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
language {
|
||||
compatible_with {
|
||||
opentofu = ">= 1.12"
|
||||
|
||||
# Anything else that's valid in an HCL body is permitted here and completely
|
||||
# ignored by OpenTofu, so that other software can define its own
|
||||
# compatibility-related arguments.
|
||||
ignored = "blah"
|
||||
also_ignored {}
|
||||
}
|
||||
|
||||
# The following are the only valid ways to set these arguments in today's
|
||||
# OpenTofu. We support these only enough to return specialized error messages
|
||||
# if we find something like what we're imagining we might support in future
|
||||
# versions of OpenTofu.
|
||||
edition = tofu2024
|
||||
experiments = []
|
||||
}
|
||||
6
internal/configs/testdata/valid-files/required-version-ignored.tf
vendored
Normal file
6
internal/configs/testdata/valid-files/required-version-ignored.tf
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
terraform {
|
||||
# Because this isn't in a .tofu-suffixed file, we ignore it on the assumption
|
||||
# that it's describing a requirement for OpenTofu's predecessor. Therefore
|
||||
# this is considered "valid" just because we pay no attention to it.
|
||||
required_version = "999.99"
|
||||
}
|
||||
4
internal/configs/testdata/valid-files/required-version-legacy.tofu
vendored
Normal file
4
internal/configs/testdata/valid-files/required-version-legacy.tofu
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
terraform {
|
||||
required_version = ">= 1.11"
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
|
||||
terraform {
|
||||
required_version = "~> 0.12.0"
|
||||
}
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
terraform {
|
||||
# If we drop support for TF2021 in a future Terraform release then this
|
||||
# test will fail. In that case, update this to a newer edition that is
|
||||
# still supported, because the purpose of this test is to verify that
|
||||
# we can successfully decode the language argument, not specifically
|
||||
# that we support TF2021.
|
||||
language = TF2021
|
||||
language {
|
||||
edition = tofu2024
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
terraform {
|
||||
# OpenTofu should ignore this constraint entirely, on the assumption that
|
||||
# it's intended for OpenTofu's predecessor.
|
||||
required_version = "0.1.99"
|
||||
}
|
||||
9
internal/configs/testdata/warning-files/ineffective-version-constraint.tf
vendored
Normal file
9
internal/configs/testdata/warning-files/ineffective-version-constraint.tf
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
language {
|
||||
compatible_with {
|
||||
# Specifying a version constraint that accepts OpenTofu v1.11.0 produces
|
||||
# a warning because this syntax for version constraints was added in
|
||||
# v1.12.0, so it's impossible to successfully declare compatibility with
|
||||
# an earlier version using this syntax.
|
||||
opentofu = ">= 1.11" # WARNING: Ineffective version constraint
|
||||
}
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ func moduleFromStringForTesting(t testing.TB, src string, fakeFilename string) *
|
|||
return nil
|
||||
}
|
||||
|
||||
file, diags := loadConfigFileBody(hclFile.Body, fakeFilename, false, true)
|
||||
file, diags := loadConfigFileBody(hclFile.Body, fakeFilename, false)
|
||||
if diags.HasErrors() {
|
||||
t.Errorf("unexpected file analysis error: %s", diags.Error())
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -162,19 +162,7 @@ func (i *ModuleInstaller) InstallModules(ctx context.Context, rootDir, testsDir
|
|||
var diags tfdiags.Diagnostics
|
||||
|
||||
rootMod, mDiags := i.loader.Parser().LoadConfigDirWithTests(rootDir, testsDir, call)
|
||||
if rootMod == nil {
|
||||
// We drop the diagnostics here because we only want to report module
|
||||
// loading errors after checking the core version constraints, which we
|
||||
// can only do if the module can be at least partially loaded.
|
||||
return nil, diags
|
||||
} else if vDiags := rootMod.CheckCoreVersionRequirements(nil, nil); vDiags.HasErrors() {
|
||||
// If the core version requirements are not met, we drop any other
|
||||
// diagnostics, as they may reflect language changes from future
|
||||
// OpenTofu versions.
|
||||
diags = diags.Append(vDiags)
|
||||
} else {
|
||||
diags = diags.Append(mDiags)
|
||||
}
|
||||
diags = diags.Append(mDiags)
|
||||
|
||||
manifest, err := modsdir.ReadManifestSnapshotForDir(i.modsDir)
|
||||
if err != nil {
|
||||
|
|
@ -320,11 +308,6 @@ func (i *ModuleInstaller) moduleInstallWalker(_ context.Context, manifest modsdi
|
|||
// nil indicates an unreadable module, which should never happen,
|
||||
// so we return the full loader diagnostics here.
|
||||
diags = diags.Extend(mDiags)
|
||||
} else if vDiags := mod.CheckCoreVersionRequirements(req.Path, req.SourceAddr); vDiags.HasErrors() {
|
||||
// If the core version requirements are not met, we drop any other
|
||||
// diagnostics, as they may reflect language changes from future
|
||||
// OpenTofu versions.
|
||||
diags = diags.Extend(vDiags)
|
||||
} else {
|
||||
diags = diags.Extend(mDiags)
|
||||
}
|
||||
|
|
@ -474,11 +457,6 @@ func (i *ModuleInstaller) installLocalModule(ctx context.Context, req *configs.M
|
|||
Summary: "Unreadable module directory",
|
||||
Detail: fmt.Sprintf("The directory %s could not be read for module %q at %s:%d.", newDir, req.Name, req.CallRange.Filename, req.CallRange.Start.Line),
|
||||
})
|
||||
} else if vDiags := mod.CheckCoreVersionRequirements(req.Path, req.SourceAddr); vDiags.HasErrors() {
|
||||
// If the core version requirements are not met, we drop any other
|
||||
// diagnostics, as they may reflect language changes from future
|
||||
// OpenTofu versions.
|
||||
diags = diags.Extend(vDiags)
|
||||
} else {
|
||||
diags = diags.Extend(mDiags)
|
||||
}
|
||||
|
|
@ -844,11 +822,6 @@ func (i *ModuleInstaller) installRegistryModule(ctx context.Context, req *config
|
|||
Detail: fmt.Sprintf("The directory %s could not be read. This is a bug in OpenTofu and should be reported.", modDir),
|
||||
})
|
||||
}
|
||||
} else if vDiags := mod.CheckCoreVersionRequirements(req.Path, req.SourceAddr); vDiags.HasErrors() {
|
||||
// If the core version requirements are not met, we drop any other
|
||||
// diagnostics, as they may reflect language changes from future
|
||||
// OpenTofu versions.
|
||||
diags = diags.Extend(vDiags)
|
||||
} else {
|
||||
diags = diags.Extend(mDiags)
|
||||
}
|
||||
|
|
@ -967,11 +940,6 @@ func (i *ModuleInstaller) installGoGetterModule(ctx context.Context, req *config
|
|||
Detail: fmt.Sprintf("The directory %s could not be read. This is a bug in OpenTofu and should be reported.", modDir),
|
||||
})
|
||||
}
|
||||
} else if vDiags := mod.CheckCoreVersionRequirements(req.Path, req.SourceAddr); vDiags.HasErrors() {
|
||||
// If the core version requirements are not met, we drop any other
|
||||
// diagnostics, as they may reflect language changes from future
|
||||
// OpenTofu versions.
|
||||
diags = diags.Extend(vDiags)
|
||||
} else {
|
||||
diags = diags.Extend(mDiags)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,10 +323,6 @@ func (c *Context) watchStop(walker *ContextGraphWalker) (chan struct{}, <-chan s
|
|||
func (c *Context) checkConfigDependencies(config *configs.Config) tfdiags.Diagnostics {
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
// This checks the OpenTofu CLI version constraints specified in all of
|
||||
// the modules.
|
||||
diags = diags.Append(CheckCoreVersionRequirements(config))
|
||||
|
||||
// We only check that we have a factory for each required provider, and
|
||||
// assume the caller already assured that any separately-installed
|
||||
// plugins are of a suitable version, match expected checksums, etc.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import (
|
|||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/zclconf/go-cty/cty"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/addrs"
|
||||
|
|
@ -36,7 +35,6 @@ import (
|
|||
"github.com/opentofu/opentofu/internal/states/statefile"
|
||||
"github.com/opentofu/opentofu/internal/tfdiags"
|
||||
"github.com/opentofu/opentofu/internal/tracing"
|
||||
tfversion "github.com/opentofu/opentofu/version"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -46,138 +44,6 @@ var (
|
|||
valueTrans = cmp.Transformer("hcl2shim", hcl2shim.ConfigValueFromHCL2)
|
||||
)
|
||||
|
||||
func TestNewContextRequiredVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
Name string
|
||||
Version string
|
||||
Value string
|
||||
Err bool
|
||||
}{
|
||||
{
|
||||
"no requirement",
|
||||
"0.1.0",
|
||||
"",
|
||||
false,
|
||||
},
|
||||
|
||||
{
|
||||
"doesn't match",
|
||||
"0.1.0",
|
||||
"> 0.6.0",
|
||||
true,
|
||||
},
|
||||
|
||||
{
|
||||
"matches",
|
||||
"0.7.0",
|
||||
"> 0.6.0",
|
||||
false,
|
||||
},
|
||||
|
||||
{
|
||||
"prerelease doesn't match with inequality",
|
||||
"0.8.0",
|
||||
"> 0.7.0-beta",
|
||||
true,
|
||||
},
|
||||
|
||||
{
|
||||
"prerelease doesn't match with equality",
|
||||
"0.7.0",
|
||||
"0.7.0-beta",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for i, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) {
|
||||
// Reset the version for the tests
|
||||
old := tfversion.SemVer
|
||||
tfversion.SemVer = version.Must(version.NewVersion(tc.Version))
|
||||
defer func() { tfversion.SemVer = old }()
|
||||
|
||||
mod := testModule(t, "context-required-version")
|
||||
if tc.Value != "" {
|
||||
constraint, err := version.NewConstraint(tc.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("can't parse %q as version constraint", tc.Value)
|
||||
}
|
||||
mod.Module.CoreVersionConstraints = append(mod.Module.CoreVersionConstraints, configs.VersionConstraint{
|
||||
Required: constraint,
|
||||
})
|
||||
}
|
||||
c, diags := NewContext(&ContextOpts{})
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("unexpected NewContext errors: %s", diags.Err())
|
||||
}
|
||||
|
||||
diags = c.Validate(context.Background(), mod)
|
||||
if diags.HasErrors() != tc.Err {
|
||||
t.Fatalf("err: %s", diags.Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewContextRequiredVersion_child(t *testing.T) {
|
||||
mod := testModuleInline(t, map[string]string{
|
||||
"main.tf": `
|
||||
module "child" {
|
||||
source = "./child"
|
||||
}
|
||||
`,
|
||||
"child/main.tf": `
|
||||
terraform {}
|
||||
`,
|
||||
})
|
||||
|
||||
cases := map[string]struct {
|
||||
Version string
|
||||
Constraint string
|
||||
Err bool
|
||||
}{
|
||||
"matches": {
|
||||
"0.5.0",
|
||||
">= 0.5.0",
|
||||
false,
|
||||
},
|
||||
"doesn't match": {
|
||||
"0.4.0",
|
||||
">= 0.5.0",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Reset the version for the tests
|
||||
old := tfversion.SemVer
|
||||
tfversion.SemVer = version.Must(version.NewVersion(tc.Version))
|
||||
defer func() { tfversion.SemVer = old }()
|
||||
|
||||
if tc.Constraint != "" {
|
||||
constraint, err := version.NewConstraint(tc.Constraint)
|
||||
if err != nil {
|
||||
t.Fatalf("can't parse %q as version constraint", tc.Constraint)
|
||||
}
|
||||
child := mod.Children["child"]
|
||||
child.Module.CoreVersionConstraints = append(child.Module.CoreVersionConstraints, configs.VersionConstraint{
|
||||
Required: constraint,
|
||||
})
|
||||
}
|
||||
c, diags := NewContext(&ContextOpts{})
|
||||
if diags.HasErrors() {
|
||||
t.Fatalf("unexpected NewContext errors: %s", diags.Err())
|
||||
}
|
||||
|
||||
diags = c.Validate(context.Background(), mod)
|
||||
if diags.HasErrors() != tc.Err {
|
||||
t.Fatalf("err: %s", diags.Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext_missingPlugins(t *testing.T) {
|
||||
ctx, diags := NewContext(&ContextOpts{})
|
||||
assertNoDiagnostics(t, diags)
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package tofu
|
||||
|
||||
import (
|
||||
"github.com/opentofu/opentofu/internal/tfdiags"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/configs"
|
||||
)
|
||||
|
||||
// CheckCoreVersionRequirements visits each of the modules in the given
|
||||
// configuration tree and verifies that any given Core version constraints
|
||||
// match with the version of OpenTofu Core that is being used.
|
||||
//
|
||||
// The returned diagnostics will contain errors if any constraints do not match.
|
||||
// The returned diagnostics might also return warnings, which should be
|
||||
// displayed to the user.
|
||||
func CheckCoreVersionRequirements(config *configs.Config) tfdiags.Diagnostics {
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var diags tfdiags.Diagnostics
|
||||
diags = diags.Append(config.CheckCoreVersionRequirements())
|
||||
|
||||
return diags
|
||||
}
|
||||
Loading…
Reference in a new issue