diff --git a/cmd/porter/installations.go b/cmd/porter/installations.go index 914af8643..eb322f031 100644 --- a/cmd/porter/installations.go +++ b/cmd/porter/installations.go @@ -139,6 +139,9 @@ You can use the show command to create the initial file: "Force the bundle to be executed when no changes are detected.") f.BoolVar(&opts.DryRun, "dry-run", false, "Evaluate if the bundle would be executed based on the changes in the file.") + f.StringVarP(&opts.RawFormat, "output", "o", "plaintext", + "Specify an output format. Allowed values: plaintext, json, yaml") + return &cmd } diff --git a/docs/content/cli/installations_apply.md b/docs/content/cli/installations_apply.md index 8ef9bea1c..e883380d7 100644 --- a/docs/content/cli/installations_apply.md +++ b/docs/content/cli/installations_apply.md @@ -37,6 +37,7 @@ porter installations apply FILE [flags] --force Force the bundle to be executed when no changes are detected. -h, --help help for apply -n, --namespace string Namespace in which the installation is defined. Defaults to the namespace defined in the file. + -o, --output string Specify an output format. Allowed values: plaintext, json, yaml (default "plaintext") ``` ### Options inherited from parent commands diff --git a/go.mod b/go.mod index a9524b5f3..d3c4273d3 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/spf13/viper v1.14.0 github.com/stretchr/testify v1.8.1 github.com/xeipuuv/gojsonschema v1.2.0 + github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869 go.mongodb.org/mongo-driver v1.11.1 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.37.0 go.opentelemetry.io/otel v1.12.0 diff --git a/go.sum b/go.sum index 4d42b6a1c..432fe4211 100644 --- a/go.sum +++ b/go.sum @@ -875,6 +875,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/magefile.go b/magefile.go index 89edd64de..44fcc53cb 100644 --- a/magefile.go +++ b/magefile.go @@ -72,6 +72,10 @@ func BuildPorter() { mgx.Must(releases.BuildAll(PKG, "porter", "bin")) } +func RestartRegistry() { + mgx.Must(docker.RestartDockerRegistry()) +} + func copySchema() { // Copy the porter manifest schema into our templates directory with the other schema // We can't use symbolic links because that doesn't work on windows diff --git a/pkg/cnab/bundle.go b/pkg/cnab/bundle_reference.go similarity index 100% rename from pkg/cnab/bundle.go rename to pkg/cnab/bundle_reference.go diff --git a/pkg/cnab/bundle_test.go b/pkg/cnab/bundle_reference_test.go similarity index 100% rename from pkg/cnab/bundle_test.go rename to pkg/cnab/bundle_reference_test.go diff --git a/pkg/cnab/config-adapter/adapter.go b/pkg/cnab/config-adapter/adapter.go index a77999f63..c5e665c06 100644 --- a/pkg/cnab/config-adapter/adapter.go +++ b/pkg/cnab/config-adapter/adapter.go @@ -7,7 +7,8 @@ import ( "strings" "get.porter.sh/porter/pkg/cnab" - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" + depsv2ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" "get.porter.sh/porter/pkg/config" "get.porter.sh/porter/pkg/experimental" "get.porter.sh/porter/pkg/manifest" @@ -414,33 +415,33 @@ func (c *ManifestConverter) generateDependencies() (interface{}, string, error) // Check if they are using v1 of the dependencies spec or v2 if c.config.IsFeatureEnabled(experimental.FlagDependenciesV2) { - panic("the dependencies-v2 experimental flag was specified but is not yet implemented") + // Ok we are using v2! + deps, err := c.generateDependenciesV2() + return deps, cnab.DependenciesV2ExtensionKey, err } - deps, err := c.generateDependenciesV1() - if err != nil { - return nil, "", err - } + // Default to using v1 of deps + deps := c.generateDependenciesV1() return deps, cnab.DependenciesV1ExtensionKey, nil } -func (c *ManifestConverter) generateDependenciesV1() (*depsv1.Dependencies, error) { +func (c *ManifestConverter) generateDependenciesV1() *depsv1ext.Dependencies { if len(c.Manifest.Dependencies.Requires) == 0 { - return nil, nil + return nil } - deps := &depsv1.Dependencies{ + deps := &depsv1ext.Dependencies{ Sequence: make([]string, 0, len(c.Manifest.Dependencies.Requires)), - Requires: make(map[string]depsv1.Dependency, len(c.Manifest.Dependencies.Requires)), + Requires: make(map[string]depsv1ext.Dependency, len(c.Manifest.Dependencies.Requires)), } for _, dep := range c.Manifest.Dependencies.Requires { - dependencyRef := depsv1.Dependency{ + dependencyRef := depsv1ext.Dependency{ Name: dep.Name, Bundle: dep.Bundle.Reference, } if len(dep.Bundle.Version) > 0 { - dependencyRef.Version = &depsv1.DependencyVersion{ + dependencyRef.Version = &depsv1ext.DependencyVersion{ Ranges: []string{dep.Bundle.Version}, } @@ -454,6 +455,72 @@ func (c *ManifestConverter) generateDependenciesV1() (*depsv1.Dependencies, erro deps.Requires[dep.Name] = dependencyRef } + return deps +} + +func (c *ManifestConverter) generateDependenciesV2() (*depsv2ext.Dependencies, error) { + deps := &depsv2ext.Dependencies{ + Requires: make(map[string]depsv2ext.Dependency, len(c.Manifest.Dependencies.Requires)), + } + + for _, dep := range c.Manifest.Dependencies.Requires { + dependencyRef := depsv2ext.Dependency{ + Name: dep.Name, + Bundle: dep.Bundle.Reference, + Version: dep.Bundle.Version, + } + + if dep.Bundle.Interface != nil { + if dep.Bundle.Interface.Reference != "" { + dependencyRef.Interface.Reference = dep.Bundle.Interface.Reference + } + // Porter doesn't let you embed a random bundle.json document into your porter.yaml + // While the CNAB spec lets the document be anything, we constrain the interface to a porter representation of the bundle's parameters, credentials and outputs. + if dep.Bundle.Interface.Document != nil { + // TODO(PEP003): Convert the parameters, credentials and outputs defined on manifest.BundleInterfaceDocument and create an (incomplete) bundle.json from it + // See https://github.com/getporter/porter/issues/2548 + panic("conversion of an embedded bundle interface document for a dependency is not implemented") + } + } + + if dep.Installation != nil { + dependencyRef.Installation = &depsv2ext.DependencyInstallation{ + Labels: dep.Installation.Labels, + } + if dep.Installation.Criteria != nil { + dependencyRef.Installation.Criteria = &depsv2ext.InstallationCriteria{ + MatchInterface: dep.Installation.Criteria.MatchInterface, + MatchNamespace: dep.Installation.Criteria.MatchNamespace, + IgnoreLabels: dep.Installation.Criteria.IgnoreLabels, + } + } + } + + if len(dep.Parameters) > 0 { + dependencyRef.Parameters = make(map[string]depsv2ext.DependencySource, len(dep.Parameters)) + for param, source := range dep.Parameters { + ds, err := depsv2ext.ParseDependencySource(source) + if err != nil { + return nil, fmt.Errorf("invalid parameter wiring specified for dependency %s: %w", dep.Name, err) + } + dependencyRef.Parameters[param] = ds + } + } + + if len(dep.Credentials) > 0 { + dependencyRef.Credentials = make(map[string]depsv2ext.DependencySource, len(dep.Credentials)) + for cred, source := range dep.Credentials { + ds, err := depsv2ext.ParseDependencySource(source) + if err != nil { + return nil, fmt.Errorf("invalid credential wiring specified for dependency %s: %w", dep.Name, err) + } + dependencyRef.Credentials[cred] = ds + } + } + + deps.Requires[dep.Name] = dependencyRef + } + return deps, nil } @@ -643,6 +710,8 @@ func (c *ManifestConverter) generateRequiredExtensions(b cnab.ExtendedBundle) [] // Add the appropriate dependencies key if applicable if b.HasDependenciesV1() { requiredExtensions = append(requiredExtensions, cnab.DependenciesV1ExtensionKey) + } else if b.HasDependenciesV2() { + requiredExtensions = append(requiredExtensions, cnab.DependenciesV2ExtensionKey) } // Add the appropriate parameter sources key if applicable diff --git a/pkg/cnab/config-adapter/adapter_test.go b/pkg/cnab/config-adapter/adapter_test.go index 60f71eea1..7c783f0c6 100644 --- a/pkg/cnab/config-adapter/adapter_test.go +++ b/pkg/cnab/config-adapter/adapter_test.go @@ -8,8 +8,9 @@ import ( "testing" "get.porter.sh/porter/pkg/cnab" - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" "get.porter.sh/porter/pkg/config" + "get.porter.sh/porter/pkg/experimental" "get.porter.sh/porter/pkg/manifest" "get.porter.sh/porter/pkg/mixin" "get.porter.sh/porter/pkg/pkgmgmt" @@ -22,27 +23,53 @@ import ( func TestManifestConverter(t *testing.T) { t.Parallel() - c := config.NewTestConfig(t) - c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) + testcases := []struct { + name string + configHandler func(c *config.Config) + manifestPath string + goldenFile string + }{ + {name: "depsv1", + configHandler: func(c *config.Config) {}, + manifestPath: "tests/testdata/mybuns/porter.yaml", + goldenFile: "testdata/mybuns-depsv1.bundle.json"}, + {name: "depsv2", + configHandler: func(c *config.Config) { + c.SetExperimentalFlags(experimental.FlagDependenciesV2) + }, + manifestPath: "tests/testdata/mybuns/porter.yaml", + goldenFile: "testdata/mybuns-depsv2.bundle.json"}, + } - ctx := context.Background() - m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) - require.NoError(t, err, "could not load manifest") + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - installedMixins := []mixin.Metadata{ - {Name: "exec", VersionInfo: pkgmgmt.VersionInfo{Version: "v1.2.3"}}, - } + c := config.NewTestConfig(t) + tc.configHandler(c.Config) + c.TestContext.AddTestFileFromRoot(tc.manifestPath, config.Name) + + ctx := context.Background() + m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) + require.NoError(t, err, "could not load manifest") - a := NewManifestConverter(c.Config, m, nil, installedMixins) + installedMixins := []mixin.Metadata{ + {Name: "exec", VersionInfo: pkgmgmt.VersionInfo{Version: "v1.2.3"}}, + } - bun, err := a.ToBundle(ctx) - require.NoError(t, err, "ToBundle failed") + a := NewManifestConverter(c.Config, m, nil, installedMixins) + + bun, err := a.ToBundle(ctx) + require.NoError(t, err, "ToBundle failed") - // Compare the regular json, not the canonical, because that's hard to diff - prepBundleForDiff(&bun.Bundle) - bunD, err := json.MarshalIndent(bun, "", " ") - require.NoError(t, err) - c.TestContext.CompareGoldenFile("testdata/mybuns.bundle.json", string(bunD)) + // Compare the regular json, not the canonical, because that's hard to diff + prepBundleForDiff(&bun.Bundle) + bunD, err := json.MarshalIndent(bun, "", " ") + require.NoError(t, err) + c.TestContext.CompareGoldenFile(tc.goldenFile, string(bunD)) + }) + } } func prepBundleForDiff(b *bundle.Bundle) { @@ -59,7 +86,7 @@ func TestManifestConverter_ToBundle(t *testing.T) { t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -71,9 +98,9 @@ func TestManifestConverter_ToBundle(t *testing.T) { require.NoError(t, err, "ToBundle failed") assert.Equal(t, cnab.BundleSchemaVersion(), bun.SchemaVersion) - assert.Equal(t, "porter-hello", bun.Name) - assert.Equal(t, "0.1.0", bun.Version) - assert.Equal(t, "An example Porter configuration", bun.Description) + assert.Equal(t, "mybuns", bun.Name) + assert.Equal(t, "0.1.2", bun.Version) + assert.Equal(t, "A very thorough test bundle", bun.Description) stamp, err := LoadStamp(bun) require.NoError(t, err, "could not load porter's stamp") @@ -86,15 +113,16 @@ func TestManifestConverter_ToBundle(t *testing.T) { assert.Contains(t, bun.Definitions, "porter-debug-parameter", "porter-debug definition was not defined") assert.True(t, bun.HasDependenciesV1(), "DependenciesV1 was not populated") + assert.Contains(t, bun.RequiredExtensions, "io.cnab.dependencies") - assert.Len(t, bun.Outputs, 1, "expected one output for the bundle state") + assert.NotEmpty(t, bun.Outputs, "expected multiple outputs generated") } func TestManifestConverter_generateBundleCredentials(t *testing.T) { t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -107,14 +135,14 @@ func TestManifestConverter_generateBundleCredentials(t *testing.T) { assert.Contains(t, bun.Credentials, "username", "credential 'username' was not populated") username := bun.Credentials["username"] - assert.Equal(t, "Name of the database user", username.Description, "credential.Description was not populated") + assert.Equal(t, "The name you want on the audit log", username.Description, "credential.Description was not populated") assert.False(t, username.Required, "credential.Required was not populated correctly") assert.Equal(t, "ROOT_USERNAME", username.EnvironmentVariable, "credential.EnvironmentVariable was not populated") assert.Contains(t, bun.Credentials, "password", "credential 'password' was not populated") password := bun.Credentials["password"] assert.True(t, password.Required, "credential.Required was not populated correctly") - assert.Equal(t, []string{"uninstall"}, password.ApplyTo, "credential.ApplyTo was not populated") + assert.Equal(t, []string{"boom"}, password.ApplyTo, "credential.ApplyTo was not populated") assert.Equal(t, "/tmp/password", password.Path, "credential.Path was not populated") } @@ -533,29 +561,29 @@ func TestManifestConverter_generateBundleOutputs(t *testing.T) { require.Equal(t, wantDefinitions, defs) } -func TestManifestConverter_generateDependencies(t *testing.T) { +func TestManifestConverter_generateDependenciesv1(t *testing.T) { t.Parallel() testcases := []struct { name string - wantDep depsv1.Dependency + wantDep depsv1ext.Dependency }{ - {"no-version", depsv1.Dependency{ + {"no-version", depsv1ext.Dependency{ Name: "mysql", Bundle: "getporter/azure-mysql:5.7", }}, - {"no-ranges, uses prerelease", depsv1.Dependency{ + {"no-ranges, uses prerelease", depsv1ext.Dependency{ Name: "ad", Bundle: "getporter/azure-active-directory", - Version: &depsv1.DependencyVersion{ + Version: &depsv1ext.DependencyVersion{ AllowPrereleases: true, Ranges: []string{"1.0.0-0"}, }, }}, - {"with-ranges", depsv1.Dependency{ + {"with-ranges", depsv1ext.Dependency{ Name: "storage", Bundle: "getporter/azure-blob-storage", - Version: &depsv1.DependencyVersion{ + Version: &depsv1ext.DependencyVersion{ Ranges: []string{ "1.x - 2,2.1 - 3.x", }, @@ -568,7 +596,6 @@ func TestManifestConverter_generateDependencies(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - c := config.NewTestConfig(t) c.TestContext.AddTestFile("testdata/porter-with-deps.yaml", config.Name) @@ -581,12 +608,12 @@ func TestManifestConverter_generateDependencies(t *testing.T) { depsExt, depsExtKey, err := a.generateDependencies() require.NoError(t, err) require.Equal(t, cnab.DependenciesV1ExtensionKey, depsExtKey, "expected the v1 dependencies extension key") - require.IsType(t, &depsv1.Dependencies{}, depsExt, "expected a v1 dependencies extension section") - deps := depsExt.(*depsv1.Dependencies) + require.IsType(t, &depsv1ext.Dependencies{}, depsExt, "expected a v1 dependencies extension section") + deps := depsExt.(*depsv1ext.Dependencies) require.Len(t, deps.Requires, 3, "incorrect number of dependencies were generated") require.Equal(t, []string{"mysql", "ad", "storage"}, deps.Sequence, "incorrect sequence was generated") - var dep *depsv1.Dependency + var dep *depsv1ext.Dependency for _, d := range deps.Requires { if d.Bundle == tc.wantDep.Bundle { dep = &d @@ -600,28 +627,77 @@ func TestManifestConverter_generateDependencies(t *testing.T) { } } -func TestManifestConverter_generateRequiredExtensions_Dependencies(t *testing.T) { +func TestManifestConverter_generateDependenciesv2(t *testing.T) { t.Parallel() - c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter-with-deps.yaml", config.Name) + testcases := []struct { + name string + wantDep depsv1ext.Dependency + }{ + {"no-version", depsv1ext.Dependency{ + Name: "mysql", + Bundle: "getporter/azure-mysql:5.7", + }}, + {"no-ranges, uses prerelease", depsv1ext.Dependency{ + Name: "ad", + Bundle: "getporter/azure-active-directory", + Version: &depsv1ext.DependencyVersion{ + AllowPrereleases: true, + Ranges: []string{"1.0.0-0"}, + }, + }}, + {"with-ranges", depsv1ext.Dependency{ + Name: "storage", + Bundle: "getporter/azure-blob-storage", + Version: &depsv1ext.DependencyVersion{ + Ranges: []string{ + "1.x - 2,2.1 - 3.x", + }, + }, + }}, + } - ctx := context.Background() - m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) - require.NoError(t, err, "could not load manifest") + for _, tc := range testcases { + tc := tc - a := NewManifestConverter(c.Config, m, nil, nil) + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + c := config.NewTestConfig(t) + c.TestContext.AddTestFile("testdata/porter-with-deps.yaml", config.Name) - bun, err := a.ToBundle(ctx) - require.NoError(t, err, "ToBundle failed") - assert.Contains(t, bun.RequiredExtensions, "io.cnab.dependencies") + ctx := context.Background() + m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) + require.NoError(t, err, "could not load manifest") + + a := NewManifestConverter(c.Config, m, nil, nil) + + depsExt, depsExtKey, err := a.generateDependencies() + require.NoError(t, err) + require.Equal(t, cnab.DependenciesV1ExtensionKey, depsExtKey, "expected the v1 dependencies extension key") + require.IsType(t, &depsv1ext.Dependencies{}, depsExt, "expected a v1 dependencies extension section") + deps := depsExt.(*depsv1ext.Dependencies) + require.Len(t, deps.Requires, 3, "incorrect number of dependencies were generated") + require.Equal(t, []string{"mysql", "ad", "storage"}, deps.Sequence, "incorrect sequence was generated") + + var dep *depsv1ext.Dependency + for _, d := range deps.Requires { + if d.Bundle == tc.wantDep.Bundle { + dep = &d + break + } + } + + require.NotNil(t, dep, "could not find bundle %s", tc.wantDep.Bundle) + assert.Equal(t, &tc.wantDep, dep) + }) + } } func TestManifestConverter_generateParameterSources(t *testing.T) { t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter-with-templating.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -638,8 +714,7 @@ func TestManifestConverter_generateParameterSources(t *testing.T) { want.SetParameterFromOutput("porter-msg-output", "msg") want.SetParameterFromOutput("tfstate", "tfstate") want.SetParameterFromOutput("porter-state", "porter-state") - want.SetParameterFromDependencyOutput("porter-mysql-mysql-password-dep-output", "mysql", "mysql-password") - want.SetParameterFromDependencyOutput("root-password", "mysql", "mysql-root-password") + want.SetParameterFromDependencyOutput("mysql-connstr", "db", "connstr") assert.Equal(t, want, sources) } @@ -648,7 +723,7 @@ func TestNewManifestConverter_generateOutputWiringParameter(t *testing.T) { t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter-with-templating.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -704,7 +779,7 @@ func TestNewManifestConverter_generateDependencyOutputWiringParameter(t *testing t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter-with-templating.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -729,7 +804,7 @@ func TestManifestConverter_generateRequiredExtensions_ParameterSources(t *testin t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter-with-templating.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -746,7 +821,7 @@ func TestManifestConverter_generateRequiredExtensions(t *testing.T) { t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter-with-required-extensions.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -757,7 +832,7 @@ func TestManifestConverter_generateRequiredExtensions(t *testing.T) { bun, err := a.ToBundle(ctx) require.NoError(t, err, "ToBundle failed") - expected := []string{"sh.porter.file-parameters", "io.cnab.parameter-sources", "requiredExtension1", "requiredExtension2"} + expected := []string{"sh.porter.file-parameters", "io.cnab.dependencies", "io.cnab.parameter-sources", "io.cnab.docker"} assert.Equal(t, expected, bun.RequiredExtensions) } @@ -785,7 +860,7 @@ func TestManifestConverter_GenerateCustomActionDefinitions(t *testing.T) { t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("testdata/porter-with-custom-action.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -794,19 +869,18 @@ func TestManifestConverter_GenerateCustomActionDefinitions(t *testing.T) { a := NewManifestConverter(c.Config, m, nil, nil) defs := a.generateCustomActionDefinitions() - require.Len(t, defs, 2, "expected 2 custom action definitions to be generated") + require.Len(t, defs, 3, "expected 3 custom action definitions to be generated") require.Contains(t, defs, "status") statusDef := defs["status"] - assert.Equal(t, "Prints out status of world", statusDef.Description) - assert.True(t, statusDef.Stateless, "expected the status custom action to be stateless") + assert.Equal(t, "Print the installation status", statusDef.Description) + assert.False(t, statusDef.Stateless, "expected the status custom action to not be stateless") assert.False(t, statusDef.Modifies, "expected the status custom action to not modify resources") - require.Contains(t, defs, "zombies") - zombieDef := defs["zombies"] - assert.Equal(t, "zombies", zombieDef.Description) - assert.False(t, zombieDef.Stateless, "expected the zombies custom action to default to not stateless") - assert.True(t, zombieDef.Modifies, "expected the zombies custom action to default to modifying resources") + require.Contains(t, defs, "boom") + boomDef := defs["boom"] + assert.False(t, boomDef.Stateless, "expected the dry-run custom action to default to not stateless") + assert.True(t, boomDef.Modifies, "expected the dry-run custom action to default to modifying resources") } func TestManifestConverter_generateDefaultAction(t *testing.T) { @@ -860,7 +934,7 @@ func TestManifestConverter_generateCustomMetadata(t *testing.T) { t.Parallel() c := config.NewTestConfig(t) - c.TestContext.AddTestFile("./testdata/porter-with-custom-metadata.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) @@ -870,7 +944,7 @@ func TestManifestConverter_generateCustomMetadata(t *testing.T) { bun, err := a.ToBundle(ctx) require.NoError(t, err, "ToBundle failed") - assert.Len(t, bun.Custom, 4) + assert.Len(t, bun.Custom, 6) f, err := os.CreateTemp("", "") require.NoError(t, err, "Failed to create bundle file") @@ -895,7 +969,7 @@ func TestManifestConverter_generatedMaintainers(t *testing.T) { } c := config.NewTestConfig(t) - c.TestContext.AddTestFile("./testdata/porter-with-maintainers.yaml", config.Name) + c.TestContext.AddTestFileFromRoot("tests/testdata/mybuns/porter.yaml", config.Name) ctx := context.Background() m, err := manifest.LoadManifestFrom(ctx, c.Config, config.Name) diff --git a/pkg/cnab/config-adapter/doc.go b/pkg/cnab/config-adapter/doc.go index fa52e028c..7da1f640a 100644 --- a/pkg/cnab/config-adapter/doc.go +++ b/pkg/cnab/config-adapter/doc.go @@ -1 +1,2 @@ +// Package configadapter converts a Porter manifest (porter.yaml) to a CNAB bundle.json package configadapter diff --git a/pkg/cnab/config-adapter/helpers.go b/pkg/cnab/config-adapter/helpers.go index 318257f96..750df0909 100644 --- a/pkg/cnab/config-adapter/helpers.go +++ b/pkg/cnab/config-adapter/helpers.go @@ -2,12 +2,24 @@ package configadapter import ( "context" + "testing" + + "github.com/stretchr/testify/require" "get.porter.sh/porter/pkg/cnab" "get.porter.sh/porter/pkg/config" "get.porter.sh/porter/pkg/manifest" ) +func LoadTestBundle(t *testing.T, config *config.Config, path string) cnab.ExtendedBundle { + ctx := context.Background() + m, err := manifest.ReadManifest(config.Context, path) + require.NoError(t, err) + b, err := ConvertToTestBundle(ctx, config, m) + require.NoError(t, err) + return b +} + // ConvertToTestBundle is suitable for taking a test manifest (porter.yaml) // and making a bundle.json for it. Does not make an accurate representation // of the bundle, but is suitable for testing. diff --git a/pkg/cnab/config-adapter/testdata/mybuns-depsv1.bundle.json b/pkg/cnab/config-adapter/testdata/mybuns-depsv1.bundle.json new file mode 100644 index 000000000..07140dad3 --- /dev/null +++ b/pkg/cnab/config-adapter/testdata/mybuns-depsv1.bundle.json @@ -0,0 +1,471 @@ +{ + "schemaVersion": "1.2.0", + "name": "mybuns", + "version": "0.1.2", + "description": "A very thorough test bundle", + "maintainers": [ + { + "name": "John Doe", + "email": "john.doe@example.com", + "url": "https://example.com/a" + }, + { + "name": "Jane Doe", + "url": "https://example.com/b" + }, + { + "name": "Janine Doe", + "email": "janine.doe@example.com" + }, + { + "name": "", + "email": "mike.doe@example.com", + "url": "https://example.com/c" + } + ], + "invocationImages": [ + { + "imageType": "docker", + "image": "localhost:5000/mybuns:porter-332dd75c541511a27fc332bdcd049d5b" + } + ], + "images": { + "whalesayd": { + "imageType": "docker", + "image": "carolynvs/whalesayd:latest", + "description": "Whalesay as a service" + } + }, + "actions": { + "boom": { + "modifies": true, + "description": "boom" + }, + "dry-run": { + "stateless": true, + "description": "Make sure it will work before you run it" + }, + "status": { + "description": "Print the installation status" + } + }, + "parameters": { + "aboolean": { + "definition": "aboolean-parameter", + "destination": { + "env": "ABOOLEAN" + } + }, + "afile": { + "definition": "afile-parameter", + "destination": { + "path": "/home/nonroot/.kube/config" + } + }, + "ainteger": { + "definition": "ainteger-parameter", + "destination": { + "env": "AINTEGER" + } + }, + "anumber": { + "definition": "anumber-parameter", + "destination": { + "env": "ANUMBER" + } + }, + "astring": { + "definition": "astring-parameter", + "destination": { + "env": "ASTRING" + } + }, + "astringenum": { + "definition": "astringenum-parameter", + "destination": { + "env": "ASTRINGENUM" + } + }, + "cfg": { + "definition": "cfg-parameter", + "description": "A json config file", + "destination": { + "path": "/cnab/app/buncfg.json" + } + }, + "chaos_monkey": { + "definition": "chaos_monkey-parameter", + "description": "Set to true to make the bundle fail", + "destination": { + "env": "CHAOS_MONKEY" + } + }, + "installonly": { + "definition": "installonly-parameter", + "applyTo": [ + "install" + ], + "destination": { + "env": "INSTALLONLY" + } + }, + "jsonobject": { + "definition": "jsonobject-parameter", + "destination": { + "env": "JSONOBJECT" + } + }, + "log_level": { + "definition": "log_level-parameter", + "description": "How unhelpful would you like the logs to be?", + "destination": { + "env": "LOG_LEVEL" + } + }, + "mysql-connstr": { + "definition": "mysql-connstr-parameter", + "destination": { + "env": "MYSQL_CONNSTR" + } + }, + "notype-file": { + "definition": "notype-file-parameter", + "destination": { + "path": "/cnab/app/config.toml" + } + }, + "notype-string": { + "definition": "notype-string-parameter", + "destination": { + "env": "NOTYPE_STRING" + } + }, + "password": { + "definition": "password-parameter", + "description": "The super secret data", + "destination": { + "env": "PASSWORD" + } + }, + "porter-debug": { + "definition": "porter-debug-parameter", + "description": "Print debug information from Porter when executing the bundle", + "destination": { + "env": "PORTER_DEBUG" + } + }, + "porter-msg-output": { + "definition": "porter-msg-output", + "description": "Wires up the msg output for use as a parameter. Porter internal parameter that should not be set manually.", + "destination": { + "env": "PORTER_MSG_OUTPUT" + } + }, + "porter-state": { + "definition": "porter-state", + "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", + "destination": { + "path": "/porter/state.tgz" + } + }, + "sensitive": { + "definition": "sensitive-parameter", + "destination": { + "env": "SENSITIVE" + } + }, + "tfstate": { + "definition": "tfstate-parameter", + "applyTo": [ + "upgrade", + "uninstall" + ], + "destination": { + "path": "/cnab/app/tfstate" + }, + "required": true + } + }, + "credentials": { + "password": { + "path": "/tmp/password", + "required": true, + "applyTo": [ + "boom" + ] + }, + "username": { + "env": "ROOT_USERNAME", + "description": "The name you want on the audit log" + } + }, + "outputs": { + "connStr": { + "definition": "connStr-output", + "applyTo": [ + "install" + ], + "path": "/cnab/app/outputs/connStr" + }, + "msg": { + "definition": "msg-output", + "applyTo": [ + "install", + "upgrade", + "uninstall" + ], + "path": "/cnab/app/outputs/msg" + }, + "mylogs": { + "definition": "mylogs-output", + "applyTo": [ + "install", + "upgrade" + ], + "path": "/cnab/app/outputs/mylogs" + }, + "porter-state": { + "definition": "porter-state", + "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", + "path": "/cnab/app/outputs/porter-state" + }, + "result": { + "definition": "result-output", + "applyTo": [ + "install", + "upgrade" + ], + "path": "/cnab/app/outputs/result" + }, + "tfstate": { + "definition": "tfstate-output", + "applyTo": [ + "install", + "upgrade", + "uninstall" + ], + "path": "/cnab/app/outputs/tfstate" + } + }, + "definitions": { + "aboolean-parameter": { + "default": true, + "type": "boolean" + }, + "afile-parameter": { + "contentEncoding": "base64", + "default": "", + "type": "string" + }, + "ainteger-parameter": { + "default": 1, + "maximum": 10, + "minimum": 0, + "type": "integer" + }, + "anumber-parameter": { + "default": 0.5, + "exclusiveMaximum": 1, + "exclusiveMinimum": 0, + "type": "number" + }, + "astring-parameter": { + "default": "boop!", + "maxLength": 10, + "minLength": 1, + "type": "string" + }, + "astringenum-parameter": { + "default": "blue", + "enum": [ + "blue", + "red", + "purple", + "pink" + ], + "type": "string" + }, + "cfg-parameter": { + "contentEncoding": "base64", + "default": "", + "description": "A json config file", + "type": "string" + }, + "chaos_monkey-parameter": { + "default": false, + "description": "Set to true to make the bundle fail", + "type": "boolean" + }, + "connStr-output": { + "$id": "getporter.org/interfaces/mysql.connection-string", + "default": "", + "type": "string" + }, + "installonly-parameter": { + "default": false, + "type": "boolean" + }, + "jsonobject-parameter": { + "default": "\"myobject\": { \"foo\": \"true\", \"bar\": [ 1, 2, 3 ] }", + "type": "string" + }, + "log_level-parameter": { + "default": 5, + "description": "How unhelpful would you like the logs to be?", + "maximum": 11, + "minimum": 1, + "type": "integer" + }, + "msg-output": { + "default": "", + "type": "string" + }, + "mylogs-output": { + "type": "string" + }, + "mysql-connstr-parameter": { + "default": "", + "type": "string" + }, + "notype-file-parameter": { + "contentEncoding": "base64", + "default": "", + "type": "string" + }, + "notype-string-parameter": { + "default": "", + "type": "string" + }, + "password-parameter": { + "default": "default-secret", + "description": "The super secret data", + "type": "string", + "writeOnly": true + }, + "porter-debug-parameter": { + "$comment": "porter-internal", + "$id": "https://getporter.org/generated-bundle/#porter-debug", + "default": false, + "description": "Print debug information from Porter when executing the bundle", + "type": "boolean" + }, + "porter-msg-output": { + "$comment": "porter-internal", + "$id": "https://getporter.org/generated-bundle/#porter-parameter-source-definition", + "default": "", + "type": "string" + }, + "porter-state": { + "$comment": "porter-internal", + "$id": "https://getporter.org/generated-bundle/#porter-state", + "contentEncoding": "base64", + "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", + "type": "string" + }, + "result-output": { + "type": "string", + "writeOnly": true + }, + "sensitive-parameter": { + "default": "passw0rd123", + "type": "string", + "writeOnly": true + }, + "tfstate-output": { + "contentEncoding": "base64", + "type": "string" + }, + "tfstate-parameter": { + "contentEncoding": "base64", + "type": "string" + } + }, + "requiredExtensions": [ + "sh.porter.file-parameters", + "io.cnab.dependencies", + "io.cnab.parameter-sources", + "io.cnab.docker" + ], + "custom": { + "foo": { + "test1": true, + "test2": 1, + "test3": "value", + "test4": [ + "one", + "two", + "three" + ], + "test5": { + "1": "one", + "two": "two" + } + }, + "io.cnab.dependencies": { + "sequence": [ + "db" + ], + "requires": { + "db": { + "bundle": "localhost:5000/mydb:v0.1.0" + } + } + }, + "io.cnab.docker": null, + "io.cnab.parameter-sources": { + "mysql-connstr": { + "priority": [ + "dependencies.output" + ], + "sources": { + "dependencies.output": { + "dependency": "db", + "name": "connstr" + } + } + }, + "porter-msg-output": { + "priority": [ + "output" + ], + "sources": { + "output": { + "name": "msg" + } + } + }, + "porter-state": { + "priority": [ + "output" + ], + "sources": { + "output": { + "name": "porter-state" + } + } + }, + "tfstate": { + "priority": [ + "output" + ], + "sources": { + "output": { + "name": "tfstate" + } + } + } + }, + "sh.porter": { + "manifestDigest": "", + "mixins": { + "exec": { + "version": "v1.2.3" + } + }, + "manifest": "IyBUaGlzIGlzIGEgdGVzdCBidW5kbGUgdGhhdCBtYWtlcyBubyBsb2dpY2FsIHNlbnNlLCBidXQgaXQgZG9lcyBleGVyY2lzZSBsb3RzIG9mIGRpZmZlcmVudCBidW5kbGUgZmVhdHVyZXMKCnNjaGVtYVZlcnNpb246IDEuMC4wCm5hbWU6IG15YnVucwp2ZXJzaW9uOiAwLjEuMgpkZXNjcmlwdGlvbjogIkEgdmVyeSB0aG9yb3VnaCB0ZXN0IGJ1bmRsZSIKcmVnaXN0cnk6IGxvY2FsaG9zdDo1MDAwCmRvY2tlcmZpbGU6IERvY2tlcmZpbGUudG1wbAoKbWFpbnRhaW5lcnM6Ci0gbmFtZTogIkpvaG4gRG9lIgogIGVtYWlsOiAiam9obi5kb2VAZXhhbXBsZS5jb20iCiAgdXJsOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9hIgotIG5hbWU6ICJKYW5lIERvZSIKICB1cmw6ICJodHRwczovL2V4YW1wbGUuY29tL2IiCi0gbmFtZTogIkphbmluZSBEb2UiCiAgZW1haWw6ICJqYW5pbmUuZG9lQGV4YW1wbGUuY29tIgotIGVtYWlsOiAibWlrZS5kb2VAZXhhbXBsZS5jb20iCiAgdXJsOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9jIgoKY3VzdG9tOgogIGZvbzoKICAgIHRlc3QxOiB0cnVlCiAgICB0ZXN0MjogMQogICAgdGVzdDM6IHZhbHVlCiAgICB0ZXN0NDoKICAgICAgLSBvbmUKICAgICAgLSB0d28KICAgICAgLSB0aHJlZQogICAgdGVzdDU6CiAgICAgIDE6IG9uZQogICAgICB0d286IHR3bwoKcmVxdWlyZWQ6CiAgLSBkb2NrZXIKCmNyZWRlbnRpYWxzOgogIC0gbmFtZTogdXNlcm5hbWUKICAgIGRlc2NyaXB0aW9uOiAiVGhlIG5hbWUgeW91IHdhbnQgb24gdGhlIGF1ZGl0IGxvZyIKICAgIGVudjogUk9PVF9VU0VSTkFNRQogICAgcmVxdWlyZWQ6IGZhbHNlCiAgLSBuYW1lOiBwYXNzd29yZAogICAgcGF0aDogL3RtcC9wYXNzd29yZAogICAgYXBwbHlUbzoKICAgICAgLSBib29tCgpwYXJhbWV0ZXJzOgogIC0gbmFtZTogbG9nX2xldmVsCiAgICBkZXNjcmlwdGlvbjogIkhvdyB1bmhlbHBmdWwgd291bGQgeW91IGxpa2UgdGhlIGxvZ3MgdG8gYmU/IgogICAgdHlwZTogaW50ZWdlcgogICAgbWluaW11bTogMQogICAgbWF4aW11bTogMTEKICAgIGRlZmF1bHQ6IDUKICAtIG5hbWU6IHBhc3N3b3JkCiAgICBkZXNjcmlwdGlvbjogIlRoZSBzdXBlciBzZWNyZXQgZGF0YSIKICAgIHR5cGU6IHN0cmluZwogICAgZGVmYXVsdDogImRlZmF1bHQtc2VjcmV0IgogICAgc2Vuc2l0aXZlOiB0cnVlCiAgLSBuYW1lOiBteXNxbC1jb25uc3RyCiAgICB0eXBlOiBzdHJpbmcKICAgIGRlZmF1bHQ6ICIiICMgU2V0dGluZyBhIGRlZmF1bHQgc28gdGhhdCB3ZSBhdm9pZCBodHRwczovL2dpdGh1Yi5jb20vZ2V0cG9ydGVyL3BvcnRlci9pc3N1ZXMvMjU2MQogICAgc291cmNlOgogICAgICBkZXBlbmRlbmN5OiBkYgogICAgICBvdXRwdXQ6IGNvbm5zdHIKICAtIG5hbWU6IGNoYW9zX21vbmtleQogICAgZGVzY3JpcHRpb246ICJTZXQgdG8gdHJ1ZSB0byBtYWtlIHRoZSBidW5kbGUgZmFpbCIKICAgIHR5cGU6IGJvb2xlYW4KICAgIGRlZmF1bHQ6IGZhbHNlCiAgLSBuYW1lOiB0ZnN0YXRlCiAgICB0eXBlOiBmaWxlCiAgICBwYXRoOiAvY25hYi9hcHAvdGZzdGF0ZQogICAgc291cmNlOgogICAgICBvdXRwdXQ6IHRmc3RhdGUKICAgIGFwcGx5VG86CiAgICAgIC0gdXBncmFkZQogICAgICAtIHVuaW5zdGFsbAogIC0gbmFtZTogY2ZnCiAgICBkZXNjcmlwdGlvbjogIkEganNvbiBjb25maWcgZmlsZSIKICAgIHR5cGU6IGZpbGUKICAgIGRlZmF1bHQ6ICcnCiAgICBwYXRoOiBidW5jZmcuanNvbgogIC0gbmFtZTogYWludGVnZXIKICAgIHR5cGU6IGludGVnZXIKICAgIGRlZmF1bHQ6IDEKICAgIG1pbmltdW06IDAKICAgIG1heGltdW06IDEwCiAgLSBuYW1lOiBhbnVtYmVyCiAgICB0eXBlOiBudW1iZXIKICAgIGRlZmF1bHQ6IDAuNSAjIFRoaXMgaXMgYSByZWdyZXNzaW9uIHRlc3QgdGhhdCB3ZSBjYW4gYm90aCBidWlsZCBhbmQgcHVzaCBhIGJ1bmRsZSB0aGF0IHVzZXMgbnVtZXJpYyB0eXBlcwogICAgZXhjbHVzaXZlTWluaW11bTogMAogICAgZXhjbHVzaXZlTWF4aW11bTogMQogIC0gbmFtZTogYXN0cmluZ2VudW0KICAgIHR5cGU6IHN0cmluZwogICAgZGVmYXVsdDogYmx1ZQogICAgZW51bToKICAgICAgLSBibHVlCiAgICAgIC0gcmVkCiAgICAgIC0gcHVycGxlCiAgICAgIC0gcGluawogIC0gbmFtZTogYXN0cmluZwogICAgdHlwZTogc3RyaW5nCiAgICBtaW5MZW5ndGg6IDEKICAgIG1heExlbmd0aDogMTAKICAgIGRlZmF1bHQ6ICdib29wIScKICAtIG5hbWU6IGFib29sZWFuCiAgICB0eXBlOiBib29sZWFuCiAgICBkZWZhdWx0OiB0cnVlCiAgLSBuYW1lOiBpbnN0YWxsb25seQogICAgdHlwZTogYm9vbGVhbgogICAgZGVmYXVsdDogZmFsc2UKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogIC0gbmFtZTogc2Vuc2l0aXZlCiAgICB0eXBlOiBzdHJpbmcKICAgIHNlbnNpdGl2ZTogdHJ1ZQogICAgZGVmYXVsdDogInBhc3N3MHJkMTIzIgogIC0gbmFtZToganNvbm9iamVjdAogICAgdHlwZTogc3RyaW5nCiAgICBkZWZhdWx0OiAnIm15b2JqZWN0IjogewogICAgICAgICJmb28iOiAidHJ1ZSIsCiAgICAgICAgImJhciI6IFsKICAgICAgICAgIDEsCiAgICAgICAgICAyLAogICAgICAgICAgMwogICAgICAgIF0KICAgICAgfScKICAtIG5hbWU6IGFmaWxlCiAgICB0eXBlOiBmaWxlCiAgICBkZWZhdWx0OiAnJwogICAgcGF0aDogL2hvbWUvbm9ucm9vdC8ua3ViZS9jb25maWcKICAtIG5hbWU6IG5vdHlwZS1maWxlCiAgICBkZWZhdWx0OiAnJwogICAgcGF0aDogL2NuYWIvYXBwL2NvbmZpZy50b21sCiAgLSBuYW1lOiBub3R5cGUtc3RyaW5nCiAgICBkZWZhdWx0OiAnJwoKb3V0cHV0czoKICAtIG5hbWU6IG1zZwogICAgdHlwZTogc3RyaW5nCiAgICBkZWZhdWx0OiAiIgogICAgYXBwbHlUbzoKICAgICAgLSBpbnN0YWxsCiAgICAgIC0gdXBncmFkZQogICAgICAtIHVuaW5zdGFsbAogIC0gbmFtZTogY29ublN0cgogICAgJGlkOiAiZ2V0cG9ydGVyLm9yZy9pbnRlcmZhY2VzL215c3FsLmNvbm5lY3Rpb24tc3RyaW5nIgogICAgZGVmYXVsdDogIiIKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogIC0gbmFtZTogbXlsb2dzCiAgICBhcHBseVRvOgogICAgICAtIGluc3RhbGwKICAgICAgLSB1cGdyYWRlCiAgLSBuYW1lOiByZXN1bHQKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogICAgICAtIHVwZ3JhZGUKICAgIHNlbnNpdGl2ZTogdHJ1ZQogIC0gbmFtZTogdGZzdGF0ZQogICAgdHlwZTogZmlsZQogICAgcGF0aDogL2NuYWIvYXBwL3Rmc3RhdGUKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogICAgICAtIHVwZ3JhZGUKICAgICAgLSB1bmluc3RhbGwKCnN0YXRlOgogIC0gbmFtZTogbWFnaWNfZmlsZQogICAgcGF0aDogbWFnaWMudHh0CgpkZXBlbmRlbmNpZXM6CiAgcmVxdWlyZXM6CiAgICAtIG5hbWU6IGRiCiAgICAgIGJ1bmRsZToKICAgICAgICByZWZlcmVuY2U6ICJsb2NhbGhvc3Q6NTAwMC9teWRiOnYwLjEuMCIKICAgICAgICAjIFRPRE8oUEVQMDAzKTogSW1wbGVtZW50IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9nZXRwb3J0ZXIvcG9ydGVyL2lzc3Vlcy8yNTQ4CiAgICAgICAgI2ludGVyZmFjZToKICAgICAgICAjICBkb2N1bWVudDoKICAgICAgICAjICAgIG91dHB1dHM6CiAgICAgICAgIyAgICAgIC0gbmFtZTogY29ubnN0cgogICAgICAgICMgICAgICAgICRpZDogImdldHBvcnRlci5vcmcvaW50ZXJmYWNlcy9teXNxbC5jb25uZWN0aW9uLXN0cmluZyIKICAgICAgcGFyYW1ldGVyczoKICAgICAgICBkYXRhYmFzZTogYmlnZGIKICAgICAgICBjb2xsYXRpb246ICR7YnVuZGxlLnBhcmFtZXRlcnMuZGItY29sbGF0aW9ufQogICAgICBjcmVkZW50aWFsczoKICAgICAgICB1c2VybmFtZTogJHtidW5kbGUuY3JlZGVudGlhbHMudXNlcm5hbWV9CgppbWFnZXM6CiAgd2hhbGVzYXlkOgogICAgZGVzY3JpcHRpb246ICJXaGFsZXNheSBhcyBhIHNlcnZpY2UiCiAgICBpbWFnZVR5cGU6ICJkb2NrZXIiCiAgICByZXBvc2l0b3J5OiBjYXJvbHludnMvd2hhbGVzYXlkCiAgICB0YWc6ICJsYXRlc3QiCgptaXhpbnM6CiAgLSBleGVjCiAgLSB0ZXN0bWl4aW46CiAgICAgIGNsaWVudFZlcnNpb246IDEuMi4zCgpjdXN0b21BY3Rpb25zOgogIGRyeS1ydW46CiAgICBkZXNjcmlwdGlvbjogIk1ha2Ugc3VyZSBpdCB3aWxsIHdvcmsgYmVmb3JlIHlvdSBydW4gaXQiCiAgICBzdGF0ZWxlc3M6IHRydWUKICAgIG1vZGlmaWVzOiBmYWxzZQogIHN0YXR1czoKICAgIGRlc2NyaXB0aW9uOiAiUHJpbnQgdGhlIGluc3RhbGxhdGlvbiBzdGF0dXMiCiAgICBzdGF0ZWxlc3M6IGZhbHNlCiAgICBtb2RpZmllczogZmFsc2UKCmluc3RhbGw6CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkNoZWNrIHRoZSBkb2NrZXIgc29ja2V0IgogICAgICBjb21tYW5kOiBzdGF0CiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIC92YXIvcnVuL2RvY2tlci5zb2NrCiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkxldCdzIG1ha2Ugc29tZSBtYWdpYyIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIG1ha2VNYWdpYwogICAgICAgIC0gIiR7IGJ1bmRsZS5jcmVkZW50aWFscy51c2VybmFtZSB9IGlzIGEgdW5pY29ybiB3aXRoICR7IGJ1bmRsZS5wYXJhbWV0ZXJzLnBhc3N3b3JkIH0gc2VjcmV0LiIKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAiaW5zdGFsbCIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIGluc3RhbGwKICAgICAgb3V0cHV0czoKICAgICAgICAtIG5hbWU6IG15bG9ncwogICAgICAgICAgcmVnZXg6ICIoLiopIgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJyb2xsIHRoZSBkaWNlIHdpdGggeW91ciBjaGFvcyBtb25rZXkiCiAgICAgIGNvbW1hbmQ6IC4vaGVscGVycy5zaAogICAgICBhcmd1bWVudHM6CiAgICAgICAgLSBjaGFvc19tb25rZXkKICAgICAgICAtICR7IGJ1bmRsZS5wYXJhbWV0ZXJzLmNoYW9zX21vbmtleSB9CiAgICAgIG91dHB1dHM6CiAgICAgICAgLSBuYW1lOiByZXN1bHQKICAgICAgICAgIHJlZ2V4OiAiKC4qKSIKCmRyeS1ydW46CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkNoZWNrIHNvbWUgdGhpbmdzIgogICAgICBjb21tYW5kOiBlY2hvCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtICJBbGwgY2xlYXIhIgoKc3RhdHVzOgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJQcmludCBjb25maWciCiAgICAgIGNvbW1hbmQ6IGNhdAogICAgICBhcmd1bWVudHM6CiAgICAgICAgLSAkeyBidW5kbGUucGFyYW1ldGVycy5jZmcgfQogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJQcmludCBtYWdpYyIKICAgICAgY29tbWFuZDogY2F0CiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIG1hZ2ljLnR4dAoKYm9vbToKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAibW9kaWZ5IHRoZSBidW5kbGUgaW4gdW5rbm93YWJsZSB3YXlzIgogICAgICBjb21tYW5kOiBlY2hvCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtICJZT0xPIgoKdXBncmFkZToKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAiRW5zdXJlIG1hZ2ljIgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gZW5zdXJlTWFnaWMKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAidXBncmFkZSIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIHVwZ3JhZGUKICAgICAgICAtICR7IGJ1bmRsZS5vdXRwdXRzLm1zZyB9CiAgICAgIG91dHB1dHM6CiAgICAgICAgLSBuYW1lOiBteWxvZ3MKICAgICAgICAgIHJlZ2V4OiAiKC4qKSIKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAicm9sbCB0aGUgZGljZSB3aXRoIHlvdXIgY2hhb3MgbW9ua2V5IgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gY2hhb3NfbW9ua2V5CiAgICAgICAgLSAkeyBidW5kbGUucGFyYW1ldGVycy5jaGFvc19tb25rZXkgfQogICAgICBvdXRwdXRzOgogICAgICAgIC0gbmFtZTogcmVzdWx0CiAgICAgICAgICByZWdleDogIiguKikiCgp1bmluc3RhbGw6CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkVuc3VyZSBNYWdpYyIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIGVuc3VyZU1hZ2ljCiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogInVuaW5zdGFsbCIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIHVuaW5zdGFsbAogICAgICAgIC0gJHsgYnVuZGxlLm91dHB1dHMubXNnIH0KICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAicm9sbCB0aGUgZGljZSB3aXRoIHlvdXIgY2hhb3MgbW9ua2V5IgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gY2hhb3NfbW9ua2V5CiAgICAgICAgLSAkeyBidW5kbGUucGFyYW1ldGVycy5jaGFvc19tb25rZXkgfQo=", + "version": "", + "commit": "" + }, + "sh.porter.file-parameters": {} + } +} \ No newline at end of file diff --git a/pkg/cnab/config-adapter/testdata/mybuns-depsv2.bundle.json b/pkg/cnab/config-adapter/testdata/mybuns-depsv2.bundle.json new file mode 100644 index 000000000..9b293e59f --- /dev/null +++ b/pkg/cnab/config-adapter/testdata/mybuns-depsv2.bundle.json @@ -0,0 +1,481 @@ +{ + "schemaVersion": "1.2.0", + "name": "mybuns", + "version": "0.1.2", + "description": "A very thorough test bundle", + "maintainers": [ + { + "name": "John Doe", + "email": "john.doe@example.com", + "url": "https://example.com/a" + }, + { + "name": "Jane Doe", + "url": "https://example.com/b" + }, + { + "name": "Janine Doe", + "email": "janine.doe@example.com" + }, + { + "name": "", + "email": "mike.doe@example.com", + "url": "https://example.com/c" + } + ], + "invocationImages": [ + { + "imageType": "docker", + "image": "localhost:5000/mybuns:porter-332dd75c541511a27fc332bdcd049d5b" + } + ], + "images": { + "whalesayd": { + "imageType": "docker", + "image": "carolynvs/whalesayd:latest", + "description": "Whalesay as a service" + } + }, + "actions": { + "boom": { + "modifies": true, + "description": "boom" + }, + "dry-run": { + "stateless": true, + "description": "Make sure it will work before you run it" + }, + "status": { + "description": "Print the installation status" + } + }, + "parameters": { + "aboolean": { + "definition": "aboolean-parameter", + "destination": { + "env": "ABOOLEAN" + } + }, + "afile": { + "definition": "afile-parameter", + "destination": { + "path": "/home/nonroot/.kube/config" + } + }, + "ainteger": { + "definition": "ainteger-parameter", + "destination": { + "env": "AINTEGER" + } + }, + "anumber": { + "definition": "anumber-parameter", + "destination": { + "env": "ANUMBER" + } + }, + "astring": { + "definition": "astring-parameter", + "destination": { + "env": "ASTRING" + } + }, + "astringenum": { + "definition": "astringenum-parameter", + "destination": { + "env": "ASTRINGENUM" + } + }, + "cfg": { + "definition": "cfg-parameter", + "description": "A json config file", + "destination": { + "path": "/cnab/app/buncfg.json" + } + }, + "chaos_monkey": { + "definition": "chaos_monkey-parameter", + "description": "Set to true to make the bundle fail", + "destination": { + "env": "CHAOS_MONKEY" + } + }, + "installonly": { + "definition": "installonly-parameter", + "applyTo": [ + "install" + ], + "destination": { + "env": "INSTALLONLY" + } + }, + "jsonobject": { + "definition": "jsonobject-parameter", + "destination": { + "env": "JSONOBJECT" + } + }, + "log_level": { + "definition": "log_level-parameter", + "description": "How unhelpful would you like the logs to be?", + "destination": { + "env": "LOG_LEVEL" + } + }, + "mysql-connstr": { + "definition": "mysql-connstr-parameter", + "destination": { + "env": "MYSQL_CONNSTR" + } + }, + "notype-file": { + "definition": "notype-file-parameter", + "destination": { + "path": "/cnab/app/config.toml" + } + }, + "notype-string": { + "definition": "notype-string-parameter", + "destination": { + "env": "NOTYPE_STRING" + } + }, + "password": { + "definition": "password-parameter", + "description": "The super secret data", + "destination": { + "env": "PASSWORD" + } + }, + "porter-debug": { + "definition": "porter-debug-parameter", + "description": "Print debug information from Porter when executing the bundle", + "destination": { + "env": "PORTER_DEBUG" + } + }, + "porter-msg-output": { + "definition": "porter-msg-output", + "description": "Wires up the msg output for use as a parameter. Porter internal parameter that should not be set manually.", + "destination": { + "env": "PORTER_MSG_OUTPUT" + } + }, + "porter-state": { + "definition": "porter-state", + "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", + "destination": { + "path": "/porter/state.tgz" + } + }, + "sensitive": { + "definition": "sensitive-parameter", + "destination": { + "env": "SENSITIVE" + } + }, + "tfstate": { + "definition": "tfstate-parameter", + "applyTo": [ + "upgrade", + "uninstall" + ], + "destination": { + "path": "/cnab/app/tfstate" + }, + "required": true + } + }, + "credentials": { + "password": { + "path": "/tmp/password", + "required": true, + "applyTo": [ + "boom" + ] + }, + "username": { + "env": "ROOT_USERNAME", + "description": "The name you want on the audit log" + } + }, + "outputs": { + "connStr": { + "definition": "connStr-output", + "applyTo": [ + "install" + ], + "path": "/cnab/app/outputs/connStr" + }, + "msg": { + "definition": "msg-output", + "applyTo": [ + "install", + "upgrade", + "uninstall" + ], + "path": "/cnab/app/outputs/msg" + }, + "mylogs": { + "definition": "mylogs-output", + "applyTo": [ + "install", + "upgrade" + ], + "path": "/cnab/app/outputs/mylogs" + }, + "porter-state": { + "definition": "porter-state", + "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", + "path": "/cnab/app/outputs/porter-state" + }, + "result": { + "definition": "result-output", + "applyTo": [ + "install", + "upgrade" + ], + "path": "/cnab/app/outputs/result" + }, + "tfstate": { + "definition": "tfstate-output", + "applyTo": [ + "install", + "upgrade", + "uninstall" + ], + "path": "/cnab/app/outputs/tfstate" + } + }, + "definitions": { + "aboolean-parameter": { + "default": true, + "type": "boolean" + }, + "afile-parameter": { + "contentEncoding": "base64", + "default": "", + "type": "string" + }, + "ainteger-parameter": { + "default": 1, + "maximum": 10, + "minimum": 0, + "type": "integer" + }, + "anumber-parameter": { + "default": 0.5, + "exclusiveMaximum": 1, + "exclusiveMinimum": 0, + "type": "number" + }, + "astring-parameter": { + "default": "boop!", + "maxLength": 10, + "minLength": 1, + "type": "string" + }, + "astringenum-parameter": { + "default": "blue", + "enum": [ + "blue", + "red", + "purple", + "pink" + ], + "type": "string" + }, + "cfg-parameter": { + "contentEncoding": "base64", + "default": "", + "description": "A json config file", + "type": "string" + }, + "chaos_monkey-parameter": { + "default": false, + "description": "Set to true to make the bundle fail", + "type": "boolean" + }, + "connStr-output": { + "$id": "getporter.org/interfaces/mysql.connection-string", + "default": "", + "type": "string" + }, + "installonly-parameter": { + "default": false, + "type": "boolean" + }, + "jsonobject-parameter": { + "default": "\"myobject\": { \"foo\": \"true\", \"bar\": [ 1, 2, 3 ] }", + "type": "string" + }, + "log_level-parameter": { + "default": 5, + "description": "How unhelpful would you like the logs to be?", + "maximum": 11, + "minimum": 1, + "type": "integer" + }, + "msg-output": { + "default": "", + "type": "string" + }, + "mylogs-output": { + "type": "string" + }, + "mysql-connstr-parameter": { + "default": "", + "type": "string" + }, + "notype-file-parameter": { + "contentEncoding": "base64", + "default": "", + "type": "string" + }, + "notype-string-parameter": { + "default": "", + "type": "string" + }, + "password-parameter": { + "default": "default-secret", + "description": "The super secret data", + "type": "string", + "writeOnly": true + }, + "porter-debug-parameter": { + "$comment": "porter-internal", + "$id": "https://getporter.org/generated-bundle/#porter-debug", + "default": false, + "description": "Print debug information from Porter when executing the bundle", + "type": "boolean" + }, + "porter-msg-output": { + "$comment": "porter-internal", + "$id": "https://getporter.org/generated-bundle/#porter-parameter-source-definition", + "default": "", + "type": "string" + }, + "porter-state": { + "$comment": "porter-internal", + "$id": "https://getporter.org/generated-bundle/#porter-state", + "contentEncoding": "base64", + "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", + "type": "string" + }, + "result-output": { + "type": "string", + "writeOnly": true + }, + "sensitive-parameter": { + "default": "passw0rd123", + "type": "string", + "writeOnly": true + }, + "tfstate-output": { + "contentEncoding": "base64", + "type": "string" + }, + "tfstate-parameter": { + "contentEncoding": "base64", + "type": "string" + } + }, + "requiredExtensions": [ + "sh.porter.file-parameters", + "sh.porter.dependencies.v2", + "io.cnab.parameter-sources", + "io.cnab.docker" + ], + "custom": { + "foo": { + "test1": true, + "test2": 1, + "test3": "value", + "test4": [ + "one", + "two", + "three" + ], + "test5": { + "1": "one", + "two": "two" + } + }, + "io.cnab.docker": null, + "io.cnab.parameter-sources": { + "mysql-connstr": { + "priority": [ + "dependencies.output" + ], + "sources": { + "dependencies.output": { + "dependency": "db", + "name": "connstr" + } + } + }, + "porter-msg-output": { + "priority": [ + "output" + ], + "sources": { + "output": { + "name": "msg" + } + } + }, + "porter-state": { + "priority": [ + "output" + ], + "sources": { + "output": { + "name": "porter-state" + } + } + }, + "tfstate": { + "priority": [ + "output" + ], + "sources": { + "output": { + "name": "tfstate" + } + } + } + }, + "sh.porter": { + "manifestDigest": "", + "mixins": { + "exec": { + "version": "v1.2.3" + } + }, + "manifest": "IyBUaGlzIGlzIGEgdGVzdCBidW5kbGUgdGhhdCBtYWtlcyBubyBsb2dpY2FsIHNlbnNlLCBidXQgaXQgZG9lcyBleGVyY2lzZSBsb3RzIG9mIGRpZmZlcmVudCBidW5kbGUgZmVhdHVyZXMKCnNjaGVtYVZlcnNpb246IDEuMC4wCm5hbWU6IG15YnVucwp2ZXJzaW9uOiAwLjEuMgpkZXNjcmlwdGlvbjogIkEgdmVyeSB0aG9yb3VnaCB0ZXN0IGJ1bmRsZSIKcmVnaXN0cnk6IGxvY2FsaG9zdDo1MDAwCmRvY2tlcmZpbGU6IERvY2tlcmZpbGUudG1wbAoKbWFpbnRhaW5lcnM6Ci0gbmFtZTogIkpvaG4gRG9lIgogIGVtYWlsOiAiam9obi5kb2VAZXhhbXBsZS5jb20iCiAgdXJsOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9hIgotIG5hbWU6ICJKYW5lIERvZSIKICB1cmw6ICJodHRwczovL2V4YW1wbGUuY29tL2IiCi0gbmFtZTogIkphbmluZSBEb2UiCiAgZW1haWw6ICJqYW5pbmUuZG9lQGV4YW1wbGUuY29tIgotIGVtYWlsOiAibWlrZS5kb2VAZXhhbXBsZS5jb20iCiAgdXJsOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9jIgoKY3VzdG9tOgogIGZvbzoKICAgIHRlc3QxOiB0cnVlCiAgICB0ZXN0MjogMQogICAgdGVzdDM6IHZhbHVlCiAgICB0ZXN0NDoKICAgICAgLSBvbmUKICAgICAgLSB0d28KICAgICAgLSB0aHJlZQogICAgdGVzdDU6CiAgICAgIDE6IG9uZQogICAgICB0d286IHR3bwoKcmVxdWlyZWQ6CiAgLSBkb2NrZXIKCmNyZWRlbnRpYWxzOgogIC0gbmFtZTogdXNlcm5hbWUKICAgIGRlc2NyaXB0aW9uOiAiVGhlIG5hbWUgeW91IHdhbnQgb24gdGhlIGF1ZGl0IGxvZyIKICAgIGVudjogUk9PVF9VU0VSTkFNRQogICAgcmVxdWlyZWQ6IGZhbHNlCiAgLSBuYW1lOiBwYXNzd29yZAogICAgcGF0aDogL3RtcC9wYXNzd29yZAogICAgYXBwbHlUbzoKICAgICAgLSBib29tCgpwYXJhbWV0ZXJzOgogIC0gbmFtZTogbG9nX2xldmVsCiAgICBkZXNjcmlwdGlvbjogIkhvdyB1bmhlbHBmdWwgd291bGQgeW91IGxpa2UgdGhlIGxvZ3MgdG8gYmU/IgogICAgdHlwZTogaW50ZWdlcgogICAgbWluaW11bTogMQogICAgbWF4aW11bTogMTEKICAgIGRlZmF1bHQ6IDUKICAtIG5hbWU6IHBhc3N3b3JkCiAgICBkZXNjcmlwdGlvbjogIlRoZSBzdXBlciBzZWNyZXQgZGF0YSIKICAgIHR5cGU6IHN0cmluZwogICAgZGVmYXVsdDogImRlZmF1bHQtc2VjcmV0IgogICAgc2Vuc2l0aXZlOiB0cnVlCiAgLSBuYW1lOiBteXNxbC1jb25uc3RyCiAgICB0eXBlOiBzdHJpbmcKICAgIGRlZmF1bHQ6ICIiICMgU2V0dGluZyBhIGRlZmF1bHQgc28gdGhhdCB3ZSBhdm9pZCBodHRwczovL2dpdGh1Yi5jb20vZ2V0cG9ydGVyL3BvcnRlci9pc3N1ZXMvMjU2MQogICAgc291cmNlOgogICAgICBkZXBlbmRlbmN5OiBkYgogICAgICBvdXRwdXQ6IGNvbm5zdHIKICAtIG5hbWU6IGNoYW9zX21vbmtleQogICAgZGVzY3JpcHRpb246ICJTZXQgdG8gdHJ1ZSB0byBtYWtlIHRoZSBidW5kbGUgZmFpbCIKICAgIHR5cGU6IGJvb2xlYW4KICAgIGRlZmF1bHQ6IGZhbHNlCiAgLSBuYW1lOiB0ZnN0YXRlCiAgICB0eXBlOiBmaWxlCiAgICBwYXRoOiAvY25hYi9hcHAvdGZzdGF0ZQogICAgc291cmNlOgogICAgICBvdXRwdXQ6IHRmc3RhdGUKICAgIGFwcGx5VG86CiAgICAgIC0gdXBncmFkZQogICAgICAtIHVuaW5zdGFsbAogIC0gbmFtZTogY2ZnCiAgICBkZXNjcmlwdGlvbjogIkEganNvbiBjb25maWcgZmlsZSIKICAgIHR5cGU6IGZpbGUKICAgIGRlZmF1bHQ6ICcnCiAgICBwYXRoOiBidW5jZmcuanNvbgogIC0gbmFtZTogYWludGVnZXIKICAgIHR5cGU6IGludGVnZXIKICAgIGRlZmF1bHQ6IDEKICAgIG1pbmltdW06IDAKICAgIG1heGltdW06IDEwCiAgLSBuYW1lOiBhbnVtYmVyCiAgICB0eXBlOiBudW1iZXIKICAgIGRlZmF1bHQ6IDAuNSAjIFRoaXMgaXMgYSByZWdyZXNzaW9uIHRlc3QgdGhhdCB3ZSBjYW4gYm90aCBidWlsZCBhbmQgcHVzaCBhIGJ1bmRsZSB0aGF0IHVzZXMgbnVtZXJpYyB0eXBlcwogICAgZXhjbHVzaXZlTWluaW11bTogMAogICAgZXhjbHVzaXZlTWF4aW11bTogMQogIC0gbmFtZTogYXN0cmluZ2VudW0KICAgIHR5cGU6IHN0cmluZwogICAgZGVmYXVsdDogYmx1ZQogICAgZW51bToKICAgICAgLSBibHVlCiAgICAgIC0gcmVkCiAgICAgIC0gcHVycGxlCiAgICAgIC0gcGluawogIC0gbmFtZTogYXN0cmluZwogICAgdHlwZTogc3RyaW5nCiAgICBtaW5MZW5ndGg6IDEKICAgIG1heExlbmd0aDogMTAKICAgIGRlZmF1bHQ6ICdib29wIScKICAtIG5hbWU6IGFib29sZWFuCiAgICB0eXBlOiBib29sZWFuCiAgICBkZWZhdWx0OiB0cnVlCiAgLSBuYW1lOiBpbnN0YWxsb25seQogICAgdHlwZTogYm9vbGVhbgogICAgZGVmYXVsdDogZmFsc2UKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogIC0gbmFtZTogc2Vuc2l0aXZlCiAgICB0eXBlOiBzdHJpbmcKICAgIHNlbnNpdGl2ZTogdHJ1ZQogICAgZGVmYXVsdDogInBhc3N3MHJkMTIzIgogIC0gbmFtZToganNvbm9iamVjdAogICAgdHlwZTogc3RyaW5nCiAgICBkZWZhdWx0OiAnIm15b2JqZWN0IjogewogICAgICAgICJmb28iOiAidHJ1ZSIsCiAgICAgICAgImJhciI6IFsKICAgICAgICAgIDEsCiAgICAgICAgICAyLAogICAgICAgICAgMwogICAgICAgIF0KICAgICAgfScKICAtIG5hbWU6IGFmaWxlCiAgICB0eXBlOiBmaWxlCiAgICBkZWZhdWx0OiAnJwogICAgcGF0aDogL2hvbWUvbm9ucm9vdC8ua3ViZS9jb25maWcKICAtIG5hbWU6IG5vdHlwZS1maWxlCiAgICBkZWZhdWx0OiAnJwogICAgcGF0aDogL2NuYWIvYXBwL2NvbmZpZy50b21sCiAgLSBuYW1lOiBub3R5cGUtc3RyaW5nCiAgICBkZWZhdWx0OiAnJwoKb3V0cHV0czoKICAtIG5hbWU6IG1zZwogICAgdHlwZTogc3RyaW5nCiAgICBkZWZhdWx0OiAiIgogICAgYXBwbHlUbzoKICAgICAgLSBpbnN0YWxsCiAgICAgIC0gdXBncmFkZQogICAgICAtIHVuaW5zdGFsbAogIC0gbmFtZTogY29ublN0cgogICAgJGlkOiAiZ2V0cG9ydGVyLm9yZy9pbnRlcmZhY2VzL215c3FsLmNvbm5lY3Rpb24tc3RyaW5nIgogICAgZGVmYXVsdDogIiIKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogIC0gbmFtZTogbXlsb2dzCiAgICBhcHBseVRvOgogICAgICAtIGluc3RhbGwKICAgICAgLSB1cGdyYWRlCiAgLSBuYW1lOiByZXN1bHQKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogICAgICAtIHVwZ3JhZGUKICAgIHNlbnNpdGl2ZTogdHJ1ZQogIC0gbmFtZTogdGZzdGF0ZQogICAgdHlwZTogZmlsZQogICAgcGF0aDogL2NuYWIvYXBwL3Rmc3RhdGUKICAgIGFwcGx5VG86CiAgICAgIC0gaW5zdGFsbAogICAgICAtIHVwZ3JhZGUKICAgICAgLSB1bmluc3RhbGwKCnN0YXRlOgogIC0gbmFtZTogbWFnaWNfZmlsZQogICAgcGF0aDogbWFnaWMudHh0CgpkZXBlbmRlbmNpZXM6CiAgcmVxdWlyZXM6CiAgICAtIG5hbWU6IGRiCiAgICAgIGJ1bmRsZToKICAgICAgICByZWZlcmVuY2U6ICJsb2NhbGhvc3Q6NTAwMC9teWRiOnYwLjEuMCIKICAgICAgICAjIFRPRE8oUEVQMDAzKTogSW1wbGVtZW50IGluIGh0dHBzOi8vZ2l0aHViLmNvbS9nZXRwb3J0ZXIvcG9ydGVyL2lzc3Vlcy8yNTQ4CiAgICAgICAgI2ludGVyZmFjZToKICAgICAgICAjICBkb2N1bWVudDoKICAgICAgICAjICAgIG91dHB1dHM6CiAgICAgICAgIyAgICAgIC0gbmFtZTogY29ubnN0cgogICAgICAgICMgICAgICAgICRpZDogImdldHBvcnRlci5vcmcvaW50ZXJmYWNlcy9teXNxbC5jb25uZWN0aW9uLXN0cmluZyIKICAgICAgcGFyYW1ldGVyczoKICAgICAgICBkYXRhYmFzZTogYmlnZGIKICAgICAgICBjb2xsYXRpb246ICR7YnVuZGxlLnBhcmFtZXRlcnMuZGItY29sbGF0aW9ufQogICAgICBjcmVkZW50aWFsczoKICAgICAgICB1c2VybmFtZTogJHtidW5kbGUuY3JlZGVudGlhbHMudXNlcm5hbWV9CgppbWFnZXM6CiAgd2hhbGVzYXlkOgogICAgZGVzY3JpcHRpb246ICJXaGFsZXNheSBhcyBhIHNlcnZpY2UiCiAgICBpbWFnZVR5cGU6ICJkb2NrZXIiCiAgICByZXBvc2l0b3J5OiBjYXJvbHludnMvd2hhbGVzYXlkCiAgICB0YWc6ICJsYXRlc3QiCgptaXhpbnM6CiAgLSBleGVjCiAgLSB0ZXN0bWl4aW46CiAgICAgIGNsaWVudFZlcnNpb246IDEuMi4zCgpjdXN0b21BY3Rpb25zOgogIGRyeS1ydW46CiAgICBkZXNjcmlwdGlvbjogIk1ha2Ugc3VyZSBpdCB3aWxsIHdvcmsgYmVmb3JlIHlvdSBydW4gaXQiCiAgICBzdGF0ZWxlc3M6IHRydWUKICAgIG1vZGlmaWVzOiBmYWxzZQogIHN0YXR1czoKICAgIGRlc2NyaXB0aW9uOiAiUHJpbnQgdGhlIGluc3RhbGxhdGlvbiBzdGF0dXMiCiAgICBzdGF0ZWxlc3M6IGZhbHNlCiAgICBtb2RpZmllczogZmFsc2UKCmluc3RhbGw6CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkNoZWNrIHRoZSBkb2NrZXIgc29ja2V0IgogICAgICBjb21tYW5kOiBzdGF0CiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIC92YXIvcnVuL2RvY2tlci5zb2NrCiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkxldCdzIG1ha2Ugc29tZSBtYWdpYyIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIG1ha2VNYWdpYwogICAgICAgIC0gIiR7IGJ1bmRsZS5jcmVkZW50aWFscy51c2VybmFtZSB9IGlzIGEgdW5pY29ybiB3aXRoICR7IGJ1bmRsZS5wYXJhbWV0ZXJzLnBhc3N3b3JkIH0gc2VjcmV0LiIKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAiaW5zdGFsbCIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIGluc3RhbGwKICAgICAgb3V0cHV0czoKICAgICAgICAtIG5hbWU6IG15bG9ncwogICAgICAgICAgcmVnZXg6ICIoLiopIgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJyb2xsIHRoZSBkaWNlIHdpdGggeW91ciBjaGFvcyBtb25rZXkiCiAgICAgIGNvbW1hbmQ6IC4vaGVscGVycy5zaAogICAgICBhcmd1bWVudHM6CiAgICAgICAgLSBjaGFvc19tb25rZXkKICAgICAgICAtICR7IGJ1bmRsZS5wYXJhbWV0ZXJzLmNoYW9zX21vbmtleSB9CiAgICAgIG91dHB1dHM6CiAgICAgICAgLSBuYW1lOiByZXN1bHQKICAgICAgICAgIHJlZ2V4OiAiKC4qKSIKCmRyeS1ydW46CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkNoZWNrIHNvbWUgdGhpbmdzIgogICAgICBjb21tYW5kOiBlY2hvCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtICJBbGwgY2xlYXIhIgoKc3RhdHVzOgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJQcmludCBjb25maWciCiAgICAgIGNvbW1hbmQ6IGNhdAogICAgICBhcmd1bWVudHM6CiAgICAgICAgLSAkeyBidW5kbGUucGFyYW1ldGVycy5jZmcgfQogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJQcmludCBtYWdpYyIKICAgICAgY29tbWFuZDogY2F0CiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIG1hZ2ljLnR4dAoKYm9vbToKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAibW9kaWZ5IHRoZSBidW5kbGUgaW4gdW5rbm93YWJsZSB3YXlzIgogICAgICBjb21tYW5kOiBlY2hvCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtICJZT0xPIgoKdXBncmFkZToKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAiRW5zdXJlIG1hZ2ljIgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gZW5zdXJlTWFnaWMKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAidXBncmFkZSIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIHVwZ3JhZGUKICAgICAgICAtICR7IGJ1bmRsZS5vdXRwdXRzLm1zZyB9CiAgICAgIG91dHB1dHM6CiAgICAgICAgLSBuYW1lOiBteWxvZ3MKICAgICAgICAgIHJlZ2V4OiAiKC4qKSIKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAicm9sbCB0aGUgZGljZSB3aXRoIHlvdXIgY2hhb3MgbW9ua2V5IgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gY2hhb3NfbW9ua2V5CiAgICAgICAgLSAkeyBidW5kbGUucGFyYW1ldGVycy5jaGFvc19tb25rZXkgfQogICAgICBvdXRwdXRzOgogICAgICAgIC0gbmFtZTogcmVzdWx0CiAgICAgICAgICByZWdleDogIiguKikiCgp1bmluc3RhbGw6CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIkVuc3VyZSBNYWdpYyIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIGVuc3VyZU1hZ2ljCiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogInVuaW5zdGFsbCIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIHVuaW5zdGFsbAogICAgICAgIC0gJHsgYnVuZGxlLm91dHB1dHMubXNnIH0KICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAicm9sbCB0aGUgZGljZSB3aXRoIHlvdXIgY2hhb3MgbW9ua2V5IgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gY2hhb3NfbW9ua2V5CiAgICAgICAgLSAkeyBidW5kbGUucGFyYW1ldGVycy5jaGFvc19tb25rZXkgfQo=", + "version": "", + "commit": "" + }, + "sh.porter.dependencies.v2": { + "requires": { + "db": { + "bundle": "localhost:5000/mydb:v0.1.0", + "parameters": { + "collation": { + "parameter": "db-collation" + }, + "database": { + "value": "bigdb" + } + }, + "credentials": { + "username": { + "credential": "username" + } + } + } + } + }, + "sh.porter.file-parameters": {} + } +} \ No newline at end of file diff --git a/pkg/cnab/config-adapter/testdata/mybuns.bundle.json b/pkg/cnab/config-adapter/testdata/mybuns.bundle.json deleted file mode 100644 index 69a068e46..000000000 --- a/pkg/cnab/config-adapter/testdata/mybuns.bundle.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "schemaVersion": "1.2.0", - "name": "mybuns", - "version": "0.1.2", - "description": "A very thorough test bundle", - "invocationImages": [ - { - "imageType": "docker", - "image": "localhost:5000/mybuns:porter-332dd75c541511a27fc332bdcd049d5b" - } - ], - "images": { - "whalesayd": { - "imageType": "docker", - "image": "carolynvs/whalesayd:latest", - "description": "Whalesay as a service" - } - }, - "actions": { - "boom": { - "modifies": true, - "description": "boom" - }, - "dry-run": { - "stateless": true, - "description": "Make sure it will work before you run it" - }, - "status": { - "description": "Print the installation status" - } - }, - "parameters": { - "anumber": { - "definition": "anumber-parameter", - "destination": { - "env": "ANUMBER" - } - }, - "cfg": { - "definition": "cfg-parameter", - "description": "A json config file", - "destination": { - "path": "/cnab/app/buncfg.json" - } - }, - "chaos_monkey": { - "definition": "chaos_monkey-parameter", - "description": "Set to true to make the bundle fail", - "destination": { - "env": "CHAOS_MONKEY" - } - }, - "log_level": { - "definition": "log_level-parameter", - "description": "How unhelpful would you like the logs to be?", - "destination": { - "env": "LOG_LEVEL" - } - }, - "password": { - "definition": "password-parameter", - "description": "The super secret data", - "destination": { - "env": "PASSWORD" - } - }, - "porter-debug": { - "definition": "porter-debug-parameter", - "description": "Print debug information from Porter when executing the bundle", - "destination": { - "env": "PORTER_DEBUG" - } - }, - "porter-state": { - "definition": "porter-state", - "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", - "destination": { - "path": "/porter/state.tgz" - } - } - }, - "credentials": { - "username": { - "env": "USERNAME", - "description": "The name you want on the audit log", - "required": true - } - }, - "outputs": { - "mylogs": { - "definition": "mylogs-output", - "applyTo": [ - "install", - "upgrade" - ], - "path": "/cnab/app/outputs/mylogs" - }, - "porter-state": { - "definition": "porter-state", - "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", - "path": "/cnab/app/outputs/porter-state" - }, - "result": { - "definition": "result-output", - "applyTo": [ - "install", - "upgrade" - ], - "path": "/cnab/app/outputs/result" - } - }, - "definitions": { - "anumber-parameter": { - "default": 0.5, - "exclusiveMaximum": 1, - "exclusiveMinimum": 0, - "type": "number" - }, - "cfg-parameter": { - "contentEncoding": "base64", - "default": "", - "description": "A json config file", - "type": "string" - }, - "chaos_monkey-parameter": { - "default": false, - "description": "Set to true to make the bundle fail", - "type": "boolean" - }, - "log_level-parameter": { - "default": 5, - "description": "How unhelpful would you like the logs to be?", - "maximum": 11, - "minimum": 1, - "type": "integer" - }, - "mylogs-output": { - "type": "string" - }, - "password-parameter": { - "default": "default-secret", - "description": "The super secret data", - "type": "string", - "writeOnly": true - }, - "porter-debug-parameter": { - "$comment": "porter-internal", - "$id": "https://getporter.org/generated-bundle/#porter-debug", - "default": false, - "description": "Print debug information from Porter when executing the bundle", - "type": "boolean" - }, - "porter-state": { - "$comment": "porter-internal", - "$id": "https://getporter.org/generated-bundle/#porter-state", - "contentEncoding": "base64", - "description": "Supports persisting state for bundles. Porter internal parameter that should not be set manually.", - "type": "string" - }, - "result-output": { - "type": "string", - "writeOnly": true - } - }, - "requiredExtensions": [ - "sh.porter.file-parameters", - "io.cnab.dependencies", - "io.cnab.parameter-sources", - "io.cnab.docker" - ], - "custom": { - "io.cnab.dependencies": { - "sequence": [ - "db" - ], - "requires": { - "db": { - "name": "db", - "bundle": "localhost:5000/mydb:v0.1.0" - } - } - }, - "io.cnab.docker": null, - "io.cnab.parameter-sources": { - "porter-state": { - "priority": [ - "output" - ], - "sources": { - "output": { - "name": "porter-state" - } - } - } - }, - "sh.porter": { - "manifestDigest": "", - "mixins": { - "exec": { - "version": "v1.2.3" - } - }, - "manifest": "IyBUaGlzIGlzIGEgdGVzdCBidW5kbGUgdGhhdCBtYWtlcyBubyBsb2dpY2FsIHNlbnNlLCBidXQgaXQgZG9lcyBleGVyY2lzZSBsb3RzIG9mIGRpZmZlcmVudCBidW5kbGUgZmVhdHVyZXMKCnNjaGVtYVZlcnNpb246IDEuMC4wCm5hbWU6IG15YnVucwp2ZXJzaW9uOiAwLjEuMgpkZXNjcmlwdGlvbjogIkEgdmVyeSB0aG9yb3VnaCB0ZXN0IGJ1bmRsZSIKcmVnaXN0cnk6IGxvY2FsaG9zdDo1MDAwCmRvY2tlcmZpbGU6IERvY2tlcmZpbGUudG1wbAoKcmVxdWlyZWQ6CiAgLSBkb2NrZXIKCmNyZWRlbnRpYWxzOgogIC0gbmFtZTogdXNlcm5hbWUKICAgIGRlc2NyaXB0aW9uOiAiVGhlIG5hbWUgeW91IHdhbnQgb24gdGhlIGF1ZGl0IGxvZyIKICAgIGVudjogVVNFUk5BTUUKCnBhcmFtZXRlcnM6CiAgLSBuYW1lOiBsb2dfbGV2ZWwKICAgIGRlc2NyaXB0aW9uOiAiSG93IHVuaGVscGZ1bCB3b3VsZCB5b3UgbGlrZSB0aGUgbG9ncyB0byBiZT8iCiAgICB0eXBlOiBpbnRlZ2VyCiAgICBtaW5pbXVtOiAxCiAgICBtYXhpbXVtOiAxMQogICAgZGVmYXVsdDogNQogIC0gbmFtZTogcGFzc3dvcmQKICAgIGRlc2NyaXB0aW9uOiAiVGhlIHN1cGVyIHNlY3JldCBkYXRhIgogICAgdHlwZTogc3RyaW5nCiAgICBkZWZhdWx0OiAiZGVmYXVsdC1zZWNyZXQiCiAgICBzZW5zaXRpdmU6IHRydWUKICAtIG5hbWU6IGNoYW9zX21vbmtleQogICAgZGVzY3JpcHRpb246ICJTZXQgdG8gdHJ1ZSB0byBtYWtlIHRoZSBidW5kbGUgZmFpbCIKICAgIHR5cGU6IGJvb2xlYW4KICAgIGRlZmF1bHQ6IGZhbHNlCiAgLSBuYW1lOiBjZmcKICAgIGRlc2NyaXB0aW9uOiAiQSBqc29uIGNvbmZpZyBmaWxlIgogICAgdHlwZTogZmlsZQogICAgZGVmYXVsdDogJycKICAgIHBhdGg6IGJ1bmNmZy5qc29uCiAgLSBuYW1lOiBhbnVtYmVyCiAgICB0eXBlOiBudW1iZXIKICAgIGRlZmF1bHQ6IDAuNSAjIFRoaXMgaXMgYSByZWdyZXNzaW9uIHRlc3QgdGhhdCB3ZSBjYW4gYm90aCBidWlsZCBhbmQgcHVzaCBhIGJ1bmRsZSB0aGF0IHVzZXMgbnVtZXJpYyB0eXBlcwogICAgZXhjbHVzaXZlTWluaW11bTogMAogICAgZXhjbHVzaXZlTWF4aW11bTogMQoKb3V0cHV0czoKICAtIG5hbWU6IG15bG9ncwogICAgYXBwbHlUbzoKICAgICAgLSBpbnN0YWxsCiAgICAgIC0gdXBncmFkZQogIC0gbmFtZTogcmVzdWx0CiAgICBhcHBseVRvOgogICAgICAtIGluc3RhbGwKICAgICAgLSB1cGdyYWRlCiAgICBzZW5zaXRpdmU6IHRydWUKCnN0YXRlOgogIC0gbmFtZTogbWFnaWNfZmlsZQogICAgcGF0aDogbWFnaWMudHh0CgpkZXBlbmRlbmNpZXM6CiAgcmVxdWlyZXM6CiAgICAtIG5hbWU6IGRiCiAgICAgIGJ1bmRsZToKICAgICAgICByZWZlcmVuY2U6ICJsb2NhbGhvc3Q6NTAwMC9teWRiOnYwLjEuMCIKICAgICAgcGFyYW1ldGVyczoKICAgICAgICBkYXRhYmFzZTogYmlnZGIKCmltYWdlczoKICB3aGFsZXNheWQ6CiAgICBkZXNjcmlwdGlvbjogIldoYWxlc2F5IGFzIGEgc2VydmljZSIKICAgIGltYWdlVHlwZTogImRvY2tlciIKICAgIHJlcG9zaXRvcnk6IGNhcm9seW52cy93aGFsZXNheWQKICAgIHRhZzogImxhdGVzdCIKCm1peGluczoKICAtIGV4ZWMKICAtIHRlc3RtaXhpbjoKICAgICAgY2xpZW50VmVyc2lvbjogMS4yLjMKCmN1c3RvbUFjdGlvbnM6CiAgZHJ5LXJ1bjoKICAgIGRlc2NyaXB0aW9uOiAiTWFrZSBzdXJlIGl0IHdpbGwgd29yayBiZWZvcmUgeW91IHJ1biBpdCIKICAgIHN0YXRlbGVzczogdHJ1ZQogICAgbW9kaWZpZXM6IGZhbHNlCiAgc3RhdHVzOgogICAgZGVzY3JpcHRpb246ICJQcmludCB0aGUgaW5zdGFsbGF0aW9uIHN0YXR1cyIKICAgIHN0YXRlbGVzczogZmFsc2UKICAgIG1vZGlmaWVzOiBmYWxzZQoKaW5zdGFsbDoKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAiQ2hlY2sgdGhlIGRvY2tlciBzb2NrZXQiCiAgICAgIGNvbW1hbmQ6IHN0YXQKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gL3Zhci9ydW4vZG9ja2VyLnNvY2sKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAiTGV0J3MgbWFrZSBzb21lIG1hZ2ljIgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gbWFrZU1hZ2ljCiAgICAgICAgLSAiJHsgYnVuZGxlLmNyZWRlbnRpYWxzLnVzZXJuYW1lIH0gaXMgYSB1bmljb3JuIHdpdGggJHsgYnVuZGxlLnBhcmFtZXRlcnMucGFzc3dvcmQgfSBzZWNyZXQuIgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJpbnN0YWxsIgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gaW5zdGFsbAogICAgICBvdXRwdXRzOgogICAgICAgIC0gbmFtZTogbXlsb2dzCiAgICAgICAgICByZWdleDogIiguKikiCiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogInJvbGwgdGhlIGRpY2Ugd2l0aCB5b3VyIGNoYW9zIG1vbmtleSIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIGNoYW9zX21vbmtleQogICAgICAgIC0gJHsgYnVuZGxlLnBhcmFtZXRlcnMuY2hhb3NfbW9ua2V5IH0KICAgICAgb3V0cHV0czoKICAgICAgICAtIG5hbWU6IHJlc3VsdAogICAgICAgICAgcmVnZXg6ICIoLiopIgoKZHJ5LXJ1bjoKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAiQ2hlY2sgc29tZSB0aGluZ3MiCiAgICAgIGNvbW1hbmQ6IGVjaG8KICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gIkFsbCBjbGVhciEiCgpzdGF0dXM6CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIlByaW50IGNvbmZpZyIKICAgICAgY29tbWFuZDogY2F0CiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtICR7IGJ1bmRsZS5wYXJhbWV0ZXJzLmNmZyB9CiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogIlByaW50IG1hZ2ljIgogICAgICBjb21tYW5kOiBjYXQKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gbWFnaWMudHh0Cgpib29tOgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJtb2RpZnkgdGhlIGJ1bmRsZSBpbiB1bmtub3dhYmxlIHdheXMiCiAgICAgIGNvbW1hbmQ6IGVjaG8KICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gIllPTE8iCgp1cGdyYWRlOgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJFbnN1cmUgbWFnaWMiCiAgICAgIGNvbW1hbmQ6IC4vaGVscGVycy5zaAogICAgICBhcmd1bWVudHM6CiAgICAgICAgLSBlbnN1cmVNYWdpYwogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJ1cGdyYWRlIgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gdXBncmFkZQogICAgICBvdXRwdXRzOgogICAgICAgIC0gbmFtZTogbXlsb2dzCiAgICAgICAgICByZWdleDogIiguKikiCiAgLSBleGVjOgogICAgICBkZXNjcmlwdGlvbjogInJvbGwgdGhlIGRpY2Ugd2l0aCB5b3VyIGNoYW9zIG1vbmtleSIKICAgICAgY29tbWFuZDogLi9oZWxwZXJzLnNoCiAgICAgIGFyZ3VtZW50czoKICAgICAgICAtIGNoYW9zX21vbmtleQogICAgICAgIC0gJHsgYnVuZGxlLnBhcmFtZXRlcnMuY2hhb3NfbW9ua2V5IH0KICAgICAgb3V0cHV0czoKICAgICAgICAtIG5hbWU6IHJlc3VsdAogICAgICAgICAgcmVnZXg6ICIoLiopIgoKdW5pbnN0YWxsOgogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJFbnN1cmUgTWFnaWMiCiAgICAgIGNvbW1hbmQ6IC4vaGVscGVycy5zaAogICAgICBhcmd1bWVudHM6CiAgICAgICAgLSBlbnN1cmVNYWdpYwogIC0gZXhlYzoKICAgICAgZGVzY3JpcHRpb246ICJ1bmluc3RhbGwiCiAgICAgIGNvbW1hbmQ6IC4vaGVscGVycy5zaAogICAgICBhcmd1bWVudHM6CiAgICAgICAgLSB1bmluc3RhbGwKICAtIGV4ZWM6CiAgICAgIGRlc2NyaXB0aW9uOiAicm9sbCB0aGUgZGljZSB3aXRoIHlvdXIgY2hhb3MgbW9ua2V5IgogICAgICBjb21tYW5kOiAuL2hlbHBlcnMuc2gKICAgICAgYXJndW1lbnRzOgogICAgICAgIC0gY2hhb3NfbW9ua2V5CiAgICAgICAgLSAkeyBidW5kbGUucGFyYW1ldGVycy5jaGFvc19tb25rZXkgfQo=", - "version": "", - "commit": "" - }, - "sh.porter.file-parameters": {} - } -} \ No newline at end of file diff --git a/pkg/cnab/config-adapter/testdata/porter-with-custom-action.yaml b/pkg/cnab/config-adapter/testdata/porter-with-custom-action.yaml deleted file mode 100644 index fc4ed86dc..000000000 --- a/pkg/cnab/config-adapter/testdata/porter-with-custom-action.yaml +++ /dev/null @@ -1,49 +0,0 @@ -schemaVersion: 1.0.0-alpha.1 -name: porter-hello -version: 0.1.0 -description: "A bundle with a custom action" -registry: "localhost:5000" - -mixins: - - exec - -customActions: - status: - description: "Prints out status of world" - modifies: false - stateless: true - -install: - - exec: - description: "Install Hello World" - command: bash - flags: - c: echo Hello World - -upgrade: - - exec: - description: "World 2.0" - command: bash - flags: - c: echo World 2.0 - -status: - - exec: - description: "Get World Status" - command: bash - flags: - c: echo The world is on fire - -zombies: - - exec: - description: "Trigger zombie apocalypse" - command: bash - flags: - c: echo oh noes my brains - -uninstall: - - exec: - description: "Uninstall Hello World" - command: bash - flags: - c: echo Goodbye World diff --git a/pkg/cnab/config-adapter/testdata/porter-with-custom-metadata.yaml b/pkg/cnab/config-adapter/testdata/porter-with-custom-metadata.yaml deleted file mode 100644 index a4d7501a5..000000000 --- a/pkg/cnab/config-adapter/testdata/porter-with-custom-metadata.yaml +++ /dev/null @@ -1,30 +0,0 @@ -schemaVersion: 1.0.0-alpha.1 -name: mybundle -version: 0.1.0 -registry: example.com - -mixins: - - exec - -install: - - exec: - description: "Install Hello World" - command: bash - -uninstall: - - exec: - description: "Uninstall Hello World" - command: bash - -custom: - foo: - test1: true - test2: 1 - test3: value - test4: - - one - - two - - three - test5: - 1: one - two: two \ No newline at end of file diff --git a/pkg/cnab/config-adapter/testdata/porter-with-maintainers.yaml b/pkg/cnab/config-adapter/testdata/porter-with-maintainers.yaml deleted file mode 100644 index d4cffbf95..000000000 --- a/pkg/cnab/config-adapter/testdata/porter-with-maintainers.yaml +++ /dev/null @@ -1,56 +0,0 @@ -schemaVersion: 1.0.0-alpha.1 -name: porter-hello -description: "An example Porter configuration" -version: 0.1.0 -registry: "localhost:5000" - -maintainers: -- name: "John Doe" - email: "john.doe@example.com" - url: "https://example.com/a" -- name: "Jane Doe" - url: "https://example.com/b" -- name: "Janine Doe" - email: "janine.doe@example.com" -- email: "mike.doe@example.com" - url: "https://example.com/c" - -credentials: - - name: username - description: Name of the database user - required: false - env: ROOT_USERNAME - - name: password - path: /tmp/password - applyTo: - - uninstall - -dependencies: - requires: - - name: mysql - bundle: - reference: "getporter/azure-mysql:5.7" - -mixins: -- exec - -install: -- exec: - description: "Say Hello" - command: bash - flags: - c: echo Hello World - -status: -- exec: - description: "Get World Status" - command: bash - flags: - c: echo The world is on fire - -uninstall: -- exec: - description: "Say Goodbye" - command: bash - flags: - c: echo Goodbye World diff --git a/pkg/cnab/config-adapter/testdata/porter-with-templating.yaml b/pkg/cnab/config-adapter/testdata/porter-with-templating.yaml deleted file mode 100644 index 255f2746c..000000000 --- a/pkg/cnab/config-adapter/testdata/porter-with-templating.yaml +++ /dev/null @@ -1,69 +0,0 @@ -schemaVersion: 1.0.0 -name: hello-with-templating -version: 0.1.0 -registry: "localhost:5000" - -mixins: - - exec - -dependencies: - requires: - - name: mysql - bundle: - reference: getporter/mysql:v0.1.3 - -parameters: - - name: tfstate - type: file - path: /cnab/app/tfstate - source: - output: tfstate - - name: root-password - type: string - source: - dependency: mysql - output: mysql-root-password - -outputs: - - name: msg - type: string - default: "" - - name: tfstate - type: file - path: /cnab/app/outputs/tfstate - -install: - - exec: - description: "Say Hello" - command: ./helpers.sh - arguments: - - install - - ${ bundle.dependencies.mysql.outputs.mysql-password } - outputs: - - name: name - regex: Hello (.*) - - name: tfstate - path: /cnab/app/outputs/tfstate - - exec: - description: "Use step output" - command: ./helpers.sh - arguments: - - debug - - ${ bundle.outputs.name } - -upgrade: - - exec: - description: "Upgrade" - command: ./helpers.sh - arguments: - - upgrade - - ${ bundle.outputs.msg } - -uninstall: - - exec: - description: "Say Goodbye" - command: ./helpers.sh - arguments: - - uninstall - - ${ bundle.outputs.msg } - diff --git a/pkg/cnab/config-adapter/testdata/porter.yaml b/pkg/cnab/config-adapter/testdata/porter.yaml deleted file mode 100644 index f9a5dce63..000000000 --- a/pkg/cnab/config-adapter/testdata/porter.yaml +++ /dev/null @@ -1,45 +0,0 @@ -schemaVersion: 1.0.0-alpha.1 -name: porter-hello -description: "An example Porter configuration" -version: 0.1.0 -registry: "localhost:5000" - -credentials: - - name: username - description: Name of the database user - required: false - env: ROOT_USERNAME - - name: password - path: /tmp/password - applyTo: - - uninstall - -dependencies: - requires: - - name: mysql - bundle: - reference: "getporter/azure-mysql:5.7" - -mixins: -- exec - -install: -- exec: - description: "Say Hello" - command: bash - flags: - c: echo Hello World - -status: -- exec: - description: "Get World Status" - command: bash - flags: - c: echo The world is on fire - -uninstall: -- exec: - description: "Say Goodbye" - command: bash - flags: - c: echo Goodbye World diff --git a/pkg/cnab/dependencies/v1/doc.go b/pkg/cnab/dependencies/v1/doc.go new file mode 100644 index 000000000..b3fc9a00e --- /dev/null +++ b/pkg/cnab/dependencies/v1/doc.go @@ -0,0 +1,2 @@ +// Package v1 contains the implementation for v1 of the CNAB Dependencies specification. +package v1 diff --git a/pkg/cnab/solver.go b/pkg/cnab/dependencies/v1/solver.go similarity index 77% rename from pkg/cnab/solver.go rename to pkg/cnab/dependencies/v1/solver.go index d8bbc5eb8..814498c77 100644 --- a/pkg/cnab/solver.go +++ b/pkg/cnab/dependencies/v1/solver.go @@ -1,10 +1,12 @@ -package cnab +package v1 import ( "fmt" "sort" - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + "get.porter.sh/porter/pkg/cnab" + + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" "github.com/Masterminds/semver/v3" "github.com/google/go-containerregistry/pkg/crane" ) @@ -18,7 +20,7 @@ type DependencyLock struct { type DependencySolver struct { } -func (s *DependencySolver) ResolveDependencies(bun ExtendedBundle) ([]DependencyLock, error) { +func (s *DependencySolver) ResolveDependencies(bun cnab.ExtendedBundle) ([]DependencyLock, error) { if !bun.HasDependenciesV1() { return nil, nil } @@ -49,10 +51,10 @@ func (s *DependencySolver) ResolveDependencies(bun ExtendedBundle) ([]Dependency } // ResolveVersion returns the bundle name, its version and any error. -func (s *DependencySolver) ResolveVersion(name string, dep depsv1.Dependency) (OCIReference, error) { - ref, err := ParseOCIReference(dep.Bundle) +func (s *DependencySolver) ResolveVersion(name string, dep depsv1ext.Dependency) (cnab.OCIReference, error) { + ref, err := cnab.ParseOCIReference(dep.Bundle) if err != nil { - return OCIReference{}, fmt.Errorf("error parsing dependency (%s) bundle %q as OCI reference: %w", name, dep.Bundle, err) + return cnab.OCIReference{}, fmt.Errorf("error parsing dependency (%s) bundle %q as OCI reference: %w", name, dep.Bundle, err) } // Here is where we could split out this logic into multiple strategy funcs / structs if necessary @@ -64,16 +66,16 @@ func (s *DependencySolver) ResolveVersion(name string, dep depsv1.Dependency) (O tag, err := s.determineDefaultTag(dep) if err != nil { - return OCIReference{}, err + return cnab.OCIReference{}, err } return ref.WithTag(tag) } - return OCIReference{}, fmt.Errorf("not implemented: dependency version range specified for %s: %w", name, err) + return cnab.OCIReference{}, fmt.Errorf("not implemented: dependency version range specified for %s: %w", name, err) } -func (s *DependencySolver) determineDefaultTag(dep depsv1.Dependency) (string, error) { +func (s *DependencySolver) determineDefaultTag(dep depsv1ext.Dependency) (string, error) { tags, err := crane.ListTags(dep.Bundle) if err != nil { return "", fmt.Errorf("error listing tags for %s: %w", dep.Bundle, err) diff --git a/pkg/cnab/solver_test.go b/pkg/cnab/dependencies/v1/solver_test.go similarity index 65% rename from pkg/cnab/solver_test.go rename to pkg/cnab/dependencies/v1/solver_test.go index 416d6555c..d0e4b93c9 100644 --- a/pkg/cnab/solver_test.go +++ b/pkg/cnab/dependencies/v1/solver_test.go @@ -1,9 +1,10 @@ -package cnab +package v1 import ( "testing" - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + "get.porter.sh/porter/pkg/cnab" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" "github.com/cnabio/cnab-go/bundle" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -12,10 +13,10 @@ import ( func TestDependencySolver_ResolveDependencies(t *testing.T) { t.Parallel() - bun := NewBundle(bundle.Bundle{ + bun := cnab.NewBundle(bundle.Bundle{ Custom: map[string]interface{}{ - DependenciesV1ExtensionKey: depsv1.Dependencies{ - Requires: map[string]depsv1.Dependency{ + cnab.DependenciesV1ExtensionKey: depsv1ext.Dependencies{ + Requires: map[string]depsv1ext.Dependency{ "mysql": { Bundle: "getporter/mysql:5.7", }, @@ -52,30 +53,30 @@ func TestDependencySolver_ResolveVersion(t *testing.T) { testcases := []struct { name string - dep depsv1.Dependency + dep depsv1ext.Dependency wantVersion string wantError string }{ {name: "pinned version", - dep: depsv1.Dependency{Bundle: "mysql:5.7"}, + dep: depsv1ext.Dependency{Bundle: "mysql:5.7"}, wantVersion: "5.7"}, {name: "unimplemented range", - dep: depsv1.Dependency{Bundle: "mysql", Version: &depsv1.DependencyVersion{Ranges: []string{"1 - 1.5"}}}, + dep: depsv1ext.Dependency{Bundle: "mysql", Version: &depsv1ext.DependencyVersion{Ranges: []string{"1 - 1.5"}}}, wantError: "not implemented"}, {name: "default tag to latest", - dep: depsv1.Dependency{Bundle: "getporterci/porter-test-only-latest"}, + dep: depsv1ext.Dependency{Bundle: "getporterci/porter-test-only-latest"}, wantVersion: "latest"}, {name: "no default tag", - dep: depsv1.Dependency{Bundle: "getporterci/porter-test-no-default-tag"}, + dep: depsv1ext.Dependency{Bundle: "getporterci/porter-test-no-default-tag"}, wantError: "no tag was specified"}, {name: "default tag to highest semver", - dep: depsv1.Dependency{Bundle: "getporterci/porter-test-with-versions", Version: &depsv1.DependencyVersion{Ranges: nil, AllowPrereleases: true}}, + dep: depsv1ext.Dependency{Bundle: "getporterci/porter-test-with-versions", Version: &depsv1ext.DependencyVersion{Ranges: nil, AllowPrereleases: true}}, wantVersion: "v1.3-beta1"}, {name: "default tag to highest semver, explicitly excluding prereleases", - dep: depsv1.Dependency{Bundle: "getporterci/porter-test-with-versions", Version: &depsv1.DependencyVersion{Ranges: nil, AllowPrereleases: false}}, + dep: depsv1ext.Dependency{Bundle: "getporterci/porter-test-with-versions", Version: &depsv1ext.DependencyVersion{Ranges: nil, AllowPrereleases: false}}, wantVersion: "v1.2"}, {name: "default tag to highest semver, excluding prereleases by default", - dep: depsv1.Dependency{Bundle: "getporterci/porter-test-with-versions"}, + dep: depsv1ext.Dependency{Bundle: "getporterci/porter-test-with-versions"}, wantVersion: "v1.2"}, } diff --git a/pkg/cnab/dependencies/v2/bundle_graph.go b/pkg/cnab/dependencies/v2/bundle_graph.go new file mode 100644 index 000000000..de5a4b00d --- /dev/null +++ b/pkg/cnab/dependencies/v2/bundle_graph.go @@ -0,0 +1,129 @@ +package v2 + +import ( + "sort" + + "get.porter.sh/porter/pkg/cnab" + depsv2ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" + "github.com/yourbasic/graph" +) + +// BundleGraph is a directed acyclic graph of a bundle and its dependencies +// (which may be other bundles, or installations) It is used to resolve the +// dependency order in which the bundles must be executed. +type BundleGraph struct { + // nodeKeys is a map from the node key to its index in nodes + nodeKeys map[string]int + nodes []Node +} + +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 +} + +// Node in a BundleGraph. +type Node interface { + GetRequires() []string + GetKey() string +} + +var _ Node = BundleNode{} +var _ Node = InstallationNode{} + +// BundleNode is a Node in a BundleGraph that represents a dependency on a bundle +// that has not yet been installed. +type BundleNode struct { + Key string + ParentKey string + Reference cnab.BundleReference + Requires []string + + // TODO(PEP003): DO we need to store this? Can we do it somewhere else or hold a reference to the dep and add more to the Node interface? + Credentials map[string]depsv2ext.DependencySource + Parameters map[string]depsv2ext.DependencySource +} + +func (d BundleNode) GetKey() string { + return d.Key +} + +func (d BundleNode) GetParentKey() string { + return d.ParentKey +} + +func (d BundleNode) GetRequires() []string { + sort.Strings(d.Requires) + return d.Requires +} + +func (d BundleNode) IsRoot() bool { + return d.Key == "root" +} + +// InstallationNode is a Node in a BundleGraph that represents a dependency on an +// installed bundle (installation). +type InstallationNode struct { + Key string + ParentKey string + Namespace string + Name string +} + +func (d InstallationNode) GetKey() string { + return d.Key +} + +func (d InstallationNode) GetParentKey() string { + return d.ParentKey +} + +func (d InstallationNode) GetRequires() []string { + return nil +} diff --git a/pkg/cnab/dependencies/v2/bundle_graph_test.go b/pkg/cnab/dependencies/v2/bundle_graph_test.go new file mode 100644 index 000000000..a61278ed1 --- /dev/null +++ b/pkg/cnab/dependencies/v2/bundle_graph_test.go @@ -0,0 +1,70 @@ +package v2 + +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/cnab/dependencies/v2/bundle_puller.go b/pkg/cnab/dependencies/v2/bundle_puller.go new file mode 100644 index 000000000..e26303335 --- /dev/null +++ b/pkg/cnab/dependencies/v2/bundle_puller.go @@ -0,0 +1,17 @@ +package v2 + +import ( + "context" + + "get.porter.sh/porter/pkg/cache" + "get.porter.sh/porter/pkg/cnab" +) + +// BundlePuller can query and pull bundles. +type BundlePuller interface { + // GetBundle retrieves a bundle definition. + GetBundle(ctx context.Context, ref cnab.OCIReference) (cache.CachedBundle, error) + + // ListTags retrieves all tags defined for a bundle. + ListTags(ctx context.Context, ref cnab.OCIReference) ([]string, error) +} diff --git a/pkg/cnab/dependencies/v2/composite_resolver.go b/pkg/cnab/dependencies/v2/composite_resolver.go new file mode 100644 index 000000000..e03574d43 --- /dev/null +++ b/pkg/cnab/dependencies/v2/composite_resolver.go @@ -0,0 +1,203 @@ +package v2 + +import ( + "context" + "fmt" + + "get.porter.sh/porter/pkg/cnab" + depsv2ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" + "get.porter.sh/porter/pkg/storage" + "github.com/Masterminds/semver/v3" +) + +var _ DependencyResolver = CompositeResolver{} +var _ BundleGraphResolver = CompositeResolver{} + +// CompositeResolver combines multiple resolution strategies into a single +// resolver that applies each strategy in the proper order to resolve a +// Dependency to an action in an ExecutionPlan. +type CompositeResolver struct { + namespace string + resolvers []DependencyResolver +} + +func NewCompositeResolver(namespace string, puller BundlePuller, store storage.InstallationProvider) CompositeResolver { + instResolver := InstallationResolver{ + store: store, + namespace: namespace, + } + versionResolver := VersionResolver{ + puller: puller, + } + return CompositeResolver{ + namespace: namespace, + resolvers: []DependencyResolver{ + instResolver, + versionResolver, + DefaultBundleResolver{puller: puller}, + }, + } +} + +func (r CompositeResolver) ResolveDependency(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.ResolveDependency(ctx, dep) + if err != nil { + return nil, false, err + } + if resolved { + return depNode, true, nil + } + } + + return nil, false, nil +} + +func (r CompositeResolver) ResolveDependencyGraph(ctx context.Context, bun cnab.ExtendedBundle) (*BundleGraph, error) { + g := NewBundleGraph() + + // Add the root bundle + root := BundleNode{ + Key: "root", + Reference: cnab.BundleReference{Definition: bun}, + } + + err := r.addBundleToGraph(ctx, g, root) + return g, err +} + +func (r CompositeResolver) addBundleToGraph(ctx context.Context, g *BundleGraph, node BundleNode) error { + if _, exists := g.GetNode(node.Key); exists { + // We have already processed this bundle, return to avoid an infinite loop + return nil + } + + // Process dependencies, if it has any + bun := node.Reference.Definition + + // TODO(PEP003) If the bundle uses depsv1, convert to depsv2. This should be an extension on the ExtendedBundle + if !bun.HasDependenciesV2() { + // No deps so let's move on + g.RegisterNode(node) + return nil + } + + deps, err := bun.ReadDependenciesV2() + if err != nil { + return err + } + + node.Requires = make([]string, 0, len(deps.Requires)) + for _, dep := range deps.Requires { + // Resolve the dependency + resolved, err := r.resolveDependency(ctx, node.Key, dep) + if err != nil { + return err + } + + // Update the node to track its dependencies + node.Requires = append(node.Requires, resolved.GetKey()) + + // + // Add the dependency to the graph + // + depNode, ok := resolved.(BundleNode) + if !ok { + // installations don't have any dependencies so there's nothing left to do + g.RegisterNode(resolved) + continue + } + + // Make connections between the dependency and any outputs of other dependencies that it requires + requireOutput := func(source depsv2ext.DependencySource) { + if source.Output == "" { + return + } + + outputRequires := node.Key + if source.Dependency != "" { + // PEP(003): How do we ensure that these keys are unique in deep graphs where root + current dep key is unique? + outputRequires = MakeDependencyKey(node.Key, source.Dependency) + } + depNode.Requires = append(depNode.Requires, outputRequires) + } + for _, source := range dep.Parameters { + requireOutput(source) + } + for _, source := range dep.Credentials { + requireOutput(source) + } + r.addBundleToGraph(ctx, g, depNode) + } + + g.RegisterNode(node) + return nil +} + +func (r CompositeResolver) resolveDependency(ctx context.Context, parentKey string, dep depsv2ext.Dependency) (Node, error) { + unresolved := Dependency{ + ParentKey: parentKey, + Key: MakeDependencyKey(parentKey, dep.Name), + Parameters: dep.Parameters, + Credentials: dep.Credentials, + } + if dep.Bundle != "" { + ref, err := cnab.ParseOCIReference(dep.Bundle) + if err != nil { + return nil, fmt.Errorf("invalid bundle for dependency %s: %w", unresolved.Key, err) + } + 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(PEP003): convert the interface document into a BundleInterfaceSelector + panic("bundle interfaces are not implemented") + } + + 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, r.namespace) + if !dep.Installation.Criteria.MatchNamespace && r.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 := r.ResolveDependency(ctx, unresolved) + if err != nil { + return nil, err + } + + if !resolved { + return nil, fmt.Errorf("could not resolve dependency %s", unresolved.Key) + } + + return depNode, nil +} diff --git a/pkg/cnab/dependencies/v2/composite_resolver_test.go b/pkg/cnab/dependencies/v2/composite_resolver_test.go new file mode 100644 index 000000000..2b3f03998 --- /dev/null +++ b/pkg/cnab/dependencies/v2/composite_resolver_test.go @@ -0,0 +1,64 @@ +package v2 + +import ( + "context" + "testing" + + "get.porter.sh/porter/pkg/cnab" + configadapter "get.porter.sh/porter/pkg/cnab/config-adapter" + "get.porter.sh/porter/pkg/config" + "get.porter.sh/porter/pkg/experimental" + "get.porter.sh/porter/pkg/manifest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompositeResolver_ResolveDependencyGraph(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) + + r := CompositeResolver{ + resolvers: []DependencyResolver{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 := r.ResolveDependencyGraph(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) + + // Check the dependencies of each node + rootNode, _ := g.GetNode("root") + require.IsType(t, BundleNode{}, rootNode, "expected the root node to be a bundle") + require.Equal(t, []string{"root/load-balancer", "root/mysql"}, rootNode.GetRequires(), "expected the root bundle to depend on the load balancer and mysql") + + mysqlNode, _ := g.GetNode("root/mysql") + require.IsType(t, BundleNode{}, mysqlNode, "expected the mysql node to be a bundle") + require.Equal(t, []string{"root/load-balancer"}, mysqlNode.GetRequires(), "expected mysql to depend only on the load balancer") + + loadBalancerNode, _ := g.GetNode("root/load-balancer") + require.IsType(t, InstallationNode{}, loadBalancerNode, "expected the load balancer node to be an installation") + require.Empty(t, loadBalancerNode.GetRequires(), "expected the load balancer to have no dependencies") +} diff --git a/pkg/cnab/dependencies/v2/default_bundle_resolver.go b/pkg/cnab/dependencies/v2/default_bundle_resolver.go new file mode 100644 index 000000000..28e542aca --- /dev/null +++ b/pkg/cnab/dependencies/v2/default_bundle_resolver.go @@ -0,0 +1,33 @@ +package v2 + +import ( + "context" +) + +var _ DependencyResolver = DefaultBundleResolver{} + +// DefaultBundleResolver resolves the default bundle defined on the dependency. +type DefaultBundleResolver struct { + puller BundlePuller +} + +func (d DefaultBundleResolver) ResolveDependency(ctx context.Context, dep Dependency) (Node, bool, error) { + if dep.DefaultBundle == nil { + return nil, false, nil + } + + cb, err := d.puller.GetBundle(ctx, dep.DefaultBundle.Reference) + if err != nil { + // wrap not found error and indicate that we could resolve anything + return nil, false, err + } + + return BundleNode{ + Key: dep.Key, + ParentKey: dep.ParentKey, + Reference: cb.BundleReference, + // TODO(PEP003): Do we have to duplicate this? Can't we get it from the bundle def when we need it? + Parameters: dep.Parameters, + Credentials: dep.Credentials, + }, true, nil +} diff --git a/pkg/cnab/dependencies/v2/dependency.go b/pkg/cnab/dependencies/v2/dependency.go new file mode 100644 index 000000000..32f7eeca6 --- /dev/null +++ b/pkg/cnab/dependencies/v2/dependency.go @@ -0,0 +1,174 @@ +package v2 + +import ( + "context" + "fmt" + + depsv2ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" + + "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" + "go.opentelemetry.io/otel/attribute" +) + +// Dependency is a fully hydrated representation of a bundle dependency with +// sufficient information to be resolved using a DependencyResolver to an action +// that can be used in an Execution Plan. +// TODO: can we come up with a better name, e.g. unresolve dependency, dependency selector, etc +type Dependency struct { + Key string + ParentKey string + DefaultBundle *BundleReferenceSelector + Interface *BundleInterfaceSelector + InstallationSelector *InstallationSelector + Requires []string + Parameters map[string]depsv2ext.DependencySource + Credentials map[string]depsv2ext.DependencySource +} + +// BundleReferenceSelector evaluates the bundle criteria of a Dependency. +type BundleReferenceSelector struct { + // Reference to a bundle, optionally including a default tag or digest. + Reference cnab.OCIReference + + // Version specifies the range of allowed versions that Porter may select from + // when resolving this bundle to a specific reference. When a version is not + // specified or cannot be resolved, the tag/digest specified on the Reference is + // used as a default. + Version *semver.Constraints +} + +// IsMatch determines if the specified installation satisfies a Dependency's bundle criteria. +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 + } + } +} + +// InstallationSelector evaluates the installation criteria of a Dependency. +type InstallationSelector struct { + // Bundle is the criteria used for evaluating if a bundle satisfies a Dependency. + Bundle *BundleReferenceSelector + + // Interface is the criteria used for evaluating if an installation or bundle + // satisfies a Dependency. + Interface *BundleInterfaceSelector + + // Labels is the set of labels used to find an existing installation that may be + // used to satisfy a Dependency. + Labels map[string]string + + // Namespaces is the set of namespaces used when searching for an existing + // installation that may be used to satisfy a Dependency. + Namespaces []string +} + +// IsMatch determines if the specified installation satisfies a Dependency's installation criteria. +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 +} + +// IsMatch determines if the specified installation satisfies a Dependency's bundle interface criteria. +func (s BundleInterfaceSelector) IsMatch(ctx context.Context, inst storage.Installation) bool { + // TODO: implement + return true +} + +func MakeDependencyKey(parent string, dep string) string { + return fmt.Sprintf("%s/%s", parent, dep) +} diff --git a/pkg/cnab/dependencies/v2/dependency_resolver.go b/pkg/cnab/dependencies/v2/dependency_resolver.go new file mode 100644 index 000000000..b49d2bc96 --- /dev/null +++ b/pkg/cnab/dependencies/v2/dependency_resolver.go @@ -0,0 +1,17 @@ +package v2 + +import ( + "context" + + "get.porter.sh/porter/pkg/cnab" +) + +// DependencyResolver is an interface for various strategies of resolving a +// Dependency to an action in an ExecutionPlan. +type DependencyResolver interface { + ResolveDependency(ctx context.Context, dep Dependency) (Node, bool, error) +} + +type BundleGraphResolver interface { + ResolveDependencyGraph(ctx context.Context, bun cnab.ExtendedBundle) (*BundleGraph, error) +} diff --git a/pkg/cnab/dependencies/v2/doc.go b/pkg/cnab/dependencies/v2/doc.go new file mode 100644 index 000000000..fe08036ee --- /dev/null +++ b/pkg/cnab/dependencies/v2/doc.go @@ -0,0 +1,3 @@ +// Package v2 contains the implementation of the v2 Porter Dependency +// specification, sh.porter.dependencies.v2. +package v2 diff --git a/pkg/cnab/dependencies/v2/helpers.go b/pkg/cnab/dependencies/v2/helpers.go new file mode 100644 index 000000000..4ad34e6be --- /dev/null +++ b/pkg/cnab/dependencies/v2/helpers.go @@ -0,0 +1,41 @@ +package v2 + +import ( + "context" + "fmt" + + "get.porter.sh/porter/pkg/cnab" +) + +var _ DependencyResolver = TestResolver{} +var _ BundleGraphResolver = TestResolver{} + +// TODO(PEP003): I think we should remove this and just mock the underlying stores used to resolve, e.g. existing installations, or registry queries. +// Otherwise we also have to handle copying values from the dep to the mocked node, or mocking it too +type TestResolver struct { + Namespace string + Mocks map[string]Node +} + +func (t TestResolver) ResolveDependency(ctx context.Context, dep Dependency) (Node, bool, error) { + node, ok := t.Mocks[dep.Key] + if ok { + if bunNode, ok := node.(BundleNode); ok { + bunNode.Parameters = dep.Parameters + bunNode.Credentials = dep.Credentials + node = bunNode + } + + return node, true, nil + } + + return nil, false, fmt.Errorf("no mock exists for %s", dep.Key) +} + +func (t TestResolver) ResolveDependencyGraph(ctx context.Context, bun cnab.ExtendedBundle) (*BundleGraph, error) { + r := CompositeResolver{ + resolvers: []DependencyResolver{t}, + namespace: t.Namespace, + } + return r.ResolveDependencyGraph(ctx, bun) +} diff --git a/pkg/cnab/dependencies/v2/installation_resolver.go b/pkg/cnab/dependencies/v2/installation_resolver.go new file mode 100644 index 000000000..d8fbef473 --- /dev/null +++ b/pkg/cnab/dependencies/v2/installation_resolver.go @@ -0,0 +1,129 @@ +package v2 + +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.InstallationProvider + + // Namespace of the root installation + namespace string +} + +// Resolve attempts to identify an existing installation that satisfies the +// specified Dependency. +// +// Returns the matching installation (if found), whether +// a matching installation was found, and an error if applicable. +func (r InstallationResolver) ResolveDependency(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/cnab/dependencies/v2/testdata/porter.yaml b/pkg/cnab/dependencies/v2/testdata/porter.yaml new file mode 100644 index 000000000..c62be8df5 --- /dev/null +++ b/pkg/cnab/dependencies/v2/testdata/porter.yaml @@ -0,0 +1,27 @@ +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 + parameters: + region: bundle.parameters.region + - name: mysql + bundle: + reference: example/mysql:v1.0.0 + parameters: + ip: bundle.dependencies.load-balancer.outputs.ipAddress + credentials: + kubeconfig: bundle.credentials.kubeconfig diff --git a/pkg/cnab/dependencies/v2/version_resolver.go b/pkg/cnab/dependencies/v2/version_resolver.go new file mode 100644 index 000000000..7b0a431d4 --- /dev/null +++ b/pkg/cnab/dependencies/v2/version_resolver.go @@ -0,0 +1,60 @@ +package v2 + +import ( + "context" + "sort" + + "get.porter.sh/porter/pkg/cnab" + "github.com/Masterminds/semver/v3" +) + +var _ DependencyResolver = VersionResolver{} + +// VersionResolver resolves the highest version of the default bundle reference of a Dependency. +type VersionResolver struct { + puller BundlePuller +} + +// Resolve attempts to find the highest available version of the default bundle for the specified Dependency. +// +// Returns the resolved bundle reference, whether a match was found, and an error if applicable. +func (v VersionResolver) ResolveDependency(ctx context.Context, dep Dependency) (Node, bool, error) { + bundle := dep.DefaultBundle + if bundle == nil || bundle.Version == nil { + return nil, false, nil + } + + tags, err := v.puller.ListTags(ctx, bundle.Reference) + 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, + ParentKey: dep.ParentKey, + Reference: bunRef, + Parameters: dep.Parameters, + Credentials: dep.Credentials, + }, true, nil +} diff --git a/pkg/cnab/dependencies_v1.go b/pkg/cnab/dependencies_v1.go index 2169edc35..acb567f8e 100644 --- a/pkg/cnab/dependencies_v1.go +++ b/pkg/cnab/dependencies_v1.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" ) const ( @@ -32,15 +32,15 @@ var DependenciesV1Extension = RequiredExtension{ // ReadDependenciesV1 is a convenience method for returning a bonafide // Dependencies reference after reading from the applicable section from // the provided bundle -func (b ExtendedBundle) ReadDependenciesV1() (depsv1.Dependencies, error) { +func (b ExtendedBundle) ReadDependenciesV1() (depsv1ext.Dependencies, error) { raw, err := b.DependencyV1Reader() if err != nil { - return depsv1.Dependencies{}, err + return depsv1ext.Dependencies{}, err } - deps, ok := raw.(depsv1.Dependencies) + deps, ok := raw.(depsv1ext.Dependencies) if !ok { - return depsv1.Dependencies{}, errors.New("unable to read dependencies extension data") + return depsv1ext.Dependencies{}, errors.New("unable to read dependencies extension data") } // Return the dependencies @@ -61,7 +61,7 @@ func (b ExtendedBundle) DependencyV1Reader() (interface{}, error) { return nil, fmt.Errorf("could not marshal the untyped dependencies extension data %q: %w", string(dataB), err) } - deps := depsv1.Dependencies{} + deps := depsv1ext.Dependencies{} err = json.Unmarshal(dataB, &deps) if err != nil { return nil, fmt.Errorf("could not unmarshal the dependencies extension %q: %w", string(dataB), err) diff --git a/pkg/cnab/dependencies_v1_test.go b/pkg/cnab/dependencies_v1_test.go index 60907aac4..57e52254e 100644 --- a/pkg/cnab/dependencies_v1_test.go +++ b/pkg/cnab/dependencies_v1_test.go @@ -1,7 +1,6 @@ package cnab import ( - "os" "testing" "github.com/cnabio/cnab-go/bundle" @@ -12,14 +11,8 @@ import ( func TestReadDependencyV1Properties(t *testing.T) { t.Parallel() - data, err := os.ReadFile("testdata/bundle.json") - require.NoError(t, err, "cannot read bundle file") - - b, err := bundle.Unmarshal(data) - require.NoError(t, err, "could not unmarshal the bundle") - - bun := ExtendedBundle{*b} - assert.True(t, bun.HasDependenciesV1()) + bun := ReadTestBundle(t, "testdata/bundle.json") + require.True(t, bun.HasDependenciesV1()) deps, err := bun.ReadDependenciesV1() require.NoError(t, err, "ReadDependenciesV1 failed") @@ -27,12 +20,12 @@ func TestReadDependencyV1Properties(t *testing.T) { assert.Len(t, deps.Requires, 2, "Dependencies.Requires is the wrong length") dep := deps.Requires["storage"] - assert.NotNil(t, dep, "expected Dependencies.Requires to have an entry for 'storage'") + require.NotNil(t, dep, "expected Dependencies.Requires to have an entry for 'storage'") assert.Equal(t, "somecloud/blob-storage", dep.Bundle, "Dependency.Bundle is incorrect") assert.Nil(t, dep.Version, "Dependency.Version should be nil") dep = deps.Requires["mysql"] - assert.NotNil(t, dep, "expected Dependencies.Requires to have an entry for 'mysql'") + require.NotNil(t, dep, "expected Dependencies.Requires to have an entry for 'mysql'") assert.Equal(t, "somecloud/mysql", dep.Bundle, "Dependency.Bundle is incorrect") assert.True(t, dep.Version.AllowPrereleases, "Dependency.Bundle.Version.AllowPrereleases should be true") assert.Equal(t, []string{"5.7.x"}, dep.Version.Ranges, "Dependency.Bundle.Version.Ranges is incorrect") diff --git a/pkg/cnab/dependencies_v2.go b/pkg/cnab/dependencies_v2.go new file mode 100644 index 000000000..adbc49563 --- /dev/null +++ b/pkg/cnab/dependencies_v2.go @@ -0,0 +1,88 @@ +package cnab + +import ( + "encoding/json" + "errors" + "fmt" + + v2 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" +) + +const ( + // DependenciesV2ExtensionShortHand is the short suffix of the DependenciesV2ExtensionKey + DependenciesV2ExtensionShortHand = "dependencies.v2" + + // DependenciesV2ExtensionKey represents the full key for the DependenciesV2Extension. + DependenciesV2ExtensionKey = PorterExtension + "." + DependenciesV2ExtensionShortHand + + // DependenciesV2Schema represents the schema for the DependenciesV2 Extension + DependenciesV2Schema = "https://porter.sh/extensions/dependencies/v2/schema.json" +) + +// DependenciesV2Extension represents the required extension to enable dependencies +var DependenciesV2Extension = RequiredExtension{ + Shorthand: DependenciesV2ExtensionShortHand, + Key: DependenciesV2ExtensionKey, + Schema: DependenciesV2Schema, + Reader: func(b ExtendedBundle) (interface{}, error) { + return b.DependencyV2Reader() + }, +} + +// ReadDependenciesV2 is a convenience method for returning a bonafide +// DependenciesV2 reference after reading from the applicable section from +// the provided bundle +func (b ExtendedBundle) ReadDependenciesV2() (v2.Dependencies, error) { + raw, err := b.DependencyV2Reader() + if err != nil { + return v2.Dependencies{}, err + } + + deps, ok := raw.(v2.Dependencies) + if !ok { + return v2.Dependencies{}, errors.New("unable to read dependencies v2 extension data") + } + + // Return the dependencies + return deps, nil +} + +// DependencyV2Reader is a Reader for the DependenciesV2Extension, which reads +// from the applicable section in the provided bundle and returns the raw +// data in the form of an interface +func (b ExtendedBundle) DependencyV2Reader() (interface{}, error) { + data, ok := b.Custom[DependenciesV2ExtensionKey] + if !ok { + return nil, errors.New("attempted to read dependencies from bundle but none are defined") + } + + dataB, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("could not marshal the untyped dependencies extension data %q: %w", string(dataB), err) + } + + deps := v2.Dependencies{} + err = json.Unmarshal(dataB, &deps) + if err != nil { + return nil, fmt.Errorf("could not unmarshal the dependencies extension %q: %w", string(dataB), err) + } + + // Name is not persisted to json, so it needs to be hydrated manually + for depName, dep := range deps.Requires { + dep.Name = depName + deps.Requires[depName] = dep + } + + return deps, nil +} + +// SupportsDependenciesV2 checks if the bundle supports dependencies +func (b ExtendedBundle) SupportsDependenciesV2() bool { + return b.SupportsExtension(DependenciesV2ExtensionKey) +} + +// HasDependenciesV2 returns whether or not the bundle has parameter sources defined. +func (b ExtendedBundle) HasDependenciesV2() bool { + _, ok := b.Custom[DependenciesV2ExtensionKey] + return ok +} diff --git a/pkg/cnab/dependencies_v2_test.go b/pkg/cnab/dependencies_v2_test.go new file mode 100644 index 000000000..1c64b9e73 --- /dev/null +++ b/pkg/cnab/dependencies_v2_test.go @@ -0,0 +1,72 @@ +package cnab + +import ( + "testing" + + "github.com/cnabio/cnab-go/bundle" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadDependencyV2Properties(t *testing.T) { + t.Parallel() + + bun := ReadTestBundle(t, "testdata/bundle-depsv2.json") + require.True(t, bun.HasDependenciesV2()) + + deps, err := bun.ReadDependenciesV2() + require.NoError(t, err) + + require.NotNil(t, deps, "DependenciesV2 was not populated") + assert.Len(t, deps.Requires, 2, "DependenciesV2.Requires is the wrong length") + + dep := deps.Requires["storage"] + require.NotNil(t, dep, "expected DependenciesV2.Requires to have an entry for 'storage'") + assert.Equal(t, "somecloud/blob-storage", dep.Bundle, "DependencyV2.Bundle is incorrect") + assert.Empty(t, dep.Version, "DependencyV2.Version should be nil") + + dep = deps.Requires["mysql"] + require.NotNil(t, dep, "expected DependenciesV2.Requires to have an entry for 'mysql'") + assert.Equal(t, "somecloud/mysql", dep.Bundle, "DependencyV2.Bundle is incorrect") + assert.Equal(t, "5.7.x", dep.Version, "DependencyV2.Bundle.Version is incorrect") + +} + +func TestSupportsDependenciesV2(t *testing.T) { + t.Parallel() + + t.Run("supported", func(t *testing.T) { + b := ExtendedBundle{bundle.Bundle{ + RequiredExtensions: []string{DependenciesV2ExtensionKey}, + }} + + assert.True(t, b.SupportsDependenciesV2()) + }) + t.Run("unsupported", func(t *testing.T) { + b := ExtendedBundle{} + + assert.False(t, b.SupportsDependenciesV2()) + }) +} + +func TestHasDependenciesV2(t *testing.T) { + t.Parallel() + + t.Run("has dependencies", func(t *testing.T) { + b := ExtendedBundle{bundle.Bundle{ + RequiredExtensions: []string{DependenciesV2ExtensionKey}, + Custom: map[string]interface{}{ + DependenciesV2ExtensionKey: struct{}{}, + }, + }} + + assert.True(t, b.HasDependenciesV2()) + }) + t.Run("no dependencies", func(t *testing.T) { + b := ExtendedBundle{bundle.Bundle{ + RequiredExtensions: []string{DependenciesV2ExtensionKey}, + }} + + assert.False(t, b.HasDependenciesV2()) + }) +} diff --git a/pkg/cnab/extensions.go b/pkg/cnab/extensions.go index b1698ff9d..60c5357aa 100644 --- a/pkg/cnab/extensions.go +++ b/pkg/cnab/extensions.go @@ -1,5 +1,7 @@ package cnab +import "fmt" + const ( // PorterExtension is the key for all Porter configuration stored the the custom section of bundles. PorterExtension = "sh.porter" @@ -15,6 +17,69 @@ const ( PorterInternal = "porter-internal" ) +// RequiredExtension represents a required extension that is known and supported by Porter +type RequiredExtension struct { + Shorthand string + Key string + Schema string + Reader func(b ExtendedBundle) (interface{}, error) +} + +// SupportedExtensions represent a listing of the current required extensions +// that Porter supports +var SupportedExtensions = []RequiredExtension{ + DependenciesV1Extension, + DependenciesV2Extension, + DockerExtension, + FileParameterExtension, + ParameterSourcesExtension, +} + +// ProcessedExtensions represents a map of the extension name to the +// processed extension configuration +type ProcessedExtensions map[string]interface{} + +// ProcessRequiredExtensions checks all required extensions in the provided +// bundle and makes sure Porter supports them. +// +// If an unsupported required extension is found, an error is returned. +// +// For each supported required extension, the configuration for that extension +// is read and returned in the form of a map of the extension name to +// the extension configuration +func (b ExtendedBundle) ProcessRequiredExtensions() (ProcessedExtensions, error) { + processed := ProcessedExtensions{} + for _, reqExt := range b.RequiredExtensions { + supportedExtension, err := GetSupportedExtension(reqExt) + if err != nil { + return processed, err + } + + raw, err := supportedExtension.Reader(b) + if err != nil { + return processed, fmt.Errorf("unable to process extension: %s: %w", reqExt, err) + } + + processed[supportedExtension.Key] = raw + } + + return processed, nil +} + +// GetSupportedExtension returns a supported extension according to the +// provided name, or an error +func GetSupportedExtension(e string) (*RequiredExtension, error) { + for _, ext := range SupportedExtensions { + // TODO(v1) we should only check for the key in v1.0.0 + // We are checking for both because of a bug in the cnab dependencies spec + // https://github.com/cnabio/cnab-spec/issues/403 + if e == ext.Key || e == ext.Shorthand { + return &ext, nil + } + } + return nil, fmt.Errorf("unsupported required extension: %s", e) +} + // SupportsExtension checks if the bundle supports the specified CNAB extension. func (b ExtendedBundle) SupportsExtension(key string) bool { for _, ext := range b.RequiredExtensions { diff --git a/pkg/cnab/extensions/dependencies/v1/doc.go b/pkg/cnab/extensions/dependencies/v1/doc.go new file mode 100644 index 000000000..b739e8b5c --- /dev/null +++ b/pkg/cnab/extensions/dependencies/v1/doc.go @@ -0,0 +1,2 @@ +// Package v1 defines the v1 CNAB Dependency specification, io.cnab.dependencies. +package v1 diff --git a/pkg/cnab/dependencies/v1/types.go b/pkg/cnab/extensions/dependencies/v1/types.go similarity index 94% rename from pkg/cnab/dependencies/v1/types.go rename to pkg/cnab/extensions/dependencies/v1/types.go index 5076e3b59..843435a02 100644 --- a/pkg/cnab/dependencies/v1/types.go +++ b/pkg/cnab/extensions/dependencies/v1/types.go @@ -32,7 +32,8 @@ func (d Dependencies) ListBySequence() []Dependency { // Dependency describes a dependency on another bundle type Dependency struct { // Name of the dependency - Name string `json:"name" mapstructure:"name"` + // This is used internally but isn't persisted to bundle.json + Name string `json:"-" mapstructure:"-"` // Bundle is the location of the bundle in a registry, for example REGISTRY/NAME:TAG Bundle string `json:"bundle" mapstructure:"bundle"` diff --git a/pkg/cnab/dependencies/v1/types_test.go b/pkg/cnab/extensions/dependencies/v1/types_test.go similarity index 100% rename from pkg/cnab/dependencies/v1/types_test.go rename to pkg/cnab/extensions/dependencies/v1/types_test.go diff --git a/pkg/cnab/extensions/dependencies/v2/doc.go b/pkg/cnab/extensions/dependencies/v2/doc.go new file mode 100644 index 000000000..b387c3aca --- /dev/null +++ b/pkg/cnab/extensions/dependencies/v2/doc.go @@ -0,0 +1,3 @@ +// Package v2 defines the v2 Porter Dependency specification, +// sh.porter.dependencies.v2. +package v2 diff --git a/pkg/cnab/extensions/dependencies/v2/types.go b/pkg/cnab/extensions/dependencies/v2/types.go new file mode 100644 index 000000000..2739f9aeb --- /dev/null +++ b/pkg/cnab/extensions/dependencies/v2/types.go @@ -0,0 +1,221 @@ +package v2 + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + + "get.porter.sh/porter/pkg/secrets" +) + +// Dependencies describes the set of custom extension metadata associated with the dependencies spec +// https://github.com/cnabio/cnab-spec/blob/master/500-CNAB-dependencies.md +type Dependencies struct { + // Requires is a list of bundles required by this bundle + Requires map[string]Dependency `json:"requires,omitempty" mapstructure:"requires"` +} + +/* +dependencies: + requires: # dependencies are always created in the current namespace, never global though they can match globally? + mysql: + bundle: + reference: getporter/mysql:v1.0.2 + version: 1.x + interface: # Porter defaults the interface based on usage + reference: getporter/generic-mysql-interface:v1.0.0 # point to an interface bundle to be more specific + document: # add extra interface requirements + outputs: + - $id: "mysql-5.7-connection-string" # match on something other than name, so that outputs with different names can be reused + installation: + labels: # labels applied to the installation if created + app: myapp + installation: {{ installation.name }} # exclusive resource + criteria: # criteria for reusing an existing installation, by default must be the same bundle, labels and allows global + matchInterface: true # only match the interface, not the bundle too + matchNamespace: true # must be in the same namespace, disallow global + ignoreLabels: true # allow different labels +*/ + +// Dependency describes a dependency on another bundle +type Dependency struct { + // Name of the dependency + // This is used internally but isn't persisted to bundle.json + Name string `json:"-" mapstructure:"-"` + + // Bundle is the location of the bundle in a registry, for example REGISTRY/NAME:TAG + Bundle string `json:"bundle" mapstructure:"bundle"` + + // Version is a set of allowed versions + Version string `json:"version,omitempty" mapstructure:"version"` + + Interface *DependencyInterface `json:"interface,omitempty" mapstructure:"interface,omitempty"` + + Installation *DependencyInstallation `json:"installation,omitempty" mapstructure:"installation,omitempty"` + + Parameters map[string]DependencySource `json:"parameters,omitempty" mapstructure:"parameters,omitempty"` + Credentials map[string]DependencySource `json:"credentials,omitempty" mapstructure:"credentials,omitempty"` +} + +type DependencySource struct { + Value string `json:"value,omitempty" mapstructure:"value,omitempty"` + Dependency string `json:"dependency,omitempty" mapstructure:"dependency,omitempty"` + Credential string `json:"credential,omitempty" mapstructure:"credential,omitempty"` + Parameter string `json:"parameter,omitempty" mapstructure:"parameter,omitempty"` + Output string `json:"output,omitempty" mapstructure:"output,omitempty"` +} + +// ignore template syntax, ${...}, if found +var dependencySourceWiringRegex = regexp.MustCompile(`(\s*\$\{\s*)?bundle(\.dependencies\.([^.]+))?\.([^.]+)\.([^\s\}]+)(\s*\}\s*)?`) + +// ParseDependencySource identifies the components specified in a wiring string. +func ParseDependencySource(value string) (DependencySource, error) { + matches := dependencySourceWiringRegex.FindStringSubmatch(value) + + // If it doesn't match our wiring syntax, assume that it is a hard coded value + if matches == nil || len(matches) < 5 { + return DependencySource{Value: value}, nil + } + + dependencyName := matches[3] // bundle.dependencies.DEPENDENCY_NAME + itemType := matches[4] // bundle.dependencies.dependency_name.PARAMETERS.name or bundle.OUTPUTS.name + itemName := matches[5] // bundle.dependencies.dependency_name.parameters.NAME or bundle.outputs.NAME + + result := DependencySource{Dependency: dependencyName} + switch itemType { + case "parameters": + result.Parameter = itemName + case "credentials": + result.Credential = itemName + case "outputs": + // Cannot pass the root bundle's output to a dependency + // Check that we are attempting to pass another dependency's output + if dependencyName == "" { + return DependencySource{}, errors.New("cannot pass the root bundle output to a dependency") + } + result.Output = itemName + } + return result, nil +} + +func (s DependencySource) AsWorkflowStrategy(name string, parentJob string) secrets.Strategy { + strategy := secrets.Strategy{ + Name: name, + Source: secrets.Source{ + // bundle.dependencies.DEP.outputs.OUTPUT -> workflow.jobs.JOB.outputs.OUTPUT + // TODO(PEP003): Figure out if we need a job id, or if we can do okay with just a job key that we resolve to a run later + Value: s.AsWorkflowWiring(parentJob), + }, + } + + // TODO(PEP003): Are other strategies valid when talking about dependency wiring? Or can we only pass hard-coded values and data from a previous job? + if s.Value != "" { + strategy.Source.Key = "value" + } else { + strategy.Source.Key = "porter" + } + + return strategy +} + +// AsBundleWiring is the wiring string representation in the bundle definition. +// For example, bundle.parameters.PARAM or bundle.dependencies.DEP.outputs.OUTPUT +func (s DependencySource) AsBundleWiring() string { + if s.Value != "" { + return s.Value + } + + suffix := s.WiringSuffix() + if s.Dependency != "" { + return fmt.Sprintf("bundle.dependencies.%s.%s", s.Dependency, suffix) + } + + return fmt.Sprintf("bundle.%s", suffix) +} + +// AsWorkflowWiring is the wiring string representation in a workflow definition. +// For example, workflow.jobs.JOB.outputs.OUTPUT +func (s DependencySource) AsWorkflowWiring(jobID string) string { + if s.Value != "" { + return s.Value + } + + return fmt.Sprintf("workflow.jobs.%s.%s", jobID, s.WiringSuffix()) +} + +// WiringSuffix identifies the data to retrieve from the source. +// For example, parameters.PARAM or outputs.OUTPUT +func (s DependencySource) WiringSuffix() string { + if s.Parameter != "" { + return fmt.Sprintf("parameters.%s", s.Parameter) + } + + if s.Credential != "" { + return fmt.Sprintf("credentials.%s", s.Credential) + } + + if s.Output != "" { + return fmt.Sprintf("outputs.%s", s.Output) + } + + return s.Value +} + +type WorkflowWiring struct { + WorkflowID string + JobKey string + Parameter string + Credential string + Output string +} + +var workflowWiringRegex = regexp.MustCompile(`workflow\.([^\.]+)\.jobs\.([^\.]+)\.([^\.]+)\.(.+)`) + +func ParseWorkflowWiring(value string) (WorkflowWiring, error) { + matches := workflowWiringRegex.FindStringSubmatch(value) + if len(matches) < 5 { + return WorkflowWiring{}, fmt.Errorf("invalid workflow wiring was passed to the porter strategy, %s", value) + } + + // the first group is the entire match, we don't care about it + workflowID := matches[1] + jobKey := matches[2] + dataType := matches[3] // e.g. parameters, credentials or outputs + dataKey := matches[4] // e.g. the name of the param/cred/output + + wiring := WorkflowWiring{ + WorkflowID: workflowID, + JobKey: jobKey, + } + + switch dataType { + case "parameters": + wiring.Parameter = dataKey + case "credentials": + wiring.Credential = dataKey + case "outputs": + wiring.Output = dataKey + default: + return WorkflowWiring{}, fmt.Errorf("invalid workflow wiring was passed to the porter strategy, %s", value) + } + + return wiring, nil +} + +type DependencyInstallation struct { + Labels map[string]string `json:"labels,omitempty" mapstructure:"labels,omitempty"` + Criteria *InstallationCriteria `json:"criteria,omitempty" mapstructure:"criteria,omitempty"` +} + +type InstallationCriteria struct { + // MatchInterface specifies if the installation should use the same bundle or just needs to match the interface + MatchInterface bool `json:"matchInterface,omitempty" mapstructure:"matchInterface,omitEmpty"` + MatchNamespace bool `json:"matchNamespace,omitempty" mapstructure:"matchNamespace,omitEmpty"` + IgnoreLabels bool `json:"ignoreLabels,omitempty" mapstructure:"ignoreLabels,omitempty"` +} + +type DependencyInterface struct { + Reference string `json:"reference,omitempty" mapstructure:"reference,omitempty"` + Document *json.RawMessage `json:"document,omitempty" mapstructure:"document,omitempty"` +} diff --git a/pkg/cnab/extensions/dependencies/v2/types_test.go b/pkg/cnab/extensions/dependencies/v2/types_test.go new file mode 100644 index 000000000..65b97a72a --- /dev/null +++ b/pkg/cnab/extensions/dependencies/v2/types_test.go @@ -0,0 +1,163 @@ +package v2 + +import ( + "testing" + + "get.porter.sh/porter/tests" + "github.com/stretchr/testify/require" +) + +func TestDependencySource(t *testing.T) { + t.Parallel() + + jobKey := "1" + testcases := []struct { + name string + bundleWiring string + wantSource DependencySource + wantWorkflowWiring string + wantErr string + }{ + { // Check that we can still pass hard-coded values in a workflow + name: "value", + bundleWiring: "11", + wantSource: DependencySource{ + Value: "11", + }, + wantWorkflowWiring: "11", + }, + { + name: "parameter", + bundleWiring: "bundle.parameters.color", + wantSource: DependencySource{ + Parameter: "color", + }, + wantWorkflowWiring: "workflow.jobs.1.parameters.color", + }, + { + name: "credential", + bundleWiring: "bundle.credentials.kubeconfig", + wantSource: DependencySource{ + Credential: "kubeconfig", + }, + wantWorkflowWiring: "workflow.jobs.1.credentials.kubeconfig", + }, + { + name: "invalid: output", + bundleWiring: "bundle.outputs.port", + wantErr: "cannot pass the root bundle output to a dependency", + }, + { + name: "dependency parameter", + bundleWiring: "bundle.dependencies.mysql.parameters.name", + wantSource: DependencySource{ + Dependency: "mysql", + Parameter: "name", + }, + wantWorkflowWiring: "workflow.jobs.1.parameters.name", + }, + { + name: "dependency credential", + bundleWiring: "bundle.dependencies.mysql.credentials.password", + wantSource: DependencySource{ + Dependency: "mysql", + Credential: "password", + }, + wantWorkflowWiring: "workflow.jobs.1.credentials.password", + }, + { + name: "dependency output", + bundleWiring: "bundle.dependencies.mysql.outputs.connstr", + wantSource: DependencySource{ + Dependency: "mysql", + Output: "connstr", + }, + wantWorkflowWiring: "workflow.jobs.1.outputs.connstr", + }, + } + + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotSource, err := ParseDependencySource(tc.bundleWiring) + if tc.wantErr == "" { + require.Equal(t, tc.wantSource, gotSource, "incorrect DependencySource was parsed") + + // Check that we can convert it back to a bundle wiring string + gotBundleWiring := gotSource.AsBundleWiring() + require.Equal(t, tc.bundleWiring, gotBundleWiring, "incorrect bundle wiring was returned") + + // Check that we can convert to a workflow wiring form + gotWorkflowWiringValue := gotSource.AsWorkflowWiring(jobKey) + require.Equal(t, tc.wantWorkflowWiring, gotWorkflowWiringValue, "incorrect workflow wiring string value was returned") + } else { + tests.RequireErrorContains(t, err, tc.wantErr) + } + }) + } +} + +func TestParseWorkflowWiring(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + wiringStr string + wantWorkflowWiring WorkflowWiring + wantErr string + }{ + { // Check that we can still pass hard-coded values in a workflow + name: "value not supported", + wiringStr: "11", + wantErr: "invalid workflow wiring", + }, + { + name: "parameter", + wiringStr: "workflow.abc123.jobs.myjerb.parameters.logLevel", + wantWorkflowWiring: WorkflowWiring{ + WorkflowID: "abc123", + JobKey: "myjerb", + Parameter: "logLevel", + }, + }, + { + name: "credential", + wiringStr: "workflow.myworkflow.jobs.root.credentials.kubeconfig", + wantWorkflowWiring: WorkflowWiring{ + WorkflowID: "myworkflow", + JobKey: "root", + Credential: "kubeconfig", + }, + }, + { + name: "output", + wiringStr: "workflow.abc123.jobs.mydb.outputs.connstr", + wantWorkflowWiring: WorkflowWiring{ + WorkflowID: "abc123", + JobKey: "mydb", + Output: "connstr", + }, + }, + { + name: "dependencies not allowed", + wiringStr: "workflow.abc123.jobs.root.dependencies.mydb.outputs.connstr", + wantErr: "invalid workflow wiring", + }, + } + + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotWiring, err := ParseWorkflowWiring(tc.wiringStr) + if tc.wantErr == "" { + require.Equal(t, tc.wantWorkflowWiring, gotWiring, "incorrect WorkflowWiring was parsed") + } else { + tests.RequireErrorContains(t, err, tc.wantErr) + } + }) + } +} diff --git a/pkg/cnab/extensions_test.go b/pkg/cnab/extensions_test.go index 4045244d6..907018d2d 100644 --- a/pkg/cnab/extensions_test.go +++ b/pkg/cnab/extensions_test.go @@ -1,12 +1,108 @@ package cnab import ( + "fmt" "testing" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" "github.com/cnabio/cnab-go/bundle" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestProcessRequiredExtensions(t *testing.T) { + t.Parallel() + + t.Run("supported", func(t *testing.T) { + t.Parallel() + + bun := ReadTestBundle(t, "testdata/bundle.json") + exts, err := bun.ProcessRequiredExtensions() + require.NoError(t, err, "could not process required extensions") + + expected := ProcessedExtensions{ + "sh.porter.file-parameters": nil, + "io.cnab.dependencies": depsv1ext.Dependencies{ + Requires: map[string]depsv1ext.Dependency{ + "storage": depsv1ext.Dependency{ + Bundle: "somecloud/blob-storage", + }, + "mysql": depsv1ext.Dependency{ + Bundle: "somecloud/mysql", + Version: &depsv1ext.DependencyVersion{ + AllowPrereleases: true, + Ranges: []string{"5.7.x"}, + }, + }, + }, + }, + "io.cnab.parameter-sources": ParameterSources{ + "tfstate": ParameterSource{ + Priority: []string{ParameterSourceTypeOutput}, + Sources: ParameterSourceMap{ + ParameterSourceTypeOutput: OutputParameterSource{"tfstate"}, + }, + }, + "mysql_connstr": ParameterSource{ + Priority: []string{ParameterSourceTypeDependencyOutput}, + Sources: ParameterSourceMap{ + ParameterSourceTypeDependencyOutput: DependencyOutputParameterSource{ + Dependency: "mysql", + OutputName: "connstr", + }, + }, + }, + }, + } + require.Equal(t, expected, exts) + }) + + t.Run("supported unprocessable", func(t *testing.T) { + t.Parallel() + + bun := ReadTestBundle(t, "testdata/bundle-supported-unprocessable.json") + _, err := bun.ProcessRequiredExtensions() + require.EqualError(t, err, "unable to process extension: io.cnab.docker: no custom extension configuration found") + }) + + t.Run("unsupported", func(t *testing.T) { + t.Parallel() + + bun := ReadTestBundle(t, "testdata/bundle-unsupported-required.json") + _, err := bun.ProcessRequiredExtensions() + require.EqualError(t, err, "unsupported required extension: donuts") + }) +} + +func TestGetSupportedExtension(t *testing.T) { + t.Parallel() + + for _, supported := range SupportedExtensions { + t.Run(fmt.Sprintf("%s - shorthand", supported.Shorthand), func(t *testing.T) { + t.Parallel() + + ext, err := GetSupportedExtension(supported.Shorthand) + require.NoError(t, err) + require.Equal(t, supported.Key, ext.Key) + }) + + t.Run(fmt.Sprintf("%s - key", supported.Key), func(t *testing.T) { + t.Parallel() + + ext, err := GetSupportedExtension(supported.Key) + require.NoError(t, err) + require.Equal(t, supported.Key, ext.Key) + }) + } + + t.Run("unsupported", func(t *testing.T) { + t.Parallel() + + _, err := GetSupportedExtension("donuts") + require.EqualError(t, err, "unsupported required extension: donuts") + }) +} + func TestSupportsExtension(t *testing.T) { t.Run("key present", func(t *testing.T) { b := NewBundle(bundle.Bundle{RequiredExtensions: []string{"io.test.thing"}}) diff --git a/pkg/cnab/helpers.go b/pkg/cnab/helpers.go index eca675ed0..eb35b33d5 100644 --- a/pkg/cnab/helpers.go +++ b/pkg/cnab/helpers.go @@ -2,6 +2,7 @@ package cnab import ( "os" + "strconv" "testing" "github.com/cnabio/cnab-go/bundle" @@ -17,3 +18,15 @@ func ReadTestBundle(t *testing.T, path string) ExtendedBundle { return NewBundle(*bun) } + +// TestIDGenerator returns a sequential set of ids (default starting at 0) +// Used for predictable IDs for tests. +type TestIDGenerator struct { + NextID int +} + +func (g *TestIDGenerator) NewID() string { + id := g.NextID + g.NextID++ + return strconv.Itoa(id) +} diff --git a/pkg/cnab/reference.go b/pkg/cnab/oci_reference.go similarity index 100% rename from pkg/cnab/reference.go rename to pkg/cnab/oci_reference.go diff --git a/pkg/cnab/reference_test.go b/pkg/cnab/oci_reference_test.go similarity index 100% rename from pkg/cnab/reference_test.go rename to pkg/cnab/oci_reference_test.go diff --git a/pkg/cnab/parameter_sources_test.go b/pkg/cnab/parameter_sources_test.go index 564b11017..8d705aa60 100644 --- a/pkg/cnab/parameter_sources_test.go +++ b/pkg/cnab/parameter_sources_test.go @@ -1,7 +1,6 @@ package cnab import ( - "os" "testing" "github.com/cnabio/cnab-go/bundle" @@ -55,13 +54,8 @@ func TestProcessedExtensions_GetParameterSourcesExtension(t *testing.T) { func TestReadParameterSourcesProperties(t *testing.T) { t.Parallel() - data, err := os.ReadFile("testdata/bundle.json") - require.NoError(t, err, "cannot read bundle file") - - b, err := bundle.Unmarshal(data) - require.NoError(t, err, "could not unmarshal the bundle") - bun := NewBundle(*b) - assert.True(t, bun.HasParameterSources()) + bun := ReadTestBundle(t, "testdata/bundle.json") + require.True(t, bun.HasParameterSources()) ps, err := bun.ReadParameterSources() require.NoError(t, err, "could not read parameter sources") diff --git a/pkg/cnab/provider/action.go b/pkg/cnab/provider/action.go index 13cbe6ae7..873758971 100644 --- a/pkg/cnab/provider/action.go +++ b/pkg/cnab/provider/action.go @@ -125,11 +125,11 @@ func (r *Runtime) AddRelocation(args ActionArguments) cnabaction.OperationConfig } } -func (r *Runtime) Execute(ctx context.Context, args ActionArguments) error { +func (r *Runtime) Execute(ctx context.Context, args ActionArguments) (storage.Run, storage.Result, error) { // Check if we've been asked to stop before executing long blocking calls select { case <-ctx.Done(): - return ctx.Err() + return storage.Run{}, storage.Result{}, ctx.Err() default: ctx, log := tracing.StartSpan(ctx, attribute.String("action", args.Action), @@ -140,33 +140,33 @@ func (r *Runtime) Execute(ctx context.Context, args ActionArguments) error { args.Installation.AddToTrace(ctx) if args.Action == "" { - return log.Error(errors.New("action is required")) + return storage.Run{}, storage.Result{}, log.Error(errors.New("action is required")) } b, err := r.ProcessBundle(ctx, args.BundleReference.Definition) if err != nil { - return log.Error(err) + return storage.Run{}, storage.Result{}, log.Error(err) } currentRun, err := r.CreateRun(ctx, args, b) if err != nil { - return log.Error(err) + return storage.Run{}, storage.Result{}, log.Error(err) } // Validate the action if _, err := b.GetAction(currentRun.Action); err != nil { - return log.Error(fmt.Errorf("invalid action '%s' specified for bundle %s: %w", currentRun.Action, b.Name, err)) + return storage.Run{}, storage.Result{}, log.Error(fmt.Errorf("invalid action '%s' specified for bundle %s: %w", currentRun.Action, b.Name, err)) } creds, err := r.loadCredentials(ctx, b, args) if err != nil { - return log.Error(fmt.Errorf("not load credentials: %w", err)) + return storage.Run{}, storage.Result{}, log.Error(fmt.Errorf("could not load credentials: %w", err)) } log.Debugf("Using runtime driver %s\n", args.Driver) driver, err := r.newDriver(args.Driver, args) if err != nil { - return log.Error(fmt.Errorf("unable to instantiate driver: %w", err)) + return storage.Run{}, storage.Result{}, log.Error(fmt.Errorf("unable to instantiate driver: %w", err)) } a := cnabaction.New(driver) @@ -175,7 +175,7 @@ func (r *Runtime) Execute(ctx context.Context, args ActionArguments) error { if currentRun.ShouldRecord() { err = r.SaveRun(ctx, args.Installation, currentRun, cnab.StatusRunning) if err != nil { - return log.Error(fmt.Errorf("could not save the pending action's status, the bundle was not executed: %w", err)) + return currentRun, storage.Result{}, log.Error(fmt.Errorf("could not save the pending action's status, the bundle was not executed: %w", err)) } } @@ -187,19 +187,20 @@ func (r *Runtime) Execute(ctx context.Context, args ActionArguments) error { tracing.ObjectAttribute("cnab-credentials", cnabCreds)) opResult, result, err := a.Run(cnabClaim, cnabCreds, r.ApplyConfig(ctx, args)...) + currentResult := currentRun.NewResultFrom(result) if currentRun.ShouldRecord() { if err != nil { err = r.appendFailedResult(ctx, err, currentRun) - return log.Error(fmt.Errorf("failed to record that %s for installation %s failed: %w", args.Action, args.Installation.Name, err)) + return currentRun, currentResult, log.Error(fmt.Errorf("failed to record that %s for installation %s failed: %w", args.Action, args.Installation.Name, err)) } - return r.SaveOperationResult(ctx, opResult, args.Installation, currentRun, currentRun.NewResultFrom(result)) + return currentRun, currentResult, r.SaveOperationResult(ctx, opResult, args.Installation, currentRun, currentResult) } if err != nil { - return log.Error(fmt.Errorf("execution of %s for installation %s failed: %w", args.Action, args.Installation.Name, err)) + return currentRun, currentResult, log.Error(fmt.Errorf("execution of %s for installation %s failed: %w", args.Action, args.Installation.Name, err)) } - return nil + return currentRun, currentResult, nil } } diff --git a/pkg/cnab/provider/helpers.go b/pkg/cnab/provider/helpers.go index 84ea423d7..a25cba8b7 100644 --- a/pkg/cnab/provider/helpers.go +++ b/pkg/cnab/provider/helpers.go @@ -69,7 +69,7 @@ func (t *TestRuntime) LoadTestBundle(bundleFile string) cnab.ExtendedBundle { return bun } -func (t *TestRuntime) Execute(ctx context.Context, args ActionArguments) error { +func (t *TestRuntime) Execute(ctx context.Context, args ActionArguments) (storage.Run, storage.Result, error) { if args.Driver == "" { args.Driver = debugDriver } diff --git a/pkg/cnab/provider/provider.go b/pkg/cnab/provider/provider.go index d7f01af36..d4e21266c 100644 --- a/pkg/cnab/provider/provider.go +++ b/pkg/cnab/provider/provider.go @@ -3,11 +3,13 @@ package cnabprovider import ( "context" + "get.porter.sh/porter/pkg/storage" + "get.porter.sh/porter/pkg/cnab" ) // CNABProvider is the interface Porter uses to communicate with the CNAB runtime type CNABProvider interface { LoadBundle(bundleFile string) (cnab.ExtendedBundle, error) - Execute(ctx context.Context, arguments ActionArguments) error + Execute(ctx context.Context, arguments ActionArguments) (storage.Run, storage.Result, error) } diff --git a/pkg/cnab/required.go b/pkg/cnab/required.go deleted file mode 100644 index 98888eba6..000000000 --- a/pkg/cnab/required.go +++ /dev/null @@ -1,65 +0,0 @@ -package cnab - -import "fmt" - -// RequiredExtension represents a required extension that is known and supported by Porter -type RequiredExtension struct { - Shorthand string - Key string - Schema string - Reader func(b ExtendedBundle) (interface{}, error) -} - -// SupportedExtensions represent a listing of the current required extensions -// that Porter supports -var SupportedExtensions = []RequiredExtension{ - DependenciesV1Extension, - DockerExtension, - FileParameterExtension, - ParameterSourcesExtension, -} - -// ProcessedExtensions represents a map of the extension name to the -// processed extension configuration -type ProcessedExtensions map[string]interface{} - -// ProcessRequiredExtensions checks all required extensions in the provided -// bundle and makes sure Porter supports them. -// -// If an unsupported required extension is found, an error is returned. -// -// For each supported required extension, the configuration for that extension -// is read and returned in the form of a map of the extension name to -// the extension configuration -func (b ExtendedBundle) ProcessRequiredExtensions() (ProcessedExtensions, error) { - processed := ProcessedExtensions{} - for _, reqExt := range b.RequiredExtensions { - supportedExtension, err := GetSupportedExtension(reqExt) - if err != nil { - return processed, err - } - - raw, err := supportedExtension.Reader(b) - if err != nil { - return processed, fmt.Errorf("unable to process extension: %s: %w", reqExt, err) - } - - processed[supportedExtension.Key] = raw - } - - return processed, nil -} - -// GetSupportedExtension returns a supported extension according to the -// provided name, or an error -func GetSupportedExtension(e string) (*RequiredExtension, error) { - for _, ext := range SupportedExtensions { - // TODO(v1) we should only check for the key in v1.0.0 - // We are checking for both because of a bug in the cnab dependencies spec - // https://github.com/cnabio/cnab-spec/issues/403 - if e == ext.Key || e == ext.Shorthand { - return &ext, nil - } - } - return nil, fmt.Errorf("unsupported required extension: %s", e) -} diff --git a/pkg/cnab/required_test.go b/pkg/cnab/required_test.go deleted file mode 100644 index 433388cd0..000000000 --- a/pkg/cnab/required_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package cnab - -import ( - "fmt" - "testing" - - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" - "github.com/stretchr/testify/require" -) - -func TestProcessRequiredExtensions(t *testing.T) { - t.Parallel() - - t.Run("supported", func(t *testing.T) { - t.Parallel() - - bun := ReadTestBundle(t, "testdata/bundle.json") - exts, err := bun.ProcessRequiredExtensions() - require.NoError(t, err, "could not process required extensions") - - expected := ProcessedExtensions{ - "sh.porter.file-parameters": nil, - "io.cnab.dependencies": depsv1.Dependencies{ - Requires: map[string]depsv1.Dependency{ - "storage": depsv1.Dependency{ - Bundle: "somecloud/blob-storage", - }, - "mysql": depsv1.Dependency{ - Bundle: "somecloud/mysql", - Version: &depsv1.DependencyVersion{ - AllowPrereleases: true, - Ranges: []string{"5.7.x"}, - }, - }, - }, - }, - "io.cnab.parameter-sources": ParameterSources{ - "tfstate": ParameterSource{ - Priority: []string{ParameterSourceTypeOutput}, - Sources: ParameterSourceMap{ - ParameterSourceTypeOutput: OutputParameterSource{"tfstate"}, - }, - }, - "mysql_connstr": ParameterSource{ - Priority: []string{ParameterSourceTypeDependencyOutput}, - Sources: ParameterSourceMap{ - ParameterSourceTypeDependencyOutput: DependencyOutputParameterSource{ - Dependency: "mysql", - OutputName: "connstr", - }, - }, - }, - }, - } - require.Equal(t, expected, exts) - }) - - t.Run("supported unprocessable", func(t *testing.T) { - t.Parallel() - - bun := ReadTestBundle(t, "testdata/bundle-supported-unprocessable.json") - _, err := bun.ProcessRequiredExtensions() - require.EqualError(t, err, "unable to process extension: io.cnab.docker: no custom extension configuration found") - }) - - t.Run("unsupported", func(t *testing.T) { - t.Parallel() - - bun := ReadTestBundle(t, "testdata/bundle-unsupported-required.json") - _, err := bun.ProcessRequiredExtensions() - require.EqualError(t, err, "unsupported required extension: donuts") - }) -} - -func TestGetSupportedExtension(t *testing.T) { - t.Parallel() - - for _, supported := range SupportedExtensions { - t.Run(fmt.Sprintf("%s - shorthand", supported.Shorthand), func(t *testing.T) { - t.Parallel() - - ext, err := GetSupportedExtension(supported.Shorthand) - require.NoError(t, err) - require.Equal(t, supported.Key, ext.Key) - }) - - t.Run(fmt.Sprintf("%s - key", supported.Key), func(t *testing.T) { - t.Parallel() - - ext, err := GetSupportedExtension(supported.Key) - require.NoError(t, err) - require.Equal(t, supported.Key, ext.Key) - }) - } - - t.Run("unsupported", func(t *testing.T) { - t.Parallel() - - _, err := GetSupportedExtension("donuts") - require.EqualError(t, err, "unsupported required extension: donuts") - }) -} diff --git a/pkg/cnab/testdata/bundle-depsv2.json b/pkg/cnab/testdata/bundle-depsv2.json new file mode 100644 index 000000000..a60f87453 --- /dev/null +++ b/pkg/cnab/testdata/bundle-depsv2.json @@ -0,0 +1,140 @@ +{ + "name": "foo", + "version": "1.0", + "schemaVersion": "99.99", + "invocationImages": [ + { + "imageType": "docker", + "image": "technosophos/helloworld:0.1.0" + } + ], + "images": { + "image1": { + "description": "image1", + "image": "urn:image1uri", + "refs": [ + { + "path": "image1path", + "field": "image.1.field" + } + ] + }, + "image2": { + "name": "image2", + "uri": "urn:image2uri", + "refs": [ + { + "path": "image2path", + "field": "image.2.field" + } + ] + } + }, + "credentials": { + "foo": { + "path": "pfoo" + }, + "bar": { + "env": "ebar" + }, + "quux": { + "path": "pquux", + "env": "equux" + } + }, + "requiredExtensions": [ + "sh.porter.dependencies.v2", + "io.cnab.parameter-sources", + "sh.porter.file-parameters" + ], + "custom": { + "com.example.duffle-bag": { + "icon": "https://example.com/icon.png", + "iconType": "PNG" + }, + "com.example.backup-preferences": { + "enabled": true, + "frequency": "daily" + }, + "sh.porter.dependencies.v2": { + "requires": { + "storage": { + "bundle": "somecloud/blob-storage" + }, + "mysql": { + "bundle": "somecloud/mysql", + "version": "5.7.x" + } + } + }, + "io.cnab.parameter-sources": { + "tfstate": { + "priority": ["output"], + "sources": { + "output": { + "name": "tfstate" + } + } + }, + "mysql_connstr": { + "priority": ["dependencies.output"], + "sources": { + "dependencies.output": { + "dependency": "mysql", + "name": "connstr" + } + } + } + } + }, + "definitions": { + "complexThing": { + "type": "object", + "properties": { + "host": { + "default": "localhost", + "type": "string", + "minLength": 3, + "maxLength": 10 + }, + "port": { + "type": "integer", + "minimum": 8000 + } + }, + "required": [ + "port" + ] + }, + "mysql_connstr": { + "type": "string" + }, + "tfstate": { + "contentEncoding": "base64", + "type": "string" + } + }, + "parameters": { + "serverConfig": { + "definition": "complexThing", + "destination": { + "path": "/cnab/is/go" + } + }, + "tfstate": { + "applyTo": [ "upgrade", "uninstall" ], + "definition": "tfstate", + "required": true + }, + "mysql_connstr": { + "definition": "mysql_connstr" + } + }, + "outputs": { + "tfstate": { + "applyTo": [ "install", "upgrade", "uninstall" ], + "definition": "tfstate", + "path": "/cnab/app/outputs/tfstate" + } + } +} diff --git a/pkg/cnab/ulid.go b/pkg/cnab/ulid.go new file mode 100644 index 000000000..96b83615e --- /dev/null +++ b/pkg/cnab/ulid.go @@ -0,0 +1,18 @@ +package cnab + +import ( + cnabclaims "github.com/cnabio/cnab-go/claim" +) + +// IDGenerator is a test friendly interface for swapping out how we generate IDs. +type IDGenerator interface { + // NewID returns a new unique ID. + NewID() string +} + +// ULIDGenerator creates IDs that are ULIDs. +type ULIDGenerator struct{} + +func (g ULIDGenerator) NewID() string { + return cnabclaims.MustNewULID() +} diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index d1f2d389a..2e349ae41 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -273,6 +273,34 @@ func (m *Manifest) GetTemplatedDependencyOutputs() DependencyOutputReferences { return outputs } +// DetermineDependenciesExtensionUsed looks for how dependencies are used +// by the bundle and which version of the dependency extension can be used. +func (m *Manifest) DetermineDependenciesExtensionUsed() string { + if len(m.Dependencies.Requires) == 0 { + // dependencies are not used at all + return "" + } + + // Check if v2 deps are explicitly specified + for _, ext := range m.Required { + if ext.Name == cnab.DependenciesV2ExtensionShortHand || + ext.Name == cnab.DependenciesV2ExtensionKey { + return cnab.DependenciesV2ExtensionKey + } + } + + // Check each dependency for use of v2 only features + for _, dep := range m.Dependencies.Requires { + if dep.Installation != nil || + len(dep.Credentials) > 0 || + dep.Bundle.Interface != nil { + return cnab.DependenciesV2ExtensionKey + } + } + + return cnab.DependenciesV1ExtensionKey +} + type CustomDefinitions map[string]interface{} func (cd *CustomDefinitions) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -623,18 +651,36 @@ func (mi *MappedImage) ToOCIReference() (cnab.OCIReference, error) { return ref, nil } +// Dependencies specifies other bundles that the current bundle depends up on to run. type Dependencies struct { + // Requires specifies bundles required by the current bundle. Requires []*Dependency `yaml:"requires,omitempty"` } +// Dependency defines a bundle dependency. type Dependency struct { + // Name of the dependency, used to reference the dependency from other parts of + // the bundle such as the template syntax, bundle.dependencies.NAME Name string `yaml:"name"` + // Bundle specifies criteria for selecting a bundle to satisfy the dependency. Bundle BundleCriteria `yaml:"bundle"` + // Installation specifies criteria for selecting an installation to satisfy the dependency. + Installation *DependencyInstallationConfig `yaml:"installation,omitempty"` + + // Parameters to pass from the bundle to the dependency. + // May either be a hard-coded value, or a template value such as bundle.parameters.NAME Parameters map[string]string `yaml:"parameters,omitempty"` + + // Credentials to pass from the bundle to the dependency. + // May either be a hard-coded value, or a template value such as bundle.credentials.NAME + Credentials map[string]string `yaml:"credentials,omitempty"` } +type DependencySource string + +// BundleCriteria criteria for selecting a bundle to satisfy a dependency. type BundleCriteria struct { // Reference is the full bundle reference for the dependency // in the format REGISTRY/NAME:TAG @@ -646,6 +692,48 @@ type BundleCriteria struct { // If you want to have it include pre-releases a simple solution is to include -0 in your range." // https://github.com/Masterminds/semver/blob/master/README.md#checking-version-constraints Version string `yaml:"version,omitempty"` + + // Interface specifies criteria for allowing a bundle to satisfy a dependency. + Interface *BundleInterface `yaml:"interface,omitempty"` +} + +// BundleInterface specifies how a bundle can satisfy a dependency. +type BundleInterface struct { + Reference string `yaml:"reference,omitempty"` + Document *BundleInterfaceDocument `yaml:"document,omitempty"` +} + +// BundleInterfaceDocument specifies the interface that a bundle must support in +// order to satisfy a dependency. +type BundleInterfaceDocument struct { + Parameters ParameterDefinitions `yaml:"parameters,omitempty"` + Credentials CredentialDefinitions `yaml:"credentials,omitempty"` + Outputs OutputDefinitions `yaml:"outputs,omitempty"` +} + +// DependencyInstallationConfig specifies how an installation is created or +// reused to satisfy a dependency. +type DependencyInstallationConfig struct { + Labels map[string]string `yaml:"labels,omitempty"` + Criteria *InstallationCriteria `yaml:"criteria,omitempty"` +} + +// InstallationCriteria specifies criteria for selecting an installation to satisfy the dependency. +type InstallationCriteria struct { + // MatchInterface specifies that the dependency must only match the bundle + // interface, and not the bundle repository from the reference. Defaults to + // false, so that the bundle must be the same. + MatchInterface bool `yaml:"matchInterface,omitempty"` + + // MatchNamespace specifies that the existing installation must be in the same + // namespace and cannot be a global installation. Defaults to false, which allows + // reusing global installations as dependencies. + MatchNamespace bool `yaml:"matchNamespace,omitempty"` + + // IgnoreLabels allows reusing an existing installation that does not + // have the labels specified above. By default, the labels must match to reuse an + // installation. + IgnoreLabels bool `yaml:"ignoreLabels,omitempty"` } func (d *Dependency) Validate(cxt *portercontext.Context) error { @@ -657,8 +745,13 @@ func (d *Dependency) Validate(cxt *portercontext.Context) error { return fmt.Errorf("reference is required for dependency %q", d.Name) } - if strings.Contains(d.Bundle.Reference, ":") && len(d.Bundle.Version) > 0 { - return fmt.Errorf("reference for dependency %q can only specify REGISTRY/NAME when version ranges are specified", d.Name) + ref, err := cnab.ParseOCIReference(d.Bundle.Reference) + if err != nil { + return fmt.Errorf("invalid reference %s for dependency %s: %w", d.Bundle.Reference, d.Name, err) + } + + if ref.IsRepositoryOnly() && d.Bundle.Version == "" { + return fmt.Errorf("reference for dependency %q can specify only a repository, without a digest or tag, when a version constraint is specified", d.Name) } return nil diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index 462e50d56..592453c5e 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -5,7 +5,9 @@ import ( "os" "testing" + "get.porter.sh/porter/pkg/cnab" "get.porter.sh/porter/pkg/config" + "get.porter.sh/porter/pkg/experimental" "get.porter.sh/porter/pkg/portercontext" "get.porter.sh/porter/pkg/schema" "get.porter.sh/porter/pkg/yaml" @@ -70,48 +72,63 @@ func TestLoadManifest(t *testing.T) { } func TestLoadManifestWithDependencies(t *testing.T) { - c := config.NewTestConfig(t) - - c.TestContext.AddTestFile("testdata/porter.yaml", config.Name) - c.TestContext.AddTestDirectory("testdata/bundles", "bundles") - - m, err := LoadManifestFrom(context.Background(), c.Config, config.Name) - require.NoError(t, err, "could not load manifest") - - require.NotNil(t, m) - assert.Equal(t, []MixinDeclaration{{Name: "exec"}}, m.Mixins) - require.Len(t, m.Install, 1) - - installStep := m.Install[0] - description, _ := installStep.GetDescription() - require.NotNil(t, description) - - mixin := installStep.GetMixinName() - assert.Equal(t, "exec", mixin) - - require.Len(t, m.Dependencies.Requires, 1, "expected one dependency") - assert.Equal(t, "getporter/azure-mysql:5.7", m.Dependencies.Requires[0].Bundle.Reference, "expected a v1 schema for the dependency delcaration") -} - -func TestLoadManifestWithDependenciesInOrder(t *testing.T) { - c := config.NewTestConfig(t) - - c.TestContext.AddTestFile("testdata/porter-with-deps.yaml", config.Name) - c.TestContext.AddTestDirectory("testdata/bundles", "bundles") - - m, err := LoadManifestFrom(context.Background(), c.Config, config.Name) - require.NoError(t, err, "could not load manifest") - assert.NotNil(t, m) + // Make sure that we can parse the bundle in both v1 dep mode and v2 dep mode + testcases := []struct { + name string + depsv2enabled bool + }{ + {"deps v1", false}, + {"deps v2", true}, + } - nginxDep := m.Dependencies.Requires[0] - assert.Equal(t, "nginx", nginxDep.Name) - assert.Equal(t, "localhost:5000/nginx:1.19", nginxDep.Bundle.Reference) + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { - mysqlDep := m.Dependencies.Requires[1] - assert.Equal(t, "mysql", mysqlDep.Name) - assert.Equal(t, "getporter/azure-mysql:5.7", mysqlDep.Bundle.Reference) - assert.Len(t, mysqlDep.Parameters, 1) + c := config.NewTestConfig(t) + if tc.depsv2enabled { + c.SetExperimentalFlags(experimental.FlagDependenciesV2) + } + c.TestContext.AddTestFile("testdata/porter.yaml", config.Name) + c.TestContext.AddTestDirectory("testdata/bundles", "bundles") + + m, err := LoadManifestFrom(context.Background(), c.Config, config.Name) + require.NoError(t, err, "could not load manifest") + + require.NotNil(t, m) + assert.Equal(t, []MixinDeclaration{{Name: "exec"}}, m.Mixins) + require.Len(t, m.Install, 1) + + installStep := m.Install[0] + description, _ := installStep.GetDescription() + require.NotNil(t, description) + + mixin := installStep.GetMixinName() + assert.Equal(t, "exec", mixin) + + require.Len(t, m.Dependencies.Requires, 1, "expected one dependency") + dep := m.Dependencies.Requires[0] + assert.Equal(t, "getporter/azure-mysql:5.7", dep.Bundle.Reference, "expected the dependency to be set") + assert.Equal(t, "5.7.x", dep.Bundle.Version, "expected the version range to be set") + assert.Equal(t, map[string]string{"database-name": "wordpress"}, dep.Parameters, "expected the dependency parameters to be set") + + // The remaining fields are only supported in depsv2 but the manifest still parses them. It's only a behavior difference if we act on the information or not. + assert.Equal(t, map[string]string{"password": "mcstuffins"}, dep.Credentials, "expected the dependency credentials to be set") + + // TODO(PEP003) validate the bundle interface document + /* + wantDoc := &BundleInterfaceDocument{ + Parameters: map[string]ParameterDefinition{ + "password": { + Name: "password", + Schema: definition.Schema{Type: "string"}}, + }, + } + assert.Equal(t, "getporter/azure-mysql:5.7-interface", dep.Bundle.Interface.Reference, "expected the bundle interface reference to be set") + assert.Equal(t, wantDoc, dep.Bundle.Interface.Document, "expected the bundle interface document to be set") + */ + }) + } } func TestAction_Validate_RequireMixinDeclaration(t *testing.T) { @@ -847,3 +864,104 @@ func TestManifest_getTemplatePrefix(t *testing.T) { }) } } + +func TestManifest_DetermineDependenciesExtensionUsed(t *testing.T) { + t.Run("no dependencies used", func(t *testing.T) { + m := Manifest{} + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Empty(t, depsExt) + }) + + t.Run("v1 features only", func(t *testing.T) { + m := Manifest{ + Dependencies: Dependencies{Requires: []*Dependency{ + { + Name: "mysql", + Bundle: BundleCriteria{Reference: "mysql:5.7", Version: "5.7 - 6"}, + Parameters: map[string]string{"loglevel": "4"}, + }, + }}, + } + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Equal(t, cnab.DependenciesV1ExtensionKey, depsExt) + }) + + t.Run("v2 declared but no deps defined", func(t *testing.T) { + m := Manifest{ + Required: []RequiredExtension{ + {Name: cnab.DependenciesV2ExtensionShortHand}, + }, + } + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Empty(t, depsExt) + }) + + t.Run("v2 shorthand declared", func(t *testing.T) { + m := Manifest{ + Required: []RequiredExtension{ + {Name: cnab.DependenciesV2ExtensionShortHand}, + }, + Dependencies: Dependencies{Requires: []*Dependency{ + {Name: "mysql", Bundle: BundleCriteria{Reference: "mysql:5.7", Version: "5.7 - 6"}}, + }}, + } + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Equal(t, cnab.DependenciesV2ExtensionKey, depsExt) + }) + + t.Run("v2 full key declared", func(t *testing.T) { + m := Manifest{ + Required: []RequiredExtension{ + {Name: cnab.DependenciesV2ExtensionKey}, + }, + Dependencies: Dependencies{Requires: []*Dependency{ + {Name: "mysql", Bundle: BundleCriteria{Reference: "mysql:5.7", Version: "5.7 - 6"}}, + }}, + } + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Equal(t, cnab.DependenciesV2ExtensionKey, depsExt) + }) + + t.Run("bundle interface criteria used", func(t *testing.T) { + m := Manifest{ + Dependencies: Dependencies{Requires: []*Dependency{ + { + Name: "mysql", + Bundle: BundleCriteria{ + Reference: "mysql:5.7", + Version: "5.7 - 6", + Interface: &BundleInterface{}}}, + }}, + } + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Equal(t, cnab.DependenciesV2ExtensionKey, depsExt) + }) + + t.Run("installation criteria used", func(t *testing.T) { + m := Manifest{ + Dependencies: Dependencies{Requires: []*Dependency{ + { + Name: "mysql", + Bundle: BundleCriteria{Reference: "mysql:5.7", Version: "5.7 - 6"}, + Installation: &DependencyInstallationConfig{}, + }, + }}, + } + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Equal(t, cnab.DependenciesV2ExtensionKey, depsExt) + }) + + t.Run("credential wiring used", func(t *testing.T) { + m := Manifest{ + Dependencies: Dependencies{Requires: []*Dependency{ + { + Name: "mysql", + Bundle: BundleCriteria{Reference: "mysql:5.7", Version: "5.7 - 6"}, + Credentials: map[string]string{"kubeconfig": "bundle.credentials.kubeconfig"}, + }, + }}, + } + depsExt := m.DetermineDependenciesExtensionUsed() + assert.Equal(t, cnab.DependenciesV2ExtensionKey, depsExt) + }) +} diff --git a/pkg/manifest/testdata/porter.yaml b/pkg/manifest/testdata/porter.yaml index 407bbda68..48d0f0dea 100644 --- a/pkg/manifest/testdata/porter.yaml +++ b/pkg/manifest/testdata/porter.yaml @@ -6,13 +6,24 @@ registry: example.com mixins: - exec +# contains a mix of v1 and v2 supported syntax which lights up depending on what is enabled in config dependencies: requires: - name: mysql bundle: reference: "getporter/azure-mysql:5.7" + version: 5.7.x + # TODO(PEP003): Implement in https://github.com/getporter/porter/issues/2548 + #interface: + # reference: "getporter/azure-mysql:5.7-interface" + # document: + # parameters: + # - name: password + # type: string parameters: database-name: wordpress + credentials: + password: mcstuffins install: - exec: @@ -34,4 +45,3 @@ required: - requiredExtension1 - requiredExtension2: config: true - diff --git a/pkg/porter/action.go b/pkg/porter/action.go index bb4ed3e7e..a2f21921f 100644 --- a/pkg/porter/action.go +++ b/pkg/porter/action.go @@ -1,28 +1,125 @@ package porter import ( + "bytes" "context" + "fmt" + + "go.opentelemetry.io/otel/attribute" + + "get.porter.sh/porter/pkg/printer" + "get.porter.sh/porter/pkg/tracing" "get.porter.sh/porter/pkg/storage" ) -// ExecuteAction runs the specified action. Supported actions are: install, upgrade, invoke. -// The uninstall action works in reverse so it's implemented separately. -func (p *Porter) ExecuteAction(ctx context.Context, installation storage.Installation, action BundleAction) error { - deperator := newDependencyExecutioner(p, installation, action) - err := deperator.Prepare(ctx) - if err != nil { - return err +// ExecuteBundleAndDependencies runs a specified action for a root bundle. +// The bundle should be a root bundle, and if there are dependencies, they will also be executed as appropriate. +// Supported actions are: install, upgrade, invoke. +// The uninstall action works in reverse, so it's implemented separately. +// Dependencies are resolved and executed differently depending on whether the deps-v2 feature is enabled (workflow). +func (p *Porter) ExecuteBundleAndDependencies(ctx context.Context, installation storage.Installation, action BundleAction) error { + // Callers should check for a noop action (because the installation is up-to-date, but let's check too just in case + if action == nil { + return nil } - err = deperator.Execute(ctx) - if err != nil { + opts := action.GetOptions() + bundleRef := opts.bundleRef + + ctx, span := tracing.StartSpan(ctx, + tracing.ObjectAttribute("installation", installation), + attribute.String("action", action.GetAction()), + attribute.Bool("dry-run", opts.DryRun), + ) + defer span.EndSpan() + + // Switch between our two dependency implementations + depsv2 := p.useWorkflowEngine(bundleRef.Definition) + span.SetAttributes(attribute.Bool("deps-v2", depsv2)) + + if depsv2 { + // TODO(PEP003): Use new getregistryoptions elsewhere that we create that + puller := NewBundleResolver(p.Cache, opts.Force, p.Registry, opts.GetRegistryOptions()) + eng := NewWorkflowEngine(installation.Namespace, puller, p.Installations, p) + workflowOpts := CreateWorkflowOptions{ + Installation: installation, + Bundle: bundleRef.Definition, + DebugMode: opts.DebugMode, + MaxParallel: 1, + } + w, err := eng.CreateWorkflow(ctx, workflowOpts) + if err != nil { + return err + } + + if opts.DryRun { + span.Info("Skipping workflow execution because --dry-run was specified") + + // TODO(PEP003): It would be better to have a way to always emit something to stdout, and capture it in the trace at the same time + var buf bytes.Buffer + dw := NewDisplayWorkflow(w).AsSpecOnly() + err = printer.PrintYaml(&buf, dw) + fmt.Fprintln(p.Out, buf.String()) + span.SetAttributes(attribute.String("workflow", buf.String())) + + // TODO(PEP003): Print out the generated workflow according to opts.Format + // TODO(PEP003): how do we want to get Format in here so we can print properly? + return err + } + + if err := p.Installations.InsertWorkflow(ctx, w); err != nil { + return err + } + + return eng.RunWorkflow(ctx, w) + } else { // Fallback to the old implementation of dependencies and bundle execution + if opts.DryRun { + span.Info("Skipping bundle execution because --dry-run was specified") + return nil + } + + deperator := newDependencyExecutioner(p, installation, action) + err := deperator.Prepare(ctx) + if err != nil { + return err + } + + err = deperator.Execute(ctx) + if err != nil { + return err + } + + actionArgs, err := deperator.PrepareRootActionArguments(ctx) + if err != nil { + return err + } + + _, _, err = p.CNAB.Execute(ctx, actionArgs) return err } +} + +// ExecuteRootBundleOnly runs a single bundle that has already had its dependencies resolved by a workflow. +// The workflow is responsible identifying the bundles to run, their order, what to pass between them, etc. +// It is only intended to be used with the deps-v2 feature. +func (p *Porter) ExecuteRootBundleOnly(ctx context.Context, installation storage.Installation, action BundleAction) (storage.Run, storage.Result, error) { + // Callers should check for a noop action (because the installation is up-to-date, but let's check too just in case + if action == nil { + return storage.Run{}, storage.Result{}, nil + } - actionArgs, err := deperator.PrepareRootActionArguments(ctx) + opts := action.GetOptions() + ctx, span := tracing.StartSpan(ctx, + tracing.ObjectAttribute("installation", installation), + attribute.String("action", action.GetAction()), + attribute.Bool("dry-run", opts.DryRun), + ) + defer span.EndSpan() + + actionArgs, err := p.BuildActionArgs(ctx, installation, action) if err != nil { - return err + return storage.Run{}, storage.Result{}, err } return p.CNAB.Execute(ctx, actionArgs) diff --git a/pkg/porter/apply.go b/pkg/porter/apply.go index 31fd95bf0..e318b0bcb 100644 --- a/pkg/porter/apply.go +++ b/pkg/porter/apply.go @@ -8,13 +8,14 @@ import ( "get.porter.sh/porter/pkg/encoding" "get.porter.sh/porter/pkg/portercontext" "get.porter.sh/porter/pkg/printer" - "get.porter.sh/porter/pkg/storage" "get.porter.sh/porter/pkg/tracing" "go.opentelemetry.io/otel/attribute" "go.uber.org/zap/zapcore" ) type ApplyOptions struct { + printer.PrintOptions + Namespace string File string @@ -47,24 +48,24 @@ func (o *ApplyOptions) Validate(cxt *portercontext.Context, args []string) error return fmt.Errorf("invalid file argument %s, must be a file not a directory", o.File) } - return nil + return o.PrintOptions.Validate(ApplyDefaultFormat, ApplyAllowedFormats) } func (p *Porter) InstallationApply(ctx context.Context, opts ApplyOptions) error { - ctx, log := tracing.StartSpan(ctx) - defer log.EndSpan() + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() - log.Debugf("Reading input file %s", opts.File) + span.Debugf("Reading input file %s", opts.File) namespace, err := p.getNamespaceFromFile(opts) if err != nil { return err } - if log.ShouldLog(zapcore.DebugLevel) { + if span.ShouldLog(zapcore.DebugLevel) { // ignoring any error here, printing debug info isn't critical contents, _ := p.FileSystem.ReadFile(opts.File) - log.Debug("read input file", attribute.String("contents", string(contents))) + span.Debug("read input file", attribute.String("contents", string(contents))) } var input DisplayInstallation @@ -72,38 +73,17 @@ func (p *Porter) InstallationApply(ctx context.Context, opts ApplyOptions) error return fmt.Errorf("unable to parse %s as an installation document: %w", opts.File, err) } input.Namespace = namespace - inputInstallation, err := input.ConvertToInstallation() + inst, err := input.ConvertToInstallation() if err != nil { return err } - installation, err := p.Installations.GetInstallation(ctx, inputInstallation.Namespace, inputInstallation.Name) - if err != nil { - if !errors.Is(err, storage.ErrNotFound{}) { - return fmt.Errorf("could not query for an existing installation document for %s: %w", inputInstallation, err) - } - - // Create a new installation - installation = storage.NewInstallation(input.Namespace, input.Name) - installation.Apply(inputInstallation.InstallationSpec) - - log.Info("Creating a new installation", attribute.String("installation", installation.String())) - } else { - // Apply the specified changes to the installation - installation.Apply(inputInstallation.InstallationSpec) - if err := installation.Validate(); err != nil { - return err - } - - fmt.Fprintf(p.Err, "Updating %s installation\n", installation) - } - + span.Info("Reconciling installation") reconcileOpts := ReconcileOptions{ - Namespace: input.Namespace, - Name: input.Name, - Installation: installation, + Installation: inst.InstallationSpec, Force: opts.Force, DryRun: opts.DryRun, + Format: opts.Format, } - return p.ReconcileInstallation(ctx, reconcileOpts) + return p.ReconcileInstallationAndDependencies(ctx, reconcileOpts) } diff --git a/pkg/porter/build.go b/pkg/porter/build.go index 51a1f3f08..3a5bac789 100644 --- a/pkg/porter/build.go +++ b/pkg/porter/build.go @@ -232,7 +232,7 @@ func (p *Porter) buildBundle(ctx context.Context, m *manifest.Manifest, digest d return p.writeBundle(bun) } -func (p Porter) writeBundle(b cnab.ExtendedBundle) error { +func (p *Porter) writeBundle(b cnab.ExtendedBundle) error { f, err := p.Config.FileSystem.OpenFile(build.LOCAL_BUNDLE, os.O_RDWR|os.O_CREATE|os.O_TRUNC, pkg.FileModeWritable) if err != nil { return fmt.Errorf("error creating %s: %w", build.LOCAL_BUNDLE, err) diff --git a/pkg/porter/dependencies.go b/pkg/porter/dependencies.go index e9f0a7d4b..380f3c093 100644 --- a/pkg/porter/dependencies.go +++ b/pkg/porter/dependencies.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" + "get.porter.sh/porter/pkg/cnab" depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" cnabprovider "get.porter.sh/porter/pkg/cnab/provider" @@ -21,7 +23,7 @@ type dependencyExecutioner struct { *config.Config porter *Porter - Resolver BundleResolver + resolver BundleResolver CNAB cnabprovider.CNABProvider Installations storage.InstallationProvider @@ -35,24 +37,24 @@ type dependencyExecutioner struct { } func newDependencyExecutioner(p *Porter, installation storage.Installation, action BundleAction) *dependencyExecutioner { - resolver := BundleResolver{ - Cache: p.Cache, - Registry: p.Registry, - } + parentOpts := action.GetOptions() + regOpts := cnabtooci.RegistryOptions{InsecureRegistry: parentOpts.InsecureRegistry} + resolver := NewBundleResolver(p.Cache, parentOpts.Force, p.Registry, regOpts) + return &dependencyExecutioner{ porter: p, parentInstallation: installation, parentAction: action, - parentOpts: action.GetOptions(), + parentOpts: parentOpts, + resolver: resolver, Config: p.Config, - Resolver: resolver, CNAB: p.CNAB, Installations: p.Installations, } } type queuedDependency struct { - cnab.DependencyLock + depsv1.DependencyLock BundleReference cnab.BundleReference Parameters map[string]string @@ -140,7 +142,7 @@ func (e *dependencyExecutioner) identifyDependencies(ctx context.Context) error } bun = bundle } else if e.parentOpts.Reference != "" { - cachedBundle, err := e.Resolver.Resolve(ctx, e.parentOpts.BundlePullOptions) + cachedBundle, err := e.resolver.GetBundle(ctx, e.parentOpts.GetReference()) if err != nil { return span.Error(fmt.Errorf("could not resolve bundle: %w", err)) } @@ -158,7 +160,7 @@ func (e *dependencyExecutioner) identifyDependencies(ctx context.Context) error return span.Error(errors.New("identifyDependencies failed to load the bundle because no bundle was specified. Please report this bug to https://github.com/getporter/porter/issues/new/choose")) } - solver := &cnab.DependencySolver{} + solver := &depsv1.DependencySolver{} locks, err := solver.ResolveDependencies(bun) if err != nil { return span.Error(err) @@ -180,16 +182,12 @@ func (e *dependencyExecutioner) prepareDependency(ctx context.Context, dep *queu defer span.EndSpan() // Pull the dependency - var err error - pullOpts := BundlePullOptions{ - Reference: dep.Reference, - InsecureRegistry: e.parentOpts.InsecureRegistry, - Force: e.parentOpts.Force, - } - if err := pullOpts.Validate(); err != nil { - return span.Error(fmt.Errorf("error preparing dependency %s: %w", dep.Alias, err)) + depRef, err := cnab.ParseOCIReference(dep.Reference) + if err != nil { + return err } - cachedDep, err := e.Resolver.Resolve(ctx, pullOpts) + fmt.Println("DEPREF: ", depRef) + cachedDep, err := e.resolver.GetBundle(ctx, depRef) if err != nil { return span.Error(fmt.Errorf("error pulling dependency %s: %w", dep.Alias, err)) } @@ -317,7 +315,7 @@ func (e *dependencyExecutioner) executeDependency(ctx context.Context, dep *queu var executeErrs error span.Infof("Executing dependency %s...", dep.Alias) - err = e.CNAB.Execute(ctx, depArgs) + _, _, err = e.CNAB.Execute(ctx, depArgs) if err != nil { executeErrs = multierror.Append(executeErrs, fmt.Errorf("error executing dependency %s: %w", dep.Alias, err)) diff --git a/pkg/porter/explain.go b/pkg/porter/explain.go index 6012a55ce..d303810ae 100644 --- a/pkg/porter/explain.go +++ b/pkg/porter/explain.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" + depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + "get.porter.sh/porter/pkg/cnab" configadapter "get.porter.sh/porter/pkg/cnab/config-adapter" "get.porter.sh/porter/pkg/portercontext" @@ -190,7 +192,7 @@ func generatePrintable(bun cnab.ExtendedBundle, action string) (*PrintableBundle stamp = configadapter.Stamp{} } - solver := &cnab.DependencySolver{} + solver := &depsv1.DependencySolver{} deps, err := solver.ResolveDependencies(bun) if err != nil { return nil, fmt.Errorf("error resolving bundle dependencies: %w", err) diff --git a/pkg/porter/explain_test.go b/pkg/porter/explain_test.go index d1023454e..2b958aeb5 100644 --- a/pkg/porter/explain_test.go +++ b/pkg/porter/explain_test.go @@ -5,7 +5,7 @@ import ( "testing" "get.porter.sh/porter/pkg/cnab" - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" "get.porter.sh/porter/pkg/portercontext" "get.porter.sh/porter/pkg/test" "github.com/cnabio/cnab-go/bundle" @@ -417,9 +417,9 @@ func TestExplain_generatePrintableBundleDependencies(t *testing.T) { sequenceMock := []string{"nginx", "storage", "mysql"} bun := cnab.NewBundle(bundle.Bundle{ Custom: map[string]interface{}{ - cnab.DependenciesV1ExtensionKey: depsv1.Dependencies{ + cnab.DependenciesV1ExtensionKey: depsv1ext.Dependencies{ Sequence: sequenceMock, - Requires: map[string]depsv1.Dependency{ + Requires: map[string]depsv1ext.Dependency{ "mysql": { Name: "mysql", Bundle: "somecloud/mysql:0.1.0", diff --git a/pkg/porter/helpers.go b/pkg/porter/helpers.go index c5143240d..6f6b43e62 100644 --- a/pkg/porter/helpers.go +++ b/pkg/porter/helpers.go @@ -24,7 +24,6 @@ import ( "get.porter.sh/porter/pkg/storage" "get.porter.sh/porter/pkg/tracing" "get.porter.sh/porter/pkg/yaml" - "github.com/cnabio/cnab-go/bundle" "github.com/stretchr/testify/require" ) @@ -181,13 +180,7 @@ func (p *TestPorter) T() *testing.T { } func (p *TestPorter) ReadBundle(path string) cnab.ExtendedBundle { - bunD, err := os.ReadFile(path) - require.NoError(p.T(), err, "ReadFile failed for %s", path) - - bun, err := bundle.Unmarshal(bunD) - require.NoError(p.T(), err, "Unmarshal failed for bundle at %s", path) - - return cnab.NewBundle(*bun) + return cnab.ReadTestBundle(p.T(), path) } func (p *TestPorter) RandomString(len int) string { diff --git a/pkg/porter/install.go b/pkg/porter/install.go index 819987286..e7c7d9259 100644 --- a/pkg/porter/install.go +++ b/pkg/porter/install.go @@ -6,6 +6,7 @@ import ( "fmt" "get.porter.sh/porter/pkg/cnab" + "get.porter.sh/porter/pkg/experimental" "get.porter.sh/porter/pkg/storage" "get.porter.sh/porter/pkg/tracing" ) @@ -83,13 +84,56 @@ func (p *Porter) InstallBundle(ctx context.Context, opts InstallOptions) error { return err } + bundleRef, err := opts.GetBundleReference(ctx, p) + if err != nil { + return err + } + + if p.useWorkflowEngine(bundleRef.Definition) { + // TODO(PEP003): Use new getregistryoptions elsewhere that we create that + puller := NewBundleResolver(p.Cache, opts.Force, p.Registry, opts.GetRegistryOptions()) + eng := NewWorkflowEngine(i.Namespace, puller, p.Installations, p) + workflowOpts := CreateWorkflowOptions{ + Installation: i, + Bundle: bundleRef.Definition, + DebugMode: opts.DebugMode, + MaxParallel: 1, + } + w, err := eng.CreateWorkflow(ctx, workflowOpts) + if err != nil { + return err + } + + if err := p.Installations.InsertWorkflow(ctx, w); err != nil { + return err + } + + // TODO(PEP003): if a dry-run is requested, print out the execution plan and then exit + return eng.RunWorkflow(ctx, w) + } + + // Use the old implementation of bundle execution compatible with depsv1 err = p.Installations.UpsertInstallation(ctx, i) if err != nil { return fmt.Errorf("error saving installation record: %w", err) } // Run install using the updated installation record - return p.ExecuteAction(ctx, i, opts) + return p.ExecuteBundleAndDependencies(ctx, i, opts) +} + +// useWorkflowEngine determines if the new workflow engine or the old bundle execution code should be used. +// Once depsv2 is no longer experimental, we can switch 100% to the workflow engine +// Old bundles can still use depsv1, since depsv2 is a superset of depsv1. +// It will change how the bundle is run, for example calling install right now twice in a row +// results in an error, and this would remove that limitation, and instead a second call to install causes it to be reconciled and possibly skipped. +// In either case, the solution to the user is to call --force so the change isn't breaking. +func (p *Porter) useWorkflowEngine(bun cnab.ExtendedBundle) bool { + if bun.HasDependenciesV2() { + return true + } + + return p.Config.IsFeatureEnabled(experimental.FlagDependenciesV2) } func (p *Porter) sanitizeInstallation(ctx context.Context, inst *storage.Installation, bun cnab.ExtendedBundle) error { diff --git a/pkg/porter/invoke.go b/pkg/porter/invoke.go index ec83535da..0a6d9ac1b 100644 --- a/pkg/porter/invoke.go +++ b/pkg/porter/invoke.go @@ -71,5 +71,28 @@ func (p *Porter) InvokeBundle(ctx context.Context, opts InvokeOptions) error { return err } - return p.ExecuteAction(ctx, installation, opts) + if p.useWorkflowEngine(opts.bundleRef.Definition) { + puller := NewBundleResolver(p.Cache, opts.Force, p.Registry, opts.GetRegistryOptions()) + eng := NewWorkflowEngine(installation.Namespace, puller, p.Installations, p) + workflowOpts := CreateWorkflowOptions{ + Installation: installation, + CustomAction: opts.Action, + Bundle: opts.bundleRef.Definition, + DebugMode: opts.DebugMode, + MaxParallel: 1, // TODO(PEP003): make this configurable + } + w, err := eng.CreateWorkflow(ctx, workflowOpts) + if err != nil { + return err + } + + if err := p.Installations.InsertWorkflow(ctx, w); err != nil { + return err + } + + // TODO(PEP003): if a dry-run is requested, print out the execution plan and then exit + return eng.RunWorkflow(ctx, w) + } + + return p.ExecuteBundleAndDependencies(ctx, installation, opts) } diff --git a/pkg/porter/lifecycle.go b/pkg/porter/lifecycle.go index a302ba23e..89ceeab61 100644 --- a/pkg/porter/lifecycle.go +++ b/pkg/porter/lifecycle.go @@ -44,6 +44,9 @@ type BundleExecutionOptions struct { // DebugMode indicates if the bundle should be run in debug mode. DebugMode bool + // DryRun specifies that the bundle should not be executed and the execution plan should be printed instead. + DryRun bool + // NoLogs runs the bundle without persisting any logs. NoLogs bool diff --git a/pkg/porter/list.go b/pkg/porter/list.go index edc58f5a6..4ebb4c56f 100644 --- a/pkg/porter/list.go +++ b/pkg/porter/list.go @@ -74,11 +74,11 @@ func parseLabels(raw []string) map[string]string { // originating from its runs, results and outputs records type DisplayInstallation struct { // SchemaType helps when we export the definition so editors can detect the type of document, it's not used by porter. - SchemaType string `json:"schemaType" yaml:"schemaType" toml:"schemaType"` + SchemaType string `json:"schemaType,omitempty" yaml:"schemaType,omitempty" toml:"schemaType,omitempty"` - SchemaVersion schema.Version `json:"schemaVersion" yaml:"schemaVersion" toml:"schemaVersion"` + SchemaVersion schema.Version `json:"schemaVersion,omitempty" yaml:"schemaVersion,omitempty" toml:"schemaVersion,omitempty"` - ID string `json:"id" yaml:"id" toml:"id"` + ID string `json:"id,omitempty" yaml:"id,omitempty" toml:"id,omitempty"` // Name of the installation. Immutable. Name string `json:"name" yaml:"name" toml:"name"` @@ -98,11 +98,15 @@ type DisplayInstallation struct { // Labels applied to the installation. Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty" toml:"labels,omitempty"` + Credentials []secrets.Strategy `json:"credentials,omitempty" yaml:"credentials,omitempty" toml:"credentials,omitempty"` + // CredentialSets that should be included when the bundle is reconciled. CredentialSets []string `json:"credentialSets,omitempty" yaml:"credentialSets,omitempty" toml:"credentialSets,omitempty"` // Parameters specified by the user through overrides. // Does not include defaults, or values resolved from parameter sources. + // TODO(PEP003): we can check if the interface is a secrets.Strategy so that it can store either key value pairs of user specified value parameters + // or it can store strategies specified by a workflow Parameters map[string]interface{} `json:"parameters,omitempty" yaml:"parameters,omitempty" toml:"parameters,omitempty"` // ParameterSets that should be included when the bundle is reconciled. @@ -110,11 +114,11 @@ type DisplayInstallation struct { // Status of the installation. Status storage.InstallationStatus `json:"status,omitempty" yaml:"status,omitempty" toml:"status,omitempty"` - DisplayInstallationMetadata `json:"_calculated" yaml:"_calculated"` + DisplayInstallationMetadata `json:"_calculated,omitempty" yaml:"_calculated,omitempty"` } type DisplayInstallationMetadata struct { - ResolvedParameters DisplayValues `json:"resolvedParameters" yaml:"resolvedParameters"` + ResolvedParameters DisplayValues `json:"resolvedParameters,omitempty" yaml:"resolvedParameters,omitempty"` // DisplayInstallationState is the latest state of the installation. // It is either "installed", "uninstalled", or "defined". @@ -136,9 +140,13 @@ func NewDisplayInstallation(installation storage.Installation) DisplayInstallati Bundle: installation.Bundle, Custom: installation.Custom, Labels: installation.Labels, + Credentials: installation.Credentials.Credentials, CredentialSets: installation.CredentialSets, - ParameterSets: installation.ParameterSets, - Status: installation.Status, + // TODO(PEP003): I think this should be in main but was missed? + // populate parameters from the installation + //Parameters: installation.Parameters.Parameters, + ParameterSets: installation.ParameterSets, + Status: installation.Status, DisplayInstallationMetadata: DisplayInstallationMetadata{ DisplayInstallationState: getDisplayInstallationState(installation), DisplayInstallationStatus: getDisplayInstallationStatus(installation), @@ -148,7 +156,7 @@ func NewDisplayInstallation(installation storage.Installation) DisplayInstallati return di } -// ConvertToInstallationClaim transforms the data from DisplayInstallation into +// ConvertToInstallation transforms the data from DisplayInstallation into // a Installation record. func (d DisplayInstallation) ConvertToInstallation() (storage.Installation, error) { i := storage.Installation{ @@ -196,6 +204,16 @@ func (d DisplayInstallation) ConvertParamToSet() (storage.ParameterSet, error) { return storage.NewInternalParameterSet(d.Namespace, d.Name, strategies...), nil } +func (d DisplayInstallation) AsSpecOnly() DisplayInstallation { + out := d + out.SchemaVersion = "" + out.SchemaType = "" + out.ID = "" + out.Status = storage.InstallationStatus{} + out.DisplayInstallationMetadata = DisplayInstallationMetadata{} + return out +} + // TODO(carolynvs): be consistent with sorting results from list, either keep the default sort by name // or update the other types to also sort by modified type DisplayInstallations []DisplayInstallation diff --git a/pkg/porter/parameters.go b/pkg/porter/parameters.go index dfd8ae35c..b949263c1 100644 --- a/pkg/porter/parameters.go +++ b/pkg/porter/parameters.go @@ -11,8 +11,9 @@ import ( "strings" "time" - "get.porter.sh/porter/pkg/cnab" depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" + + "get.porter.sh/porter/pkg/cnab" "get.porter.sh/porter/pkg/editor" "get.porter.sh/porter/pkg/encoding" "get.porter.sh/porter/pkg/generator" diff --git a/pkg/porter/porter.go b/pkg/porter/porter.go index e0f6c3605..305336742 100644 --- a/pkg/porter/porter.go +++ b/pkg/porter/porter.go @@ -66,7 +66,7 @@ func NewFor(c *config.Config, store storage.Store, secretStorage secrets.Store) sanitizerService := storage.NewSanitizer(paramStorage, secretStorage) storageManager.Initialize(sanitizerService) // we have a bit of a dependency problem here that it would be great to figure out eventually - return &Porter{ + p := &Porter{ Config: c, Cache: cache, Storage: storageManager, @@ -81,6 +81,8 @@ func NewFor(c *config.Config, store storage.Store, secretStorage secrets.Store) CNAB: cnabprovider.NewRuntime(c, installationStorage, credStorage, secretStorage, sanitizerService), Sanitizer: sanitizerService, } + secretStorage.SetPorterStrategy(NewPorterSecretStrategy(p)) + return p } // Used to warn just a single time when Porter starts up. diff --git a/pkg/porter/porter_strategy.go b/pkg/porter/porter_strategy.go new file mode 100644 index 000000000..2eb516e1e --- /dev/null +++ b/pkg/porter/porter_strategy.go @@ -0,0 +1,112 @@ +package porter + +import ( + "context" + + v2 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" + "get.porter.sh/porter/pkg/storage" + "get.porter.sh/porter/pkg/tracing" + "go.mongodb.org/mongo-driver/bson" +) + +// PorterSecretStrategy knows how to resolve specially formatted wiring strings +// such as workflow.jobs.db.outputs.connstr from Porter instead of from a plugin. +// It is not written as a plugin because it is much more straightforward to +// retrieve the data already loaded in the running Porter instance than to start +// another one, load its config and requery the database. +// This should always run in-process within porter and never as an out-of-process plugin. +type PorterSecretStrategy struct { + porter *Porter +} + +func NewPorterSecretStrategy(p *Porter) PorterSecretStrategy { + return PorterSecretStrategy{porter: p} +} + +func (s PorterSecretStrategy) Resolve(ctx context.Context, keyName string, keyValue string) (string, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() + + // TODO(PEP003): It would be great when we configure this strategy that we also do host, so that host secret resolution isn't deferred to the plugins + // i.e. we can configure a secret strategy and still be able to resolve directly in porter any host values. + if keyName != "porter" { + return "", span.Errorf("attempted to resolve secrets of type %s from the porter strategy", keyName) + } + + wiring, err := v2.ParseWorkflowWiring(keyValue) + if err != nil { + return "", span.Errorf("invalid workflow wiring was passed to the porter strategy, %s", keyValue) + } + + // We support retrieving certain data from Porter's database: + // workflow.WORKFLOWID.jobs.JOBKEY.outputs.OUTPUT + // The WORKFLOWID is set to the current executing workflow by the workflow engine before running the job + // It is not stored in the database and is always set dynamically when the job is run. + + w, err := s.porter.Installations.GetWorkflow(ctx, wiring.WorkflowID) + if err != nil { + return "", span.Errorf("error retrieving workflow %s: %w", wiring.WorkflowID, err) + } + + // Prepare internal data structures of the workflow + w.Prepare() + + // locate the job in the workflow + j, err := w.GetJob(wiring.JobKey) + if err != nil { + return "", span.Errorf("error retrieving job from workflow %s: %w", wiring.WorkflowID) + } + + if j.Status.LastResultID == "" { + return "", span.Errorf("error retrieving job status for %s in workflow %s, no result recorded yet", wiring.JobKey, wiring.WorkflowID) + } + + /* + + */ + + // TODO(PEP003): How do we want to re-resolve credentials passed to the root bundle? They aren't recorded so it's not a simple lookup + if wiring.Credential != "" { + + } + else if wiring.Parameter != "" { + // TODO(PEP003): Resolve a parameter from another job that has not run yet + // IS THIS ACTUALLY A PROBLEM? We pass creds/params from the root job, which we need to deal with, but otherwise we only pass outputs from non-root jobs + // 1. Find the workflow definition from the db (need a way to track "current" workflow) + // 2. Grab the job based on the jobid in the workflow wiring + // 3. First check the parameters field for the param, resolve just that if available, otherwise resolve parameter sets and get it from there + // it sure would help if we remembered what params are in each set + + // TODO(PEP003): For now resolve all params, but in the future resolve the parameters on an installation but filter which params you care about so that you don't resolve stuff that isn't used + + return "", nil + } else if wiring.Output != "" { + // Lookup the result and run associated with the job run in that workflow + outputs, err := s.porter.Installations.FindOutputs(ctx, storage.FindOptions{ + Sort: []string{"-_id"}, + Skip: 0, + Limit: 1, + Filter: bson.M{ + "resultId": j.Status.LastResultID, + "name": wiring.Output, + }, + }) + if err != nil { + // TODO(PEP003): Move a lot of these values into the span attributes instead of in the error message + return "", span.Errorf("error retrieving output %s from result %s for job %s in workflow %s: %w", wiring.Output, j.Status.LastResultID, wiring.JobKey, wiring.WorkflowID) + } + + if len(outputs) == 0 { + return "", span.Errorf("no output named %s, found for result %s for job %s in workflow %s", wiring.Output, j.Status.LastResultID, wiring.JobKey, wiring.WorkflowID) + } + + output, err := s.porter.Sanitizer.RestoreOutput(ctx, outputs[1]) + if err != nil { + return "", span.Errorf("error restoring output named %s, found for result %s for job %s in workflow %s", wiring.Output, j.Status.LastResultID, wiring.JobKey, wiring.WorkflowID) + } + + return string(output.Value), nil + } + + panic("not implemented") +} diff --git a/pkg/porter/pull.go b/pkg/porter/pull.go index 12785fd1d..f24bf9432 100644 --- a/pkg/porter/pull.go +++ b/pkg/porter/pull.go @@ -6,6 +6,7 @@ import ( "get.porter.sh/porter/pkg/cache" "get.porter.sh/porter/pkg/cnab" + cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" ) type BundlePullOptions struct { @@ -39,12 +40,15 @@ func (b *BundlePullOptions) validateReference() error { return nil } +func (b *BundlePullOptions) GetRegistryOptions() cnabtooci.RegistryOptions { + return cnabtooci.RegistryOptions{ + InsecureRegistry: b.InsecureRegistry, + } +} + // PullBundle looks for a given bundle tag in the bundle cache. If it is not found, it is // pulled and stored in the cache. The path to the cached bundle is returned. func (p *Porter) PullBundle(ctx context.Context, opts BundlePullOptions) (cache.CachedBundle, error) { - resolver := BundleResolver{ - Cache: p.Cache, - Registry: p.Registry, - } - return resolver.Resolve(ctx, opts) + resolver := NewBundleResolver(p.Cache, opts.Force, p.Registry, opts.GetRegistryOptions()) + return resolver.GetBundle(ctx, opts.GetReference()) } diff --git a/pkg/porter/reconcile.go b/pkg/porter/reconcile.go index afef3a0ef..6e543eafc 100644 --- a/pkg/porter/reconcile.go +++ b/pkg/porter/reconcile.go @@ -6,6 +6,8 @@ import ( "fmt" "sort" + "get.porter.sh/porter/pkg/printer" + "get.porter.sh/porter/pkg/cnab" "get.porter.sh/porter/pkg/storage" "get.porter.sh/porter/pkg/tracing" @@ -15,33 +17,99 @@ import ( ) type ReconcileOptions struct { - Name string - Namespace string - Installation storage.Installation + Installation storage.InstallationSpec // Just reapply the installation regardless of what has changed (or not) Force bool // DryRun only checks if the changes would trigger a bundle run DryRun bool + + // Format that should be used when printing details about what Porter is (or will) do. + Format printer.Format } -// ReconcileInstallation compares the desired state of an installation +// ReconcileInstallationAndDependencies compares the desired state of an installation // as stored in the installation record with the current state of the // installation. If they are not in sync, the appropriate bundle action // is executed to bring them in sync. // This is only used for install/upgrade actions triggered by applying a file // to an installation. For uninstall or invoke, you should call those directly. -func (p *Porter) ReconcileInstallation(ctx context.Context, opts ReconcileOptions) error { - ctx, log := tracing.StartSpan(ctx) - log.Debugf("Reconciling %s/%s installation", opts.Namespace, opts.Name) +func (p *Porter) ReconcileInstallationAndDependencies(ctx context.Context, opts ReconcileOptions) error { + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() + + installation, actionOpts, err := p.reconcileInstallation(ctx, opts) + if err != nil { + return err + } + + // Nothing to do, the installation is up-to-date + if actionOpts == nil { + return nil + } + + return p.ExecuteBundleAndDependencies(ctx, installation, actionOpts) +} + +// ReconcileInstallationInWorkflow compares the desired state of an installation +// as stored in the installation record with the current state of the +// installation. If they are not in sync, the appropriate bundle action +// is executed to bring them in sync. +// This is only used for install/upgrade actions triggered by applying a file +// to an installation. For uninstall or invoke, you should call those directly. +// This should only be used with deps-v2 feature workflows. +func (p *Porter) ReconcileInstallationInWorkflow(ctx context.Context, opts ReconcileOptions) (storage.Run, storage.Result, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() + + installation, actionOpts, err := p.reconcileInstallation(ctx, opts) + if err != nil { + return storage.Run{}, storage.Result{}, err + } + + // Nothing to do, the installation is up-to-date + if actionOpts == nil { + return storage.Run{}, storage.Result{}, nil + } + + return p.ExecuteRootBundleOnly(ctx, installation, actionOpts) +} + +func (p *Porter) reconcileInstallation(ctx context.Context, opts ReconcileOptions) (storage.Installation, BundleAction, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() + + // Determine if the installation exists + inputInstallation := opts.Installation + span.Debugf("Reconciling %s/%s installation", inputInstallation.Namespace, inputInstallation.Name) + installation, err := p.Installations.GetInstallation(ctx, inputInstallation.Namespace, inputInstallation.Name) + if err != nil { + if !errors.Is(err, storage.ErrNotFound{}) { + return storage.Installation{}, nil, fmt.Errorf("could not query for an existing installation document for %s: %w", inputInstallation, err) + } + + // Create a new installation + installation = storage.NewInstallation(inputInstallation.Namespace, inputInstallation.Name) + installation.Apply(inputInstallation) + + span.Info("Creating a new installation", attribute.String("installation", installation.String())) + } else { + // Apply the specified changes to the installation + installation.Apply(inputInstallation) + if err := installation.Validate(); err != nil { + return storage.Installation{}, nil, err + } + + fmt.Fprintf(p.Err, "Updating %s installation\n", installation) + } // Get the last run of the installation, if available var lastRun *storage.Run - r, err := p.Installations.GetLastRun(ctx, opts.Namespace, opts.Name) + r, err := p.Installations.GetLastRun(ctx, inputInstallation.Namespace, inputInstallation.Name) neverRun := errors.Is(err, storage.ErrNotFound{}) if err != nil && !neverRun { - return err + return storage.Installation{}, nil, err } if !neverRun { lastRun = &r @@ -49,16 +117,16 @@ func (p *Porter) ReconcileInstallation(ctx context.Context, opts ReconcileOption ref, ok, err := opts.Installation.Bundle.GetBundleReference() if err != nil { - return log.Error(err) + return storage.Installation{}, nil, span.Error(err) } if !ok { instYaml, _ := yaml.Marshal(opts.Installation) - return log.Error(fmt.Errorf("the installation does not define a valid bundle reference.\n%s", instYaml)) + return storage.Installation{}, nil, span.Error(fmt.Errorf("the installation does not define a valid bundle reference.\n%s", instYaml)) } // Configure the bundle action that we should execute IF IT'S OUT OF SYNC var actionOpts BundleAction - if opts.Installation.IsInstalled() { + if installation.IsInstalled() { if opts.Installation.Uninstalled { actionOpts = NewUninstallOptions() } else { @@ -69,46 +137,45 @@ func (p *Porter) ReconcileInstallation(ctx context.Context, opts ReconcileOption } lifecycleOpts := actionOpts.GetOptions() + lifecycleOpts.DryRun = opts.DryRun lifecycleOpts.Reference = ref.String() - lifecycleOpts.Name = opts.Name - lifecycleOpts.Namespace = opts.Namespace + lifecycleOpts.Name = inputInstallation.Name + lifecycleOpts.Namespace = inputInstallation.Namespace + lifecycleOpts.Driver = p.Data.RuntimeDriver lifecycleOpts.CredentialIdentifiers = opts.Installation.CredentialSets lifecycleOpts.ParameterSets = opts.Installation.ParameterSets - if err = p.applyActionOptionsToInstallation(ctx, actionOpts, &opts.Installation); err != nil { - return err + if err = p.applyActionOptionsToInstallation(ctx, actionOpts, &installation); err != nil { + return storage.Installation{}, nil, err } // Determine if the installation's desired state is out of sync with reality 🤯 - inSync, err := p.IsInstallationInSync(ctx, opts.Installation, lastRun, actionOpts) + inSync, err := p.IsInstallationInSync(ctx, installation, lastRun, actionOpts) if err != nil { - return err + return storage.Installation{}, nil, err } if inSync { if opts.Force { - log.Info("The installation is up-to-date but will be re-applied because --force was specified") + span.Info("The installation is up-to-date but will be re-applied because --force was specified") } else { - log.Info("The installation is already up-to-date.") - return nil + span.Info("The installation is already up-to-date.") + return storage.Installation{}, nil, nil } } - log.Infof("The installation is out-of-sync, running the %s action...", actionOpts.GetAction()) + span.Infof("The installation is out-of-sync, running the %s action...", actionOpts.GetAction()) if err := actionOpts.Validate(ctx, nil, p); err != nil { - return err + return storage.Installation{}, nil, err } - if opts.DryRun { - log.Info("Skipping bundle execution because --dry-run was specified") - return nil - } else { - if err = p.Installations.UpsertInstallation(ctx, opts.Installation); err != nil { - return err + if !opts.DryRun { + if err = p.Installations.UpsertInstallation(ctx, installation); err != nil { + return storage.Installation{}, nil, err } } - return p.ExecuteAction(ctx, opts.Installation, actionOpts) + return installation, actionOpts, nil } // IsInstallationInSync determines if the desired state of the installation matches @@ -137,6 +204,10 @@ func (p *Porter) IsInstallationInSync(ctx context.Context, i storage.Installatio log.Info("Ignoring because installation.uninstalled is true but the installation doesn't exist yet") return true, nil } else { + // TODO(PEP003): we should check the status of the last run and handle in progress/pending by returning an error if the not in sync otherise + // i.e. if we run two commands to apply, the first starts, the second succeeds since it asked for what the other is providing? + // apply waits, so really it should wait for the pending/inprogress to complete? or stop early and say it's in progress elsewhere. + // Should we install it? if !i.IsInstalled() { log.Info("Triggering because the installation has not completed successfully yet") diff --git a/pkg/porter/resolver.go b/pkg/porter/resolver.go index da2fd77de..41ae6d4b2 100644 --- a/pkg/porter/resolver.go +++ b/pkg/porter/resolver.go @@ -4,25 +4,42 @@ import ( "context" "fmt" + "get.porter.sh/porter/pkg/cnab" + depsv2 "get.porter.sh/porter/pkg/cnab/dependencies/v2" + "get.porter.sh/porter/pkg/cache" cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" "get.porter.sh/porter/pkg/tracing" ) +var _ depsv2.BundlePuller = BundleResolver{} + +// BundleResolver supports retrieving bundles from a registry, with cache support. type BundleResolver struct { - Cache cache.BundleCache - Registry cnabtooci.RegistryProvider + cache cache.BundleCache + registry cnabtooci.RegistryProvider + regOpts cnabtooci.RegistryOptions + + // refreshCache always pulls from the registry, and ignores cached bundles. + refreshCache bool +} + +func NewBundleResolver(cache cache.BundleCache, refreshCache bool, registry cnabtooci.RegistryProvider, regOpts cnabtooci.RegistryOptions) BundleResolver { + return BundleResolver{ + cache: cache, + refreshCache: refreshCache, + registry: registry, + regOpts: regOpts, + } } -// Resolves a bundle from the cache, or pulls it and caches it -// Returns the location of the bundle or an error -func (r *BundleResolver) Resolve(ctx context.Context, opts BundlePullOptions) (cache.CachedBundle, error) { +func (r BundleResolver) GetBundle(ctx context.Context, ref cnab.OCIReference) (cache.CachedBundle, error) { log := tracing.LoggerFromContext(ctx) - if !opts.Force { - cachedBundle, ok, err := r.Cache.FindBundle(opts.GetReference()) + if !r.refreshCache { + cachedBundle, ok, err := r.cache.FindBundle(ref) if err != nil { - return cache.CachedBundle{}, log.Error(fmt.Errorf("unable to load bundle %s from cache: %w", opts.Reference, err)) + return cache.CachedBundle{}, log.Error(fmt.Errorf("unable to load bundle %s from cache: %w", ref, err)) } // If we found the bundle, return the path to the bundle.json if ok { @@ -30,15 +47,18 @@ func (r *BundleResolver) Resolve(ctx context.Context, opts BundlePullOptions) (c } } - regOpts := cnabtooci.RegistryOptions{InsecureRegistry: opts.InsecureRegistry} - bundleRef, err := r.Registry.PullBundle(ctx, opts.GetReference(), regOpts) + bundleRef, err := r.registry.PullBundle(ctx, ref, r.regOpts) if err != nil { return cache.CachedBundle{}, err } - cb, err := r.Cache.StoreBundle(bundleRef) + cb, err := r.cache.StoreBundle(bundleRef) if err != nil { return cache.CachedBundle{}, log.Errorf("error storing the bundle %s in the Porter bundle cache: %w", bundleRef, err) } return cb, nil } + +func (r BundleResolver) ListTags(ctx context.Context, repo cnab.OCIReference) ([]string, error) { + return r.registry.ListTags(ctx, repo, r.regOpts) +} diff --git a/pkg/porter/resolver_test.go b/pkg/porter/resolver_test.go index c55107bc0..479356148 100644 --- a/pkg/porter/resolver_test.go +++ b/pkg/porter/resolver_test.go @@ -9,7 +9,6 @@ import ( cnabtooci "get.porter.sh/porter/pkg/cnab/cnab-to-oci" "get.porter.sh/porter/pkg/config" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestBundleResolver_Resolve_ForcePull(t *testing.T) { @@ -17,10 +16,7 @@ func TestBundleResolver_Resolve_ForcePull(t *testing.T) { tc := config.NewTestConfig(t) testReg := cnabtooci.NewTestRegistry() testCache := cache.NewTestCache(cache.New(tc.Config)) - resolver := BundleResolver{ - Cache: testCache, - Registry: testReg, - } + resolver := NewBundleResolver(testCache, true, testReg, cnabtooci.RegistryOptions{}) cacheSearched := false testCache.FindBundleMock = func(ref cnab.OCIReference) (cache.CachedBundle, bool, error) { @@ -33,13 +29,7 @@ func TestBundleResolver_Resolve_ForcePull(t *testing.T) { pulled = true return cnab.BundleReference{Reference: ref}, nil } - - opts := BundlePullOptions{ - Reference: kahnlatest.String(), - Force: true, - } - require.NoError(t, opts.Validate()) - resolver.Resolve(ctx, opts) + resolver.GetBundle(ctx, kahnlatest) assert.False(t, cacheSearched, "Force should have skipped the cache") assert.True(t, pulled, "The bundle should have been re-pulled") @@ -50,10 +40,7 @@ func TestBundleResolver_Resolve_CacheHit(t *testing.T) { tc := config.NewTestConfig(t) testReg := cnabtooci.NewTestRegistry() testCache := cache.NewTestCache(cache.New(tc.Config)) - resolver := BundleResolver{ - Cache: testCache, - Registry: testReg, - } + resolver := NewBundleResolver(testCache, false, testReg, cnabtooci.RegistryOptions{}) cacheSearched := false testCache.FindBundleMock = func(ref cnab.OCIReference) (cache.CachedBundle, bool, error) { @@ -67,8 +54,8 @@ func TestBundleResolver_Resolve_CacheHit(t *testing.T) { return cnab.BundleReference{Reference: ref}, nil } - opts := BundlePullOptions{Reference: "ghcr.io/getporter/examples/porter-hello:v0.2.0"} - resolver.Resolve(ctx, opts) + ref := cnab.MustParseOCIReference("ghcr.io/getporter/examples/porter-hello:v0.2.0") + resolver.GetBundle(ctx, ref) assert.True(t, cacheSearched, "The cache should be searched when force is not specified") assert.False(t, pulled, "The bundle should NOT be pulled because it was found in the cache") @@ -79,10 +66,7 @@ func TestBundleResolver_Resolve_CacheMiss(t *testing.T) { tc := config.NewTestConfig(t) testReg := cnabtooci.NewTestRegistry() testCache := cache.NewTestCache(cache.New(tc.Config)) - resolver := BundleResolver{ - Cache: testCache, - Registry: testReg, - } + resolver := NewBundleResolver(testCache, false, testReg, cnabtooci.RegistryOptions{}) cacheSearched := false testCache.FindBundleMock = func(ref cnab.OCIReference) (cache.CachedBundle, bool, error) { @@ -96,8 +80,8 @@ func TestBundleResolver_Resolve_CacheMiss(t *testing.T) { return cnab.BundleReference{Reference: ref}, nil } - opts := BundlePullOptions{Reference: "ghcr.io/getporter/examples/porter-hello:v0.2.0"} - resolver.Resolve(ctx, opts) + ref := cnab.MustParseOCIReference("ghcr.io/getporter/examples/porter-hello:v0.2.0") + resolver.GetBundle(ctx, ref) assert.True(t, cacheSearched, "The cache should be searched when force is not specified") assert.True(t, pulled, "The bundle should have been pulled because the bundle was not in the cache") diff --git a/pkg/porter/testdata/list/expected-output.json b/pkg/porter/testdata/list/expected-output.json index f1a36862c..5012dd875 100644 --- a/pkg/porter/testdata/list/expected-output.json +++ b/pkg/porter/testdata/list/expected-output.json @@ -20,7 +20,6 @@ "bundleDigest": "" }, "_calculated": { - "resolvedParameters": null, "displayInstallationState": "defined", "displayInstallationStatus": "succeeded" } diff --git a/pkg/porter/testdata/list/expected-output.yaml b/pkg/porter/testdata/list/expected-output.yaml index 11cb76203..fa03a5060 100644 --- a/pkg/porter/testdata/list/expected-output.yaml +++ b/pkg/porter/testdata/list/expected-output.yaml @@ -17,6 +17,5 @@ bundleVersion: v1.2.3 bundleDigest: "" _calculated: - resolvedParameters: [] displayInstallationState: defined displayInstallationStatus: succeeded diff --git a/pkg/porter/testdata/schema.json b/pkg/porter/testdata/schema.json index d5fa1fcdd..65198473a 100644 --- a/pkg/porter/testdata/schema.json +++ b/pkg/porter/testdata/schema.json @@ -95,10 +95,21 @@ "bundle": { "$ref": "#/definitions/bundle" }, + "credentials": { + "additionalProperties": { + "type": "string" + }, + "description": "Map of credential names to a source, such as bundle.credentials.NAME or bundle.dependencies.DEP.outputs.OUTPUT", + "type": "object" + }, "name": { "type": "string" }, "parameters": { + "additionalProperties": { + "type": "string" + }, + "description": "Map of parameter names to a parameter source, such as bundle.parameters.PARAM, bundle.dependencies.DEP.outputs.OUTPUT, or bundle.credentials.CRED", "type": "object" } }, @@ -108,6 +119,28 @@ ], "type": "object" }, + "dependencySource": { + "description": "Describes how Porter should set a dependency's parameter or credential", + "properties": { + "credential": { + "description": "The credential name from which the value should be sourced", + "type": "string" + }, + "dependency": { + "description": "The name of the dependency that defines the output used for the source", + "type": "string" + }, + "output": { + "description": "The output name defined in a dependency from which the value should be sourced", + "type": "string" + }, + "parameter": { + "description": "The parameter name from which the value should be sourced", + "type": "string" + } + }, + "type": "object" + }, "image": { "additionalProperties": false, "description": "An image represents an application image used in a bundle", diff --git a/pkg/porter/testdata/workflow/imported-workflow.yaml b/pkg/porter/testdata/workflow/imported-workflow.yaml new file mode 100644 index 000000000..933396172 --- /dev/null +++ b/pkg/porter/testdata/workflow/imported-workflow.yaml @@ -0,0 +1,53 @@ +id: 01GSC42NYVWGXCFAWWNTSHX2N3 +schemaType: Workflow +schemaVersion: 1.0.0-alpha.1 +maxParallel: 1 +stages: + - jobs: + root: + action: apply + installation: + schemaVersion: 1.0.2 + name: mybuns + namespace: myns + bundle: + repository: example.com + version: 1.0.0 + digest: sha256:992f38e1a81cbdf2903fab2603f6076f3bef54262b4b2aa5b196515bac953688 + tag: v1.0.0 + custom: + color: blue + labels: + team: red + credentialSets: + - mycs + parameters: + schemaVersion: 1.0.1 + namespace: myns + name: internal-parameter-set-mybuns + parameters: [] + status: + created: 2020-04-18T01:02:03.000000004Z + modified: 2020-04-18T01:02:03.000000004Z + parameterSets: + - myps + status: + runId: "" + action: "" + resultId: "" + resultStatus: "" + created: 2020-04-18T01:02:03.000000004Z + modified: 2020-04-18T01:02:03.000000004Z + installed: null + uninstalled: null + bundleReference: "" + bundleVersion: "" + bundleDigest: "" + depends: + - other-thing + status: + lastrunid: "" + lastresultid: "" + resultids: [] + status: "" + message: "" diff --git a/pkg/porter/testdata/workflow/workflow-spec-only.yaml b/pkg/porter/testdata/workflow/workflow-spec-only.yaml new file mode 100644 index 000000000..c868f2a54 --- /dev/null +++ b/pkg/porter/testdata/workflow/workflow-spec-only.yaml @@ -0,0 +1,29 @@ +schemaType: Workflow +schemaVersion: 1.0.0-alpha.1 +maxParallel: 1 +stages: + - jobs: + root: + action: apply + installation: + name: mybuns + namespace: myns + bundle: + repository: example.com + version: 1.0.0 + digest: sha256:992f38e1a81cbdf2903fab2603f6076f3bef54262b4b2aa5b196515bac953688 + tag: v1.0.0 + custom: + color: blue + labels: + team: red + credentials: + - name: password + source: + secret: mypassword + credentialSets: + - mycs + parameterSets: + - myps + depends: + - other-thing diff --git a/pkg/porter/uninstall.go b/pkg/porter/uninstall.go index 3086cbe1e..0bbd538ee 100644 --- a/pkg/porter/uninstall.go +++ b/pkg/porter/uninstall.go @@ -79,11 +79,35 @@ func (p *Porter) UninstallBundle(ctx context.Context, opts UninstallOptions) err return fmt.Errorf("could not find installation %s/%s: %w", opts.Namespace, opts.Name, err) } + // TODO(PEP003): I think we should flip uninstall flag on the spec? + // gotta figure out how that interacts with the operator54r5 err = p.applyActionOptionsToInstallation(ctx, opts, &installation) if err != nil { return err } + if p.useWorkflowEngine(opts.bundleRef.Definition) { + puller := NewBundleResolver(p.Cache, opts.Force, p.Registry, opts.GetRegistryOptions()) + eng := NewWorkflowEngine(installation.Namespace, puller, p.Installations, p) + workflowOpts := CreateWorkflowOptions{ + Installation: installation, + Bundle: opts.bundleRef.Definition, + DebugMode: opts.DebugMode, + MaxParallel: 1, + } + w, err := eng.CreateWorkflow(ctx, workflowOpts) + if err != nil { + return err + } + + if err := p.Installations.InsertWorkflow(ctx, w); err != nil { + return err + } + + // TODO(PEP003): if a dry-run is requested, print out the execution plan and then exit + return eng.RunWorkflow(ctx, w) + } + deperator := newDependencyExecutioner(p, installation, opts) err = deperator.Prepare(ctx) if err != nil { @@ -96,7 +120,7 @@ func (p *Porter) UninstallBundle(ctx context.Context, opts UninstallOptions) err } log.Infof("%s bundle", opts.GetActionVerb()) - err = p.CNAB.Execute(ctx, actionArgs) + _, _, err = p.CNAB.Execute(ctx, actionArgs) var uninstallErrs error if err != nil { diff --git a/pkg/porter/upgrade.go b/pkg/porter/upgrade.go index b7937789e..d75ec10d9 100644 --- a/pkg/porter/upgrade.go +++ b/pkg/porter/upgrade.go @@ -73,16 +73,35 @@ func (p *Porter) UpgradeBundle(ctx context.Context, opts *UpgradeOptions) error return err } - err = p.Installations.UpdateInstallation(ctx, i) - if err != nil { - return err + if p.useWorkflowEngine(opts.bundleRef.Definition) { + puller := NewBundleResolver(p.Cache, opts.Force, p.Registry, opts.GetRegistryOptions()) + eng := NewWorkflowEngine(i.Namespace, puller, p.Installations, p) + workflowOpts := CreateWorkflowOptions{ + Installation: i, + Bundle: opts.bundleRef.Definition, + DebugMode: opts.DebugMode, + MaxParallel: 1, + } + w, err := eng.CreateWorkflow(ctx, workflowOpts) + if err != nil { + return err + } + + if err := p.Installations.InsertWorkflow(ctx, w); err != nil { + return err + } + + // TODO(PEP003): if a dry-run is requested, print out the execution plan and then exit + return eng.RunWorkflow(ctx, w) } // Re-resolve the bundle after we have figured out the version we are upgrading to opts.UnsetBundleReference() if _, err := opts.GetBundleReference(ctx, p); err != nil { + + err = p.Installations.UpdateInstallation(ctx, i) return err } - return p.ExecuteAction(ctx, i, opts) + return p.ExecuteBundleAndDependencies(ctx, i, opts) } diff --git a/pkg/porter/workflow.go b/pkg/porter/workflow.go new file mode 100644 index 000000000..5bd72f0a7 --- /dev/null +++ b/pkg/porter/workflow.go @@ -0,0 +1,188 @@ +package porter + +import ( + "fmt" + + "get.porter.sh/porter/pkg/cnab" + "get.porter.sh/porter/pkg/storage" + "github.com/cnabio/cnab-go/schema" +) + +type DisplayWorkflow struct { + // ID of the workflow. + ID string `json:"id,omitempty" yaml:"id,omitempty"` + + SchemaType string `json:"schemaType" yaml:"schemaType"` + + SchemaVersion schema.Version `json:"schemaVersion" yaml:"schemaVersion"` + + // MaxParallel is the maximum number of jobs that can run in parallel. + MaxParallel int `json:"maxParallel,omitempty" yaml:"maxParallel,omitempty"` + + // DebugMode tweaks how the workflow is run to make it easier to debug + DebugMode bool `json:"debugMode,omitempty" yaml:"debugMode,omitempty"` + + // Stages are groups of jobs that run in serial. + Stages []DisplayStage `json:"stages" yaml:"stages"` + + // TODO(PEP003): When we wrap this in a DisplayWorkflow, override marshal so that we don't marshal an ID or status when empty + // i.e. if we do a dry run, we shouldn't get out an empty id or status + Status DisplayWorkflowStatus `json:"status,omitempty" yaml:"status,omitempty"` +} + +func NewDisplayWorkflow(in storage.Workflow) DisplayWorkflow { + out := DisplayWorkflow{ + ID: in.ID, + SchemaType: "Workflow", + SchemaVersion: in.SchemaVersion, + MaxParallel: in.MaxParallel, + DebugMode: in.DebugMode, + Stages: make([]DisplayStage, len(in.Stages)), + Status: NewDisplayWorkflowStatus(in.Status), + } + + for i, inStage := range in.Stages { + out.Stages[i] = NewDisplayStage(inStage) + } + + return out +} + +// AsSpecOnly creates a subset of user-facing spec fields for exporting to the user +func (w DisplayWorkflow) AsSpecOnly() DisplayWorkflow { + out := w + out.ID = "" + out.Status = DisplayWorkflowStatus{} + + for i, stage := range out.Stages { + for j, job := range stage.Jobs { + job = job.AsSpecOnly() + stage.Jobs[j] = job + } + out.Stages[i] = stage + } + return out +} + +func (w DisplayWorkflow) ToWorkflow() (storage.Workflow, error) { + out := storage.Workflow{ + ID: cnab.NewULID(), + WorkflowSpec: storage.WorkflowSpec{ + SchemaType: storage.SchemaTypeWorkflow, + SchemaVersion: storage.WorkflowSchemaVersion, + MaxParallel: w.MaxParallel, + DebugMode: w.DebugMode, + Stages: make([]storage.Stage, len(w.Stages)), + }, + } + + for i, inStage := range w.Stages { + outStage, err := inStage.ToStage() + if err != nil { + return storage.Workflow{}, err + } + out.Stages[i] = outStage + } + + return out, nil +} + +type DisplayStage struct { + Jobs map[string]DisplayJob `json:"jobs" yaml:"jobs"` +} + +func (s DisplayStage) ToStage() (storage.Stage, error) { + out := storage.Stage{ + Jobs: make(map[string]*storage.Job, len(s.Jobs)), + } + + for jobKey, inJob := range s.Jobs { + outJob, err := inJob.ToJob(jobKey) + if err != nil { + return storage.Stage{}, err + } + out.Jobs[jobKey] = outJob + } + return out, nil +} + +func NewDisplayStage(in storage.Stage) DisplayStage { + out := DisplayStage{ + Jobs: make(map[string]DisplayJob, len(in.Jobs)), + } + + for jobKey, inJob := range in.Jobs { + out.Jobs[jobKey] = NewDisplayJob(*inJob) + } + + return out +} + +type DisplayJob struct { + Action string `json:"action,omitempty" yaml:"action,omitempty"` + Installation DisplayInstallation `json:"installation" yaml:"installation"` + Depends []string `json:"depends,omitempty" yaml:"depends,omitempty"` + Status DisplayJobStatus `json:"status,omitempty" yaml:"status,omitempty"` +} + +func (j DisplayJob) AsSpecOnly() DisplayJob { + out := j + out.Installation = out.Installation.AsSpecOnly() + out.Status = DisplayJobStatus{} + return out +} + +func (j DisplayJob) ToJob(key string) (*storage.Job, error) { + + // We only set the schemaVersion on the workflow and that implies that the schemaVersion for nested types also matches the schema version of related fields. + // TODO(PEP003): Verify this won't bit us in the 🍑 later + j.Installation.SchemaVersion = storage.InstallationSchemaVersion + + inst, err := j.Installation.ConvertToInstallation() + if err != nil { + return nil, fmt.Errorf("Error converting DisplayInstallation to a storage.Installation: %w", err) + } + + out := &storage.Job{ + Key: key, + Action: j.Action, + Installation: inst, + Depends: j.Depends, + } + + return out, nil +} + +func NewDisplayJob(in storage.Job) DisplayJob { + return DisplayJob{ + Action: in.Action, + Installation: NewDisplayInstallation(in.Installation), + Depends: in.Depends, + Status: NewDisplayJobStatus(in.Status), + } +} + +type DisplayJobStatus struct { + LastRunID string `json:"lastRunID" yaml:"lastRunID"` + LastResultID string `json:"lastResultID" yaml:"lastResultID"` + ResultIDs []string `json:"resultIDs" yaml:"resultIDs"` + Status string `json:"status" yaml:"status"` + Message string `json:"message" yaml:"message"` +} + +func NewDisplayJobStatus(in storage.JobStatus) DisplayJobStatus { + return DisplayJobStatus{ + LastRunID: in.LastRunID, + LastResultID: in.LastResultID, + ResultIDs: in.ResultIDs, + Status: in.Status, + Message: in.Message, + } +} + +type DisplayWorkflowStatus struct { +} + +func NewDisplayWorkflowStatus(in storage.WorkflowStatus) DisplayWorkflowStatus { + return DisplayWorkflowStatus{} +} diff --git a/pkg/porter/workflow_engine.go b/pkg/porter/workflow_engine.go new file mode 100644 index 000000000..47f1ef597 --- /dev/null +++ b/pkg/porter/workflow_engine.go @@ -0,0 +1,340 @@ +package porter + +import ( + "context" + "fmt" + "runtime" + "strings" + + "get.porter.sh/porter/pkg/cnab" + depsv2 "get.porter.sh/porter/pkg/cnab/dependencies/v2" + "get.porter.sh/porter/pkg/secrets" + "get.porter.sh/porter/pkg/storage" + "get.porter.sh/porter/pkg/tracing" + "go.opentelemetry.io/otel/attribute" + "golang.org/x/sync/errgroup" +) + +// Engine handles executing a workflow of bundles to execute. +type Engine struct { + namespace string + store storage.InstallationProvider + + // TODO(PEP003): don't inject a resolver, inject the stuff that the resolver uses (store and puller) mock those two instead + resolver depsv2.BundleGraphResolver + p *Porter +} + +// NewWorkflowEngine configures a Workflow Engine +func NewWorkflowEngine(namespace string, puller depsv2.BundlePuller, store storage.InstallationProvider, p *Porter) *Engine { + return &Engine{ + namespace: namespace, + resolver: depsv2.NewCompositeResolver(namespace, puller, store), + store: store, + p: p, + } +} + +// CreateWorkflowOptions are the set of options for creating a Workflow. +type CreateWorkflowOptions struct { + // DebugMode alters how the workflow is executed so that it can be stepped through. + DebugMode bool + + // MaxParallel indicates how many parallel bundles may be executed at the same + // time. Defaults to 0, indicating that the maximum should be determined by the + // number of available CPUs or cluster nodes (depending on the runtime driver). + MaxParallel int + + // Installation that triggered the workflow. + // TODO(PEP003): Does this need to be a full installation? can it just be the spec? + Installation storage.Installation + + // Bundle definition of the Installation. + Bundle cnab.ExtendedBundle + + // CustomAction is the name of a custom action defined on the bundle to execute. + // When not set, the installation is reconciled. + CustomAction string +} + +func (t Engine) CreateWorkflow(ctx context.Context, opts CreateWorkflowOptions) (storage.Workflow, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() + + g, err := t.resolver.ResolveDependencyGraph(ctx, opts.Bundle) + if err != nil { + return storage.Workflow{}, err + } + + nodes, ok := g.Sort() + if !ok { + return storage.Workflow{}, fmt.Errorf("could not generate a workflow for the bundle: the bundle graph has a cyle") + } + + // Now build job definitions for each node in the graph + jobs := make(map[string]*storage.Job, len(nodes)) + for _, node := range nodes { + switch tn := node.(type) { + case depsv2.BundleNode: + var inst storage.Installation + if tn.IsRoot() { + inst = opts.Installation + } else { + // TODO(PEP003?): generate a unique installation name, e.g. ROOTINSTALLATION-DEPKEY-SUFFIX + // I think we discussed this in a meeting? go look for notes or suggestions + // TODO(PEP003): can we fix the key so that it uses something real from the installation and not root for the root key name? + instName := strings.Replace(tn.Key, "root/", opts.Installation.Name+"/", 1) + inst = storage.NewInstallation(t.namespace, instName) + inst.Bundle = storage.NewOCIReferenceParts(tn.Reference.Reference) + // TODO(PEP003): Add labels so that we know who is the parent installation + + // Populate the dependency's credentials from the wiring + inst.Credentials.SchemaVersion = storage.CredentialSetSchemaVersion + inst.Credentials.Credentials = make([]secrets.Strategy, 0, len(tn.Credentials)) + for credName, source := range tn.Credentials { + inst.Credentials.Credentials = append(inst.Credentials.Credentials, + source.AsWorkflowStrategy(credName, tn.ParentKey)) + } + + // Populate the dependency's parameters from the wiring + inst.Parameters.SchemaVersion = storage.ParameterSetSchemaVersion + inst.Parameters.Parameters = make([]secrets.Strategy, 0, len(tn.Parameters)) + for paramName, source := range tn.Parameters { + inst.Parameters.Parameters = append(inst.Parameters.Parameters, + source.AsWorkflowStrategy(paramName, tn.ParentKey)) + } + } + + // Determine which jobs in the workflow we rely upon + requires := node.GetRequires() + requiredJobs := make([]string, 0, len(requires)) + for _, requiredKey := range requires { + // Check if a job was created for this dependency (some won't exist because they are already installed) + if _, ok := jobs[requiredKey]; ok { + requiredJobs = append(requiredJobs, requiredKey) + } + } + + jobs[tn.Key] = &storage.Job{ + Action: cnab.ActionInstall, // TODO(PEP003): eventually this needs to support all actions + Installation: inst, + Depends: requiredJobs, + } + + case depsv2.InstallationNode: + // TODO(PEP003): Do we need to do anything for this part? Check the status of the installation? + + default: + return storage.Workflow{}, fmt.Errorf("invalid node type: %T", tn) + } + + } + + w := storage.NewWorkflow() + w.Stages = []storage.Stage{{Jobs: jobs}} + w.MaxParallel = opts.MaxParallel + w.DebugMode = opts.DebugMode + + return w, nil +} + +func (t Engine) RunWorkflow(ctx context.Context, w storage.Workflow) error { + ctx, span := tracing.StartSpan(ctx, tracing.ObjectAttribute("workflow", w)) + defer span.EndSpan() + + // TODO(PEP003): 2. anything we need to validate? + w.Prepare() + + // 1. save the workflow to the database + if err := t.store.UpsertWorkflow(ctx, w); err != nil { + return err + } + + // 3. go through each stage and execute it + for i := range w.Stages { + // Run each stage in series + if err := t.executeStage(ctx, w, i); err != nil { + return fmt.Errorf("stage[%d] failed: %w", i, err) + } + } + + /* + + + + 4. what type of status do we want to track? active jobs? + 5. how do we determine which to run? we need to resolve a graph again, with depends. Is there any way to not have to do that? + 6. be smarter + */ + + return nil +} + +func (t Engine) CancelWorkflow(ctx context.Context, workflow storage.Workflow) error { + //TODO implement me + // What should cancel do? Mark a status on the record that we check before running the next thing? + // Who can call this and when? + panic("implement me") +} + +func (t Engine) RetryWorkflow(ctx context.Context, workflow storage.Workflow) error { + //TODO implement me + // Start executing from the last failed job (retry the run, keep it and add a second result), skip over everything completed + panic("implement me") +} + +func (t Engine) StepThrough(ctx context.Context, workflow storage.Workflow, job string) error { + //TODO implement me + // execute the specified job only and update the status + panic("implement me") +} + +func (t Engine) executeStage(ctx context.Context, w storage.Workflow, stageIndex int) error { + ctx, span := tracing.StartSpan(ctx, attribute.Int("stage", stageIndex)) + defer span.EndSpan() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + s := w.Stages[stageIndex] + stageGraph := depsv2.NewBundleGraph() + for _, job := range s.Jobs { + stageGraph.RegisterNode(job) + } + + sortedJobs, ok := stageGraph.Sort() + if !ok { + return fmt.Errorf("could not sort jobs in stage") + } + + availableJobs := make(chan *storage.Job, len(s.Jobs)) + completedJobs := make(chan *storage.Job, len(s.Jobs)) + + // Default the number of parallel jobs to the number of CPUs + // This gives us 1 CPU per invocation image. + var maxParallel int + if w.DebugMode { + maxParallel = 1 + } else if w.MaxParallel == 0 { + maxParallel = runtime.NumCPU() + } else { + maxParallel = w.MaxParallel + } + + // Start up workers to run the jobs as they are available + jobsInProgress := errgroup.Group{} + for i := 0; i < maxParallel; i++ { + go func() { + for { + select { + case <-ctx.Done(): + return + case job := <-availableJobs: + jobsInProgress.Go(func() error { + // TODO(PEP003) why do we have to look this up again? + return t.executeJob(ctx, s.Jobs[job.Key], completedJobs) + }) + } + } + }() + } + + t.queueAvailableJobs(ctx, s, sortedJobs, availableJobs) + + jobsInProgress.Go(func() error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case completedJob := <-completedJobs: + // If succeeded, remove from the graph so we don't need to keep evaluating it + // Leave failed ones since they act to stop graph traversal + if completedJob.Status.IsSucceeded() { + for i, job := range sortedJobs { + if job.GetKey() == completedJob.Key { + if i == 0 { + sortedJobs = sortedJobs[1:] + } else { + sortedJobs = append(sortedJobs[:i-1], sortedJobs[i+1:]...) + } + break + } + } + + // Look for more jobs to run + stop := t.queueAvailableJobs(ctx, s, sortedJobs, availableJobs) + if stop { + // Stop running all of our jobs + cancel() + return nil + } + } else { + return fmt.Errorf("job %s failed: %s", completedJob.Key, completedJob.Status.Message) + } + } + } + + }) + + err := jobsInProgress.Wait() + return err +} + +func (t Engine) queueAvailableJobs(ctx context.Context, s storage.Stage, sortedNodes []depsv2.Node, availableJobs chan *storage.Job) bool { + // Walk through the graph in sorted order (bottom up) + // if the node's dependencies are all successful, schedule it + // as soon as it's not schedule, stop looking because none of the remainder will be either + var i int + for i = 0; i < len(sortedNodes); i++ { + node := sortedNodes[i] + + job := node.(*storage.Job) + switch job.Status.Status { + case cnab.StatusFailed: + // stop scheduling more jobs + return true + case "": + jobReady := true + for _, depKey := range job.Depends { + dep := s.Jobs[depKey] + if !dep.Status.IsSucceeded() { + jobReady = false + break + } + } + + if jobReady { + availableJobs <- job + // there are still more jobs to process + return false + } + default: + continue + } + } + + // Did we iterate through all the nodes? Can we stop now? + return i >= len(sortedNodes) +} + +func (t Engine) executeJob(ctx context.Context, j *storage.Job, jobs chan *storage.Job) error { + ctx, span := tracing.StartSpan(ctx, tracing.ObjectAttribute("job", j)) + defer span.EndSpan() + + opts := ReconcileOptions{ + Installation: j.Installation.InstallationSpec, + } + run, result, err := t.p.ReconcileInstallationInWorkflow(ctx, opts) + j.Status.LastRunID = run.ID + j.Status.LastResultID = result.ID + j.Status.ResultIDs = append(j.Status.ResultIDs, result.ID) + if err != nil { + j.Status.Status = cnab.StatusFailed + j.Status.Message = err.Error() + } else { + j.Status.Status = cnab.StatusSucceeded + j.Status.Message = "" + } + jobs <- j + return nil +} diff --git a/pkg/porter/workflow_test.go b/pkg/porter/workflow_test.go new file mode 100644 index 000000000..0a914dce3 --- /dev/null +++ b/pkg/porter/workflow_test.go @@ -0,0 +1,141 @@ +package porter + +import ( + "testing" + + "get.porter.sh/porter/pkg/encoding" + "github.com/carolynvs/aferox" + "github.com/spf13/afero" + + "get.porter.sh/porter/pkg/secrets" + "get.porter.sh/porter/pkg/storage" + "get.porter.sh/porter/pkg/test" + "get.porter.sh/porter/pkg/yaml" + "github.com/stretchr/testify/require" +) + +// Check that AsSpecOnly results in a workflow that doesn't print anything for non-user settable fields +func TestDisplayWorkflow_AsSpecOnly(t *testing.T) { + w := buildTestWorkflow() + dw := NewDisplayWorkflow(w).AsSpecOnly() + result, err := yaml.Marshal(dw) + require.NoError(t, err, "Marshall failed") + test.CompareGoldenFile(t, "testdata/workflow/workflow-spec-only.yaml", string(result)) +} + +func buildTestWorkflow() storage.Workflow { + return storage.Workflow{ + ID: "abc123", + WorkflowSpec: storage.WorkflowSpec{ + SchemaType: storage.SchemaTypeWorkflow, + SchemaVersion: storage.WorkflowSchemaVersion, + MaxParallel: 1, + DebugMode: false, + Stages: []storage.Stage{ + { + Jobs: map[string]*storage.Job{ + "root": { + Key: "root", + Action: "apply", + Installation: storage.Installation{ + ID: "abc123", + InstallationSpec: storage.InstallationSpec{ + SchemaVersion: storage.InstallationSchemaVersion, + Name: "mybuns", + Namespace: "myns", + Uninstalled: false, + Bundle: storage.OCIReferenceParts{ + Repository: "example.com", + Version: "1.0.0", + Digest: "sha256:992f38e1a81cbdf2903fab2603f6076f3bef54262b4b2aa5b196515bac953688", + Tag: "v1.0.0", + }, + Custom: map[string]interface{}{"color": "blue"}, + Labels: map[string]string{"team": "red"}, + CredentialSets: []string{"mycs"}, + Credentials: storage.CredentialSetSpec{ + SchemaVersion: storage.CredentialSetSchemaVersion, + Namespace: "", + Name: "internal-cs", + Credentials: []secrets.Strategy{ + { + Name: "password", + Source: secrets.Source{Key: "secret", Value: "mypassword"}, + Value: "TOPSECRET", + }, + }, + }, + ParameterSets: []string{"myps"}, + Parameters: storage.ParameterSet{ + ParameterSetSpec: storage.ParameterSetSpec{ + SchemaVersion: storage.ParameterSetSchemaVersion, + Namespace: "myns", + Name: "myps", + Labels: nil, + Parameters: []secrets.Strategy{ + { + Name: "logLevel", + Source: secrets.Source{Key: "value", Value: "11"}, + Value: "11", + }, + }, + }, + Status: storage.ParameterSetStatus{ + Created: now, + Modified: now, + }, + }, + }, + Status: storage.InstallationStatus{ + RunID: "abc123", + Action: "install", + ResultID: "abc123", + ResultStatus: "failed", + Created: now, + Modified: now, + Installed: &now, + Uninstalled: &now, + BundleReference: "abc123", + BundleVersion: "abc123", + BundleDigest: "abc123", + }, + }, + Depends: []string{"other-thing"}, + Status: storage.JobStatus{ + LastRunID: "abc123", + LastResultID: "abc123", + ResultIDs: []string{"1", "2"}, + Status: "mystatus", + Message: "mymessage", + }, + }, + }, + }, + }, + }, + Status: storage.WorkflowStatus{}, + } +} + +func TestNewWorkflowFromDisplayWorkflow(t *testing.T) { + fs := aferox.NewAferox(".", afero.NewOsFs()) + + var dw DisplayWorkflow + err := encoding.UnmarshalFile(fs, "testdata/workflow/workflow-spec-only.yaml", &dw) + require.NoError(t, err, "failed to read testdata") + + w, err := dw.ToWorkflow() + require.NoError(t, err, "ToWorkflow failed") + + // Prepare the workflow for a golden file comparison, setting unstable values to well-known values for easier comparison + w.ID = "01GSC42NYVWGXCFAWWNTSHX2N3" + rootJob := w.Stages[0].Jobs["root"] + rootJob.Installation.Status.Created = now + rootJob.Installation.Status.Modified = now + rootJob.Installation.Parameters.Status.Created = now + rootJob.Installation.Parameters.Status.Modified = now + + result, err := yaml.Marshal(w) + require.NoError(t, err, "Marshall failed") + test.CompareGoldenFile(t, "testdata/workflow/imported-workflow.yaml", string(result)) +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 4f05cb297..b70cf3c45 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -6,6 +6,10 @@ import ( "fmt" "path/filepath" + "go.opentelemetry.io/otel/attribute" + + "get.porter.sh/porter/pkg/tracing" + "get.porter.sh/porter/pkg" "get.porter.sh/porter/pkg/cnab" "get.porter.sh/porter/pkg/config" @@ -32,13 +36,18 @@ func NewPorterRuntime(runtimeCfg RuntimeConfig, mixins pkgmgmt.PackageManager) * } func (r *PorterRuntime) Execute(ctx context.Context, rm *RuntimeManifest) error { - r.RuntimeManifest = rm - installationName := r.config.Getenv(config.EnvInstallationName) bundleName := r.config.Getenv(config.EnvBundleName) - fmt.Fprintf(r.config.Out, "executing %s action from %s (installation: %s)\n", r.RuntimeManifest.Action, bundleName, installationName) - err := r.RuntimeManifest.Validate() + ctx, span := tracing.StartSpan(ctx, + attribute.String("action", rm.Action), + attribute.String("bundleName", bundleName), + attribute.String("installation", installationName)) + defer span.EndSpan() + r.RuntimeManifest = rm + span.Infof("executing %s action from %s (installation: %s)\n", r.RuntimeManifest.Action, bundleName, installationName) + + err := r.RuntimeManifest.Validate(ctx) if err != nil { return err } @@ -54,21 +63,21 @@ func (r *PorterRuntime) Execute(ctx context.Context, rm *RuntimeManifest) error // Update the runtimeManifest images with the bundle.json and relocation mapping (if it's there) rtb, reloMap, err := r.getImageMappingFiles() if err != nil { - return err + return span.Error(err) } err = r.RuntimeManifest.ResolveInvocationImage(rtb, reloMap) if err != nil { - return fmt.Errorf("unable to resolve bundle invocation images: %w", err) + return span.Errorf("unable to resolve bundle invocation images: %w", err) } err = r.RuntimeManifest.ResolveImages(rtb, reloMap) if err != nil { - return fmt.Errorf("unable to resolve bundle images: %w", err) + return span.Errorf("unable to resolve bundle images: %w", err) } err = r.config.FileSystem.MkdirAll(portercontext.MixinOutputsDir, pkg.FileModeDirectory) if err != nil { - return fmt.Errorf("could not create outputs directory %s: %w", portercontext.MixinOutputsDir, err) + return span.Errorf("could not create outputs directory %s: %w", portercontext.MixinOutputsDir, err) } var bigErr *multierror.Error @@ -85,7 +94,7 @@ func (r *PorterRuntime) Execute(ctx context.Context, rm *RuntimeManifest) error bigErr = multierror.Append(bigErr, err) } - return bigErr.ErrorOrNil() + return span.Error(bigErr.ErrorOrNil()) } func (r *PorterRuntime) executeStep(ctx context.Context, stepIndex int, step *manifest.Step) error { diff --git a/pkg/runtime/runtime_manifest.go b/pkg/runtime/runtime_manifest.go index 03f50f8e7..135be5762 100644 --- a/pkg/runtime/runtime_manifest.go +++ b/pkg/runtime/runtime_manifest.go @@ -68,25 +68,28 @@ func (m *RuntimeManifest) debugf(log tracing.TraceLogger, msg string, args ...in } } -func (m *RuntimeManifest) Validate() error { +func (m *RuntimeManifest) Validate(ctx context.Context) error { + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() + err := m.loadBundle() if err != nil { - return err + return span.Error(err) } - err = m.loadDependencyDefinitions() + err = m.loadDependencyDefinitions(ctx) if err != nil { return err } err = m.setStepsByAction() if err != nil { - return err + return span.Error(err) } err = m.steps.Validate(m.Manifest) if err != nil { - return fmt.Errorf("invalid action configuration: %w", err) + return span.Errorf("invalid action configuration: %w", err) } return nil @@ -111,17 +114,22 @@ func (m *RuntimeManifest) GetInstallationName() string { return m.config.Getenv(config.EnvPorterInstallationName) } -func (m *RuntimeManifest) loadDependencyDefinitions() error { +func (m *RuntimeManifest) loadDependencyDefinitions(ctx context.Context) error { + ctx, span := tracing.StartSpan(ctx) + defer span.EndSpan() + m.bundles = make(map[string]cnab.ExtendedBundle, len(m.Dependencies.Requires)) for _, dep := range m.Dependencies.Requires { bunD, err := GetDependencyDefinition(m.config.Context, dep.Name) if err != nil { - return err + // TODO(PEP003): Implement passing bundle.json files for dependencies, or cut feature from v2. + span.Warnf("WARNING: %w", err.Error()) + continue } bun, err := bundle.Unmarshal(bunD) if err != nil { - return fmt.Errorf("error unmarshaling bundle definition for dependency %s: %w", dep.Name, err) + return span.Errorf("error unmarshaling bundle definition for dependency %s: %w", dep.Name, err) } m.bundles[dep.Name] = cnab.NewBundle(*bun) diff --git a/pkg/runtime/runtime_manifest_test.go b/pkg/runtime/runtime_manifest_test.go index bf1e38ca1..ca65ad9fb 100644 --- a/pkg/runtime/runtime_manifest_test.go +++ b/pkg/runtime/runtime_manifest_test.go @@ -602,10 +602,15 @@ func TestDependencyV1_Validate(t *testing.T) { wantOutput: "", wantError: `reference is required for dependency "mysql"`, }, { - name: "version double specified", + name: "version not specified", + dep: manifest.Dependency{Name: "mysql", Bundle: manifest.BundleCriteria{Reference: "deislabs/azure-mysql", Version: ""}}, + wantOutput: "", + wantError: `reference for dependency "mysql" can specify only a repository, without a digest or tag, when a version constraint is specified`, + }, { // When a range is specified, but also a default version, we use the default version when we can't find a matching version from the range + name: "default version and range specified", dep: manifest.Dependency{Name: "mysql", Bundle: manifest.BundleCriteria{Reference: "deislabs/azure-mysql:5.7", Version: "5.7.x-6"}}, wantOutput: "", - wantError: `reference for dependency "mysql" can only specify REGISTRY/NAME when version ranges are specified`, + wantError: "", }, } @@ -618,7 +623,7 @@ func TestDependencyV1_Validate(t *testing.T) { if tc.wantError == "" { require.NoError(t, err) } else { - require.Equal(t, tc.wantError, err.Error()) + tests.RequireErrorContains(t, err, tc.wantError) } gotOutput := pCtx.GetOutput() diff --git a/pkg/schema/manifest.schema.json b/pkg/schema/manifest.schema.json index 23619b87f..ba01ce479 100644 --- a/pkg/schema/manifest.schema.json +++ b/pkg/schema/manifest.schema.json @@ -174,7 +174,18 @@ "$ref": "#/definitions/bundle" }, "parameters": { - "type": "object" + "description": "Map of parameter names to a parameter source, such as bundle.parameters.PARAM, bundle.dependencies.DEP.outputs.OUTPUT, or bundle.credentials.CRED", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "credentials": { + "description": "Map of credential names to a source, such as bundle.credentials.NAME or bundle.dependencies.DEP.outputs.OUTPUT", + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -183,6 +194,28 @@ ], "type": "object" }, + "dependencySource": { + "description": "Describes how Porter should set a dependency's parameter or credential", + "type": "object", + "properties": { + "dependency": { + "description": "The name of the dependency that defines the output used for the source", + "type": "string" + }, + "parameter": { + "description": "The parameter name from which the value should be sourced", + "type": "string" + }, + "credential": { + "description": "The credential name from which the value should be sourced", + "type": "string" + }, + "output": { + "description": "The output name defined in a dependency from which the value should be sourced", + "type": "string" + } + } + }, "bundle": { "description": "The defintion of a bundle reference", "properties": { diff --git a/pkg/secrets/helpers.go b/pkg/secrets/helpers.go index 83c4ded58..d80c6bf60 100644 --- a/pkg/secrets/helpers.go +++ b/pkg/secrets/helpers.go @@ -5,7 +5,7 @@ import inmemory "get.porter.sh/porter/pkg/secrets/plugins/in-memory" var _ Store = &TestSecretsProvider{} type TestSecretsProvider struct { - PluginAdapter + *PluginAdapter secrets *inmemory.Store } diff --git a/pkg/secrets/plugin_adapter.go b/pkg/secrets/plugin_adapter.go index 1f84a9db9..e1c5e64e5 100644 --- a/pkg/secrets/plugin_adapter.go +++ b/pkg/secrets/plugin_adapter.go @@ -7,30 +7,47 @@ import ( "get.porter.sh/porter/pkg/secrets/plugins" ) -var _ Store = PluginAdapter{} +var _ Store = &PluginAdapter{} + +type PorterSecretStrategy interface { + Resolve(ctx context.Context, keyName string, keyValue string) (string, error) +} // PluginAdapter converts between the low-level plugins.SecretsProtocol and // the secrets.Store interface. type PluginAdapter struct { - plugin plugins.SecretsProtocol + plugin plugins.SecretsProtocol + porterPlugin PorterSecretStrategy +} + +func (a *PluginAdapter) SetPorterStrategy(strategy PorterSecretStrategy) { + a.porterPlugin = strategy } // NewPluginAdapter wraps the specified storage plugin. -func NewPluginAdapter(plugin plugins.SecretsProtocol) PluginAdapter { - return PluginAdapter{plugin: plugin} +func NewPluginAdapter(plugin plugins.SecretsProtocol) *PluginAdapter { + return &PluginAdapter{ + plugin: plugin, + } } -func (a PluginAdapter) Close() error { +func (a *PluginAdapter) Close() error { if closer, ok := a.plugin.(io.Closer); ok { return closer.Close() } return nil } -func (a PluginAdapter) Resolve(ctx context.Context, keyName string, keyValue string) (string, error) { +func (a *PluginAdapter) Resolve(ctx context.Context, keyName string, keyValue string) (string, error) { + // Intercept requests for Porter to resolve an internal value and run the plugin in-process. + // This supports bundle workflows where we are sourcing data from other runs, e.g. passing a connection string from a dependency to another bundle + if keyName == "porter" { + return a.porterPlugin.Resolve(ctx, keyName, keyValue) + } + return a.plugin.Resolve(ctx, keyName, keyValue) } -func (a PluginAdapter) Create(ctx context.Context, keyName string, keyValue string, value string) error { +func (a *PluginAdapter) Create(ctx context.Context, keyName string, keyValue string, value string) error { return a.plugin.Create(ctx, keyName, keyValue, value) } diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index b32a4f394..c17726b14 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -27,4 +27,8 @@ type Store interface { // - keyName=key, keyValue=conn-string, value=redis://foo // - keyName=path, keyValue=/tmp/connstring.txt, value=redis://foo Create(ctx context.Context, keyName string, keyValue string, value string) error + + // SetPorterStrategy gives the secret store the ability to resolve the porter secret strategy + // using Porter's database. + SetPorterStrategy(strategy PorterSecretStrategy) } diff --git a/pkg/storage/installation.go b/pkg/storage/installation.go index d9bc44f39..519521c7e 100644 --- a/pkg/storage/installation.go +++ b/pkg/storage/installation.go @@ -20,50 +20,53 @@ var _ Document = Installation{} type Installation struct { // ID is the unique identifier for an installation record. - ID string `json:"id"` + ID string `json:"id,omitempty" yaml:"id,omitempty"` - InstallationSpec + InstallationSpec `yaml:",inline"` // Status of the installation. - Status InstallationStatus `json:"status,omitempty"` + Status InstallationStatus `json:"status,omitempty" yaml:"status,omitempty"` } // InstallationSpec contains installation fields that represent the desired state of the installation. type InstallationSpec struct { // SchemaVersion is the version of the installation state schema. - SchemaVersion schema.Version `json:"schemaVersion"` + SchemaVersion schema.Version `json:"schemaVersion,omitempty" yaml:"schemaVersion,omitempty"` // Name of the installation. Immutable. - Name string `json:"name"` + Name string `json:"name" yaml:"name"` // Namespace in which the installation is defined. - Namespace string `json:"namespace"` + Namespace string `json:"namespace" yaml:"namespace"` // Uninstalled specifies if the installation isn't used anymore and should be uninstalled. - Uninstalled bool `json:"uninstalled,omitempty"` + Uninstalled bool `json:"uninstalled,omitempty" yaml:"uninstalled,omitempty"` // Bundle specifies the bundle reference to use with the installation. - Bundle OCIReferenceParts `json:"bundle"` + Bundle OCIReferenceParts `json:"bundle" yaml:"bundle"` // Custom extension data applicable to a given runtime. // TODO(carolynvs): remove and populate in ToCNAB when we firm up the spec - Custom interface{} `json:"custom,omitempty"` + Custom interface{} `json:"custom,omitempty" yaml:"custom,omitempty"` // Labels applied to the installation. - Labels map[string]string `json:"labels,omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` - // CredentialSets that should be included when the bundle is reconciled. - CredentialSets []string `json:"credentialSets,omitempty"` + // Credentials specified by the user through overrides. + // Does not include defaults, or values resolved from credential sets. + // TODO(PEP003): use this when executing the bundle + Credentials CredentialSetSpec `json:"credentials,omitempty" yaml:"credentials,omitempty"` - // ParameterSets that should be included when the bundle is reconciled. - ParameterSets []string `json:"parameterSets,omitempty"` + // CredentialSets that should be included when the bundle is reconciled. + CredentialSets []string `json:"credentialSets,omitempty" yaml:"credentialSets,omitempty"` // Parameters specified by the user through overrides. - // Does not include defaults, or values resolved from parameter sources. - Parameters ParameterSet `json:"parameters,omitempty"` + // Does not include defaults, or values resolved from parameter sets. + // TODO(PEP003): We should consider if it makes sense to store just the ParameterSetSpec instead, like we do for credentials which was added later + Parameters ParameterSet `json:"parameters,omitempty" yaml:"parameters,omitempty"` - // Status of the installation. - Status InstallationStatus `json:"status,omitempty"` + // ParameterSets that should be included when the bundle is reconciled. + ParameterSets []string `json:"parameterSets,omitempty" yaml:"parameterSets,omitempty"` } func (i InstallationSpec) String() string { @@ -270,6 +273,15 @@ type OCIReferenceParts struct { Tag string `json:"tag,omitempty" yaml:"tag,omitempty" toml:"tag,omitempty"` } +func NewOCIReferenceParts(ref cnab.OCIReference) OCIReferenceParts { + return OCIReferenceParts{ + Repository: ref.Repository(), + Version: ref.Version(), + Digest: ref.Digest().String(), + Tag: ref.Tag(), + } +} + func (r OCIReferenceParts) GetBundleReference() (cnab.OCIReference, bool, error) { if r.Repository == "" { return cnab.OCIReference{}, false, nil diff --git a/pkg/storage/installation_provider.go b/pkg/storage/installation_provider.go index 9d0b6b929..dd2dcc431 100644 --- a/pkg/storage/installation_provider.go +++ b/pkg/storage/installation_provider.go @@ -55,6 +55,10 @@ type InstallationProvider interface { // GetLastRun returns the last run of an Installation. GetLastRun(ctx context.Context, namespace string, installation string) (Run, error) + // FindOutputs applies the find operation against outputs collection + // using the specified options. + FindOutputs(ctx context.Context, opts FindOptions) ([]Output, error) + // GetLastOutput returns the most recent value (last) of the specified // Output associated with the installation. GetLastOutput(ctx context.Context, namespace string, installation string, name string) (Output, error) @@ -71,4 +75,9 @@ type InstallationProvider interface { // GetLastLogs returns the logs from the last run of an Installation. GetLastLogs(ctx context.Context, namespace string, installation string) (logs string, hasLogs bool, err error) + + // TODO(PEP003): document and make sure we have all standard functions here + GetWorkflow(ctx context.Context, id string) (Workflow, error) + InsertWorkflow(ctx context.Context, workflow Workflow) error + UpsertWorkflow(ctx context.Context, workflow Workflow) error } diff --git a/pkg/storage/installation_store.go b/pkg/storage/installation_store.go index c2c11fa1c..28b1eecc8 100644 --- a/pkg/storage/installation_store.go +++ b/pkg/storage/installation_store.go @@ -13,24 +13,21 @@ const ( CollectionRuns = "runs" CollectionResults = "results" CollectionOutputs = "outputs" + CollectionWorkflows = "workflows" ) var _ InstallationProvider = InstallationStore{} // InstallationStore is a persistent store for installation documents. type InstallationStore struct { - store Store - encrypt EncryptionHandler - decrypt EncryptionHandler + store Store } // NewInstallationStore creates a persistent store for installations using the specified // backing datastore. func NewInstallationStore(datastore Store) InstallationStore { return InstallationStore{ - store: datastore, - encrypt: noOpEncryptionHandler, - decrypt: noOpEncryptionHandler, + store: datastore, } } @@ -57,6 +54,8 @@ func EnsureInstallationIndices(ctx context.Context, store Store) error { {Collection: CollectionOutputs, Keys: []string{"resultId", "name"}, Unique: true}, // query most recent outputs by name for an installation {Collection: CollectionOutputs, Keys: []string{"namespace", "installation", "name", "-resultId"}}, + // query workflows by id (list) + {Collection: CollectionWorkflows, Keys: []string{"id"}, Unique: true}, }, } @@ -143,6 +142,15 @@ func (s InstallationStore) FindInstallations(ctx context.Context, findOpts FindO return out, err } +func (s InstallationStore) FindOutputs(ctx context.Context, findOpts FindOptions) ([]Output, error) { + _, log := tracing.StartSpan(ctx) + defer log.EndSpan() + + var out []Output + err := s.store.Find(ctx, CollectionOutputs, findOpts, &out) + return out, err +} + func (s InstallationStore) GetInstallation(ctx context.Context, namespace string, name string) (Installation, error) { var out Installation @@ -373,10 +381,30 @@ func (s InstallationStore) RemoveInstallation(ctx context.Context, namespace str return nil } -// EncryptionHandler is a function that transforms data by encrypting or decrypting it. -type EncryptionHandler func([]byte) ([]byte, error) +func (s InstallationStore) GetWorkflow(ctx context.Context, id string) (Workflow, error) { + var out Workflow -// noOpEncryptHandler is used when no handler is specified. -var noOpEncryptionHandler = func(data []byte) ([]byte, error) { - return data, nil + opts := FindOptions{ + Filter: bson.M{ + "id": id, + }, + } + err := s.store.FindOne(ctx, CollectionWorkflows, opts, &out) + return out, err +} + +func (s InstallationStore) InsertWorkflow(ctx context.Context, workflow Workflow) error { + opts := InsertOptions{ + Documents: []interface{}{workflow}, + } + return s.store.Insert(ctx, CollectionWorkflows, opts) +} + +func (s InstallationStore) UpsertWorkflow(ctx context.Context, workflow Workflow) error { + workflow.SchemaVersion = WorkflowSchemaVersion + opts := UpdateOptions{ + Upsert: true, + Document: workflow, + } + return s.store.Update(ctx, CollectionWorkflows, opts) } diff --git a/pkg/storage/schema.go b/pkg/storage/schema.go index b2c09d0ba..3f590a753 100644 --- a/pkg/storage/schema.go +++ b/pkg/storage/schema.go @@ -7,10 +7,16 @@ import ( var _ Document = Schema{} const ( + SchemaTypeWorkflow = "Workflow" + // InstallationSchemaVersion represents the version associated with the schema // for all installation documents: installations, runs, results and outputs. InstallationSchemaVersion = schema.Version("1.0.2") + // WorkflowSchemaVersion represents the version associated with the schema + // for all workflow documents: workflow. + WorkflowSchemaVersion = schema.Version("1.0.0-alpha.1") + // CredentialSetSchemaVersion represents the version associated with the schema // credential set documents. CredentialSetSchemaVersion = schema.Version("1.0.1") diff --git a/pkg/storage/workflow.go b/pkg/storage/workflow.go new file mode 100644 index 000000000..bc11be8d0 --- /dev/null +++ b/pkg/storage/workflow.go @@ -0,0 +1,168 @@ +package storage + +import ( + "fmt" + "strings" + + "get.porter.sh/porter/pkg/cnab" + "github.com/cnabio/cnab-go/schema" +) + +// Workflow represents how a bundle and its dependencies should be run by Porter. +type Workflow struct { + // ID of the workflow. + ID string `json:"id,omitempty" yaml:"id,omitempty"` + + WorkflowSpec `yaml:",inline"` + + // TODO(PEP003): When we wrap this in a DisplayWorkflow, override marshal so that we don't marshal an ID or status when empty + // i.e. if we do a dry run, we shouldn't get out an empty id or status + Status WorkflowStatus `json:"status,omitempty" yaml:"status,omitempty"` +} + +func NewWorkflow() Workflow { + return Workflow{ + ID: cnab.NewULID(), + WorkflowSpec: WorkflowSpec{ + SchemaType: SchemaTypeWorkflow, + SchemaVersion: WorkflowSchemaVersion, + }, + } +} + +type WorkflowSpec struct { + SchemaType string `json:"schemaType" yaml:"schemaType"` + + SchemaVersion schema.Version `json:"schemaVersion" yaml:"schemaVersion"` + + // MaxParallel is the maximum number of jobs that can run in parallel. + MaxParallel int `json:"maxParallel,omitempty" yaml:"maxParallel,omitempty"` + + // DebugMode tweaks how the workflow is run to make it easier to debug + DebugMode bool `json:"debugMode,omitempty" yaml:"debugMode,omitempty"` + + // Stages are groups of jobs that run in serial. + Stages []Stage `json:"stages" yaml:"stages"` +} + +// GetJob finds the specified job by its key. +// All job keys within a workflow must be unique. +func (w *WorkflowSpec) GetJob(jobKey string) (*Job, error) { + for _, s := range w.Stages { + for k, j := range s.Jobs { + if k == jobKey { + return j, nil + } + } + } + + return nil, fmt.Errorf("workflow does not contain job key %s", jobKey) +} + +// WorkflowExecutionPlan is Porter's plan for executing a workflow after resolving all dependencies. +type WorkflowExecutionPlan struct { + // Runs maps from the job key to the Run that will be executed on behalf of the job. + Runs map[string]WorkflowRun `json:"runs" yaml:"runs"` +} + +// WorkflowRun associates a run to a Workflow. +type WorkflowRun struct { + // RunID is the Run that will be executed on behalf of this job. + RunID string `json:"runID" yaml:"runID"` + + // Depends is a list of other job keys that this run depends upon. + Depends []string `json:"depends" yaml:"depends"` +} + +// TODO(PEP003): Figure out what needs to be persisted, and how to persist multiple or continued runs +type WorkflowStatus struct { + Plan WorkflowExecutionPlan `json:"plan,omitempty" yaml:"plan,omitempty"` +} + +// Prepare updates the internal data representation of the workflow before running it. +func (w *Workflow) Prepare() { + // Assign an id to the workflow if needed + if w.ID == "" { + w.ID = cnab.NewULID() + } + + for _, s := range w.Stages { + s.Prepare(w.ID) + } +} + +// Stage represents a set of jobs that should run, possibly in parallel. +type Stage struct { + // Jobs is the set of bundles to execute, keyed by the job name. + Jobs map[string]*Job `json:"jobs", yaml:"jobs"` +} + +func (s *Stage) Prepare(workflowID string) { + // Update the jobs so that they know their job key (since they won't be used within the larger workflow, but as independent jobs) + for jobKey, job := range s.Jobs { + job.Prepare(workflowID, jobKey) + } +} + +// Job represents the execution of a bundle. +type Job struct { + // TODO(PEP003): no job can have the same name as the parent installation (which is keyed from the installation). Or we need to stick to root and reserve that? + // Key is the unique name of the job within a stage. + // We handle copying this value so that it's easier to work with a single job when not in a map + Key string `json:"-" yaml:"-"` + + // Action name to execute on the bundle, when empty default to applying the installation. + Action string `json:"action,omitempty" yaml:"action,omitempty"` + + Installation Installation `json:"installation" yaml:"installation"` + + // Depends is a list of job keys that the Job depends upon. + Depends []string `json:"depends,omitempty" yaml:"depends,omitempty"` + + Status JobStatus `json:"status,omitempty"` +} + +func (j *Job) GetRequires() []string { + return j.Depends +} + +func (j *Job) GetKey() string { + return j.Key +} + +func (j *Job) Prepare(workflowId string, jobKey string) { + j.Key = jobKey + for i, param := range j.Installation.Parameters.Parameters { + if param.Source.Key != "porter" { + continue + } + + // Update the template string with the id of the current workflow + param.Source.Value = strings.Replace(param.Source.Value, "workflow.jobs", fmt.Sprintf("workflow.%s.jobs", workflowId), 1) + j.Installation.Parameters.Parameters[i] = param + } +} + +type JobStatus struct { + LastRunID string `json:"lastRunID"` + LastResultID string `json:"lastResultID"` + ResultIDs []string `json:"resultIDs"` + Status string `json:"status"` + Message string `json:"message"` +} + +func (s JobStatus) IsSucceeded() bool { + return s.Status == cnab.StatusSucceeded +} + +func (s JobStatus) IsFailed() bool { + return s.Status == cnab.StatusFailed +} + +func (s JobStatus) IsDone() bool { + switch s.Status { + case cnab.StatusSucceeded, cnab.StatusFailed: + return true + } + return false +} diff --git a/pkg/storage/workflow_test.go b/pkg/storage/workflow_test.go new file mode 100644 index 000000000..3f59fdd65 --- /dev/null +++ b/pkg/storage/workflow_test.go @@ -0,0 +1,21 @@ +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJobStatus_IsFailed(t *testing.T) { + s := JobStatus{} + assert.False(t, s.IsFailed(), "IsFailed should be false when the status is not failed") + s.Status = "failed" + assert.True(t, s.IsFailed(), "IsFailed should be true when the status is failed") +} + +func TestJobStatus_IsCompleted(t *testing.T) { + s := JobStatus{} + assert.False(t, s.IsSucceeded(), "IsSucceeded should be false when the status is not succeeded") + s.Status = "succeeded" + assert.True(t, s.IsSucceeded(), "IsSucceeded should be true when the status is succeeded") +} diff --git a/tests/integration/copy_test.go b/tests/integration/copy_test.go index 18be267f5..25e71f519 100644 --- a/tests/integration/copy_test.go +++ b/tests/integration/copy_test.go @@ -23,7 +23,7 @@ func TestCopy_UsesRelocationMap(t *testing.T) { // Publish the bundle to the insecure registry origRef := fmt.Sprintf("%s/orig-mydb:v0.1.1", reg1) - test.MakeTestBundle(testdata.MyDb, origRef) + test.MakeTestBundle(testdata.MyDb, origRef, false) ociRef, err := cnab.ParseOCIReference(origRef) require.NoError(t, err) diff --git a/tests/integration/mybuns.yaml b/tests/integration/mybuns.yaml new file mode 100644 index 000000000..ea86e11b0 --- /dev/null +++ b/tests/integration/mybuns.yaml @@ -0,0 +1,15 @@ +schemaVersion: 1.0.2 +name: mybuns +labels: + generator: porter-operator + generatorVersion: v0.2.0 + thing: "1" +bundle: + repository: localhost:5000/mybuns + version: v0.1.2 +parameters: + password: "supersecret" +credentialSets: + - mybuns +parameterSets: + - mybuns diff --git a/tests/integration/myenv.yaml b/tests/integration/myenv.yaml new file mode 100644 index 000000000..c1d8462b6 --- /dev/null +++ b/tests/integration/myenv.yaml @@ -0,0 +1,9 @@ +schemaVersion: 1.0.2 +name: myenv +bundle: + repository: localhost:5000/myenv + version: v0.1.0 +credentialSets: + - myenv +parameterSets: + - myenv diff --git a/tests/integration/schema_test.go b/tests/integration/schema_test.go index 7bfa2552a..68a4aae92 100644 --- a/tests/integration/schema_test.go +++ b/tests/integration/schema_test.go @@ -8,8 +8,11 @@ import ( "strings" "testing" - testhelper "get.porter.sh/porter/pkg/test" "get.porter.sh/porter/pkg/yaml" + + "get.porter.sh/porter/pkg/manifest" + + testhelper "get.porter.sh/porter/pkg/test" "get.porter.sh/porter/tests" "get.porter.sh/porter/tests/tester" "github.com/stretchr/testify/assert" @@ -53,11 +56,13 @@ mixins.2.testmixin: Additional property missingproperty is not allowed`}, t.Run(tm.name, func(t *testing.T) { // Load the manifest as a go dump testManifestPath := tm.path - testManifest, err := os.ReadFile(testManifestPath) - require.NoError(t, err, "failed to read %s", testManifestPath) + mani, err := manifest.ReadManifest(test.TestContext.Context, testManifestPath) + + maniYaml, err := yaml.Marshal(mani) + require.NoError(t, err, "error marshaling manifest to yaml") m := make(map[string]interface{}) - err = yaml.Unmarshal(testManifest, &m) + err = yaml.Unmarshal(maniYaml, &m) require.NoError(t, err, "failed to unmarshal %s", testManifestPath) // Load the manifest schema returned from `porter schema` diff --git a/tests/integration/testdata/schema/schema.json b/tests/integration/testdata/schema/schema.json index d5fa1fcdd..65198473a 100644 --- a/tests/integration/testdata/schema/schema.json +++ b/tests/integration/testdata/schema/schema.json @@ -95,10 +95,21 @@ "bundle": { "$ref": "#/definitions/bundle" }, + "credentials": { + "additionalProperties": { + "type": "string" + }, + "description": "Map of credential names to a source, such as bundle.credentials.NAME or bundle.dependencies.DEP.outputs.OUTPUT", + "type": "object" + }, "name": { "type": "string" }, "parameters": { + "additionalProperties": { + "type": "string" + }, + "description": "Map of parameter names to a parameter source, such as bundle.parameters.PARAM, bundle.dependencies.DEP.outputs.OUTPUT, or bundle.credentials.CRED", "type": "object" } }, @@ -108,6 +119,28 @@ ], "type": "object" }, + "dependencySource": { + "description": "Describes how Porter should set a dependency's parameter or credential", + "properties": { + "credential": { + "description": "The credential name from which the value should be sourced", + "type": "string" + }, + "dependency": { + "description": "The name of the dependency that defines the output used for the source", + "type": "string" + }, + "output": { + "description": "The output name defined in a dependency from which the value should be sourced", + "type": "string" + }, + "parameter": { + "description": "The parameter name from which the value should be sourced", + "type": "string" + } + }, + "type": "object" + }, "image": { "additionalProperties": false, "description": "An image represents an application image used in a bundle", diff --git a/tests/integration/testdata/workflow/mybuns.yaml b/tests/integration/testdata/workflow/mybuns.yaml new file mode 100644 index 000000000..37108e1f2 --- /dev/null +++ b/tests/integration/testdata/workflow/mybuns.yaml @@ -0,0 +1,40 @@ +schemaVersion: 1.0.0-alpha.1 +maxParallel: 1 +debugMode: false +stages: + - jobs: + root: + action: install + installation: + schemaVersion: 1.0.2 + name: mybuns + namespace: dev + bundle: + repository: localhost:5000/mybuns + version: v0.1.2 + labels: + generator: porter-operator + generatorVersion: v0.2.0 + thing: "1" + credentialSets: + - mybuns + parameterSets: + - mybuns + parameters: + - name: password + source: + secret: 01GG5VKAA5VS24CGRGSPY09DDX-password + root/db: + action: install + installation: + schemaVersion: 1.0.2 + name: mybuns/db + namespace: dev + bundle: + repository: localhost:5000/mydb + version: 0.1.0 + tag: v0.1.0 + parameters: + - name: database + source: + value: bigdb diff --git a/tests/integration/testdata/workflow/myenv.yaml b/tests/integration/testdata/workflow/myenv.yaml new file mode 100644 index 000000000..9b0f6328c --- /dev/null +++ b/tests/integration/testdata/workflow/myenv.yaml @@ -0,0 +1,64 @@ +schemaType: Workflow +schemaVersion: 1.0.0-alpha.1 +maxParallel: 1 +stages: + - jobs: + root: + action: install + installation: + name: myenv + namespace: dev + bundle: + repository: localhost:5000/myenv + version: 0.1.0 + credentialSets: + - myenv + parameterSets: + - myenv + depends: + - root/app + - root/infra + root/app: + action: install + installation: + name: myenv/app + namespace: dev + bundle: + repository: localhost:5000/myapp + version: 1.2.3 + tag: v1.2.3 + credentials: + - name: db-connstr + source: + porter: workflow.jobs.root.outputs.mysql-connstr + depends: + - root/infra + root/infra: + action: install + installation: + name: myenv/infra + namespace: dev + bundle: + repository: localhost:5000/myinfra + version: 0.1.0 + tag: v0.1.0 + credentials: + - name: token + source: + porter: workflow.jobs.root.credentials.token + depends: + - root/infra/db + root/infra/db: + action: install + installation: + name: myenv/infra/db + namespace: dev + bundle: + repository: localhost:5000/mysqldb + version: 0.1.0 + tag: v0.1.0 + credentials: + - name: token + source: + porter: workflow.jobs.root/infra.credentials.token + diff --git a/tests/integration/workflow_test.go b/tests/integration/workflow_test.go new file mode 100644 index 000000000..63e889f97 --- /dev/null +++ b/tests/integration/workflow_test.go @@ -0,0 +1,62 @@ +//go:build integration + +package integration + +import ( + "fmt" + "path/filepath" + "testing" + + testhelpers "get.porter.sh/porter/pkg/test" + + "get.porter.sh/porter/tests/testdata" + + "get.porter.sh/porter/tests/tester" + "github.com/stretchr/testify/require" +) + +// prepareDependenciesBundle ensures that the myenv set of dependencies bundles are ready to use. +func prepareDependenciesBundle(t tester.Tester) { + // Build and publish an interesting test bundle and its dependency + t.MakeTestBundle(testdata.MyEnv, testdata.MyEnvRef, true) + t.MakeTestBundle(testdata.MyInfra, testdata.MyInfraRef, true) + t.MakeTestBundle(testdata.MyApp, testdata.MyAppRef, true) + t.MakeTestBundle(testdata.MySQLDb, testdata.MySQLDbRef, true) + + t.RequirePorter("parameters", "apply", filepath.Join(t.RepoRoot, "tests/testdata/params/myenv.yaml"), "--namespace=") + t.RequirePorter("credentials", "apply", filepath.Join(t.RepoRoot, "tests/testdata/creds/myenv.yaml"), "--namespace=") +} + +func TestWorkflow(t *testing.T) { + // Since we are working with depsv2 enabled, don't reuse a bundle that was already built for depsv1 in other tests + test, err := tester.NewTestWithConfig(t, "tests/testdata/config/config-with-depsv2.yaml") + defer test.Close() + require.NoError(t, err, "test setup failed") + prepareDependenciesBundle(test) + + test.TestContext.AddTestFileFromRoot("tests/testdata/installations/myenv.yaml", "myenv.yaml") + + // First validate the plan for the workflow + // TODO(PEP003): Do we want to use different terms/commands for generating a workflow? This pretty much associates --dry-run with "print out your workflow" + workflowContents, output := test.RequirePorter("installation", "apply", "myenv.yaml", "--output=yaml", "--dry-run") + fmt.Println(output) + // TODO(PEP003): Until we have a display workflow, this comparison doesn't work because of extra status printed out + _ = workflowContents + testhelpers.CompareGoldenFile(t, "testdata/workflow/myenv.yaml", workflowContents) + + // Run the workflow + _, output = test.RequirePorter("installation", "apply", "myenv.yaml") + fmt.Println(output) + + // TODO A workflow should be persisted, and it should match the execution plan generated first with --dry-run + // We don't expose workflow commands yet though so the only way to test this is call the db directly + + // We should have 4 installations: myenv, myinfra, myapp and mydb + test.RequireInstallationExists(test.CurrentNamespace(), "myenv") + mydb := test.RequireInstallationExists(test.CurrentNamespace(), "myenv/db") + require.Contains(t, mydb.Parameters, "database", "myenv should have explicitly set the database parameter on its db dependency") + require.Equal(t, "bigdb", mydb.Parameters["database"], "incorrect value used for the database parameter on the db dependency, expected the hard coded value specified by the root bundle") + + // TODO mydb should have a parameter that was set by the workflow, e.g. the db name + // TODO mybuns should have used an output from mydb that we saved as a root bundle output so that we can validate that it was used properly +} diff --git a/tests/smoke/desiredstate_test.go b/tests/smoke/desiredstate_test.go index 3352c05f2..cbde9ef56 100644 --- a/tests/smoke/desiredstate_test.go +++ b/tests/smoke/desiredstate_test.go @@ -69,7 +69,13 @@ func TestDesiredState(t *testing.T) { installation := test.RequireInstallationExists("operator", "mybuns") require.Equal(t, "succeeded", installation.Status.ResultStatus) - // Repeat the apply command, there should be no changes detected. Using dry run because we just want to know if it _would_ be re-executed. + // Repeat the apply command, this should result in an upgrade because mybuns has different parameters for install and upgrade + // so porter will detect different params and run again. + _, stderr = test.RequirePorter("installation", "apply", "mybuns.yaml", "--namespace", "operator") + tests.RequireOutputContains(t, stderr, "The installation is out-of-sync, running the upgrade action...") + + // Repeat the apply command, there should be no changes detected now that it's two upgrades in a row. + // Using dry run because we just want to know if it _would_ be re-executed. _, output, err = test.RunPorter("installation", "apply", "mybuns.yaml", "--namespace", "operator", "--dry-run") require.NoError(t, err) tests.RequireOutputContains(t, output, "The installation is already up-to-date") diff --git a/tests/testdata/config/config-with-depsv2.yaml b/tests/testdata/config/config-with-depsv2.yaml new file mode 100644 index 000000000..8c09e3c54 --- /dev/null +++ b/tests/testdata/config/config-with-depsv2.yaml @@ -0,0 +1,24 @@ +experimental: + - dependencies-v2 + +namespace: dev +default-storage: testdb +default-secrets-plugin: filesystem + +# The test bundle, mybuns, requires docker +allow-docker-host-access: true + +logs: + enabled: true + level: debug + +telemetry: + enabled: ${env.PORTER_TEST_TELEMETRY_ENABLED} + protocol: grpc + insecure: true + +storage: + - name: testdb + plugin: mongodb + config: + url: mongodb://localhost:27017/${env.PORTER_TEST_DB_NAME}?connect=direct diff --git a/tests/testdata/creds/myenv.yaml b/tests/testdata/creds/myenv.yaml new file mode 100644 index 000000000..67b74dc09 --- /dev/null +++ b/tests/testdata/creds/myenv.yaml @@ -0,0 +1,6 @@ +schemaVersion: 1.0.1 +name: myenv +credentials: + - name: token + source: + value: MYCLOUDPROVIDERTOKEN diff --git a/tests/testdata/helpers.go b/tests/testdata/helpers.go index 0885349e8..fd5cc03f5 100644 --- a/tests/testdata/helpers.go +++ b/tests/testdata/helpers.go @@ -12,4 +12,28 @@ const ( // MyDbRef is the full reference to the mydb test bundle. MyDbRef = "localhost:5000/mydb:v0.1.0" + + // MyEnv is the root test bundle that exercises dependencies. + MyEnv = "myenv" + + // MyEnvRef is the full reference to the myenv test bundle. + MyEnvRef = "localhost:5000/myenv:v0.1.0" + + // MyInfra is the root test bundle that exercises dependencies. + MyInfra = "myinfra" + + // MyInfraRef is the full reference to the myinfra test bundle. + MyInfraRef = "localhost:5000/myinfra:v0.1.0" + + // MySQLDb is the test bundle that is a dependency of the myinfra test bundle. + MySQLDb = "mysqldb" + + // MySQLDbRef is the full reference to the mysqldb test bundle. + MySQLDbRef = "localhost:5000/mysqldb:v0.1.0" + + // MyApp is the root test bundle that exercises dependencies. + MyApp = "myapp" + + // MyAppRef is the full reference to the myapp test bundle. + MyAppRef = "localhost:5000/myapp:v1.2.3" ) diff --git a/tests/testdata/installations/myenv.yaml b/tests/testdata/installations/myenv.yaml new file mode 100644 index 000000000..c1d8462b6 --- /dev/null +++ b/tests/testdata/installations/myenv.yaml @@ -0,0 +1,9 @@ +schemaVersion: 1.0.2 +name: myenv +bundle: + repository: localhost:5000/myenv + version: v0.1.0 +credentialSets: + - myenv +parameterSets: + - myenv diff --git a/tests/testdata/myapp/.dockerignore b/tests/testdata/myapp/.dockerignore new file mode 100644 index 000000000..2919244c8 --- /dev/null +++ b/tests/testdata/myapp/.dockerignore @@ -0,0 +1,4 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file +# Put files here that you don't want copied into your bundle's invocation image +.gitignore +Dockerfile.tmpl diff --git a/tests/testdata/myapp/.gitignore b/tests/testdata/myapp/.gitignore new file mode 100644 index 000000000..e08a3e22b --- /dev/null +++ b/tests/testdata/myapp/.gitignore @@ -0,0 +1 @@ +.cnab/ diff --git a/tests/testdata/myapp/porter.yaml b/tests/testdata/myapp/porter.yaml new file mode 100644 index 000000000..5018ed902 --- /dev/null +++ b/tests/testdata/myapp/porter.yaml @@ -0,0 +1,40 @@ +schemaVersion: 1.0.0 +name: myapp +version: 1.2.3 +description: "Deploys my app" +registry: localhost:5000 + +parameters: + - name: connstr + description: "The database connection string" + env: CONNSTR + $id: "getporter.org/interfaces/mysql.connection-string" + - name: logLevel + type: string + +mixins: + - exec + +install: + - exec: + command: echo + arguments: + - "installing myapp" + +status: + - exec: + command: echo + arguments: + - "myapp is looking great!" + +upgrade: + - exec: + command: echo + arguments: + - "upgrading myapp" + +uninstall: + - exec: + command: echo + arguments: + - "uninstalling myapp" diff --git a/tests/testdata/mybuns/helpers.sh b/tests/testdata/mybuns/helpers.sh index 468da228c..79add842c 100755 --- a/tests/testdata/mybuns/helpers.sh +++ b/tests/testdata/mybuns/helpers.sh @@ -2,13 +2,14 @@ set -euo pipefail install() { + touch /cnab/app/outputs/tfstate if [[ "$LOG_LEVEL" == "11" ]]; then - echo Hello, $USERNAME + echo Hello, "$ROOT_USERNAME"! fi } makeMagic() { - echo $1 > /cnab/app/magic.txt + echo "$1" > /cnab/app/magic.txt } ensureMagic() { diff --git a/tests/testdata/mybuns/porter.yaml b/tests/testdata/mybuns/porter.yaml index 354a5aeb9..75a70a57c 100644 --- a/tests/testdata/mybuns/porter.yaml +++ b/tests/testdata/mybuns/porter.yaml @@ -1,5 +1,5 @@ # This is a test bundle that makes no logical sense, but it does exercise lots of different bundle features - +#schemaType: Bundle schemaVersion: 1.0.0 name: mybuns version: 0.1.2 @@ -7,13 +7,42 @@ description: "A very thorough test bundle" registry: localhost:5000 dockerfile: Dockerfile.tmpl +maintainers: +- name: "John Doe" + email: "john.doe@example.com" + url: "https://example.com/a" +- name: "Jane Doe" + url: "https://example.com/b" +- name: "Janine Doe" + email: "janine.doe@example.com" +- email: "mike.doe@example.com" + url: "https://example.com/c" + +custom: + foo: + test1: true + test2: 1 + test3: value + test4: + - one + - two + - three + test5: + 1: one + two: two + required: - docker credentials: - name: username description: "The name you want on the audit log" - env: USERNAME + env: ROOT_USERNAME + required: false + - name: password + path: /tmp/password + applyTo: + - boom parameters: - name: log_level @@ -27,22 +56,97 @@ parameters: type: string default: "default-secret" sensitive: true + - name: mysql-connstr + type: string + default: "" # Setting a default so that we avoid https://github.com/getporter/porter/issues/2561 + source: + dependency: db + output: connstr - name: chaos_monkey description: "Set to true to make the bundle fail" type: boolean default: false + - name: tfstate + type: file + path: /cnab/app/tfstate + source: + output: tfstate + applyTo: + - upgrade + - uninstall - name: cfg description: "A json config file" type: file default: '' path: buncfg.json + - name: ainteger + type: integer + default: 1 + minimum: 0 + maximum: 10 - name: anumber type: number default: 0.5 # This is a regression test that we can both build and push a bundle that uses numeric types exclusiveMinimum: 0 exclusiveMaximum: 1 + - name: astringenum + type: string + default: blue + enum: + - blue + - red + - purple + - pink + - name: astring + type: string + minLength: 1 + maxLength: 10 + default: 'boop!' + - name: aboolean + type: boolean + default: true + - name: installonly + type: boolean + default: false + applyTo: + - install + - name: sensitive + type: string + sensitive: true + default: "passw0rd123" + - name: jsonobject + type: string + default: '"myobject": { + "foo": "true", + "bar": [ + 1, + 2, + 3 + ] + }' + - name: afile + type: file + default: '' + path: /home/nonroot/.kube/config + - name: notype-file + default: '' + path: /cnab/app/config.toml + - name: notype-string + default: '' outputs: + - name: msg + type: string + default: "" + applyTo: + - install + - upgrade + - uninstall + - name: connStr + $id: "getporter.org/interfaces/mysql.connection-string" + default: "" + applyTo: + - install - name: mylogs applyTo: - install @@ -52,6 +156,13 @@ outputs: - install - upgrade sensitive: true + - name: tfstate + type: file + path: /cnab/app/tfstate + applyTo: + - install + - upgrade + - uninstall state: - name: magic_file @@ -154,6 +265,7 @@ upgrade: command: ./helpers.sh arguments: - upgrade + - ${ bundle.outputs.msg } outputs: - name: mylogs regex: "(.*)" @@ -178,6 +290,7 @@ uninstall: command: ./helpers.sh arguments: - uninstall + - ${ bundle.outputs.msg } - exec: description: "roll the dice with your chaos monkey" command: ./helpers.sh diff --git a/tests/testdata/myenv/.dockerignore b/tests/testdata/myenv/.dockerignore new file mode 100644 index 000000000..0d210e7bb --- /dev/null +++ b/tests/testdata/myenv/.dockerignore @@ -0,0 +1,4 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file +# Put files here that you don't want copied into your bundle's invocation image +.gitignore +template.Dockerfile diff --git a/tests/testdata/myenv/.gitignore b/tests/testdata/myenv/.gitignore new file mode 100644 index 000000000..e08a3e22b --- /dev/null +++ b/tests/testdata/myenv/.gitignore @@ -0,0 +1 @@ +.cnab/ diff --git a/tests/testdata/myenv/porter.yaml b/tests/testdata/myenv/porter.yaml new file mode 100644 index 000000000..af9429cc3 --- /dev/null +++ b/tests/testdata/myenv/porter.yaml @@ -0,0 +1,60 @@ +schemaVersion: 1.0.0 +name: myenv +version: 0.1.0 +description: "A 'meta' bundle that deploys everything it needs by adding dependencies" +registry: "localhost:5000" + +credentials: + - name: token + +parameters: + - name: logLevel + type: string + default: info + +dependencies: + requires: + - name: infra + bundle: + reference: "localhost:5000/myinfra:v0.1.0" + # TODO(PEP003): Implement with https://github.com/getporter/porter/issues/2548 + #interface: + # document: + # outputs: + # - name: mysql-connstr + # $id: "getporter.org/interfaces/mysql.connection-string" + credentials: + token: ${bundle.credentials.token} + parameters: + database: myenvdb + logLevel: ${bundle.parameters.logLevel} + - name: app + bundle: + reference: "localhost:5000/myapp:v1.2.3" + credentials: + db-connstr: ${bundle.dependencies.infra.outputs.mysql-connstr} + parameters: + logLevel: ${bundle.parameters.logLevel } + +# The rest below is boilerplate to make porter happy +# Since this is a "meta" bundle, it doesn't do anything itself, just references other bundles +mixins: + - exec + +install: + - exec: + command: echo + arguments: + - "Installing an environment" + +upgrade: + - exec: + command: echo + arguments: + - "Upgrading an environment" + +uninstall: + - exec: + command: echo + arguments: + - "Uninstalling an environment" diff --git a/tests/testdata/myinfra/.dockerignore b/tests/testdata/myinfra/.dockerignore new file mode 100644 index 000000000..0d210e7bb --- /dev/null +++ b/tests/testdata/myinfra/.dockerignore @@ -0,0 +1,4 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file +# Put files here that you don't want copied into your bundle's invocation image +.gitignore +template.Dockerfile diff --git a/tests/testdata/myinfra/.gitignore b/tests/testdata/myinfra/.gitignore new file mode 100644 index 000000000..e08a3e22b --- /dev/null +++ b/tests/testdata/myinfra/.gitignore @@ -0,0 +1 @@ +.cnab/ diff --git a/tests/testdata/myinfra/porter.yaml b/tests/testdata/myinfra/porter.yaml new file mode 100644 index 000000000..c38ae1a82 --- /dev/null +++ b/tests/testdata/myinfra/porter.yaml @@ -0,0 +1,50 @@ +schemaVersion: 1.0.0 +name: myinfra +version: 0.1.0 +description: "Deploys my infrastructure" +registry: "localhost:5000" + +parameters: + - name: database + type: string + default: mydb + - name: logLevel + type: string + +credentials: + - name: token + +outputs: + - name: mysql-connstr + +dependencies: + requires: + - name: db + bundle: + reference: "localhost:5000/mysqldb:v0.1.0" + credentials: + token: ${bundle.credentials.token} + parameters: + database: ${bundle.parameters.database} + logLevel: ${bundle.parameters.logLevel} + +mixins: + - exec + +install: + - exec: + command: echo + arguments: + - "Installing some infrastructure" + +upgrade: + - exec: + command: echo + arguments: + - "Upgrading some infrastructure" + +uninstall: + - exec: + command: echo + arguments: + - "Uninstalling some infrastructure" diff --git a/tests/testdata/mysqldb/.dockerignore b/tests/testdata/mysqldb/.dockerignore new file mode 100644 index 000000000..2919244c8 --- /dev/null +++ b/tests/testdata/mysqldb/.dockerignore @@ -0,0 +1,4 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file +# Put files here that you don't want copied into your bundle's invocation image +.gitignore +Dockerfile.tmpl diff --git a/tests/testdata/mysqldb/.gitignore b/tests/testdata/mysqldb/.gitignore new file mode 100644 index 000000000..e08a3e22b --- /dev/null +++ b/tests/testdata/mysqldb/.gitignore @@ -0,0 +1 @@ +.cnab/ diff --git a/tests/testdata/mysqldb/README.md b/tests/testdata/mysqldb/README.md new file mode 100644 index 000000000..bff570931 --- /dev/null +++ b/tests/testdata/mysqldb/README.md @@ -0,0 +1 @@ +This is a modified copy of the mydb bundle so that we can make changes for the dependencies v2 feature without breaking existing tests that use mybuns/mydb. diff --git a/tests/testdata/mysqldb/connection-string.txt b/tests/testdata/mysqldb/connection-string.txt new file mode 100644 index 000000000..c7b8c1f11 --- /dev/null +++ b/tests/testdata/mysqldb/connection-string.txt @@ -0,0 +1 @@ +this is a connection string \ No newline at end of file diff --git a/tests/testdata/mysqldb/porter.yaml b/tests/testdata/mysqldb/porter.yaml new file mode 100644 index 000000000..93d9ce710 --- /dev/null +++ b/tests/testdata/mysqldb/porter.yaml @@ -0,0 +1,72 @@ +# This is a test bundle that can be used as a dependency +# TODO(PEP003): Do not depend on this bundle from mybuns, and move testcases like installerImage out of here +schemaVersion: 1.0.0 +name: mysqldb +version: 0.1.0 +description: "A test bundle dependency" +registry: localhost:5000 + +parameters: + - name: database + type: string + default: "mysqldb" + - name: collation + type: string + default: "Latin1_General_100_CS_AS" + - name: logLevel + type: string + default: error + +credentials: + - name: token + env: CLOUD_TOKEN + +outputs: + - name: connStr + type: file + path: /cnab/app/connection-string.txt + applyTo: + - install + - upgrade + +mixins: + - exec + +dry-run: + - exec: + command: echo + arguments: + - "ready to install mysqldb" + +install: + - exec: + command: echo + arguments: + - "installing mysqldb" + - exec: + command: echo + arguments: + - "database: ${ bundle.parameters.database }" + - exec: + description: "Debug" + command: echo + arguments: + - "image: ${ bundle.installerImage }" + +status: + - exec: + command: echo + arguments: + - "mysqldb is looking great!" + +upgrade: + - exec: + command: echo + arguments: + - "upgrading mysqldb" + +uninstall: + - exec: + command: echo + arguments: + - "uninstalling mysqldb" diff --git a/tests/testdata/params/myenv.yaml b/tests/testdata/params/myenv.yaml new file mode 100644 index 000000000..a6451e706 --- /dev/null +++ b/tests/testdata/params/myenv.yaml @@ -0,0 +1,6 @@ +schemaVersion: 1.0.1 +name: myenv +parameters: + - name: logLevel + source: + value: "debug" diff --git a/tests/tester/helpers.go b/tests/tester/helpers.go index 4a8bdb8f0..489278cd6 100644 --- a/tests/tester/helpers.go +++ b/tests/tester/helpers.go @@ -4,22 +4,20 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "get.porter.sh/porter/pkg/porter" "get.porter.sh/porter/pkg/storage" "get.porter.sh/porter/pkg/yaml" "get.porter.sh/porter/tests" "get.porter.sh/porter/tests/testdata" - "github.com/carolynvs/magex/shx" "github.com/stretchr/testify/require" ) // PrepareTestBundle ensures that the mybuns test bundle is ready to use. func (t Tester) PrepareTestBundle() { // Build and publish an interesting test bundle and its dependency - t.MakeTestBundle(testdata.MyDb, testdata.MyDbRef) - t.MakeTestBundle(testdata.MyBuns, testdata.MyBunsRef) + t.MakeTestBundle(testdata.MyDb, testdata.MyDbRef, false) + t.MakeTestBundle(testdata.MyBuns, testdata.MyBunsRef, false) t.ApplyTestBundlePrerequisites() } @@ -34,24 +32,24 @@ func (t Tester) ApplyTestBundlePrerequisites() { t.RequirePorter("credentials", "apply", filepath.Join(t.RepoRoot, "tests/testdata/creds/mybuns.yaml"), "--namespace=") } -func (t Tester) MakeTestBundle(name string, ref string) { - // Skip if we've already pushed it for another test - if _, _, err := t.RunPorter("explain", ref); err == nil { - return +func (t Tester) MakeTestBundle(name string, ref string, force bool) { + if !force { + // Skip if we've already pushed it for another test + if _, _, err := t.RunPorter("explain", ref); err == nil { + return + } } pwd, _ := os.Getwd() defer t.Chdir(pwd) t.Chdir(filepath.Join(t.RepoRoot, "tests/testdata/", name)) - // TODO(carolynvs): porter publish detection of needing a build should do this - output, err := shx.OutputS("docker", "inspect", strings.Replace(ref, name, name+"-installer", 1)) - if output == "[]" || err != nil { - t.RequirePorter("build") - } - // Rely on the auto build functionality to avoid long slow rebuilds when nothing has changed - t.RequirePorter("publish", "--reference", ref) + if force { + t.RequirePorter("publish", "--reference", ref, "--force") + } else { + t.RequirePorter("publish", "--reference", ref) + } } func (t Tester) ShowInstallation(namespace string, name string) (porter.DisplayInstallation, error) { diff --git a/tests/tester/main.go b/tests/tester/main.go index b23c83183..c09c797db 100644 --- a/tests/tester/main.go +++ b/tests/tester/main.go @@ -167,7 +167,7 @@ func (t Tester) buildPorterCommand(opts ...func(*shx.PreparedCommand)) shx.Prepa port = "55942" } porterPath := filepath.Join(t.RepoRoot, "bin/porter") - cmd = shx.Command("dlv", "exec", porterPath, "--listen=:"+port, "--headless=true", "--api-version=2", "--accept-multiclient", "--") + cmd = shx.Command("dlv", "exec", porterPath, "--listen=:"+port, "--headless=true", "--api-version=2", "--accept-multiclient", "--log-dest=/tmp/porter-dlv", "--") configureCommand(cmd) }