diff --git a/go.mod b/go.mod index 001aff7e11..afe18e8e74 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/spf13/viper v1.8.1 github.com/stretchr/testify v1.7.1 github.com/xeipuuv/gojsonschema v1.2.0 + github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869 go.mongodb.org/mongo-driver v1.7.1 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.29.0 go.opentelemetry.io/otel v1.7.0 diff --git a/go.sum b/go.sum index 93ad862ae1..c789598107 100644 --- a/go.sum +++ b/go.sum @@ -1578,6 +1578,8 @@ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMx github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869 h1:7v7L5lsfw4w8iqBBXETukHo4IPltmD+mWoLRYUmeGN8= +github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869/go.mod h1:Rfzr+sqaDreiCaoQbFCu3sTXxeFq/9kXRuyOoSlGQHE= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/pkg/workflow/bundle_graph.go b/pkg/workflow/bundle_graph.go new file mode 100644 index 0000000000..a13a94ff9d --- /dev/null +++ b/pkg/workflow/bundle_graph.go @@ -0,0 +1,234 @@ +package workflow + +import ( + "context" + + "get.porter.sh/porter/pkg/cnab" + "get.porter.sh/porter/pkg/storage" + "get.porter.sh/porter/pkg/tracing" + "github.com/Masterminds/semver/v3" + "github.com/cnabio/cnab-go/bundle" + "github.com/yourbasic/graph" + "go.opentelemetry.io/otel/attribute" +) + +type BundleGraph struct { + // map[node.key]nodeIndex + nodeKeys map[string]int + nodes []Node + // (DependencyV1 (unresolved), Bundle, Installation) +} + +func NewBundleGraph() *BundleGraph { + return &BundleGraph{ + nodeKeys: make(map[string]int), + } +} + +// RegisterNode adds the specified node to the graph +// returning true if the node is already present. +func (g *BundleGraph) RegisterNode(node Node) bool { + _, exists := g.nodeKeys[node.GetKey()] + if !exists { + nodeIndex := len(g.nodes) + g.nodes = append(g.nodes, node) + g.nodeKeys[node.GetKey()] = nodeIndex + } + return exists +} + +func (g *BundleGraph) Sort() ([]Node, bool) { + dag := graph.New(len(g.nodes)) + for nodeIndex, node := range g.nodes { + for _, depKey := range node.GetRequires() { + depIndex, ok := g.nodeKeys[depKey] + if !ok { + panic("oops") + } + dag.Add(nodeIndex, depIndex) + } + } + + indices, ok := graph.TopSort(dag) + if !ok { + return nil, false + } + + // Reverse the sort so that items with no dependencies are listed first + count := len(indices) + results := make([]Node, count) + for i, nodeIndex := range indices { + results[count-i-1] = g.nodes[nodeIndex] + } + return results, true +} + +func (g *BundleGraph) GetNode(key string) (Node, bool) { + if nodeIndex, ok := g.nodeKeys[key]; ok { + return g.nodes[nodeIndex], true + } + return nil, false +} + +type Node interface { + GetRequires() []string + GetKey() string +} + +var _ Node = BundleNode{} +var _ Node = InstallationNode{} + +type BundleNode struct { + Key string + Reference cnab.BundleReference + Requires []string // TODO: we don't need to know this while resolving, find a less confusing way of storing this so it's clear who should set it +} + +func (d BundleNode) GetKey() string { + return d.Key +} + +func (d BundleNode) GetRequires() []string { + return d.Requires +} + +type InstallationNode struct { + Key string + Namespace string + Name string +} + +func (d InstallationNode) GetKey() string { + return d.Key +} + +func (d InstallationNode) GetRequires() []string { + return nil +} + +type Dependency struct { + Key string + DefaultBundle *BundleReferenceSelector + Interface *BundleInterfaceSelector + InstallationSelector *InstallationSelector + Requires []string +} + +type BundleReferenceSelector struct { + Reference cnab.OCIReference + Version *semver.Constraints +} + +func (s *BundleReferenceSelector) IsMatch(ctx context.Context, inst storage.Installation) bool { + log := tracing.LoggerFromContext(ctx) + log.Debug("Evaluating installation bundle definition") + + if inst.Status.BundleReference == "" { + log.Debug("Installation does not match because it does not have an associated bundle") + return false + } + + ref, err := cnab.ParseOCIReference(inst.Status.BundleReference) + if err != nil { + log.Warn("Could not evaluate installation because the BundleReference is invalid", + attribute.String("reference", inst.Status.BundleReference)) + return false + } + + // If no selector is defined, consider it a match + if s == nil { + return true + } + + // If a version range is specified, ignore the version on the selector and apply the range + // otherwise match the tag or digest + if s.Version != nil { + if inst.Status.BundleVersion == "" { + log.Debug("Installation does not match because it does not have an associated bundle version") + return false + } + + // First check that the repository is the same + gotRepo := ref.Repository() + wantRepo := s.Reference.Repository() + if gotRepo != wantRepo { + log.Warn("Installation does not match because the bundle repository is incorrect", + attribute.String("installation-bundle-repository", gotRepo), + attribute.String("dependency-bundle-repository", wantRepo), + ) + return false + } + + gotVersion, err := semver.NewVersion(inst.Status.BundleVersion) + if err != nil { + log.Warn("Installation does not match because the bundle version is invalid", + attribute.String("installation-bundle-version", inst.Status.BundleVersion), + ) + return false + } + + if s.Version.Check(gotVersion) { + log.Debug("Installation matches because the bundle version is in range", + attribute.String("installation-bundle-version", inst.Status.BundleVersion), + attribute.String("dependency-bundle-version", s.Version.String()), + ) + return true + } else { + log.Debug("Installation does not match because the bundle version is incorrect", + attribute.String("installation-bundle-version", inst.Status.BundleVersion), + attribute.String("dependency-bundle-version", s.Version.String()), + ) + return false + } + } else { + gotRef := ref.String() + wantRef := s.Reference.String() + if gotRef == wantRef { + log.Warn("Installation matches because the bundle reference is correct", + attribute.String("installation-bundle-reference", gotRef), + attribute.String("dependency-bundle-reference", wantRef), + ) + return true + } else { + log.Warn("Installation does not match because the bundle reference is incorrect", + attribute.String("installation-bundle-reference", gotRef), + attribute.String("dependency-bundle-reference", wantRef), + ) + return false + } + } +} + +type InstallationSelector struct { + Bundle *BundleReferenceSelector + Interface *BundleInterfaceSelector + Labels map[string]string + Namespaces []string +} + +func (s InstallationSelector) IsMatch(ctx context.Context, inst storage.Installation) bool { + // Skip checking labels and namespaces, those were used to query the set of + // installations that we are checking + + bundleMatches := s.Bundle.IsMatch(ctx, inst) + if !bundleMatches { + return false + } + + interfaceMatches := s.Interface.IsMatch(ctx, inst) + return interfaceMatches +} + +// BundleInterfaceSelector defines how a bundle is going to be used. +// It is not the same as the bundle definition. +// It works like go interfaces where its defined by its consumer. +type BundleInterfaceSelector struct { + Parameters []bundle.Parameter + Credentials []bundle.Credential + Outputs []bundle.Output +} + +func (s BundleInterfaceSelector) IsMatch(ctx context.Context, inst storage.Installation) bool { + // TODO: implement + return true +} diff --git a/pkg/workflow/bundle_graph_test.go b/pkg/workflow/bundle_graph_test.go new file mode 100644 index 0000000000..a1796d7915 --- /dev/null +++ b/pkg/workflow/bundle_graph_test.go @@ -0,0 +1,70 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stretchr/testify/require" +) + +func TestEngine_DependOnInstallation(t *testing.T) { + /* + A -> B (installation) + A -> C (bundle) + c.parameters.connstr <- B.outputs.connstr + */ + + b := InstallationNode{Key: "b"} + c := BundleNode{ + Key: "c", + Requires: []string{"b"}, + } + a := BundleNode{ + Key: "root", + Requires: []string{"b", "c"}, + } + + g := NewBundleGraph() + g.RegisterNode(a) + g.RegisterNode(b) + g.RegisterNode(c) + sortedNodes, ok := g.Sort() + require.True(t, ok, "graph should not be cyclic") + + gotOrder := make([]string, len(sortedNodes)) + for i, node := range sortedNodes { + gotOrder[i] = node.GetKey() + } + wantOrder := []string{ + "b", + "c", + "root", + } + assert.Equal(t, wantOrder, gotOrder) +} + +/* +✅ need to represent new dependency structure on an extended bundle wrapper +(put in cnab-go later) + +need to read a bundle and make a BundleGraph +? how to handle a param that isn't a pure assignment, e.g. connstr: ${bundle.deps.VM.outputs.ip}:${bundle.deps.SVC.outputs.port} +? when are templates evaluated as the graph is executed (for simplicity, first draft no composition / templating) + +need to resolve dependencies in the graph +* lookup against existing installations +* lookup against semver tags in registry +* lookup against bundle index? when would we look here? (i.e. preferred/registered implementations of interfaces) + +need to turn the sorted nodes into an execution plan +execution plan needs: +* bundle to execute and the installation it will become +* parameters and credentials to pass + * sources: + root parameters/creds + installation outputs + +need to write something that can run an execution plan +* knows how to grab sources and pass them into the bundle +*/ diff --git a/pkg/workflow/default_bundle_resolver.go b/pkg/workflow/default_bundle_resolver.go new file mode 100644 index 0000000000..e6c2a8799c --- /dev/null +++ b/pkg/workflow/default_bundle_resolver.go @@ -0,0 +1,38 @@ +package workflow + +import ( + "context" + + "get.porter.sh/porter/pkg/porter" +) + +var _ DependencyResolver = DefaultBundleResolver{} + +// DefaultBundleResolver resolves the default bundle defined on the dependency. +type DefaultBundleResolver struct { + puller porter.BundleResolver +} + +func (d DefaultBundleResolver) Resolve(ctx context.Context, dep Dependency) (Node, bool, error) { + if dep.DefaultBundle == nil { + return nil, false, nil + } + + pullOpts := porter.BundlePullOptions{ + Reference: dep.DefaultBundle.Reference.String(), + // todo: respect force pull and insecure registry + } + if err := pullOpts.Validate(); err != nil { + return nil, false, err + } + cb, err := d.puller.Resolve(ctx, pullOpts) + if err != nil { + // wrap not found error and indicate that we could resolve anything + return nil, false, err + } + + return BundleNode{ + Key: dep.Key, + Reference: cb.BundleReference, + }, true, nil +} diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go new file mode 100644 index 0000000000..7a28075d20 --- /dev/null +++ b/pkg/workflow/engine.go @@ -0,0 +1,169 @@ +package workflow + +import ( + "context" + "fmt" + "github.com/Masterminds/semver/v3" + + "get.porter.sh/porter/pkg/storage" + + "get.porter.sh/porter/pkg/cnab" + depsv2 "get.porter.sh/porter/pkg/cnab/dependencies/v2" + "github.com/pkg/errors" +) + +// Engine handles executing a workflow of bundles to execute. +type Engine struct { + driver WorkflowDriver + resolver DependencyResolver + rootInstallation storage.Installation +} + +// TODO: do we need both a dep graph made up of just bundles (i.e. the unresolved representation) and other with everything resolved (execution plan half filled out)? +func (t Engine) GetDependencyGraph(ctx context.Context, bun cnab.ExtendedBundle) (*BundleGraph, error) { + g := NewBundleGraph() + + // Add the root bundle + root := BundleNode{ + Key: "root", + Reference: cnab.BundleReference{Definition: bun}, + } + + err := t.addBundleToGraph(ctx, g, root) + return g, err +} + +func (t Engine) addBundleToGraph(ctx context.Context, g *BundleGraph, node BundleNode) error { + if exists := g.RegisterNode(node); exists { + // We have already processed this bundle, return to avoid an infinite loop + return nil + } + + bun := node.Reference.Definition + if !bun.HasDependenciesV2() { + return nil + } + + deps, err := bun.ReadDependenciesV2() + if err != nil { + return err + } + + node.Requires = make([]string, 0, len(deps.Requires)) + for depName, dep := range deps.Requires { + depKey := fmt.Sprintf("%s.%s", node.Key, depName) + + resolved, err := t.resolveDependency(ctx, depKey, dep) + if err != nil { + return err + } + + node.Requires = append(node.Requires, depKey) + + depNode, ok := resolved.(BundleNode) + if !ok { + // installations don't have any dependencies so there's nothing left to do + g.RegisterNode(resolved) + continue + } + + requireOutput := func(source depsv2.DependencySource) { + if source.Output == "" { + return + } + + outputRequires := node.Key + if source.Dependency != "" { + outputRequires = node.Key + "." + source.Dependency + } + depNode.Requires = append(depNode.Requires, outputRequires) + } + for _, source := range dep.Parameters { + requireOutput(source) + } + for _, source := range dep.Credentials { + requireOutput(source) + } + t.addBundleToGraph(ctx, g, depNode) + } + + return nil +} + +func (t Engine) resolveDependency(ctx context.Context, name string, dep depsv2.Dependency) (Node, error) { + unresolved := Dependency{Key: name} + if dep.Bundle != "" { + ref, err := cnab.ParseOCIReference(dep.Bundle) + if err != nil { + return nil, errors.Wrapf(err, "invalid bundle for dependency %s", name) + } + unresolved.DefaultBundle = &BundleReferenceSelector{ + Reference: ref, + } + if dep.Version != "" { + unresolved.DefaultBundle.Version, err = semver.NewConstraint(dep.Version) + if err != nil { + return nil, err + } + } + } + + if dep.Interface != nil { + // TODO: convert the interface document into a BundleInterfaceSelector + } + + if dep.Installation != nil { + unresolved.InstallationSelector = &InstallationSelector{} + + matchNamespaces := make([]string, 0, 2) + if !dep.Installation.Criteria.IgnoreLabels { + unresolved.InstallationSelector.Labels = dep.Installation.Labels + } + + matchNamespaces = append(matchNamespaces, t.rootInstallation.Namespace) + if !dep.Installation.Criteria.MatchNamespace && t.rootInstallation.Namespace != "" { + // Include the global namespace + matchNamespaces = append(matchNamespaces, "") + } + unresolved.InstallationSelector.Namespaces = matchNamespaces + + if !dep.Installation.Criteria.MatchInterface { + unresolved.InstallationSelector.Bundle = unresolved.DefaultBundle + } + } + + depNode, resolved, err := t.resolver.Resolve(ctx, unresolved) + if err != nil { + return nil, err + } + + if !resolved { + return nil, errors.Errorf("could not resolve dependency %s", name) + } + + return depNode, nil +} + +func (t Engine) BuildExecutionPlan(ctx context.Context, g *BundleGraph) (ExecutionPlan, error) { + nodes, ok := g.Sort() + if !ok { + return ExecutionPlan{}, fmt.Errorf("could not generate an execution plan, the bundle graph has a cyle") + } + + opts := ExecutionOptions{} + return NewExecutionPlan(nodes, opts), nil +} + +func (t Engine) Execute(ctx context.Context, plan ExecutionPlan) error { + // TODO: for a workflow managed by something external, do we need porter to run the entire time? Can we add a task at the end to update the installation status? + w, err := t.driver.CreateWorkflow(ctx, plan) + if err != nil { + return err + } + + if err = t.driver.StartWorkflow(ctx, w); err != nil { + return err + } + + panic("not implemented") +} diff --git a/pkg/workflow/engine_test.go b/pkg/workflow/engine_test.go new file mode 100644 index 0000000000..3f9d7da824 --- /dev/null +++ b/pkg/workflow/engine_test.go @@ -0,0 +1,68 @@ +package workflow + +import ( + "context" + "get.porter.sh/porter/pkg/experimental" + "testing" + + "get.porter.sh/porter/pkg/config" + + "get.porter.sh/porter/pkg/cnab" + configadapter "get.porter.sh/porter/pkg/cnab/config-adapter" + "get.porter.sh/porter/pkg/manifest" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var _ DependencyResolver = TestResolver{} + +type TestResolver struct { + mocks map[string]Node +} + +func (t TestResolver) Resolve(ctx context.Context, dep Dependency) (Node, bool, error) { + node, ok := t.mocks[dep.Key] + if ok { + return node, true, nil + } + return nil, false, errors.Errorf("no mock exists for %s", dep.Key) +} + +func TestGetDependencyGraphAndSort(t *testing.T) { + c := config.NewTestConfig(t) + c.SetExperimentalFlags(experimental.FlagDependenciesV2) + c.TestContext.UseFilesystem() + ctx := context.Background() + + // load our test porter.yaml into a cnab bundle + m, err := manifest.ReadManifest(c.Context, "testdata/porter.yaml") + require.NoError(t, err) + converter := configadapter.NewManifestConverter(c.Config, m, nil, nil) + bun, err := converter.ToBundle(ctx) + require.NoError(t, err) + + eng := Engine{ + resolver: TestResolver{mocks: map[string]Node{ + "root.load-balancer": InstallationNode{Key: "root.load-balancer"}, + "root.mysql": BundleNode{Key: "root.mysql", Reference: cnab.BundleReference{Definition: cnab.ExtendedBundle{}}}, + }}, + } + g, err := eng.GetDependencyGraph(ctx, bun) + require.NoError(t, err) + + sortedNodes, ok := g.Sort() + require.True(t, ok, "graph should not have a cycle") + + gotOrder := make([]string, len(sortedNodes)) + for i, node := range sortedNodes { + gotOrder[i] = node.GetKey() + } + wantOrder := []string{ + "root.load-balancer", + "root.mysql", + "root", + } + assert.Equal(t, wantOrder, gotOrder) + +} diff --git a/pkg/workflow/execution_plan.go b/pkg/workflow/execution_plan.go new file mode 100644 index 0000000000..04526551cd --- /dev/null +++ b/pkg/workflow/execution_plan.go @@ -0,0 +1,67 @@ +package workflow + +// ExecutionPlan outlines the set of tasks required to execute a bundle +// and indicates when tasks may run in parallel. +type ExecutionPlan struct { + // Ordered list of tasks + Tasks TaskSet + + // debugMode indicates that Porter is going to step through the workflow a task at a time + // This indicates that the workflow driver should generate a workflow definition that supports debugging. + DebugMode bool +} + +type ExecutionOptions struct { + // DebugMode indicates that Porter is going to step through the workflow a task at a time + // This indicates that the workflow driver should generate a workflow definition that supports debugging. + DebugMode bool +} + +func NewExecutionPlan(nodes []Node, opts ExecutionOptions) ExecutionPlan { + return ExecutionPlan{ + Tasks: nil, + DebugMode: opts.DebugMode, + } +} + +// TaskList is an ordered list of tasks. +type TaskList []Task + +// TaskSet contains groups of tasks that can be run in parallel. +type TaskSet []TaskList + +type Task struct { + // Name of the task. Used to refer to a task output + Name string + + // InstallerType defines the type of the installer: docker image, webassembly module, etc. + InstallerType string + + // InstallerReference fully qualified reference to the definition of the installer. + InstallerReference string + + // Inputs given to the task + Inputs []TaskInput + + // Outputs that were generated by the task + Outputs map[string]TaskOutput +} + +type TaskInput struct { + // Env is the name of the environment variable to inject + Env string + + // Path is the full path of the file to inject + Path string + + // Contents of the input value. + Contents string + + // Source where the contents can be resolved. Guaranteed that the source is resolvable when the task is run. + Source string +} + +type TaskOutput struct { + // Path is the full path of the file to collect. + Path string +} diff --git a/pkg/workflow/installation_resolver.go b/pkg/workflow/installation_resolver.go new file mode 100644 index 0000000000..9030067858 --- /dev/null +++ b/pkg/workflow/installation_resolver.go @@ -0,0 +1,124 @@ +package workflow + +import ( + "context" + + "get.porter.sh/porter/pkg/cnab" + "get.porter.sh/porter/pkg/storage" + "go.mongodb.org/mongo-driver/bson" +) + +var _ DependencyResolver = InstallationResolver{} + +// InstallationResolver resolves an existing installation from a dependency +type InstallationResolver struct { + store storage.InstallationStore + + // Namespace of the root installation + namespace string +} + +func (r InstallationResolver) Resolve(ctx context.Context, dep Dependency) (Node, bool, error) { + if dep.InstallationSelector == nil { + return nil, false, nil + } + + // Build a query for matching installations + filter := make(bson.M, 1) + + // Match installations with one of the specified namespaces + namespacesQuery := make([]bson.M, 2) + for _, ns := range dep.InstallationSelector.Namespaces { + namespacesQuery = append(namespacesQuery, bson.M{"namespace": ns}) + } + filter["$or"] = namespacesQuery + + // Match all specified labels + for k, v := range dep.InstallationSelector.Labels { + filter["labels."+k] = v + } + + findOpts := storage.FindOptions{ + Sort: []string{"-namespace", "name"}, + Filter: filter, + } + installations, err := r.store.FindInstallations(ctx, findOpts) + if err != nil { + return nil, false, err + } + + // map[installation index]isMatchBool + matches := make(map[int]bool) + for i, inst := range installations { + if dep.InstallationSelector.IsMatch(ctx, inst) { + matches[i] = true + } + } + + switch len(matches) { + case 0: + return nil, false, nil + case 1: + var instIndex int + for i := range matches { + instIndex = i + } + inst := installations[instIndex] + match := &InstallationNode{ + Key: dep.Key, + Namespace: inst.Namespace, + Name: inst.Name, + } + return match, true, nil + default: + var preferredMatch *storage.Installation + // Prefer an installation that is the same as the default bundle if there are multiple interface matches + if dep.DefaultBundle != nil { + for i, isCandidate := range matches { + if !isCandidate { + continue + } + + inst := installations[i] + bundleRef, err := cnab.ParseOCIReference(inst.Status.BundleReference) + if err != nil { + matches[i] = false + continue + } + + if dep.DefaultBundle.Reference.Repository() == bundleRef.Repository() { + preferredMatch = &inst + break + } + + } + } + + // Prefer an installation in the same namespace if there is both a global and local installation + if preferredMatch != nil && preferredMatch.Namespace == r.namespace { + match := &InstallationNode{ + Key: dep.Key, + Namespace: preferredMatch.Namespace, + Name: preferredMatch.Name, + } + return match, true, nil + } + + // Just pick the first installation sorted by -namespace, name (i.e. global last) + for i, isCandidate := range matches { + if !isCandidate { + continue + } + + inst := installations[i] + match := &InstallationNode{ + Key: dep.Key, + Namespace: inst.Namespace, + Name: inst.Name, + } + return match, true, nil + } + + return nil, false, nil + } +} diff --git a/pkg/workflow/resolver.go b/pkg/workflow/resolver.go new file mode 100644 index 0000000000..529795d38f --- /dev/null +++ b/pkg/workflow/resolver.go @@ -0,0 +1,68 @@ +package workflow + +import ( + "context" + + "get.porter.sh/porter/pkg/storage" + + cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" + + "get.porter.sh/porter/pkg/porter" +) + +var _ DependencyResolver = CompositeResolver{} + +type DependencyResolver interface { + Resolve(ctx context.Context, dep Dependency) (Node, bool, error) +} + +// TODO: make a composite resolver that calls all registered child resolvers until a match is found +// installation resolver +// range resolver +// specific bundle resolver +type CompositeResolver struct { + puller porter.BundleResolver + resolvers []DependencyResolver +} + +func NewCompositeResolver(puller porter.BundleResolver, store storage.InstallationStore, registry cnabtooci.RegistryProvider, namespace string) CompositeResolver { + instResolver := InstallationResolver{ + store: store, + namespace: namespace, + } + versionResolver := VersionResolver{ + registry: registry, + } + return CompositeResolver{ + puller: puller, + resolvers: []DependencyResolver{ + instResolver, + versionResolver, + DefaultBundleResolver{}, + }, + } +} + +func (r CompositeResolver) Resolve(ctx context.Context, dep Dependency) (Node, bool, error) { + // pull the default bundle if set, and verify that it meets the interface. It's a problem if it doesn't + // We should stop early if it doesn't work because most likely the interface is defined incorrectly + // We can check at build time that the bundle will work with all the defaults + // don't do this at runtime, assume the bundle has been checked + + // build an interface + // config setting to reuse existing installations + + for _, resolver := range r.resolvers { + depNode, resolved, err := resolver.Resolve(ctx, dep) + if err != nil { + return nil, false, err + } + if resolved { + return depNode, true, nil + } + } + + return nil, false, nil +} + +// TODO: implement the new error source interface and flag it as not found,so we can check for it diff --git a/pkg/workflow/testdata/porter.yaml b/pkg/workflow/testdata/porter.yaml new file mode 100644 index 0000000000..ef65b75e54 --- /dev/null +++ b/pkg/workflow/testdata/porter.yaml @@ -0,0 +1,23 @@ +parameters: + - name: region + type: string + +credentials: + - name: kubeconfig + type: file + +outputs: + - name: connstr + type: string + source: bundle.dependencies.mysql.output.admin-connstr + +dependencies: + requires: + - name: load-balancer + bundle: + reference: example/load-balancer:v1.0.0 + - name: mysql + bundle: + reference: example/mysql:v1.0.0 + parameters: + ip: bundle.dependencies.load-balancer.outputs.ipAddress diff --git a/pkg/workflow/version_resolver.go b/pkg/workflow/version_resolver.go new file mode 100644 index 0000000000..96cb133a2a --- /dev/null +++ b/pkg/workflow/version_resolver.go @@ -0,0 +1,56 @@ +package workflow + +import ( + "context" + "sort" + + "get.porter.sh/porter/pkg/cnab" + cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" + "github.com/Masterminds/semver/v3" +) + +var _ DependencyResolver = VersionResolver{} + +// VersionResolver resolves the highest version of the default bundle. +type VersionResolver struct { + registry cnabtooci.RegistryProvider +} + +func (v VersionResolver) Resolve(ctx context.Context, dep Dependency) (Node, bool, error) { + bundle := dep.DefaultBundle + if bundle == nil || bundle.Version == nil { + return nil, false, nil + } + + regOpts := cnabtooci.RegistryOptions{} // TODO: handle passing the registry flags all the way through to here + tags, err := v.registry.ListTags(ctx, bundle.Reference, regOpts) + if err != nil { + return nil, false, err + } + + versions := make(semver.Collection, 0, len(tags)) + for _, tag := range tags { + version, err := semver.NewVersion(tag) + if err == nil { + versions = append(versions, version) + } + } + + if len(versions) == 0 { + return nil, false, nil + } + + sort.Sort(sort.Reverse(versions)) + + // TODO: return the first one that matches the bundle interface + versionRef, err := bundle.Reference.WithTag(versions[0].Original()) + if err != nil { + return nil, false, err + } + + bunRef := cnab.BundleReference{Reference: versionRef} + return BundleNode{ + Key: dep.Key, + Reference: bunRef, + }, true, nil +} diff --git a/pkg/workflow/workflow_driver.go b/pkg/workflow/workflow_driver.go new file mode 100644 index 0000000000..46e53b8b40 --- /dev/null +++ b/pkg/workflow/workflow_driver.go @@ -0,0 +1,24 @@ +package workflow + +import "context" + +// WorkflowDriver is how Porter interacts with workflow drivers, e.g. argo, cadence, etc. +type WorkflowDriver interface { + // CreateWorkflow converts the ExecutionPlan into a definition that the driver understands. + CreateWorkflow(ctx context.Context, plan ExecutionPlan) (WorkflowDefinition, error) + + // StartWorkflow begins the specified workflow. + StartWorkflow(ctx context.Context, workflow WorkflowDefinition) error + + // CancelWorkflow stops the specified workflow. + CancelWorkflow(ctx context.Context, workflow WorkflowDefinition) error + + // RetryWorkflow starts the workflow over at the last failed job(s). + RetryWorkflow(ctx context.Context, workflow WorkflowDefinition) error + + // StepThrough runs only the specified task in the workflow, pausing afterwards so that the workflow can be debugged. + StepThrough(ctx context.Context, workflow WorkflowDefinition, taskName string) error +} + +// WorkflowDefinition is the representation of the ExecutionPlan against a specific workflow driver. +type WorkflowDefinition map[string]interface{}