Skip to content

Commit

Permalink
Install dependencies of operators. (#1554)
Browse files Browse the repository at this point in the history
When installing an operator package using the KUDO CLI, operator dependencies can be provided as 'KudoOperator' tasks. For each of these tasks, the respective package is resolved and its Operator and OperatorVersion installed on the cluster. Because dependent package can also have dependencies on packages, dependencies are resolved recursively. Cyclic dependencies are detected by modeling the dependencies as a directed graph and checking this graph for cycles when new dependencies are added.

Co-authored-by: Aleksey Dukhovniy <adukhovniy@mesosphere.io>
Signed-off-by: Jan Schlicht <jan@d2iq.com>
  • Loading branch information
Jan Schlicht and Aleksey Dukhovniy committed Jun 11, 2020
1 parent 2aac262 commit 2b4e0dd
Show file tree
Hide file tree
Showing 11 changed files with 365 additions and 4 deletions.
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -22,6 +22,7 @@ require (
github.com/stretchr/testify v1.5.1
github.com/thoas/go-funk v0.6.0
github.com/xlab/treeprint v1.0.0
github.com/yourbasic/graph v0.0.0-20170921192928-40eb135c0b26
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
golang.org/x/sys v0.0.0-20200408040146-ea54a3c99b9b // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Expand Up @@ -506,6 +506,8 @@ github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSf
github.com/xlab/treeprint v1.0.0 h1:J0TkWtiuYgtdlrkkrDLISYBQ92M+X5m4LrIIMKrbDTs=
github.com/xlab/treeprint v1.0.0/go.mod h1:IoImgRak9i3zJyuxOKUP1v4UZd1tMoKkq/Cimt1uhCg=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yourbasic/graph v0.0.0-20170921192928-40eb135c0b26 h1:4u7nCRnWizT8R6xOP7cGaq+Ov0oBGkKMsLWZKiwDFas=
github.com/yourbasic/graph v0.0.0-20170921192928-40eb135c0b26/go.mod h1:Rfzr+sqaDreiCaoQbFCu3sTXxeFq/9kXRuyOoSlGQHE=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
Expand Down
8 changes: 8 additions & 0 deletions pkg/apis/kudo/v1beta1/operatorversion_types_helpers.go
Expand Up @@ -11,3 +11,11 @@ func OperatorInstanceName(operatorName string) string {
func OperatorVersionName(operatorName, version string) string {
return fmt.Sprintf("%s-%s", operatorName, version)
}

func (ov *OperatorVersion) FullyQualifiedName() string {
return fmt.Sprintf("%s-%s", ov.Name, ov.Spec.AppVersion)
}

func (ov *OperatorVersion) EqualOperatorVersion(other *OperatorVersion) bool {
return ov.FullyQualifiedName() == other.FullyQualifiedName()
}
1 change: 1 addition & 0 deletions pkg/kudoctl/cmd/install/install.go
Expand Up @@ -95,5 +95,6 @@ func installOperator(operatorArgument string, options *Options, fs afero.Fs, set
settings.Namespace,
*pkg.Resources,
options.Parameters,
resolver,
installOpts)
}
135 changes: 135 additions & 0 deletions pkg/kudoctl/packages/install/dependencies.go
@@ -0,0 +1,135 @@
package install

import (
"fmt"

"github.com/thoas/go-funk"
"github.com/yourbasic/graph"

"github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1"
engtask "github.com/kudobuilder/kudo/pkg/engine/task"
"github.com/kudobuilder/kudo/pkg/kudoctl/clog"
"github.com/kudobuilder/kudo/pkg/kudoctl/packages"
pkgresolver "github.com/kudobuilder/kudo/pkg/kudoctl/packages/resolver"
)

// dependencyGraph is modeled after 'graph.Mutable' but allows to add vertices.
type dependencyGraph struct {
edges []map[int]struct{}
}

// AddVertex adds a new vertex to the dependency graph.
func (g *dependencyGraph) AddVertex() {
g.edges = append(g.edges, map[int]struct{}{})
}

// AddEdge adds an edge from vertex v to w to the dependency graph.
func (g *dependencyGraph) AddEdge(v, w int) {
g.edges[v][w] = struct{}{}
}

// Order returns the number of vertices of the dependency graph.
func (g *dependencyGraph) Order() int {
return len(g.edges)
}

func (g *dependencyGraph) Visit(v int, do func(w int, c int64) bool) bool {
for w := range g.edges[v] {
if do(w, 1) {
return true
}
}

return false
}

// gatherDependencies resolved all dependencies of a package.
// Dependencies are resolved recursively.
// Cyclic dependencies are detected and result in an error.
func gatherDependencies(root packages.Resources, resolver pkgresolver.Resolver) ([]packages.Resources, error) {
pkgs := []packages.Resources{
root,
}

// Each vertex in 'g' matches an index in 'pkgs'.
g := dependencyGraph{
edges: []map[int]struct{}{{}},
}

if err := dependencyWalk(&pkgs, &g, root, 0, resolver); err != nil {
return nil, err
}

// Remove 'root' from the list of dependencies.
pkgs = funk.Drop(pkgs, 1).([]packages.Resources) //nolint:errcheck

return pkgs, nil
}

func dependencyWalk(
pkgs *[]packages.Resources,
g *dependencyGraph,
parent packages.Resources,
parentIndex int,
resolver pkgresolver.Resolver) error {
//nolint:errcheck
childrenTasks := funk.Filter(parent.OperatorVersion.Spec.Tasks, func(task v1beta1.Task) bool {
return task.Kind == engtask.KudoOperatorTaskKind
}).([]v1beta1.Task)

for _, childTask := range childrenTasks {
childPkg, err := resolver.Resolve(
childTask.Spec.KudoOperatorTaskSpec.Package,
childTask.Spec.KudoOperatorTaskSpec.AppVersion,
childTask.Spec.KudoOperatorTaskSpec.OperatorVersion)
if err != nil {
return fmt.Errorf(
"failed to resolve package %s, dependency of package %s: %v", fullyQualifiedName(childTask.Spec.KudoOperatorTaskSpec), parent.OperatorVersion.FullyQualifiedName(), err)
}

newPackage := false
childIndex := indexOf(pkgs, childPkg.Resources)
if childIndex == -1 {
clog.Printf("Adding new dependency %s", childPkg.Resources.OperatorVersion.FullyQualifiedName())
newPackage = true

*pkgs = append(*pkgs, *childPkg.Resources)
childIndex = len(*pkgs) - 1

// The number of vertices in 'g' has to match the number of packages we're tracking.
g.AddVertex()
}

// This is a directed graph. The edge represents a dependency of the parent package on the current package.
g.AddEdge(parentIndex, childIndex)

if !graph.Acyclic(g) {
return fmt.Errorf(
"cyclic package dependency found when adding package %s -> %s", parent.OperatorVersion.FullyQualifiedName(), childPkg.Resources.OperatorVersion.FullyQualifiedName())
}

// We only need to walk the dependencies if the package is new
if newPackage {
if err := dependencyWalk(pkgs, g, *childPkg.Resources, childIndex, resolver); err != nil {
return err
}
}
}

return nil
}

// indexOf method searches for the pkg in pkgs that has the same OperatorVersion/AppVersion (using
// EqualOperatorVersion method) and returns its index or -1 if not found.
func indexOf(pkgs *[]packages.Resources, pkg *packages.Resources) int {
for i, p := range *pkgs {
if p.OperatorVersion.EqualOperatorVersion(pkg.OperatorVersion) {
return i
}
}
return -1
}

func fullyQualifiedName(kt v1beta1.KudoOperatorTaskSpec) string {
return fmt.Sprintf("%s-%s", v1beta1.OperatorVersionName(kt.Package, kt.OperatorVersion), kt.AppVersion)
}
196 changes: 196 additions & 0 deletions pkg/kudoctl/packages/install/dependencies_test.go
@@ -0,0 +1,196 @@
package install

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/thoas/go-funk"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/kudobuilder/kudo/pkg/apis/kudo/v1beta1"
engtask "github.com/kudobuilder/kudo/pkg/engine/task"
"github.com/kudobuilder/kudo/pkg/kudoctl/packages"
)

type nameResolver struct {
Pkgs []packages.Package
}

func (resolver nameResolver) Resolve(
name string,
appVersion string,
operatorVersion string) (*packages.Package, error) {
for _, pkg := range resolver.Pkgs {
if pkg.Resources.Operator.Name == name {
return &pkg, nil
}
}

return nil, fmt.Errorf("package not found")
}

func createPackage(name string, dependencies ...string) packages.Package {
p := packages.Package{
Resources: &packages.Resources{
Operator: &v1beta1.Operator{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
},
OperatorVersion: &v1beta1.OperatorVersion{
ObjectMeta: metav1.ObjectMeta{
Name: v1beta1.OperatorVersionName(name, ""),
},
},
},
}

for _, dependency := range dependencies {
p.Resources.OperatorVersion.Spec.Tasks = append(
p.Resources.OperatorVersion.Spec.Tasks,
v1beta1.Task{
Name: "dependency",
Kind: engtask.KudoOperatorTaskKind,
Spec: v1beta1.TaskSpec{
KudoOperatorTaskSpec: v1beta1.KudoOperatorTaskSpec{
Package: dependency,
},
},
})
}

return p
}

func TestGatherDependencies(t *testing.T) {
tests := []struct {
name string
pkgs []packages.Package
want []string
wantErr string
}{
{
// A
// └── A
name: "trivial circular dependency",
pkgs: []packages.Package{
createPackage("A", "A"),
},
want: []string{},
wantErr: "cyclic package dependency found when adding package A-- -> A--",
},
{
// A
// └── B
// └── A
name: "circular dependency",
pkgs: []packages.Package{
createPackage("A", "B"),
createPackage("B", "A"),
},
want: []string{},
wantErr: "cyclic package dependency found when adding package B-- -> A--",
},
{
// A
// └── B
// └── C
// └── B
name: "nested circular dependency",
pkgs: []packages.Package{
createPackage("A", "B"),
createPackage("B", "C"),
createPackage("C", "B"),
},
want: []string{},
wantErr: "cyclic package dependency found when adding package C-- -> B--",
},
{
// A
// └── (B)
name: "unknown dependency",
pkgs: []packages.Package{
createPackage("A", "B"),
},
want: []string{},
wantErr: "failed to resolve package B--, dependency of package A--: package not found",
},
{
// A
// └── B
// └── C
name: "simple dependency",
pkgs: []packages.Package{
createPackage("A", "B"),
createPackage("B", "C"),
createPackage("C"),
},
want: []string{"B", "C"},
wantErr: "",
},
{
// B -----
// | \
// | |
// v |
// A ---> C |
// | | |
// | | |
// \ v v
// ----> D ---> E
name: "complex dependency",
pkgs: []packages.Package{
createPackage("A", "C", "D"),
createPackage("B", "C", "E"),
createPackage("C", "D"),
createPackage("D", "E"),
createPackage("E"),
},
want: []string{"C", "D", "E"},
wantErr: "",
},
{
// A
// └── B
// ├── C
// │ ├── D
// │ └── A
// ├── E
// └── F
name: "complex circular dependency",
pkgs: []packages.Package{
createPackage("A", "B"),
createPackage("B", "C", "E", "F"),
createPackage("C", "D", "A"),
createPackage("D"),
createPackage("E"),
createPackage("F"),
},
want: []string{},
wantErr: "cyclic package dependency found when adding package C-- -> A--",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
resolver := nameResolver{tt.pkgs}
got, err := gatherDependencies(*tt.pkgs[0].Resources, resolver)

assert.Equal(t, err == nil, tt.wantErr == "")

if err != nil {
assert.EqualError(t, err, tt.wantErr, tt.name)
}

for _, operatorName := range tt.want {
operatorName := operatorName

assert.NotNil(t, funk.Find(got, func(p packages.Resources) bool {
return p.Operator.Name == operatorName
}), tt.name)
}
})
}
}

0 comments on commit 2b4e0dd

Please sign in to comment.