mirror of
https://github.com/hashicorp/terraform.git
synced 2026-02-03 20:50:59 -05:00
306 lines
12 KiB
Go
306 lines
12 KiB
Go
// 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
|
|
}
|