blob: 0537a1b860286620c4145049333e329495331641 [file] [log] [blame]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package mocking
import (
"fmt"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// PlanComputedValuesForResource accepts a target value, and populates its computed
// values with values from the provider 'with' argument, and if 'with' is not provided,
// it sets the computed values to cty.UnknownVal.
//
// The latter behaviour simulates the behaviour of a plan request in a real
// provider.
func PlanComputedValuesForResource(original cty.Value, with *MockedData, schema *configschema.Block) (cty.Value, tfdiags.Diagnostics) {
if with == nil {
with = &MockedData{
Value: cty.NilVal,
ComputedAsUnknown: true,
}
}
return populateComputedValues(original, *with, schema, isNull)
}
// ApplyComputedValuesForResource accepts a target value, and populates it
// either with values from the provided with argument, or with generated values
// created semi-randomly. This will only target values that are computed and
// unknown.
//
// This method basically simulates the behaviour of an apply request in a real
// provider.
func ApplyComputedValuesForResource(original cty.Value, with *MockedData, schema *configschema.Block) (cty.Value, tfdiags.Diagnostics) {
if with == nil {
with = &MockedData{
Value: cty.NilVal,
}
}
return populateComputedValues(original, *with, schema, isUnknown)
}
// ComputedValuesForDataSource accepts a target value, and populates it either
// with values from the provided with argument, or with generated values created
// semi-randomly. This will only target values that are computed and null.
//
// This function does what PlanComputedValuesForResource and
// ApplyComputedValuesForResource do but in a single step with no intermediary
// unknown stage.
//
// This method basically simulates the behaviour of a get data source request
// in a real provider.
func ComputedValuesForDataSource(original cty.Value, with *MockedData, schema *configschema.Block) (cty.Value, tfdiags.Diagnostics) {
if with == nil {
with = &MockedData{
Value: cty.NilVal,
}
}
return populateComputedValues(original, *with, schema, isNull)
}
type processValue func(value cty.Value) bool
type generateValue func(attribute *configschema.Attribute, with cty.Value, path cty.Path) (cty.Value, tfdiags.Diagnostics)
func populateComputedValues(target cty.Value, with MockedData, schema *configschema.Block, processValue processValue) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
var generateValue generateValue
// If the computed attributes should be ignored, then we will generate
// unknown values for them, otherwise we will
// generate their values based on the mocked data.
if with.ComputedAsUnknown {
generateValue = makeUnknown
} else {
generateValue = with.makeKnown
}
if !with.validate() {
// This is actually a user error, it means the user wrote something like
// `values = "not an object"` when defining the replacement values for
// this in the mock or test file. We should have caught this earlier in
// the validation, but we want this function to be robust and not panic
// so we'll check again just in case.
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid replacement value",
Detail: fmt.Sprintf("The requested replacement value must be an object type, but was %s.", with.Value.Type().FriendlyName()),
Subject: with.Range.Ptr(),
})
// We still need to produce valid data for this. So, let's pretend we
// had no mocked data. We still return the error diagnostic so whatever
// operation was happening will still fail, but we won't cause any
// panics or anything.
with = MockedData{
Value: cty.NilVal,
}
}
// We're going to search for any elements within the target value that meet
// the joint criteria of being computed and whatever processValue is
// checking.
//
// We'll then replace anything that meets the criteria with the output of
// generateValue.
//
// This transform should be robust (in that it should never fail), the
// inner call to generateValue should be robust as well so it should always
// return a valid value for us to use even if the embedded diagnostics
// return errors.
value, err := cty.Transform(target, func(path cty.Path, target cty.Value) (cty.Value, error) {
// Get the attribute for the current target.
attribute := schema.AttributeByPath(path)
if attribute == nil {
// Then this is an intermediate path which does not represent an
// attribute, and it cannot be computed.
return target, nil
}
// Now, we check if we should be replacing this value with something.
if attribute.Computed && processValue(target) {
// Get the value we should be replacing target with.
data, dataDiags := with.getMockedDataForPath(path)
diags = diags.Append(dataDiags)
// Upstream code (in node_resource_abstract_instance.go) expects
// us to return a valid object (even if we have errors). That means
// no unknown values, no cty.NilVals, etc. So, we're going to go
// ahead and call generateValue with whatever getMockedDataForPath
// gave us. getMockedDataForPath is robust, so even in an error it
// should have given us something we can use in generateValue.
// Now get the replacement value. This function should be robust in
// that it may return diagnostics explaining why it couldn't replace
// the value, but it'll still return a value for us to use.
value, valueDiags := generateValue(attribute, data, path)
diags = diags.Append(valueDiags)
// We always return a valid value, the diags are attached to the
// global diags outside the nested function.
return value, nil
}
// If we don't need to replace this value, then just return it
// untouched.
return target, nil
})
if err != nil {
// This shouldn't actually happen - we never return an error from inside
// the transform function. But, just in case:
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Detail: "Failed to generate values",
Summary: fmt.Sprintf("Terraform failed to generate computed values for a mocked resource, data source, or module: %s. This is a bug in Terraform - please report it.", err),
Subject: with.Range.Ptr(),
})
}
return value, diags
}
func isNull(target cty.Value) bool {
return target.IsNull()
}
func isUnknown(target cty.Value) bool {
return !target.IsKnown()
}
// makeUnknown produces an unknown value for the provided attribute. This is
// basically the output of a plan() call for a computed attribute in a mocked
// resource.
func makeUnknown(target *configschema.Attribute, _ cty.Value, _ cty.Path) (cty.Value, tfdiags.Diagnostics) {
return cty.UnknownVal(target.ImpliedType()), nil
}
// MockedData wraps the value and the source location of the value into a single
// struct for easy access.
type MockedData struct {
Value cty.Value
Range hcl.Range
ComputedAsUnknown bool // If true, computed values are replaced with unknown, otherwise they are replaced with overridden or generated values.
}
// NewMockedData creates a new MockedData struct with the given value and range.
func NewMockedData(value cty.Value, computedAsUnknown bool, rng hcl.Range) MockedData {
return MockedData{
Value: value,
ComputedAsUnknown: computedAsUnknown,
Range: rng,
}
}
// makeKnown produces a valid value for the given attribute. The input value
// can provide data for this attribute or child attributes if this attribute
// represents an object. The input value is expected to be a representation of
// the schema of this attribute rather than a direct value.
func (data MockedData) makeKnown(attribute *configschema.Attribute, with cty.Value, path cty.Path) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
if with != cty.NilVal {
// Then we have a pre-made value to use as the basis for our value. We
// just need to make sure the value is of the right type.
if value, err := FillAttribute(with, attribute); err != nil {
var relPath cty.Path
if err, ok := err.(cty.PathError); ok {
relPath = err.Path
}
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Failed to compute attribute",
fmt.Sprintf("Terraform could not compute a value for the target type %s with the mocked data defined at %s with the attribute %q: %s.", attribute.ImpliedType().FriendlyName(), data.Range, tfdiags.FormatCtyPath(append(path, relPath...)), err),
path))
// We still want to return a valid value here. If the conversion did
// not work we carry on and just create a value instead. We've made
// a note of the diagnostics tracking why it didn't work so the
// overall operation will still fail, but we won't crash later on
// because of an unknown value or something.
// Fall through to the GenerateValueForAttribute call below.
} else {
// Successful conversion! We can just return the new value.
return value, diags
}
}
// Otherwise, we'll have to generate some values.
return GenerateValueForAttribute(attribute), diags
}
// We can only do replacements if the replacement value is an object type.
func (data MockedData) validate() bool {
return data.Value == cty.NilVal || data.Value.Type().IsObjectType()
}
// getMockedDataForPath walks the path to find any potential mock data for the
// given path. We have implemented custom logic for walking the path here.
//
// This is to support nested block types. It's complicated to work out how to
// replace computed values within nested types. For example, how would a user
// say they just want to replace values at index 3? Or how would users indicate
// they want to replace anything at all within nested sets. The indices for sets
// will never be the same because the user supplied values will, by design, have
// values for the computed attributes which will be null or unknown within the
// values from Terraform so the paths will never match.
//
// What the above paragraph means is that for nested blocks and attributes,
// users can only specify a single replacement value that will apply to all
// the values within the nested collection.
func (data MockedData) getMockedDataForPath(path cty.Path) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
if data.Value == cty.NilVal {
return cty.NilVal, diags
}
// We want to provide a nice print out of the path in case of an error.
// We'll format it as we go.
var currentPath cty.Path
// We are copying the implementation within AttributeByPath inside the
// schema for this. We skip over GetIndexSteps as they'll be referring to
// the intermediate nested blocks and attributes that we aren't capturing
// within the user supplied mock values.
current := data.Value
for _, step := range path {
switch step := step.(type) {
case cty.GetAttrStep:
if !current.Type().IsObjectType() {
// As we're still traversing the path, we expect things to be
// objects at every level.
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Failed to compute attribute",
fmt.Sprintf("Terraform expected an object type for attribute %q defined within the mocked data at %s, but found %s.", tfdiags.FormatCtyPath(currentPath), data.Range, current.Type().FriendlyName()),
currentPath))
return cty.NilVal, diags
}
if !current.Type().HasAttribute(step.Name) {
// Then we have no mocked data for this attribute.
return cty.NilVal, diags
}
current = current.GetAttr(step.Name)
}
currentPath = append(currentPath, step)
}
return current, diags
}