diff --git a/internal/addrs/module_source.go b/internal/addrs/module_source.go index 5c258533e7..dd501fceb6 100644 --- a/internal/addrs/module_source.go +++ b/internal/addrs/module_source.go @@ -113,6 +113,14 @@ func ParseModuleSource(raw string) (ModuleSource, error) { return remoteAddr, nil } +func MustParseModuleSource(raw string) ModuleSource { + ret, err := ParseModuleSource(raw) + if err != nil { + panic(err) + } + return ret +} + // ModuleSourceLocal is a ModuleSource representing a local path reference // from the caller's directory to the callee's directory within the same // module package. diff --git a/internal/addrs/module_source_resolve.go b/internal/addrs/module_source_resolve.go new file mode 100644 index 0000000000..3c2894bc76 --- /dev/null +++ b/internal/addrs/module_source_resolve.go @@ -0,0 +1,72 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package addrs + +import ( + "fmt" + "io/fs" + "path" +) + +// FIXME: Everything in here should have unit tests + +// ResolveRelativeModuleSource calculates a new source address from the +// combination of two other source addresses, if possible. +func ResolveRelativeModuleSource(a, b ModuleSource) (ModuleSource, error) { + bLocal, ok := b.(ModuleSourceLocal) + if !ok { + return b, nil // non-local source addresses are always absolute + } + bRaw := string(bLocal) + + switch a := a.(type) { + case ModuleSourceLocal: + aRaw := string(a) + new := path.Join(aRaw, bRaw) + if !isModuleSourceLocal(new) { + new = "./" + new // ModuleSourceLocal must always have a suitable prefix + } + return ModuleSourceLocal(new), nil + case ModuleSourceRegistry: + aSub := a.Subdir + newSub, err := joinModuleSourceSubPath(aSub, bRaw) + if err != nil { + return nil, fmt.Errorf("invalid relative path from %s: %w", a.String(), err) + } + return ModuleSourceRegistry{ + Package: a.Package, + Subdir: newSub, + }, nil + case ModuleSourceRemote: + aSub := a.Subdir + newSub, err := joinModuleSourceSubPath(aSub, bRaw) + if err != nil { + return nil, fmt.Errorf("invalid relative path from %s: %w", a.String(), err) + } + return ModuleSourceRemote{ + Package: a.Package, + Subdir: newSub, + }, nil + default: + // Should not get here, because the cases above should cover all + // of the implementations of [ModuleSource]. + panic(fmt.Sprintf("unsupported ModuleSource type %T", a)) + } +} + +func joinModuleSourceSubPath(subPath, rel string) (string, error) { + new := path.Join(subPath, rel) + if new == "." { + return "", nil // the root of the package + } + // If subPath was valid then "." and ".." segments can only appear + // if "rel" has too many "../" segments, causing it to "escape" from + // its package root. + if !fs.ValidPath(new) { + return "", fmt.Errorf("relative path %s has too many \"../\" segments", rel) + } + return new, nil +} diff --git a/internal/lang/eval/config.go b/internal/lang/eval/config.go index 7c4c209ae1..96833e613b 100644 --- a/internal/lang/eval/config.go +++ b/internal/lang/eval/config.go @@ -12,7 +12,6 @@ import ( "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/lang/eval/internal/evalglue" - "github.com/opentofu/opentofu/internal/lang/eval/internal/tofu2024" "github.com/opentofu/opentofu/internal/lang/exprs" "github.com/opentofu/opentofu/internal/tfdiags" ) @@ -108,13 +107,12 @@ func (c *ConfigInstance) newRootModuleInstance(ctx context.Context, glue evalglu if diags.HasErrors() { return nil, diags } - rootModuleCall := &tofu2024.ModuleInstanceCall{ - CalleeAddr: addrs.RootModuleInstance, + ret, moreDiags := rootModule.CompileModuleInstance(ctx, &evalglue.ModuleCall{ InputValues: c.inputValues, - EvaluationGlue: glue, AllowImpureFunctions: c.allowImpureFunctions, + EvaluationGlue: glue, EvalContext: c.evalContext, - } - rootModuleInstance := tofu2024.CompileModuleInstance(ctx, rootModule, c.rootModuleSource, rootModuleCall) - return rootModuleInstance, diags + }) + diags = diags.Append(moreDiags) + return ret, diags } diff --git a/internal/lang/eval/config_validate_test.go b/internal/lang/eval/config_validate_test.go index 04aac608d7..c015ba674c 100644 --- a/internal/lang/eval/config_validate_test.go +++ b/internal/lang/eval/config_validate_test.go @@ -220,3 +220,68 @@ func TestValidate_resourceValid(t *testing.T) { t.Fatalf("unexpected errors: %s", diags.Err()) } } + +func TestValidate_childModuleCallValuesOnly(t *testing.T) { + configInst, diags := eval.NewConfigInstance(t.Context(), &eval.ConfigCall{ + EvalContext: evalglue.EvalContextForTesting(t, &eval.EvalContext{ + Modules: eval.ModulesForTesting(map[addrs.ModuleSourceLocal]*configs.Module{ + addrs.ModuleSourceLocal("."): configs.ModuleFromStringForTesting(t, ` + variable "in" { + type = string + } + module "child" { + source = "./child" + + input = var.in + } + output "out" { + value = module.child.result + } + `), + addrs.ModuleSourceLocal("./child"): configs.ModuleFromStringForTesting(t, ` + variable "input" { + type = string + } + output "result" { + value = var.input + } + `), + }), + Providers: eval.ProvidersForTesting(map[addrs.Provider]*providers.GetProviderSchemaResponse{ + addrs.MustParseProviderSourceString("test/foo"): { + Provider: providers.Schema{ + Block: &configschema.Block{}, + }, + ResourceTypes: map[string]providers.Schema{ + "foo": { + Block: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Required: true, + }, + "id": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }), + }), + RootModuleSource: addrs.ModuleSourceLocal("."), + InputValues: eval.InputValuesForTesting(map[string]cty.Value{ + "in": cty.StringVal("foo bar baz"), + }), + }) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } + + diags = configInst.Validate(t.Context()) + if diags.HasErrors() { + t.Fatalf("unexpected errors: %s", diags.Err()) + } +} diff --git a/internal/lang/eval/eval_context.go b/internal/lang/eval/eval_context.go index a9e6476de8..17d47b08b0 100644 --- a/internal/lang/eval/eval_context.go +++ b/internal/lang/eval/eval_context.go @@ -10,6 +10,7 @@ import ( "github.com/opentofu/opentofu/internal/configs" "github.com/opentofu/opentofu/internal/configs/configschema" "github.com/opentofu/opentofu/internal/lang/eval/internal/evalglue" + "github.com/opentofu/opentofu/internal/lang/eval/internal/tofu2024" "github.com/opentofu/opentofu/internal/providers" ) @@ -24,7 +25,11 @@ type Provisioners = evalglue.Provisioners type ExternalModules = evalglue.ExternalModules func ModulesForTesting(modules map[addrs.ModuleSourceLocal]*configs.Module) ExternalModules { - return evalglue.ModulesForTesting(modules) + // This one actually lives in tofu2024 because evalglue isn't allowed to + // depend on tofu2024 itself, but from the caller's perspective this is + // still presented as an evalglue re-export because the return type belongs + // to that package. + return tofu2024.ModulesForTesting(modules) } func ProvidersForTesting(schemas map[addrs.Provider]*providers.GetProviderSchemaResponse) Providers { diff --git a/internal/lang/eval/internal/configgraph/instances_test.go b/internal/lang/eval/internal/configgraph/instances_test.go new file mode 100644 index 0000000000..d950fe4d68 --- /dev/null +++ b/internal/lang/eval/internal/configgraph/instances_test.go @@ -0,0 +1,89 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configgraph + +import ( + "context" + "maps" + + "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/instances" + "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// singleInstanceSelectorForTesting returns an [InstanceSelector] that +// reports a single no-key instance, similar to when there are no repetition +// arguments at all or when "enabled = true". +func singleInstanceSelectorForTesting() InstanceSelector { + return &instanceSelectorForTesting{ + keyType: addrs.NoKeyType, + instances: map[addrs.InstanceKey]instances.RepetitionData{ + addrs.NoKey: instances.RepetitionData{}, + }, + } +} + +// disabledInstanceSelectorForTesting returns an [InstanceSelector] that +// mimics what happens when "enabled = false". +func disabledInstanceSelectorForTesting() InstanceSelector { + return &instanceSelectorForTesting{ + keyType: addrs.NoKeyType, + instances: nil, + } +} + +// countInstanceSelectorForTesting returns an [InstanceSelector] that behaves +// similarly to how we treat the "count" meta-argument. +func countInstanceSelectorForTesting(count int) InstanceSelector { + insts := make(map[addrs.InstanceKey]instances.RepetitionData, count) + for i := range count { + insts[addrs.IntKey(i)] = instances.RepetitionData{ + CountIndex: cty.NumberIntVal(int64(i)), + } + } + return &instanceSelectorForTesting{ + keyType: addrs.IntKeyType, + instances: insts, + } +} + +// countInstanceSelectorForTesting returns an [InstanceSelector] that behaves +// similarly to how we treat the "for_each" meta-argument. +func forEachInstanceSelectorForTesting(elems map[string]cty.Value) InstanceSelector { + insts := make(map[addrs.InstanceKey]instances.RepetitionData, len(elems)) + for key, val := range elems { + insts[addrs.StringKey(key)] = instances.RepetitionData{ + EachKey: cty.StringVal(key), + EachValue: val, + } + } + return &instanceSelectorForTesting{ + keyType: addrs.StringKeyType, + instances: insts, + } +} + +type instanceSelectorForTesting struct { + keyType addrs.InstanceKeyType + instances map[addrs.InstanceKey]instances.RepetitionData + instancesMarks cty.ValueMarks +} + +// InstanceKeyType implements InstanceSelector. +func (i *instanceSelectorForTesting) InstanceKeyType() addrs.InstanceKeyType { + return i.keyType +} + +// Instances implements InstanceSelector. +func (i *instanceSelectorForTesting) Instances(ctx context.Context) (Maybe[InstancesSeq], cty.ValueMarks, tfdiags.Diagnostics) { + return Known(InstancesSeq(maps.All(i.instances))), i.instancesMarks, nil +} + +// InstancesSourceRange implements InstanceSelector. +func (i *instanceSelectorForTesting) InstancesSourceRange() *tfdiags.SourceRange { + return nil +} diff --git a/internal/lang/eval/internal/configgraph/module_call.go b/internal/lang/eval/internal/configgraph/module_call.go index 5467874a11..b9607a5314 100644 --- a/internal/lang/eval/internal/configgraph/module_call.go +++ b/internal/lang/eval/internal/configgraph/module_call.go @@ -7,11 +7,17 @@ package configgraph import ( "context" + "errors" "fmt" + "maps" + "slices" + "strings" + "github.com/apparentlymart/go-versions/versions" "github.com/apparentlymart/go-workgraph/workgraph" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" "github.com/opentofu/opentofu/internal/addrs" "github.com/opentofu/opentofu/internal/instances" @@ -24,10 +30,42 @@ type ModuleCall struct { Addr addrs.AbsModuleCall DeclRange tfdiags.SourceRange + // ParentSourceAddr is the source address of the module that contained + // this module call, which is then used as the base for resolving + // any relative addresses returned from SourceAddrValuer. + ParentSourceAddr addrs.ModuleSource + // InstanceSelector represents a rule for deciding which instances of // this resource have been declared. InstanceSelector InstanceSelector + // SourceAddrValuer and VersionConstraintValuer together describe how + // to select the module to be called. + // + // We currently require these to be equal for all instances of the + // module call because although in principle this new evaluation model + // could support entirely different declarations in each module, the + // surface syntax of HCL would make that very hard to use (can't easily + // set drastically different arguments for each instance) and this + // also allows us to echo the design for resource instances where we've + // effectively already baked in what schema we ought to be validating + // against before we try to evaluate the config body inside + // [ModuleCallInstance]. + SourceAddrValuer *OnceValuer + VersionConstraintValuer *OnceValuer + + // ValidateSourceArguments is a callback function provided by whatever + // compiled this [ModuleCall] object that checks whether the source + // arguments are resolvable in the current execution context, so that we + // can report any problems just once at the call level rather than + // re-reporting the same problems once for each instance. + // + // Depending on what phase we're in this could either try to find a + // suitable module in a local cache directory or could even try to actually + // fetch a remote module over the network, and so this function may take + // a long time to return. + ValidateSourceArguments func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics + // CompileCallInstance is a callback function provided by whatever // compiled this [ModuleCall] object that knows how to produce a compiled // [ModuleCallInstance] object once we know of the instance key and @@ -36,7 +74,11 @@ type ModuleCall struct { // This indirection allows the caller to take into account the same // context it had available when it built this [ModuleCall] object, while // incorporating the new information about this specific instance. - CompileCallInstance func(ctx context.Context, key addrs.InstanceKey, repData instances.RepetitionData) *ModuleCallInstance + // + // This should only be called with a [ModuleSourceArguments] that was + // accepted by [ModuleCall.ValidateSourceArguments] without returning any + // errors. + CompileCallInstance func(ctx context.Context, sourceArgs ModuleSourceArguments, key addrs.InstanceKey, repData instances.RepetitionData) *ModuleCallInstance // instancesResult tracks the process of deciding which instances are // currently declared for this provider config, and the result of that process. @@ -57,9 +99,173 @@ func (c *ModuleCall) Instances(ctx context.Context) map[addrs.InstanceKey]*Modul return result.Instances } +func (c *ModuleCall) SourceArguments(ctx context.Context) (Maybe[ModuleSourceArguments], cty.ValueMarks, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + sourceVal, moreDiags := c.SourceAddrValuer.Value(ctx) + diags = diags.Append(moreDiags) + versionVal, moreDiags := c.VersionConstraintValuer.Value(ctx) + diags = diags.Append(moreDiags) + if diags.HasErrors() { + // Not even valid enough to try anything else. + return nil, nil, diags + } + + // We'll decode both source address and version together so that we can + // always return their combined marks even if one is invalid. + const sourceDiagSummary = "Invalid module source address" + const versionDiagSummary = "Invalid module version constraints" + sourceStr, sourceMarks, sourceErr := decodeModuleCallSourceArgumentString(sourceVal, false) + versionStr, versionMarks, versionErr := decodeModuleCallSourceArgumentString(versionVal, true) + allMarks := make(cty.ValueMarks) + maps.Copy(allMarks, sourceMarks) + maps.Copy(allMarks, versionMarks) + + if sourceErr != nil { + var detail string + switch err := sourceErr.(type) { + case moduleSourceDependsOnResourcesError: + var buf strings.Builder + fmt.Fprintln(&buf, "The module source address value is derived from the results of the following resource instances:") + for _, addr := range err.instAddrs { + fmt.Fprintf(&buf, " - %s\n", addr) + } + fmt.Fprint(&buf, "\n\nModule source selections cannot be based on resource results because they must remain consistent throughout each plan/apply round.") + detail = buf.String() + case moduleSourceDependsOnUpstreamEvalError: + // We intentionally don't generate any more diagnostics in this + // case because we assume that something upstream will already + // have reported error diagnostics for whatever caused this + // error. + default: + detail = fmt.Sprintf("Unsuitable value for module source address: %s.", tfdiags.FormatError(err)) + } + if detail != "" { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: sourceDiagSummary, + Detail: detail, + Subject: MaybeHCLSourceRange(c.SourceAddrValuer.ValueSourceRange()), + }) + } + } + if versionErr != nil { + var detail string + switch err := sourceErr.(type) { + case moduleSourceDependsOnResourcesError: + var buf strings.Builder + fmt.Fprintln(&buf, "The module version constraints value is derived from the results of the following resource instances:") + for _, addr := range err.instAddrs { + fmt.Fprintf(&buf, " - %s\n", addr) + } + fmt.Fprint(&buf, "\n\nModule source selections cannot be based on resource results because they must remain consistent throughout each plan/apply round.") + detail = buf.String() + case moduleSourceDependsOnUpstreamEvalError: + // We intentionally don't generate any more diagnostics in this + // case because we assume that something upstream will already + // have reported error diagnostics for whatever caused this + // error. + default: + detail = fmt.Sprintf("Unsuitable value for module version constraints: %s.", tfdiags.FormatError(err)) + } + if detail != "" { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: versionDiagSummary, + Detail: detail, + Subject: MaybeHCLSourceRange(c.SourceAddrValuer.ValueSourceRange()), + }) + } + } + if diags.HasErrors() { + return nil, allMarks, diags + } + + sourceAddr, err := addrs.ParseModuleSource(sourceStr) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: sourceDiagSummary, + Detail: fmt.Sprintf("Cannot use %q as module source address: %s.", sourceStr, tfdiags.FormatError(err)), + Subject: MaybeHCLSourceRange(c.SourceAddrValuer.ValueSourceRange()), + }) + return nil, allMarks, diags + } + // If the specified source address is a relative path then we need to + // resolve it to absolute based on the source address where this + // module call appeared. + sourceAddr, err = addrs.ResolveRelativeModuleSource(c.ParentSourceAddr, sourceAddr) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: sourceDiagSummary, + Detail: fmt.Sprintf("Cannot use %q as module source address: %s.", sourceStr, tfdiags.FormatError(err)), + Subject: MaybeHCLSourceRange(c.SourceAddrValuer.ValueSourceRange()), + }) + return nil, allMarks, diags + } + + // FIXME: It would be better if the rule for what source address types + // are allowed to have version constraints would live somewhere else, + // such as in "package addrs". + var allowedVersions versions.Set + if _, isRegistry := sourceAddr.(addrs.ModuleSourceRegistry); isRegistry { + if versionStr != "" { + vs, err := versions.MeetingConstraintsStringRuby(versionStr) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: versionDiagSummary, + Detail: fmt.Sprintf("Cannot use %q as module version constraints: %s.", versionStr, tfdiags.FormatError(err)), + Subject: MaybeHCLSourceRange(c.VersionConstraintValuer.ValueSourceRange()), + }) + return nil, allMarks, diags + } + allowedVersions = vs + } else { + allowedVersions = versions.All + } + } else { + if versionStr != "" { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: versionDiagSummary, + Detail: fmt.Sprintf("Module source address %q does not support version constraints.", sourceAddr), + Subject: MaybeHCLSourceRange(c.VersionConstraintValuer.ValueSourceRange()), + }) + return nil, allMarks, diags + } + } + + return Known(ModuleSourceArguments{ + Source: sourceAddr, + AllowedVersions: allowedVersions, + }), allMarks, diags +} + func (c *ModuleCall) decideInstances(ctx context.Context) (*compiledInstances[*ModuleCallInstance], tfdiags.Diagnostics) { return c.instancesResult.Do(ctx, func(ctx context.Context) (*compiledInstances[*ModuleCallInstance], tfdiags.Diagnostics) { - return compileInstances(ctx, c.InstanceSelector, c.CompileCallInstance) + // We intentionally ignore diagnostics and marks here because Value + // deals with those and skips calling this function at all when + // the arguments are too invalid. + maybeSourceArgs, _, _ := c.SourceArguments(ctx) + sourceArgs, ok := GetKnown(maybeSourceArgs) + if !ok { + // For our purposes here we just use this as a signal that we + // should not even try to select instances. [ModuleCall.Value] + // also checks this and skips even making use of the instances + // when the source arguments are invalid, and so we're just + // returning something valid enough for other callers to unwind + // successfully and move on here. + return &compiledInstances[*ModuleCallInstance]{ + KeyType: addrs.UnknownKeyType, + Instances: nil, + ValueMarks: nil, + }, nil + } + return compileInstances(ctx, c.InstanceSelector, func(ctx context.Context, key addrs.InstanceKey, repData instances.RepetitionData) *ModuleCallInstance { + return c.CompileCallInstance(ctx, sourceArgs, key, repData) + }) }) } @@ -70,8 +276,35 @@ func (c *ModuleCall) StaticCheckTraversal(traversal hcl.Traversal) tfdiags.Diagn // Value implements exprs.Valuer. func (c *ModuleCall) Value(ctx context.Context) (cty.Value, tfdiags.Diagnostics) { + // We'll first check whether the arguments specifying which module to + // call are valid, because we can't really do anything else if not. + maybeSourceArgs, sourceMarks, diags := c.SourceArguments(ctx) + sourceArgs, ok := GetKnown(maybeSourceArgs) + if !ok { + // Either the source information was invalid or was based on something + // that failed evaluation upstream, so we'll just bail out here. + // NOTE: It's possible for sourceArgs to be nil while diags does not + // have errors: we react in this way when the source information is + // unknown because it was derived from something that failed upstream, + // with the assumption that the upstream error generated its own + // diagnostics that'll come via another return path. + return cty.DynamicVal.WithMarks(sourceMarks), diags + } + + // We'll validate that the source arguments are acceptable before we + // try to instantiate any instances, because otherwise we'd likely + // detect and report the same problems separately for each instance. + moreDiags := c.ValidateSourceArguments(ctx, sourceArgs) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return cty.DynamicVal.WithMarks(sourceMarks), diags + } + + // If the source args were acceptable then we'll decide what instances + // we have and then collect their individual results into our overall + // return value. selection, diags := c.decideInstances(ctx) - return valueForInstances(ctx, selection), diags + return valueForInstances(ctx, selection).WithMarks(sourceMarks), diags } // ValueSourceRange implements exprs.Valuer. @@ -95,6 +328,15 @@ func (c *ModuleCall) CheckAll(ctx context.Context) tfdiags.Diagnostics { } func (c *ModuleCall) AnnounceAllGraphevalRequests(announce func(workgraph.RequestID, grapheval.RequestInfo)) { + announce(c.SourceAddrValuer.RequestID(), grapheval.RequestInfo{ + Name: c.Addr.String() + " source address", + SourceRange: c.SourceAddrValuer.ValueSourceRange(), + }) + announce(c.VersionConstraintValuer.RequestID(), grapheval.RequestInfo{ + Name: c.Addr.String() + " version constraint", + SourceRange: c.VersionConstraintValuer.ValueSourceRange(), + }) + // There might be other grapheval requests in our dynamic instances, but // they are hidden behind another request themselves so we'll try to // report them only if that request was already started. @@ -113,3 +355,74 @@ func (c *ModuleCall) AnnounceAllGraphevalRequests(announce func(workgraph.Reques inst.AnnounceAllGraphevalRequests(announce) } } + +// decodeModuleCallSourceArgumentString deals with the various requirements +// that apply to both the source address _and_ version constraints strings +// in a module call, producing an error that can be used as part of an +// error diagnostic saying that the argument is invalid. +// +// If nullAllowed is true then a null value is represented by returning an +// empty string. An _actual_ empty string is always rejected as invalid input, +// so a successful result with an empty string always means that the given +// value was null. +func decodeModuleCallSourceArgumentString(v cty.Value, nullAllowed bool) (string, cty.ValueMarks, error) { + v, err := convert.Convert(v, cty.String) + retV, retMarks := v.Unmark() + if err != nil { + return "", nil, err + } + if v.IsNull() { + if nullAllowed { + // Note that this is the only case where the result can be "" + // without returning an error, because we reject an _actual_ + // empty string with an error later in this function. + return "", retMarks, nil + } + return "", retMarks, errors.New("value must not be null") + } + if ris := slices.Collect(ResourceInstanceAddrs(ContributingResourceInstances(v))); len(ris) != 0 { + // A module source argument is never allowed to depend on a resource + // instance result, even if its value happens to be known in the + // current evaluation context. + return "", retMarks, moduleSourceDependsOnResourcesError{ris} + } + if !v.IsKnown() { + // Although resource instance references are the main reason for + // it to be unknown, it could also be unknown if it were derived + // from an impure function during the planning phase, or from an + // input variable that was set to an unknown value. + // + // We use a special error type when the EvalError mark is present + // because in that case we want to be quiet and avoid distracting + // the user with an error message about unknown values when the + // unknown values were likely generated by OpenTofu itself as + // an implementation detail, rather than by a module authoring mistake. + if exprs.IsEvalError(v) { + return "", nil, moduleSourceDependsOnUpstreamEvalError{} + } + return "", retMarks, errors.New("depends on a value that won't be known until the apply phase") + } + ret := retV.AsString() + if ret == "" { + return "", retMarks, errors.New("must be a non-empty string") + } + return ret, retMarks, nil +} + +type moduleSourceDependsOnResourcesError struct { + instAddrs []addrs.AbsResourceInstance +} + +// Error implements error, though it returns only a generic error message +// that should be substituted for something better when building a user-facing +// diagnostic, +func (m moduleSourceDependsOnResourcesError) Error() string { + return "must not be derived from resource instance attributes" +} + +type moduleSourceDependsOnUpstreamEvalError struct{} + +// Error implements error. +func (m moduleSourceDependsOnUpstreamEvalError) Error() string { + return "derived from upstream expression that was invalid" +} diff --git a/internal/lang/eval/internal/configgraph/module_call_instance.go b/internal/lang/eval/internal/configgraph/module_call_instance.go index dd5bf6395e..dc478d4b77 100644 --- a/internal/lang/eval/internal/configgraph/module_call_instance.go +++ b/internal/lang/eval/internal/configgraph/module_call_instance.go @@ -30,32 +30,9 @@ type ModuleCallInstance struct { // a separate address type for an "absolute module call instance". ModuleInstanceAddr addrs.ModuleInstance - // SourceAddrValuer and VersionConstraintValuer together describe how - // to select the module to be called. - // - // Allowing the entire module content to vary between phases is too - // much chaos for our plan/apply model to really support, so we make - // a pragmatic compromise here of disallowing the results from these - // to be derived from any resource instances (even if the value happens - // to be currently known) and just generally disallowing unknown - // values regardless of where they are coming from. In practice resource - // instances are the main place unknown values come from, but this - // also excludes specifying the module to use based on impure functions - // like "timestamp" whose results aren't decided until the apply step. - // - // These are associated with call instances rather than the main call, - // and so it's possible for different instances of the same call to - // select completely different modules. While that's a somewhat esoteric - // thing to do, it would make it possible to e.g. write a module call that - // uses for_each where the associated values choose between multiple - // implementations of the same general abstraction. However, our surface - // language doesn't currently allow that -- it always evaluates these - // in the global scope rather than per-instance scope -- because the - // way module blocks are currently designed means that HCL wants the - // set of arguments to be fixed statically rather than chosen - // dynamically. - SourceAddrValuer *OnceValuer - VersionConstraintValuer *OnceValuer + // Glue is provided by whatever compiled this object to allow us to learn + // more about the module that is being called. + Glue ModuleCallInstanceGlue // InputsValuer is a valuer for all of the input variable values taken // together as a single object. It's structured this way mainly for @@ -65,26 +42,45 @@ type ModuleCallInstance struct { // that allows constructing the entire map dynamically using expression // syntax. InputsValuer *OnceValuer + + // TODO: Also something for the "providers side-channel", as represented + // by the "providers" meta-argument in the current language. } var _ exprs.Valuer = (*ModuleCallInstance)(nil) // StaticCheckTraversal implements exprs.Valuer. func (m *ModuleCallInstance) StaticCheckTraversal(traversal hcl.Traversal) tfdiags.Diagnostics { - // We only do dynamic checks of accessing a module call because we - // can't know what result type it will return without fetching and - // compiling the child module source code, and that's too heavy - // an operation for "static check". + // We don't perform any static type checking of accesses to a module's + // output value. Instead, we just wait until we have the final result + // in Value. return nil } // Value implements exprs.Valuer. func (m *ModuleCallInstance) Value(ctx context.Context) (cty.Value, tfdiags.Diagnostics) { - // TODO: Evaluate the source address and version constraint and then - // use a new field with a callback to ask the compile layer to compile - // us a [evalglue.CompiledModuleInstance] for the child module, and - // then ask for its result value and return it. - panic("unimplemented") + inputsVal, diags := m.InputsValuer.Value(ctx) + if diags.HasErrors() { + return cty.DynamicVal, diags + } + + moreDiags := m.Glue.ValidateInputs(ctx, inputsVal) + // FIXME: Our "contextual diagnostics" mechanism, where the callee provides + // an attribute path and then the caller discovers a suitable source range + // for each diagnostic based on information in the body, can only work + // when we have direct access to a [hcl.Body], but we intentionally + // abstracted that away here. We'll need to find a different design for + // contextual diagnostics that can work through the [exprs.Valuer] + // abstraction to make a best effort to interpret attribute paths against + // whatever the valuer was evaluating. + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + return cty.DynamicVal, diags + } + + // The actual result value is decided by our caller, which is expected + // to know how to actually find, compile, and evaluate the target module. + return exprs.EvalResult(m.Glue.OutputsValue(ctx, inputsVal)) } // ValueSourceRange implements exprs.Valuer. @@ -100,14 +96,6 @@ func (c *ModuleCallInstance) CheckAll(ctx context.Context) tfdiags.Diagnostics { } func (m *ModuleCallInstance) AnnounceAllGraphevalRequests(announce func(workgraph.RequestID, grapheval.RequestInfo)) { - announce(m.SourceAddrValuer.RequestID(), grapheval.RequestInfo{ - Name: m.ModuleInstanceAddr.String() + " source address", - SourceRange: m.SourceAddrValuer.ValueSourceRange(), - }) - announce(m.VersionConstraintValuer.RequestID(), grapheval.RequestInfo{ - Name: m.ModuleInstanceAddr.String() + " version constraint", - SourceRange: m.VersionConstraintValuer.ValueSourceRange(), - }) announce(m.InputsValuer.RequestID(), grapheval.RequestInfo{ Name: m.ModuleInstanceAddr.String() + " input variable values", SourceRange: m.InputsValuer.ValueSourceRange(), diff --git a/internal/lang/eval/internal/configgraph/module_call_instance_glue.go b/internal/lang/eval/internal/configgraph/module_call_instance_glue.go new file mode 100644 index 0000000000..e8e2d2eb94 --- /dev/null +++ b/internal/lang/eval/internal/configgraph/module_call_instance_glue.go @@ -0,0 +1,62 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configgraph + +import ( + "context" + + "github.com/apparentlymart/go-versions/versions" + "github.com/zclconf/go-cty/cty" + + "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +// ModuleCallInstanceGlue describes a callback API that [ModuleCallInstance] +// objects use to ask the caller questions about the module that's being +// called. +// +// Real implementations of this interface will sometimes block on fetching +// a remote module package for inspection, or on operations caused by +// declarations in the child module. If that external work depends on +// information coming from any other part of this package's API then the +// implementation of that MUST use the mechanisms from package grapheval in +// order to cooperate with the self-dependency detection used by this package to +// prevent deadlocks. +type ModuleCallInstanceGlue interface { + // ValidateInputs determines whether the given value is a valid + // representation of the inputs to the target module, returning diagnostics + // describing any problems. + // + // TODO: This probably also needs an argument for describing the + // "sidechannel" provider instances, as would be specified in the "providers" + // meta-argument in the current language, so the callee can also check + // those. + ValidateInputs(ctx context.Context, inputsVal cty.Value) tfdiags.Diagnostics + + // OutputsValue returns the value representing the outputs of this module + // instance. This is what should be returned as the value of the module + // instance. + // + // TODO: This probably also needs an argument for passing the "sidechannel" + // provider instance values. + OutputsValue(ctx context.Context, inputsVal cty.Value) (cty.Value, tfdiags.Diagnostics) +} + +type ModuleSourceArguments struct { + // Source is the already-parsed-and-normalized module source address. + Source addrs.ModuleSource + + // AllowedVersions describes what subset of the available versions are + // accepted, if the source type is one that supports version constraints. + // + // It's the responsibility of the [ModuleCall] logic to reject attempts + // to set a version constraint for a source type that doesn't support + // it, so a [ModuleSourceArguments] object should not be constructed + // with a nonzero value in this field when [Source] is not of a + // version-aware source type. + AllowedVersions versions.Set +} diff --git a/internal/lang/eval/internal/configgraph/module_call_instance_glue_test.go b/internal/lang/eval/internal/configgraph/module_call_instance_glue_test.go new file mode 100644 index 0000000000..fdf9355524 --- /dev/null +++ b/internal/lang/eval/internal/configgraph/module_call_instance_glue_test.go @@ -0,0 +1,35 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configgraph + +import ( + "context" + + "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +type moduleInstanceGlueForTesting struct { + sourceAddr string +} + +func (g *moduleInstanceGlueForTesting) ValidateInputs(ctx context.Context, configVal cty.Value) tfdiags.Diagnostics { + return nil +} + +func (g *moduleInstanceGlueForTesting) OutputsValue(ctx context.Context, configVal cty.Value) (cty.Value, tfdiags.Diagnostics) { + // This simple test-only "glue" just echoes back the source address and + // config it was given, as a stub for testing [ModuleCall] and + // [ModuleCallInstance] alone without any "real" glue implementation. + // + // If you need something more specific in a particular test then it's + // reasonable to write additional implementations of + // [ModuleCallInstanceGlue]. + return cty.ObjectVal(map[string]cty.Value{ + "source": cty.StringVal(g.sourceAddr), + "config": configVal, + }), nil +} diff --git a/internal/lang/eval/internal/configgraph/module_call_test.go b/internal/lang/eval/internal/configgraph/module_call_test.go new file mode 100644 index 0000000000..171ac1fb98 --- /dev/null +++ b/internal/lang/eval/internal/configgraph/module_call_test.go @@ -0,0 +1,193 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package configgraph + +import ( + "context" + "testing" + + "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/instances" + "github.com/opentofu/opentofu/internal/lang/exprs" + "github.com/opentofu/opentofu/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func TestModuleCall_Value(t *testing.T) { + testValuer(t, map[string]valuerTest[*ModuleCall]{ + "remote source address": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("./"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("https://example.com/foo"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer(cty.NullVal(cty.String))), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.ObjectVal(map[string]cty.Value{ + "source": cty.StringVal("https://example.com/foo"), + "config": cty.EmptyObjectVal, + }), + nil, + }, + "registry source address with no version": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("./"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("example.com/foo/bar/baz"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer(cty.NullVal(cty.String))), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.ObjectVal(map[string]cty.Value{ + "source": cty.StringVal("example.com/foo/bar/baz"), + "config": cty.EmptyObjectVal, + }), + nil, + }, + "registry source address with version": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("./"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("example.com/foo/bar/baz"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("<= 2.0.0"), + )), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.ObjectVal(map[string]cty.Value{ + "source": cty.StringVal("example.com/foo/bar/baz"), + "config": cty.EmptyObjectVal, + }), + nil, + }, + "local source address relative to local": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("./modules/beep"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("../boop"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer(cty.NullVal(cty.String))), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.ObjectVal(map[string]cty.Value{ + "source": cty.StringVal("./modules/boop"), + "config": cty.EmptyObjectVal, + }), + nil, + }, + "local source address relative to remote": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("https://example.com/foo.tar.gz//beep/beep"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("../boop"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer(cty.NullVal(cty.String))), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.ObjectVal(map[string]cty.Value{ + "source": cty.StringVal("https://example.com/foo.tar.gz//beep/boop"), + "config": cty.EmptyObjectVal, + }), + nil, + }, + "local source address relative to remote escapes": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("https://example.com/foo.tar.gz//beep/beep"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("../../../outside"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer(cty.NullVal(cty.String))), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.DynamicVal, + diagsForTest(tfdiags.Sourceless( + tfdiags.Error, + "Invalid module source address", + `Cannot use "../../../outside" as module source address: invalid relative path from https://example.com/foo.tar.gz//beep/beep: relative path ../../../outside has too many "../" segments.`, + )), + }, + "local source address relative to registry": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("example.com/foo/bar/baz//beep/beep"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("../boop"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer(cty.NullVal(cty.String))), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.ObjectVal(map[string]cty.Value{ + "source": cty.StringVal("example.com/foo/bar/baz//beep/boop"), + "config": cty.EmptyObjectVal, + }), + nil, + }, + "local source address relative to registry escapes": { + &ModuleCall{ + ParentSourceAddr: addrs.MustParseModuleSource("example.com/foo/bar/baz//beep/beep"), + InstanceSelector: singleInstanceSelectorForTesting(), + SourceAddrValuer: ValuerOnce(exprs.ConstantValuer( + cty.StringVal("../../../outside"), + )), + VersionConstraintValuer: ValuerOnce(exprs.ConstantValuer(cty.NullVal(cty.String))), + ValidateSourceArguments: func(ctx context.Context, sourceArgs ModuleSourceArguments) tfdiags.Diagnostics { + return nil + }, + CompileCallInstance: compileModuleCallInstanceSimpleForTesting, + }, + cty.DynamicVal, + diagsForTest(tfdiags.Sourceless( + tfdiags.Error, + "Invalid module source address", + `Cannot use "../../../outside" as module source address: invalid relative path from example.com/foo/bar/baz//beep/beep: relative path ../../../outside has too many "../" segments.`, + )), + }, + }) +} + +// compileModuleCallInstanceSimpleForTesting is a simple implementation of +// [ModuleCall.CompileCallInstance] that just returns a stub +// [ModuleCallInstance] with its glue set to an instance of +// [moduleInstanceGlueForTesting], so that the instance's result value +// will be an object with "source" and "config" attributes just echoing +// back the source that was passed in with an empty config. +func compileModuleCallInstanceSimpleForTesting(ctx context.Context, sourceArgs ModuleSourceArguments, key addrs.InstanceKey, repData instances.RepetitionData) *ModuleCallInstance { + return &ModuleCallInstance{ + Glue: &moduleInstanceGlueForTesting{ + sourceAddr: sourceArgs.Source.String(), + }, + InputsValuer: ValuerOnce(exprs.ConstantValuer(cty.EmptyObjectVal)), + } +} diff --git a/internal/lang/eval/internal/evalglue/dependencies.go b/internal/lang/eval/internal/evalglue/dependencies.go index 27efc788c5..b7e9b04a5d 100644 --- a/internal/lang/eval/internal/evalglue/dependencies.go +++ b/internal/lang/eval/internal/evalglue/dependencies.go @@ -12,7 +12,6 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/opentofu/opentofu/internal/addrs" - "github.com/opentofu/opentofu/internal/configs" "github.com/opentofu/opentofu/internal/configs/configschema" "github.com/opentofu/opentofu/internal/providers" "github.com/opentofu/opentofu/internal/tfdiags" @@ -31,7 +30,7 @@ type ExternalModules interface { // the implementation can potentially use a lock file to determine which // version has been selected for that call in particular. forCall is // nil when requesting the root module. - ModuleConfig(ctx context.Context, source addrs.ModuleSource, allowedVersions versions.Set, forCall *addrs.AbsModuleCall) (*configs.Module, tfdiags.Diagnostics) + ModuleConfig(ctx context.Context, source addrs.ModuleSource, allowedVersions versions.Set, forCall *addrs.AbsModuleCall) (UncompiledModule, tfdiags.Diagnostics) } // Providers is implemented by callers of this package to provide access @@ -120,7 +119,7 @@ func ensureProvisioners(given Provisioners) Provisioners { } // ModuleConfig implements ExternalModules. -func (e emptyDependencies) ModuleConfig(ctx context.Context, source addrs.ModuleSource, allowedVersions versions.Set, forCall *addrs.AbsModuleCall) (*configs.Module, tfdiags.Diagnostics) { +func (e emptyDependencies) ModuleConfig(ctx context.Context, source addrs.ModuleSource, allowedVersions versions.Set, forCall *addrs.AbsModuleCall) (UncompiledModule, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, diff --git a/internal/lang/eval/internal/evalglue/dependencies_testing.go b/internal/lang/eval/internal/evalglue/dependencies_testing.go index 3a6e89ecab..46bf5f554d 100644 --- a/internal/lang/eval/internal/evalglue/dependencies_testing.go +++ b/internal/lang/eval/internal/evalglue/dependencies_testing.go @@ -9,31 +9,14 @@ import ( "context" "fmt" - "github.com/apparentlymart/go-versions/versions" "github.com/zclconf/go-cty/cty" "github.com/opentofu/opentofu/internal/addrs" - "github.com/opentofu/opentofu/internal/configs" "github.com/opentofu/opentofu/internal/configs/configschema" "github.com/opentofu/opentofu/internal/providers" "github.com/opentofu/opentofu/internal/tfdiags" ) -// ModulesForTesting returns an [ExternalModules] implementation that just -// returns module objects directly from the provided map, without any additional -// logic. -// -// This is intended for unit testing only, and only supports local module -// source addresses because it has no means to resolve remote sources or -// selected versions for registry-based modules. -// -// [configs.ModulesFromStringsForTesting] is a convenient way to build a -// suitable map to pass to this function when the required configuration is -// relatively small. -func ModulesForTesting(modules map[addrs.ModuleSourceLocal]*configs.Module) ExternalModules { - return externalModulesStatic{modules} -} - // ProvidersForTesting returns a [Providers] implementation that just returns // information directly from the given map. // @@ -50,26 +33,6 @@ func ProvisionersForTesting(schemas map[string]*configschema.Block) Provisioners return provisionersStatic{schemas} } -type externalModulesStatic struct { - modules map[addrs.ModuleSourceLocal]*configs.Module -} - -// ModuleConfig implements ExternalModules. -func (ms externalModulesStatic) ModuleConfig(_ context.Context, source addrs.ModuleSource, _ versions.Set, _ *addrs.AbsModuleCall) (*configs.Module, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - localSource, ok := source.(addrs.ModuleSourceLocal) - if !ok { - diags = diags.Append(fmt.Errorf("only local module source addresses are supported for this test")) - return nil, diags - } - ret, ok := ms.modules[localSource] - if !ok { - diags = diags.Append(fmt.Errorf("module path %q is not available to this test", localSource)) - return nil, diags - } - return ret, diags -} - type providersStatic struct { schemas map[addrs.Provider]*providers.GetProviderSchemaResponse } diff --git a/internal/lang/eval/internal/evalglue/module.go b/internal/lang/eval/internal/evalglue/module.go new file mode 100644 index 0000000000..bc6cffc1fc --- /dev/null +++ b/internal/lang/eval/internal/evalglue/module.go @@ -0,0 +1,89 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package evalglue + +import ( + "context" + + "github.com/zclconf/go-cty/cty" + + "github.com/opentofu/opentofu/internal/lang/exprs" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +// UncompiledModule is an interface implemented by objects representing a +// module that has not yet been "compiled" into a [CompiledModuleInstance]. +// +// This provides the facilities needed to validate a module call in preparation +// for using its settings to compile the module, and then for finally +// actually compiling the module into a [CompiledModuleInstance]. +// +// This interface abstracts over potentially multiple different implementations +// representing different editions of the OpenTofu language, and so describes +// the API that all editions should agree on to allow modules of different +// editions to coexist and collaborate in a single configuration tree. +type UncompiledModule interface { + // ValidateModuleInputs checks whether the given value is suitable to be + // used as the "inputs" when instantiating the module. + // + // In the current language "inputsVal" should always be of an object type + // whose attributes correspond to the input variables declared inside + // the module. + ValidateModuleInputs(ctx context.Context, inputsVal cty.Value) tfdiags.Diagnostics + + // TODO: Something similar to ValidateInputs for the "providers sidechannel" + // so we can know what providers the child module is expecting to be passed + // and thus know what's supposed to be in the "providers" argument of the + // calling module block? Annoying to expose that directly as part of this + // abstraction but probably the most pragmatic way to do it as long as + // the providers sidechannel continues to exist. + + // ModuleOutputsTypeConstraint returns the type constraint that the + // outputs value produced by all instances of this module would conform + // to. It's always valid to use the result with `cty.UnknownVal` to produce + // a placeholder for the result of an instance of this module. + // + // In the current language this should always describe an object type whose + // attributes correspond to the output values declared in the module. + ModuleOutputsTypeConstraint(ctx context.Context) cty.Type + + // CompileModuleInstance uses the given [ModuleCall] to attempt to compile + // the module into a [CompiledModuleInstance], or returns error diagnostics + // explaining why that isn't possible. + CompileModuleInstance(ctx context.Context, call *ModuleCall) (CompiledModuleInstance, tfdiags.Diagnostics) +} + +// ModuleCall represents the information needed to instantiate [UncompiledModule] +// into [CompiledModuleInstance]. +type ModuleCall struct { + // InputValues describes the inputs to the module. + // + // The value produced by this valuer should have been previously + // successfully validated using [UncompiledModule.ValidateModuleInputs], + // or compilation is likely to fail with a potentially-confusing error. + InputValues exprs.Valuer + + // AllowImpureFunctions controls whether to allow full use of a small + // number of functions that produce different results each time they are + // called, such as "timestamp". + // + // This should typically just be passed on verbatim from an equivalent + // setting in the parent module, because all module instances in a + // configuration instance should agree about whether impure functions + // are active. + AllowImpureFunctions bool + + EvaluationGlue Glue + + // EvalContext describes the context where the call is being made, dealing + // with cross-cutting concerns like which providers are available and how + // to load them. + // + // This should typically just be passed on verbatim from an equivalent + // setting in the parent module, because EvalContext holds cross-cutting + // concerns from the environment in which OpenTofu is running. + EvalContext *EvalContext +} diff --git a/internal/lang/eval/internal/tofu2024/compile.go b/internal/lang/eval/internal/tofu2024/compile.go index 7d493543a9..5b6642d379 100644 --- a/internal/lang/eval/internal/tofu2024/compile.go +++ b/internal/lang/eval/internal/tofu2024/compile.go @@ -107,7 +107,10 @@ func CompileModuleInstance( module.ModuleCalls, topScope, providersSidechannel, + moduleSourceAddr, call.CalleeAddr, + call.EvalContext.Modules, + call, ) ret.resourceNodes = compileModuleInstanceResources(ctx, module.ManagedResources, @@ -120,6 +123,15 @@ func CompileModuleInstance( call.EvaluationGlue.ResourceInstanceValue, ) + // Now that we've assembled all of the innards of the module instance, + // we'll wire the output values up to the top-level module instance + // node so that it can produce the overall result object for this module + // instance. + ret.moduleInstanceNode.OutputValuers = make(map[addrs.OutputValue]*configgraph.OnceValuer, len(ret.outputValueNodes)) + for addr, node := range ret.outputValueNodes { + ret.moduleInstanceNode.OutputValuers[addr] = configgraph.ValuerOnce(node) + } + return ret } @@ -378,61 +390,107 @@ func compileModuleInstanceModuleCalls( configs map[string]*configs.ModuleCall, declScope exprs.Scope, providersSidechannel *moduleProvidersSideChannel, + parentSourceAddr addrs.ModuleSource, moduleInstanceAddr addrs.ModuleInstance, + externalModules evalglue.ExternalModules, + parentCall *ModuleInstanceCall, ) map[addrs.ModuleCall]*configgraph.ModuleCall { ret := make(map[addrs.ModuleCall]*configgraph.ModuleCall, len(configs)) for name, config := range configs { addr := addrs.ModuleCall{Name: name} + absAddr := addr.Absolute(moduleInstanceAddr) + + var versionConstraintValuer exprs.Valuer + if config.VersionAttr != nil { + versionConstraintValuer = exprs.NewClosure( + exprs.EvalableHCLExpression(config.VersionAttr.Expr), + declScope, + ) + } else { + versionConstraintValuer = exprs.ConstantValuer(cty.NullVal(cty.String)) + } + ret[addr] = &configgraph.ModuleCall{ Addr: addr.Absolute(moduleInstanceAddr), DeclRange: tfdiags.SourceRangeFromHCL(config.DeclRange), + ParentSourceAddr: parentSourceAddr, InstanceSelector: compileInstanceSelector(ctx, declScope, config.ForEach, config.Count, nil), - CompileCallInstance: func(ctx context.Context, key addrs.InstanceKey, repData instances.RepetitionData) *configgraph.ModuleCallInstance { - var versionConstraintValuer exprs.Valuer - if config.VersionAttr != nil { - versionConstraintValuer = exprs.NewClosure( - exprs.EvalableHCLExpression(config.VersionAttr.Expr), - declScope, - ) - } else { - versionConstraintValuer = exprs.ConstantValuer(cty.NullVal(cty.String)) + SourceAddrValuer: configgraph.ValuerOnce(exprs.NewClosure( + exprs.EvalableHCLExpression(config.Source), + declScope, + )), + VersionConstraintValuer: configgraph.ValuerOnce( + versionConstraintValuer, + ), + ValidateSourceArguments: func(ctx context.Context, sourceArgs configgraph.ModuleSourceArguments) tfdiags.Diagnostics { + // We'll try to use the given source address with our + // [ExternalModules] object, and consider the arguments to be + // valid if this succeeds. + // + // If the [ExternalModules] object is one that fetches the + // requested module over the network on first request then we + // expect that it'll cache what it fetched somewhere so that + // a subsequent call with the same arguments will be relatively + // cheap. + _, diags := externalModules.ModuleConfig(ctx, sourceArgs.Source, sourceArgs.AllowedVersions, &absAddr) + return diags + }, + CompileCallInstance: func(ctx context.Context, sourceArgs configgraph.ModuleSourceArguments, key addrs.InstanceKey, repData instances.RepetitionData) *configgraph.ModuleCallInstance { + + // The contract for [configgraph.ModuleCall] is that it should only + // call this function when sourceArgs is something that was previously + // accepted by [ValidateSourceArguments]. We assume that the module + // dependencies object is doing some sort of caching so that if + // ValidateSourceArguments caused something to be downloaded over + // the network then we can re-request that same object cheaply here. + mod, diags := externalModules.ModuleConfig(ctx, sourceArgs.Source, sourceArgs.AllowedVersions, &absAddr) + if diags.HasErrors() { + // We should not typically find errors here because we would've + // already tried this in ValidateSourceArguments above, but + // we _do_ encounter problems here then we'll return a stubby + // object that just returns whatever diagnostics we found as + // soon as it tries to evaluate its inputs. + return &configgraph.ModuleCallInstance{ + ModuleInstanceAddr: addr.Absolute(moduleInstanceAddr).Instance(key), + InputsValuer: configgraph.ValuerOnce(exprs.ForcedErrorValuer(diags)), + Glue: &moduleCallInstanceGlue{ + validateInputs: func(ctx context.Context, v cty.Value) tfdiags.Diagnostics { + return diags + }, + getOutputsValue: func(ctx context.Context, v cty.Value) (cty.Value, tfdiags.Diagnostics) { + return cty.DynamicVal, diags + }, + }, + } } instanceScope := instanceLocalScope(declScope, repData) return &configgraph.ModuleCallInstance{ ModuleInstanceAddr: addr.Absolute(moduleInstanceAddr).Instance(key), - // We _could_ potentially allow the source address and - // version constraint to vary between instances by - // binding these to the instance local scope, but we - // choose not to for now because the syntax for module - // blocks means it's not possible to vary which input - // variables are defined on a per-instance basis and so - // selecting different modules wouldn't work well unless - // they all had exactly the same input variable names. - SourceAddrValuer: configgraph.ValuerOnce(exprs.NewClosure( - exprs.EvalableHCLExpression(config.Source), - declScope, - )), - VersionConstraintValuer: configgraph.ValuerOnce( - versionConstraintValuer, - ), - - // The inputs value _can_ be derived from per-instance - // values though, of course! We use "just attributes" - // mode here because on the caller side we don't yet know - // what input variables the callee is expecting. We'll - // just send this whole value over to it and let it - // check whether the object type is acceptable. InputsValuer: configgraph.ValuerOnce(exprs.NewClosure( exprs.EvalableHCLBodyJustAttributes(config.Config), instanceScope, )), - - // TODO: valuers for the "providers side-channel" from - // the "providers" meta-argument, or automatic passing - // of all of the default (unaliased) providers from - // the parent module if "providers" isn't present. + Glue: &moduleCallInstanceGlue{ + validateInputs: func(ctx context.Context, v cty.Value) tfdiags.Diagnostics { + return mod.ValidateModuleInputs(ctx, v) + }, + getOutputsValue: func(ctx context.Context, v cty.Value) (cty.Value, tfdiags.Diagnostics) { + modInst, diags := mod.CompileModuleInstance(ctx, &evalglue.ModuleCall{ + InputValues: exprs.ConstantValuer(v), + AllowImpureFunctions: parentCall.AllowImpureFunctions, + EvalContext: parentCall.EvalContext, + EvaluationGlue: parentCall.EvaluationGlue, + }) + if diags.HasErrors() { + return cty.DynamicVal, diags + } + ret, moreDiags := modInst.ResultValuer(ctx).Value(ctx) + diags = diags.Append(moreDiags) + return ret, diags + }, + }, } }, } @@ -537,3 +595,16 @@ func (r *resourceInstanceGlue) ValidateConfig(ctx context.Context, configVal cty func (r *resourceInstanceGlue) ResultValue(ctx context.Context, configVal cty.Value, providerInst configgraph.Maybe[*configgraph.ProviderInstance]) (cty.Value, tfdiags.Diagnostics) { return r.getResultValue(ctx, configVal, providerInst) } + +type moduleCallInstanceGlue struct { + validateInputs func(context.Context, cty.Value) tfdiags.Diagnostics + getOutputsValue func(context.Context, cty.Value) (cty.Value, tfdiags.Diagnostics) +} + +func (g *moduleCallInstanceGlue) ValidateInputs(ctx context.Context, inputsVal cty.Value) tfdiags.Diagnostics { + return g.validateInputs(ctx, inputsVal) +} + +func (g *moduleCallInstanceGlue) OutputsValue(ctx context.Context, inputsVal cty.Value) (cty.Value, tfdiags.Diagnostics) { + return g.getOutputsValue(ctx, inputsVal) +} diff --git a/internal/lang/eval/internal/tofu2024/doc.go b/internal/lang/eval/internal/tofu2024/doc.go index c96e789a47..595206e75f 100644 --- a/internal/lang/eval/internal/tofu2024/doc.go +++ b/internal/lang/eval/internal/tofu2024/doc.go @@ -23,6 +23,32 @@ import ( _ "github.com/opentofu/opentofu/internal/configs" ) +// === SOME HISTORICAL NOTES === +// +// For those who are coming here with familiarity with the original runtime +// in "package tofu", you might like to think of the types in this package as +// being _roughly_ analogous to the "graph builder" mechanism in package tofu. +// +// There are some notable differences that are worth knowing before you dive +// in here, though: +// +// - The "compile" code here is intentionally written as much as possible as +// straight-through code that runs to completion and returns a value, whereas +// package tofu's graph builders instead follow an inversion-of-control style +// where a bunch of transformers are run sequentially and each make arbitrary +// modifications to a shared mutable data structure. +// - The "graph" that this code is building is based on the types in the sibling +// package "configgraph", which at the time of writing has its own "historical +// notes" like this describing how it relates to the traditional graph model. +// - An express goal of this "compiler" layer is to create an abstraction +// boundary between the current surface language, presently implemented in +// "package configs", and the execution engine which ideally cares only about +// the relationships between objects and the values flowing between them. +// Therefore nothing in package configgraph should depend on anything from +// package configs, and configgraph should also only be using HCL directly for +// some ancillary concepts like diagnostics and traversals, and even those +// maybe we'll replace with some OpenTofu-specific wrapper types in future. + // Temporary note about possible future plans: // // Currently this package is working with [configs.Module] and the other types diff --git a/internal/lang/eval/internal/tofu2024/module_instance_scope.go b/internal/lang/eval/internal/tofu2024/module_instance_scope.go index d8372befe0..13a651de0e 100644 --- a/internal/lang/eval/internal/tofu2024/module_instance_scope.go +++ b/internal/lang/eval/internal/tofu2024/module_instance_scope.go @@ -207,14 +207,17 @@ func (m *moduleInstanceScope) resolveSimpleChildAttr(topSymbol string, ref hcl.T return exprs.ValueOf(v), diags case "module": - // TODO: Handle this - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Module call references not yet supported", - Detail: "This experimental new implementation does not yet support referring to module calls.", - Subject: &ref.SrcRange, - }) - return nil, diags + v, ok := m.inst.moduleCallNodes[addrs.ModuleCall{Name: ref.Name}] + if !ok { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Reference to undeclared module call", + Detail: fmt.Sprintf("There is no module call named %q declared in this module.", ref.Name), + Subject: &ref.SrcRange, + }) + return nil, diags + } + return exprs.ValueOf(v), diags default: // We should not get here because there should be a case above for diff --git a/internal/lang/eval/internal/tofu2024/testing.go b/internal/lang/eval/internal/tofu2024/testing.go new file mode 100644 index 0000000000..5d9ddee85c --- /dev/null +++ b/internal/lang/eval/internal/tofu2024/testing.go @@ -0,0 +1,55 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tofu2024 + +import ( + "context" + "fmt" + + "github.com/apparentlymart/go-versions/versions" + "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/configs" + "github.com/opentofu/opentofu/internal/lang/eval/internal/evalglue" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +// This file contains test helpers for use in other packages. These functions +// must not be used in non-test code. + +// ModulesForTesting returns an [evalglue.ExternalModules] implementation that just +// returns module objects directly from the provided map, without any additional +// logic. +// +// This is intended for unit testing only, and only supports local module +// source addresses because it has no means to resolve remote sources or +// selected versions for registry-based modules. +// +// [configs.ModulesFromStringsForTesting] is a convenient way to build a +// suitable map to pass to this function when the required configuration is +// relatively small. +func ModulesForTesting(modules map[addrs.ModuleSourceLocal]*configs.Module) evalglue.ExternalModules { + return externalModulesStatic{modules} +} + +type externalModulesStatic struct { + modules map[addrs.ModuleSourceLocal]*configs.Module +} + +// ModuleConfig implements ExternalModules. +func (ms externalModulesStatic) ModuleConfig(_ context.Context, source addrs.ModuleSource, _ versions.Set, _ *addrs.AbsModuleCall) (evalglue.UncompiledModule, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + localSource, ok := source.(addrs.ModuleSourceLocal) + if !ok { + diags = diags.Append(fmt.Errorf("only local module source addresses are supported for this test")) + return nil, diags + } + mod, ok := ms.modules[localSource] + if !ok { + diags = diags.Append(fmt.Errorf("module path %q is not available to this test", localSource)) + return nil, diags + } + return NewUncompiledModule(source, mod), diags +} diff --git a/internal/lang/eval/internal/tofu2024/uncompiled.go b/internal/lang/eval/internal/tofu2024/uncompiled.go new file mode 100644 index 0000000000..667b151d65 --- /dev/null +++ b/internal/lang/eval/internal/tofu2024/uncompiled.go @@ -0,0 +1,112 @@ +// Copyright (c) The OpenTofu Authors +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2023 HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tofu2024 + +import ( + "context" + "fmt" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + + "github.com/opentofu/opentofu/internal/addrs" + "github.com/opentofu/opentofu/internal/configs" + "github.com/opentofu/opentofu/internal/lang/eval/internal/evalglue" + "github.com/opentofu/opentofu/internal/tfdiags" +) + +type uncompiledModule struct { + sourceAddr addrs.ModuleSource + mod *configs.Module +} + +func NewUncompiledModule(sourceAddr addrs.ModuleSource, mod *configs.Module) evalglue.UncompiledModule { + return &uncompiledModule{sourceAddr, mod} +} + +// ValidateModuleInputs implements evalglue.UncompiledModule. +func (u *uncompiledModule) ValidateModuleInputs(ctx context.Context, inputsVal cty.Value) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + ty := inputsVal.Type() + if !ty.IsObjectType() { + // NOTE: As long as the rest of the system is building inputsVal by + // decoding an HCL body we shouldn't be able to get here, so this + // case is just here for robustness in case we change some assumptions + // in future. + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid module inputs value", + "Module inputs must be represented as an object whose attributes correspond to the child module's input variables.", + nil, // empty path representing that the top-level object is invalid + )) + } + + decls := u.mod.Variables + for name, decl := range decls { + if !ty.HasAttribute(name) { + if decl.Required() { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Missing definition for required input variable", + fmt.Sprintf("The child module requires a value for the input variable %q.", name), + cty.GetAttrPath(name), + )) + } + continue + } + v := inputsVal.GetAttr(name) + wantTy := decl.ConstraintType + _, err := convert.Convert(v, wantTy) + if err != nil { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Invalid value for input variable", + fmt.Sprintf("Unsuitable value for input variable %q: %s.", name, tfdiags.FormatError(err)), + cty.GetAttrPath(name), + )) + } + } + + for name := range ty.AttributeTypes() { + _, declared := decls[name] + if !declared { + diags = diags.Append(tfdiags.AttributeValue( + tfdiags.Error, + "Definition for undeclared input variable", + fmt.Sprintf("The child module has no input variable named %q.", name), + cty.GetAttrPath(name), + )) + } + } + + return diags +} + +// ModuleOutputsTypeConstraint implements evalglue.UncompiledModule. +func (u *uncompiledModule) ModuleOutputsTypeConstraint(ctx context.Context) cty.Type { + atys := make(map[string]cty.Type) + for name := range u.mod.Outputs { + // We don't currently support type constraints on output values, so + // they are always cty.DynamicPseudoType to represent what the surface + // language calls "any". + atys[name] = cty.DynamicPseudoType + } + return cty.Object(atys) +} + +// CompileModuleInstance implements evalglue.UncompiledModule. +func (u *uncompiledModule) CompileModuleInstance(ctx context.Context, call *evalglue.ModuleCall) (evalglue.CompiledModuleInstance, tfdiags.Diagnostics) { + rootModuleCall := &ModuleInstanceCall{ + CalleeAddr: addrs.RootModuleInstance, + InputValues: call.InputValues, + EvaluationGlue: call.EvaluationGlue, + AllowImpureFunctions: call.AllowImpureFunctions, + EvalContext: call.EvalContext, + } + rootModuleInstance := CompileModuleInstance(ctx, u.mod, u.sourceAddr, rootModuleCall) + return rootModuleInstance, nil +}