mirror of
https://github.com/opentffoundation/opentf.git
synced 2025-12-19 17:59:05 -05:00
To support the workflow of saving a plan to disk and applying it on some other machine we need to be able to represent the execution graph as a byte stream and then reload it later to produce an equivalent execution graph. This is an initial implementation of that, based on the way the execgraph package currently represents execution graphs. We may change that representation more in future as we get more experience working in the new architecture, but this is intended as part of our "walking skeleton" phase where we try to get the new architecture working end-to-end with simple configurations as soon as possible to help verify that we're even on the right track with this new approach, and try to find unknown unknowns that we ought to deal with before we get too deep into this. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
297 lines
11 KiB
Go
297 lines
11 KiB
Go
// Copyright (c) The OpenTofu Authors
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
// Copyright (c) 2023 HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package execgraph
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/zclconf/go-cty/cty"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
"github.com/opentofu/opentofu/internal/addrs"
|
|
"github.com/opentofu/opentofu/internal/engine/internal/execgraph/execgraphproto"
|
|
)
|
|
|
|
func TestGraphMarshalUnmarshalValid(t *testing.T) {
|
|
// This test exercises our graph marshal and unmarshal behavior by
|
|
// feeding the marshal result directly into unmarshal and then testing
|
|
// whether the resulting graph matches our expectations. The fine details
|
|
// of how things get serialized are not particularly important as long
|
|
// as we can get a functionally-equivalent graph back out again, and so
|
|
// this is a pragmatic way to get good enough test coverage while avoiding
|
|
// the need to update lots of fiddly tests each time we change the
|
|
// serialization format.
|
|
//
|
|
// (The specific serialization format is not a compatibility constraint
|
|
// because we explicitly disallow applying a plan created by one version of
|
|
// OpenTofu with a different version of OpenTofu, so it's not justified
|
|
// to unit-test the specific serialization details.)
|
|
|
|
tests := map[string]struct {
|
|
// InputGraph is a function that constructs the graph that should
|
|
// be round-tripped through the marshalling code. Implementations
|
|
// of this should typically aim to construct graphs of similar
|
|
// shape to those that the planning engine might construct.
|
|
InputGraph func(*Builder) *Graph
|
|
// WantGraph is a string representation of the expected output graph,
|
|
// using the syntax returned by [Graph.DebugRepr]. This can use a
|
|
// raw string literal indented to align with the surrounding code
|
|
// because we'll trim off the leading and trailing space from each line
|
|
// before comparing.
|
|
WantGraph string
|
|
|
|
// We intentionally focus only on valid input here because we only
|
|
// expect to be parsing graphs produced by OpenTofu itself, and so any
|
|
// errors we encounter are either bugs in OpenTofu or caused by
|
|
// something outside of OpenTofu tampering with the serialized graph.
|
|
// The error handling in [UnmarshalGraph] is primarily to help us with
|
|
// debugging, because end-users should never see those errors unless
|
|
// we've made a mistake somewhere.
|
|
}{
|
|
"managed resource instance final plan and apply": {
|
|
// This is intended to mimic how the planning engine would represent
|
|
// the process of final-planning and applying an action on a
|
|
// managed resource instance.
|
|
func(builder *Builder) *Graph {
|
|
instAddr := addrs.Resource{
|
|
Mode: addrs.ManagedResourceMode,
|
|
Type: "test",
|
|
Name: "example",
|
|
}.Absolute(addrs.RootModuleInstance).Instance(addrs.NoKey)
|
|
desiredInst := builder.DesiredResourceInstance(instAddr)
|
|
priorState := builder.ResourceInstancePriorState(instAddr)
|
|
plannedVal := builder.ConstantValue(cty.ObjectVal(map[string]cty.Value{
|
|
"name": cty.StringVal("thingy"),
|
|
}))
|
|
providerClient, registerUser := builder.ProviderInstance(
|
|
addrs.AbsProviderInstanceCorrect{
|
|
Config: addrs.AbsProviderConfigCorrect{
|
|
Config: addrs.ProviderConfigCorrect{
|
|
Provider: addrs.NewBuiltInProvider("test"),
|
|
},
|
|
},
|
|
},
|
|
nil,
|
|
)
|
|
finalPlan := builder.ManagedResourceObjectFinalPlan(desiredInst, priorState, plannedVal, providerClient, nil)
|
|
newState := builder.ApplyManagedResourceObjectChanges(finalPlan, providerClient)
|
|
registerUser(newState)
|
|
builder.SetResourceInstanceFinalStateResult(instAddr, newState)
|
|
return builder.Finish()
|
|
},
|
|
`
|
|
v[0] = cty.ObjectVal(map[string]cty.Value{
|
|
"name": cty.StringVal("thingy"),
|
|
});
|
|
|
|
r[0] = OpenProvider(provider("terraform.io/builtin/test"), providerInstConfig(provider["terraform.io/builtin/test"]), await());
|
|
r[1] = ManagedFinalPlan(desired(test.example), priorState(test.example), v[0], r[0], await());
|
|
r[2] = ManagedApplyChanges(r[1], r[0]);
|
|
r[3] = CloseProvider(r[0], await(r[2]));
|
|
|
|
test.example = r[2];
|
|
`,
|
|
},
|
|
"data resource instance read": {
|
|
// This is intended to mimic how the planning engine would represent
|
|
// the process of reading the data for a data resource instance.
|
|
func(builder *Builder) *Graph {
|
|
instAddr := addrs.Resource{
|
|
Mode: addrs.DataResourceMode,
|
|
Type: "test",
|
|
Name: "example",
|
|
}.Absolute(addrs.RootModuleInstance).Instance(addrs.NoKey)
|
|
desiredInst := builder.DesiredResourceInstance(instAddr)
|
|
providerClient, registerUser := builder.ProviderInstance(
|
|
addrs.AbsProviderInstanceCorrect{
|
|
Config: addrs.AbsProviderConfigCorrect{
|
|
Config: addrs.ProviderConfigCorrect{
|
|
Provider: addrs.NewBuiltInProvider("test"),
|
|
},
|
|
},
|
|
},
|
|
nil,
|
|
)
|
|
newState := builder.DataRead(desiredInst, providerClient, nil)
|
|
registerUser(newState)
|
|
builder.SetResourceInstanceFinalStateResult(instAddr, newState)
|
|
return builder.Finish()
|
|
},
|
|
`
|
|
r[0] = OpenProvider(provider("terraform.io/builtin/test"), providerInstConfig(provider["terraform.io/builtin/test"]), await());
|
|
r[1] = DataRead(desired(data.test.example), r[0], await());
|
|
r[2] = CloseProvider(r[0], await(r[1]));
|
|
|
|
data.test.example = r[1];
|
|
`,
|
|
},
|
|
"data resource instance reads with dependency": {
|
|
func(builder *Builder) *Graph {
|
|
instAddr1 := addrs.Resource{
|
|
Mode: addrs.DataResourceMode,
|
|
Type: "test",
|
|
Name: "example1",
|
|
}.Absolute(addrs.RootModuleInstance).Instance(addrs.NoKey)
|
|
instAddr2 := addrs.Resource{
|
|
Mode: addrs.DataResourceMode,
|
|
Type: "test",
|
|
Name: "example2",
|
|
}.Absolute(addrs.RootModuleInstance).Instance(addrs.NoKey)
|
|
desiredInst1 := builder.DesiredResourceInstance(instAddr1)
|
|
desiredInst2 := builder.DesiredResourceInstance(instAddr2)
|
|
providerClient, registerUser := builder.ProviderInstance(
|
|
addrs.AbsProviderInstanceCorrect{
|
|
Config: addrs.AbsProviderConfigCorrect{
|
|
Config: addrs.ProviderConfigCorrect{
|
|
Provider: addrs.NewBuiltInProvider("test"),
|
|
},
|
|
},
|
|
},
|
|
nil,
|
|
)
|
|
newState1 := builder.DataRead(desiredInst1, providerClient, nil)
|
|
newState2 := builder.DataRead(desiredInst2, providerClient, builder.Waiter(newState1))
|
|
registerUser(newState1)
|
|
registerUser(newState2)
|
|
builder.SetResourceInstanceFinalStateResult(instAddr1, newState1)
|
|
builder.SetResourceInstanceFinalStateResult(instAddr2, newState2)
|
|
return builder.Finish()
|
|
},
|
|
`
|
|
r[0] = OpenProvider(provider("terraform.io/builtin/test"), providerInstConfig(provider["terraform.io/builtin/test"]), await());
|
|
r[1] = DataRead(desired(data.test.example1), r[0], await());
|
|
r[2] = DataRead(desired(data.test.example2), r[0], await(r[1]));
|
|
r[3] = CloseProvider(r[0], await(r[1], r[2]));
|
|
|
|
data.test.example1 = r[1];
|
|
data.test.example2 = r[2];
|
|
`,
|
|
},
|
|
|
|
////////
|
|
// The remaining test cases are covering some weird cases just to
|
|
// make sure we can handle them without crashing or otherwise
|
|
// misbehaving. We don't need to go overboard here because this code
|
|
// only really needs to support graphs that OpenTofu's planning engine
|
|
// could reasonably generate.
|
|
////////
|
|
"empty": {
|
|
func(builder *Builder) *Graph {
|
|
return builder.Finish()
|
|
},
|
|
``,
|
|
},
|
|
"unused values discarded": {
|
|
// The graph marshaling is driven by what is refered to by the
|
|
// operations in the graph, and so anything that isn't actually
|
|
// used by an operation is irrelevant and discarded.
|
|
// (This test case is just here to remind us that this is the
|
|
// behavior. We don't actually rely on this behavior for
|
|
// correctness, because a value that isn't used in any operation
|
|
// is effectively ignored during execution anyway.)
|
|
func(b *Builder) *Graph {
|
|
b.ConstantValue(cty.True)
|
|
return b.Finish()
|
|
},
|
|
``,
|
|
},
|
|
}
|
|
|
|
for name, test := range tests {
|
|
t.Run(name, func(t *testing.T) {
|
|
inputGraph := test.InputGraph(NewBuilder())
|
|
marshaled := inputGraph.Marshal()
|
|
// The debug representation of the marshaled graph can be pretty
|
|
// verbose, so we'll print it only when we're reporting a failure
|
|
// so that a reader can look up any element indices that appear
|
|
// in the error messages.
|
|
showMarshaled := func() {
|
|
marshaledText := graphProtoDebugRepr(marshaled)
|
|
t.Log("graph marshaled as:\n" + marshaledText)
|
|
}
|
|
outputGraph, err := UnmarshalGraph(marshaled)
|
|
if err != nil {
|
|
// This test function only deals with valid cases, so we
|
|
// don't expect any errors here.
|
|
showMarshaled()
|
|
t.Fatalf("unexpected unmarshal error: %s", err)
|
|
}
|
|
gotRepr := trimLineSpaces(outputGraph.DebugRepr())
|
|
wantRepr := trimLineSpaces(test.WantGraph)
|
|
if diff := cmp.Diff(wantRepr, gotRepr); diff != "" {
|
|
showMarshaled()
|
|
t.Error("wrong output graph:\n" + diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// trimLineSpaces returns a modified version of the given string where each
|
|
// individual line has its leading and trailing spaces removed, where
|
|
// "spaces" is defined the same way as for [strings.TrimSpace].
|
|
//
|
|
// This is here just to make it easier to compare results from [Graph.DebugRepr]
|
|
// with string constants written in test code, while still having those
|
|
// string constants indented consistently with the surrounding code. Apply this
|
|
// function to both the constant string and the [Graph.DebugRepr] result and
|
|
// then compare the two e.g. using [cmp.Diff].
|
|
func trimLineSpaces(input string) string {
|
|
var buf strings.Builder
|
|
|
|
// Since this function is tailored for Graph.DebugRepr in particular
|
|
// we'll use simplistic string cutting instead of all of the complexity
|
|
// of bufio.Scanner here, which also means we can minimize copying
|
|
// in conversions between string and []byte.
|
|
remain := input
|
|
for len(remain) != 0 {
|
|
line, extra, _ := strings.Cut(remain, "\n")
|
|
remain = extra
|
|
buf.WriteString(strings.TrimSpace(line))
|
|
buf.WriteByte('\n')
|
|
}
|
|
|
|
// We also ignore any leading and trailing spaces in the result, which
|
|
// could caused if there's an extra newline at the start or end of the
|
|
// string, as tends to happen when formatting a raw string to match the
|
|
// indentation of its surroundings.
|
|
return strings.TrimSpace(buf.String())
|
|
}
|
|
|
|
// graphProtoDebugRepr produces a human-oriented string representation of
|
|
// a serialized execution graph for test debugging purposes only.
|
|
func graphProtoDebugRepr(wire []byte) string {
|
|
var buf strings.Builder
|
|
var protoGraph execgraphproto.ExecutionGraph
|
|
err := proto.Unmarshal(wire, &protoGraph)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("invalid protobuf serialization of %T: %s", &protoGraph, err))
|
|
}
|
|
|
|
// Various parts of the graph serialization involve indices into the
|
|
// same array of elements, so it's helpful to include the indices
|
|
// explicitly in the output.
|
|
for i, elem := range protoGraph.GetElements() {
|
|
asJSON := protojson.Format(elem)
|
|
fmt.Fprintf(&buf, "%d: %s\n", i, asJSON)
|
|
}
|
|
|
|
resourceInstResults := protoGraph.GetResourceInstanceResults()
|
|
if len(resourceInstResults) != 0 {
|
|
resourceInstResultsJSON, err := json.MarshalIndent(resourceInstResults, "", " ")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("can't marshal resource instance results: %s", err))
|
|
}
|
|
fmt.Fprintf(&buf, "resource instance results: %s\n", resourceInstResultsJSON)
|
|
}
|
|
|
|
return buf.String()
|
|
}
|