PBT for dependency_graph (#8404)

This commit is contained in:
Anton Tayanovskyy 2021-11-12 15:12:48 -05:00 committed by GitHub
parent 74ba28ad55
commit 5dd7851293
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -0,0 +1,364 @@
// Copyright 2016-2021, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Model-checks dependency_graph functionality against simple models
// using property-based testing.
//
// Currently this assumes a simplified model of `resource.State`
// relevant to dependency calculations; `dependency_graph` only
// accesses these fields:
//
// type State struct {
// Dependencies []resource.URN
// URN resource.URN
// Parent resource.URN
// Provider string
// Custom bool
// }
//
// At the moment only `Custom=true` (Custom, not Component) resources
// are tested.
package graph
import (
"bytes"
"fmt"
"io"
"testing"
"pgregory.net/rapid"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Models ------------------------------------------------------------------------------------------
var isParent R = func(child, parent *resource.State) bool {
return child.Parent == parent.URN
}
var hasProvider R = func(res, provider *resource.State) bool {
return resource.URN(res.Provider) == provider.URN
}
var hasDependency R = func(res, dependency *resource.State) bool {
for _, dep := range res.Dependencies {
if dependency.URN == dep {
return true
}
}
return false
}
var expectedDependenciesOf R = union(isParent, hasProvider, hasDependency)
// Verify `DependneciesOf` against `expectedDependenciesOf`.
func TestRapidDependenciesOf(t *testing.T) {
graphCheck(t, func(t *rapid.T, universe []*resource.State) {
dg := NewDependencyGraph(universe)
for _, a := range universe {
aD := dg.DependenciesOf(a)
for _, b := range universe {
if isParent(a, b) {
assert.Truef(t, aD[b],
"DependenciesOf(%v) is missing a parent %v",
a.URN, b.URN)
}
if hasProvider(a, b) {
assert.Truef(t, aD[b],
"DependenciesOf(%v) is missing a provider %v",
a.URN, b.URN)
}
if hasDependency(a, b) {
assert.Truef(t, aD[b],
"DependenciesOf(%v) is missing a dependecy %v",
a.URN, b.URN)
}
if aD[b] {
assert.True(t, expectedDependenciesOf(a, b),
"DependenciesOf(%v) includes an unexpected %v",
a.URN, b.URN)
}
}
}
})
}
// Additionally verify no immediate loops in `DependenciesOf`, no `B
// in DependenciesOf(A) && A in DependenciesOf(B)`.
func TestRapidDependenciesOfAntisymmetric(t *testing.T) {
graphCheck(t, func(t *rapid.T, universe []*resource.State) {
dg := NewDependencyGraph(universe)
for _, a := range universe {
aD := dg.DependenciesOf(a)
for _, b := range universe {
bD := dg.DependenciesOf(b)
assert.Falsef(t, aD[b] && bD[a],
"DependenciesOf symmetric over (%v, %v)", a.URN, b.URN)
}
}
})
}
// Model `DependingOn`.
func expectedDependingOn(universe []*resource.State, includeChildren bool) R {
if !includeChildren {
// TODO currently DependingOn is not the inverse transitive
// closure of `dependenciesOf`. Should this be
// `expectedDependenciesOf`?
restrictedDependenciesOf := union(hasProvider, hasDependency)
return inverse(transitively(universe)(restrictedDependenciesOf))
}
// TODO this extends base `expectedDependingOn` with
// immediate children. Should it be a transitive closure?
dependingOn := expectedDependingOn(universe, false)
return func(a, b *resource.State) bool {
if dependingOn(a, b) || isParent(b, a) {
return true
}
for _, x := range universe {
if dependingOn(x, b) && isParent(x, a) {
return true
}
}
return false
}
}
// Verify `DependingOn` against `expectedDependingOn`. Note that
// `DependingOn` is specialised with an empty ignore map, the ignore
// map is not tested yet.
func TestRapidDependingOn(t *testing.T) {
test := func(t *rapid.T, universe []*resource.State, includingChildren bool) {
expected := expectedDependingOn(universe, includingChildren)
dg := NewDependencyGraph(universe)
dependingOn := func(a, b *resource.State) bool {
for _, x := range dg.DependingOn(a, nil, includingChildren) {
if b.URN == x.URN {
return true
}
}
return false
}
for _, a := range universe {
for _, b := range universe {
actual := dependingOn(a, b)
assert.Equalf(t, expected(a, b), actual,
"Unexpected %v in dg.DependingOn(%v) = %v",
b.URN, a.URN, actual)
}
}
}
for _, includingChildren := range []bool{false, true} {
t.Run(fmt.Sprintf("includingChildren=%v", includingChildren), func(t *testing.T) {
graphCheck(t, func(t *rapid.T, universe []*resource.State) {
test(t, universe, includingChildren)
})
})
}
}
// Verify `DependingOn` results are ordered, if `D1` in
// `DependingOn(D2)` then `D1` appears before `D2`.
func TestRapidDependingOnOrdered(t *testing.T) {
test := func(t *rapid.T, universe []*resource.State, includingChildren bool) {
expectedDependingOn := expectedDependingOn(universe, includingChildren)
dg := NewDependencyGraph(universe)
for _, a := range universe {
depOnA := dg.DependingOn(a, nil, includingChildren)
for d1i, d1 := range depOnA {
for d2i, d2 := range depOnA {
if expectedDependingOn(d2, d1) {
require.Truef(t, d2i < d1i,
"%v should appear before %v",
d2.URN, d1.URN)
}
}
}
}
}
for _, includingChildren := range []bool{false, true} {
t.Run(fmt.Sprintf("includingChildren=%v", includingChildren), func(t *testing.T) {
graphCheck(t, func(t *rapid.T, universe []*resource.State) {
test(t, universe, includingChildren)
})
})
}
}
// Generators --------------------------------------------------------------------------------------
// Generates ordered values of type `[]ResourceState` that:
//
// - Have unique URNs
// - May reference preceding resouces in the slice as r.Parent
// - May reference preceding resouces in the slice in r.Dependencies
//
// In other words these slices conform with `NewDependencyGraph`
// ordering assumptions. There is a tradedoff: generated values will
// not test any error-checking code in `NewDependencyGraph`, but will
// more efficiently explore more complicated properties on the valid
// subspace of inputs.
//
// What is not currently done but may need to be extended:
//
// - Support Component resources
// - Support non-nil r.Provider references
func resourceStateSliceGenerator() *rapid.Generator {
urnGen := rapid.StringMatching(`urn:pulumi:a::b::c:d:e::[abcd][123]`)
stateGen := rapid.Custom(func(t *rapid.T) *resource.State {
urn := urnGen.Draw(t, "URN").(string)
return &resource.State{
Custom: true,
URN: resource.URN(urn),
}
})
getUrn := func(st *resource.State) resource.URN { return st.URN }
statesGen := rapid.SliceOfDistinct(stateGen, getUrn)
return rapid.Custom(func(t *rapid.T) []*resource.State {
states := statesGen.Draw(t, "states").([]*resource.State)
randInt := rapid.IntRange(-len(states), len(states))
for i, r := range states {
// Any resource at index `i` may want to declare `j < i` as parent.
// Sample negative `j` to means "no parent".
j := randInt.Draw(t, fmt.Sprintf("j%d", i)).(int)
if j >= 0 && j < i {
r.Parent = states[j].URN
}
// Similarly we can depend on resources defined prior.
deps := rapid.SliceOfDistinct(
randInt,
func(i int) int { return i },
).Draw(t, fmt.Sprintf("deps%d", i)).([]int)
for _, dep := range deps {
if dep >= 0 && dep < i {
r.Dependencies = append(r.Dependencies, states[dep].URN)
}
}
}
return states
})
}
// Helper code: relations --------------------------------------------------------------------------
// Shorthand for relations over `*resource.State`
type R = func(a, b *resource.State) bool
// Union of one or more relations.
func union(rs ...R) R {
return func(a, b *resource.State) bool {
for _, r := range rs {
if r(a, b) {
return true
}
}
return false
}
}
// Flips the relation, `inverse(R)(a,b) = R(b,a)`.
func inverse(r R) R {
return func(a, b *resource.State) bool {
return r(b, a)
}
}
// Memoized transitive closure of a relation.
func transitively(universe []*resource.State) func(R) R {
return func(rel R) R {
trel := make(map[*resource.State]map[*resource.State]bool)
for _, a := range universe {
trel[a] = make(map[*resource.State]bool)
for _, b := range universe {
if rel(a, b) {
trel[a][b] = true
}
}
}
extend := func() bool {
more := false
for _, a := range universe {
for _, b := range universe {
if !trel[a][b] {
for _, x := range universe {
if trel[x][b] && rel(a, x) {
trel[a][b] = true
more = true
}
}
}
}
}
return more
}
for extend() {
}
return func(a, b *resource.State) bool {
return trel[a][b]
}
}
}
// Helper code: misc -------------------------------------------------------------------------------
func printState(w io.Writer, st *resource.State) {
fmt.Fprintf(w, "%s", st.URN)
if st.Parent != "" {
fmt.Fprintf(w, " parent=%s", st.Parent)
}
if len(st.Dependencies) > 0 {
fmt.Fprintf(w, " deps=[")
for _, d := range st.Dependencies {
fmt.Fprintf(w, "%s, ", d)
}
fmt.Fprintf(w, "]")
}
fmt.Fprintf(w, "\n")
}
func showStates(sts []*resource.State) string {
buf := &bytes.Buffer{}
fmt.Fprintf(buf, "[\n\n")
for _, st := range sts {
printState(buf, st)
fmt.Fprintf(buf, "\n\n")
}
fmt.Fprintf(buf, "]")
return buf.String()
}
func graphCheck(t *testing.T, check func(*rapid.T, []*resource.State)) {
rss := resourceStateSliceGenerator()
rapid.Check(t, func(t *rapid.T) {
universe := rss.Draw(t, "universe").([]*resource.State)
t.Logf("Checking universe: %s", showStates(universe))
check(t, universe)
})
}