diff --git a/build/testdata/bundles/mysql/porter.yaml b/build/testdata/bundles/mysql/porter.yaml index 74ebbf04c..c8b892fb4 100644 --- a/build/testdata/bundles/mysql/porter.yaml +++ b/build/testdata/bundles/mysql/porter.yaml @@ -1,4 +1,4 @@ -schemaVersion: 1.0.0 +schemaVersion: 1.0.1 name: mysql version: 0.1.4 registry: "localhost:5000" @@ -57,7 +57,7 @@ uninstall: - exec: command: echo arguments: - - uninstalled + - uninstall mysql outputs: - name: mysql-password diff --git a/build/testdata/bundles/wordpress/porter.yaml b/build/testdata/bundles/wordpress/porter.yaml index 852eb0002..d26eea0cf 100644 --- a/build/testdata/bundles/wordpress/porter.yaml +++ b/build/testdata/bundles/wordpress/porter.yaml @@ -1,4 +1,4 @@ -schemaVersion: 1.0.0 +schemaVersion: 1.0.1 name: wordpress version: 0.1.4 registry: "localhost:5000" diff --git a/build/testdata/bundles/wordpressv2/helpers.sh b/build/testdata/bundles/wordpressv2/helpers.sh new file mode 100755 index 000000000..c8d1b00a7 --- /dev/null +++ b/build/testdata/bundles/wordpressv2/helpers.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +install() { + mkdir -p /cnab/app/outputs + echo "topsecret-blog" >> /cnab/app/outputs/wordpress-password +} + +ping() { + echo ping +} + +# Call the requested function and pass the arguments as-is +"$@" diff --git a/build/testdata/bundles/wordpressv2/porter.yaml b/build/testdata/bundles/wordpressv2/porter.yaml new file mode 100644 index 000000000..de23eb347 --- /dev/null +++ b/build/testdata/bundles/wordpressv2/porter.yaml @@ -0,0 +1,80 @@ +schemaVersion: 1.1.0 +name: wordpress +version: 0.1.4 +registry: "localhost:5000" + +mixins: + - exec + - helm3: + repositories: + bitnami: + url: "https://charts.bitnami.com/bitnami" + +dependencies: + requires: + - name: mysql + bundle: + reference: localhost:5000/mysql:v0.1.4 + sharing: + mode: true + group: + name: myapp + parameters: + database-name: wordpress + mysql-user: wordpress + namespace: wordpress + +credentials: +- name: kubeconfig + path: /home/nonroot/.kube/config + +parameters: +- name: wordpress-name + type: string + default: porter-ci-wordpress + env: WORDPRESS_NAME +- name: wordpress-password + type: string + sensitive: true + applyTo: + - install + - upgrade +- name: namespace + type: string + default: 'wordpress' + +install: + - exec: + command: ./helpers.sh + arguments: + - install + +upgrade: + - exec: + command: ./helpers.sh + arguments: + - install + +ping: + - exec: + description: "Ping" + command: ./helpers.sh + arguments: + - ping + +uninstall: + - exec: + command: echo + arguments: + - uninstall wordpress + +outputs: + - name: wordpress-password + description: "The Wordpress installation password" + type: string + default: "default-password" + applyTo: + - "install" + - "upgrade" + sensitive: true + path: /cnab/app/outputs/wordpress-password diff --git a/docs/content/docs/development/authoring-a-bundle/working-with-dependencies.md b/docs/content/docs/development/authoring-a-bundle/working-with-dependencies.md index f4f6f96e5..e9c281980 100644 --- a/docs/content/docs/development/authoring-a-bundle/working-with-dependencies.md +++ b/docs/content/docs/development/authoring-a-bundle/working-with-dependencies.md @@ -29,6 +29,53 @@ dependencies: reference: getporter/mysql:v0.1.3 ``` +## Define dependencies v2 (Shared) + +The second version of dependencies -- also called "shared dependencies" or DependenciesV2 -- is available under the [**experimental** flag](https://porter.sh/docs/configuration/configuration/#experimental-feature-flags), and therefore an experimental feature. Please proceed with caution. + +You can enable the experimental flag, thus enabling DependenciesV2, by setting an environment variable as follows: +``` +PORTER_EXPERIMENTAL=dependencies-v2 +``` + +The configuration for DependenciesV2 is similar to that of the first version, except there is now a "sharing" section with the following required fields: `mode`, `group.name`. +`mode` is a boolean, and `group.name` is the identifier that will allow for certain bundles to share parameters and outputs between each other. + +```yaml +dependencies: + requires: + - name: mysql + bundle: + reference: localhost:5000/mysql:v0.1.0 + sharing: + mode: true + group: + name: myapp + parameters: + database-name: wordpress + mysql-user: wordpress + namespace: wordpress +``` + +If there is an existing dependency installed that the parent bundle should connect to, you must create a label for the existing dependency with the `sh.porter.SharingGroup` key, with the value of the group name specified in the parent bundle. + +The existing dependency **must** be successfully installed. If it is uninstalled this key must be deleted by the users before the operation can proceed. + +``` +porter install --label sh.porter.SharingGroup=myapp +``` + +There are some safeguards in place to make it so other bundles depending on the dependency cannot be broken, therefore on the following actions this will occur: + +**Install**: For the parent bundle on existing dependency, the dependency arguments will be passed to the parent. No further changes. + +**Upgrade**: The parent bundle will execute the upgrade action, but it will not change anything about the existing dependency. + +**Invoke**: Any changes that happen here **will** change the existing dependency. It will be on the user to handle propagating those changes to other parent bundles if needed. + +**Uninstall**: The parent bundle will be uninstalled, but the existing dependency will not be and needs to be uninstalled in a separate command. + + ## Ordering of dependencies If more than one dependency is declared, they will be installed in the order they are listed. For example, if both the `mysql` and diff --git a/pkg/cnab/config-adapter/adapter_test.go b/pkg/cnab/config-adapter/adapter_test.go index a08c3bccf..c7bb275cb 100644 --- a/pkg/cnab/config-adapter/adapter_test.go +++ b/pkg/cnab/config-adapter/adapter_test.go @@ -676,7 +676,7 @@ func TestManifestConverter_generateDependenciesv2(t *testing.T) { }, }, Sharing: depsv2ext.SharingCriteria{ - Mode: depsv2ext.SharingModeGroup, + Mode: true, Group: depsv2ext.SharingGroup{Name: "myapp"}, }, Parameters: map[string]string{ diff --git a/pkg/cnab/config-adapter/testdata/mybuns-depsv2.bundle.json b/pkg/cnab/config-adapter/testdata/mybuns-depsv2.bundle.json index a033c6195..99b64e261 100644 --- a/pkg/cnab/config-adapter/testdata/mybuns-depsv2.bundle.json +++ b/pkg/cnab/config-adapter/testdata/mybuns-depsv2.bundle.json @@ -453,7 +453,6 @@ "db": { "bundle": "localhost:5000/mydb:v0.1.0", "sharing": { - "mode": "group", "group": {} }, "parameters": { diff --git a/pkg/cnab/config-adapter/testdata/myenv-depsv2.bundle.json b/pkg/cnab/config-adapter/testdata/myenv-depsv2.bundle.json index bcc591f68..9e4466e9e 100644 --- a/pkg/cnab/config-adapter/testdata/myenv-depsv2.bundle.json +++ b/pkg/cnab/config-adapter/testdata/myenv-depsv2.bundle.json @@ -137,7 +137,6 @@ "app": { "bundle": "localhost:5000/myapp:v1.2.3", "sharing": { - "mode": "group", "group": {} }, "parameters": { @@ -153,7 +152,6 @@ "infra": { "bundle": "localhost:5000/myinfra:v0.1.0", "sharing": { - "mode": "group", "group": {} }, "parameters": { diff --git a/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml b/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml index 43a793328..1f3476bff 100644 --- a/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml +++ b/pkg/cnab/config-adapter/testdata/porter-with-depsv2.yaml @@ -26,7 +26,7 @@ dependencies: description: "credential" required: true sharing: - mode: group + mode: true group: name: myapp parameters: diff --git a/pkg/cnab/dependencies/v1/doc.go b/pkg/cnab/dependencies/v1/doc.go deleted file mode 100644 index b3fc9a00e..000000000 --- a/pkg/cnab/dependencies/v1/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package v1 contains the implementation for v1 of the CNAB Dependencies specification. -package v1 diff --git a/pkg/cnab/dependencies/v1/installations.go b/pkg/cnab/dependencies/v1/installations.go deleted file mode 100644 index a4f329a70..000000000 --- a/pkg/cnab/dependencies/v1/installations.go +++ /dev/null @@ -1,10 +0,0 @@ -package v1 - -import ( - "fmt" -) - -// BuildPrerequisiteInstallationName generates the name of a prerequisite dependency installation. -func BuildPrerequisiteInstallationName(installation string, dependency string) string { - return fmt.Sprintf("%s-%s", installation, dependency) -} diff --git a/pkg/cnab/dependencies/v1/solver.go b/pkg/cnab/dependencies/v1/solver.go deleted file mode 100644 index 814498c77..000000000 --- a/pkg/cnab/dependencies/v1/solver.go +++ /dev/null @@ -1,117 +0,0 @@ -package v1 - -import ( - "fmt" - "sort" - - "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" -) - -type DependencyLock struct { - Alias string - Reference string -} - -// TODO: move this logic onto the new ExtendedBundle struct -type DependencySolver struct { -} - -func (s *DependencySolver) ResolveDependencies(bun cnab.ExtendedBundle) ([]DependencyLock, error) { - if !bun.HasDependenciesV1() { - return nil, nil - } - - rawDeps, err := bun.ReadDependenciesV1() - // We need make sure the DependenciesV1 are ordered by the desired sequence - orderedDeps := rawDeps.ListBySequence() - - if err != nil { - return nil, fmt.Errorf("error executing dependencies for %s: %w", bun.Name, err) - } - - q := make([]DependencyLock, 0, len(orderedDeps)) - for _, dep := range orderedDeps { - ref, err := s.ResolveVersion(dep.Name, dep) - if err != nil { - return nil, err - } - - lock := DependencyLock{ - Alias: dep.Name, - Reference: ref.String(), - } - q = append(q, lock) - } - - return q, nil -} - -// ResolveVersion returns the bundle name, its version and any error. -func (s *DependencySolver) ResolveVersion(name string, dep depsv1ext.Dependency) (cnab.OCIReference, error) { - ref, err := cnab.ParseOCIReference(dep.Bundle) - if err != nil { - 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 - if dep.Version == nil || len(dep.Version.Ranges) == 0 { - // Check if they specified an explicit tag in referenced bundle already - if ref.HasTag() { - return ref, nil - } - - tag, err := s.determineDefaultTag(dep) - if err != nil { - return cnab.OCIReference{}, err - } - - return ref.WithTag(tag) - } - - return cnab.OCIReference{}, fmt.Errorf("not implemented: dependency version range specified for %s: %w", name, err) -} - -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) - } - - allowPrereleases := false - if dep.Version != nil && dep.Version.AllowPrereleases { - allowPrereleases = true - } - - var hasLatest bool - versions := make(semver.Collection, 0, len(tags)) - for _, tag := range tags { - if tag == "latest" { - hasLatest = true - continue - } - - version, err := semver.NewVersion(tag) - if err == nil { - if !allowPrereleases && version.Prerelease() != "" { - continue - } - versions = append(versions, version) - } - } - - if len(versions) == 0 { - if hasLatest { - return "latest", nil - } else { - return "", fmt.Errorf("no tag was specified for %s and none of the tags defined in the registry meet the criteria: semver formatted or 'latest'", dep.Bundle) - } - } - - sort.Sort(sort.Reverse(versions)) - - return versions[0].Original(), nil -} diff --git a/pkg/cnab/dependencies/v1/solver_test.go b/pkg/cnab/dependencies/v1/solver_test.go deleted file mode 100644 index aaf82d817..000000000 --- a/pkg/cnab/dependencies/v1/solver_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package v1 - -import ( - "testing" - - "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" -) - -func TestDependencySolver_ResolveDependencies(t *testing.T) { - t.Parallel() - - bun := cnab.NewBundle(bundle.Bundle{ - Custom: map[string]interface{}{ - cnab.DependenciesV1ExtensionKey: depsv1ext.Dependencies{ - Requires: map[string]depsv1ext.Dependency{ - "mysql": { - Bundle: "getporter/mysql:5.7", - }, - "nginx": { - Bundle: "localhost:5000/nginx:1.19", - }, - }, - }, - }, - }) - - s := DependencySolver{} - locks, err := s.ResolveDependencies(bun) - require.NoError(t, err) - require.Len(t, locks, 2) - - var mysql DependencyLock - var nginx DependencyLock - for _, lock := range locks { - switch lock.Alias { - case "mysql": - mysql = lock - case "nginx": - nginx = lock - } - } - - assert.Equal(t, "getporter/mysql:5.7", mysql.Reference) - assert.Equal(t, "localhost:5000/nginx:1.19", nginx.Reference) -} - -func TestDependencySolver_ResolveVersion(t *testing.T) { - t.Parallel() - - testcases := []struct { - name string - dep depsv1ext.Dependency - wantVersion string - wantError string - }{ - {name: "pinned version", - dep: depsv1ext.Dependency{Bundle: "mysql:5.7"}, - wantVersion: "5.7"}, - {name: "unimplemented range", - dep: depsv1ext.Dependency{Bundle: "mysql", Version: &depsv1ext.DependencyVersion{Ranges: []string{"1 - 1.5"}}}, - wantError: "not implemented"}, - {name: "default tag to latest", - dep: depsv1ext.Dependency{Bundle: "getporterci/porter-test-only-latest"}, - wantVersion: "latest"}, - {name: "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: 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: 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: depsv1ext.Dependency{Bundle: "getporterci/porter-test-with-versions"}, - wantVersion: "v1.2"}, - } - - for _, tc := range testcases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - s := DependencySolver{} - version, err := s.ResolveVersion("mysql", tc.dep) - if tc.wantError != "" { - require.Error(t, err, "ResolveVersion should have returned an error") - assert.Contains(t, err.Error(), tc.wantError) - } else { - require.NoError(t, err, "ResolveVersion should not have returned an error") - - assert.Equal(t, tc.wantVersion, version.Tag(), "incorrect version resolved") - } - }) - } -} diff --git a/pkg/cnab/dependencies_v1_test.go b/pkg/cnab/dependencies_v1_test.go index 60907aac4..0750d86fb 100644 --- a/pkg/cnab/dependencies_v1_test.go +++ b/pkg/cnab/dependencies_v1_test.go @@ -44,8 +44,7 @@ func TestSupportsDependenciesV1(t *testing.T) { t.Run("supported", func(t *testing.T) { b := ExtendedBundle{bundle.Bundle{ - RequiredExtensions: []string{DependenciesV1ExtensionKey}, - }} + RequiredExtensions: []string{DependenciesV1ExtensionKey}}} assert.True(t, b.SupportsDependenciesV1()) }) @@ -64,15 +63,13 @@ func TestHasDependenciesV1(t *testing.T) { RequiredExtensions: []string{DependenciesV1ExtensionKey}, Custom: map[string]interface{}{ DependenciesV1ExtensionKey: struct{}{}, - }, - }} + }}} assert.True(t, b.HasDependenciesV1()) }) t.Run("no dependencies", func(t *testing.T) { b := ExtendedBundle{bundle.Bundle{ - RequiredExtensions: []string{DependenciesV1ExtensionKey}, - }} + RequiredExtensions: []string{DependenciesV1ExtensionKey}}} assert.False(t, b.HasDependenciesV1()) }) diff --git a/pkg/cnab/dependencies_v2.go b/pkg/cnab/dependencies_v2.go index cbce05588..6e1541ac4 100644 --- a/pkg/cnab/dependencies_v2.go +++ b/pkg/cnab/dependencies_v2.go @@ -61,6 +61,9 @@ func (b ExtendedBundle) DependencyV2Reader() (interface{}, error) { return nil, fmt.Errorf("could not marshal the untyped %s extension data %q: %w", DependenciesV2ExtensionKey, string(dataB), err) } + //Note: For depedency.Name to be set properly ReadDependencyV2 + // *must* be called. + //todo: make it so that ReadDependencyV2 is only able to be exported. deps := v2.Dependencies{} err = json.Unmarshal(dataB, &deps) if err != nil { diff --git a/pkg/cnab/dependencies_v2_test.go b/pkg/cnab/dependencies_v2_test.go index ad0a648f7..3b39032db 100644 --- a/pkg/cnab/dependencies_v2_test.go +++ b/pkg/cnab/dependencies_v2_test.go @@ -44,8 +44,7 @@ func TestSupportsDependenciesV2(t *testing.T) { t.Run("supported", func(t *testing.T) { b := ExtendedBundle{bundle.Bundle{ - RequiredExtensions: []string{DependenciesV2ExtensionKey}, - }} + RequiredExtensions: []string{DependenciesV2ExtensionKey}}} assert.True(t, b.SupportsDependenciesV2()) }) @@ -63,16 +62,13 @@ func TestHasDependenciesV2(t *testing.T) { b := ExtendedBundle{bundle.Bundle{ RequiredExtensions: []string{DependenciesV2ExtensionKey}, Custom: map[string]interface{}{ - DependenciesV2ExtensionKey: struct{}{}, - }, - }} + DependenciesV2ExtensionKey: struct{}{}}}} assert.True(t, b.HasDependenciesV2()) }) t.Run("no dependencies", func(t *testing.T) { b := ExtendedBundle{bundle.Bundle{ - RequiredExtensions: []string{DependenciesV2ExtensionKey}, - }} + RequiredExtensions: []string{DependenciesV2ExtensionKey}}} assert.False(t, b.HasDependenciesV2()) }) diff --git a/pkg/cnab/extended_bundle.go b/pkg/cnab/extended_bundle.go index adc772b69..e93f78cfa 100644 --- a/pkg/cnab/extended_bundle.go +++ b/pkg/cnab/extended_bundle.go @@ -5,12 +5,15 @@ import ( "fmt" "sort" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" + v2 "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" "get.porter.sh/porter/pkg/portercontext" "get.porter.sh/porter/pkg/schema" "github.com/Masterminds/semver/v3" "github.com/cnabio/cnab-go/bundle" "github.com/cnabio/cnab-go/bundle/definition" "github.com/cnabio/cnab-go/claim" + "github.com/google/go-containerregistry/pkg/crane" ) const SupportedVersion = "1.0.0 || 1.1.0 || 1.2.0" @@ -23,6 +26,13 @@ type ExtendedBundle struct { bundle.Bundle } +type DependencyLock struct { + Alias string + Reference string + SharingMode bool + SharingGroup string +} + // NewBundle creates an ExtendedBundle from a given bundle. func NewBundle(bundle bundle.Bundle) ExtendedBundle { return ExtendedBundle{bundle} @@ -215,3 +225,199 @@ func (b ExtendedBundle) GetReferencedRegistries() ([]string, error) { sort.Strings(regs) return regs, nil } + +func (b *ExtendedBundle) ResolveDependencies(bun ExtendedBundle) ([]DependencyLock, error) { + if bun.HasDependenciesV2() { + return b.ResolveSharedDeps(bun) + } + + if !bun.HasDependenciesV1() { + return nil, nil + } + rawDeps, err := bun.ReadDependenciesV1() + // We need make sure the DependenciesV1 are ordered by the desired sequence + orderedDeps := rawDeps.ListBySequence() + + if err != nil { + return nil, fmt.Errorf("error executing dependencies for %s: %w", bun.Name, err) + } + + q := make([]DependencyLock, 0, len(orderedDeps)) + for _, dep := range orderedDeps { + ref, err := b.ResolveVersion(dep.Name, dep) + if err != nil { + return nil, err + } + + lock := DependencyLock{ + Alias: dep.Name, + Reference: ref.String(), + SharingMode: false, + } + q = append(q, lock) + } + + return q, nil +} + +// ResolveSharedDeps only works with depsv2 +func (b *ExtendedBundle) ResolveSharedDeps(bun ExtendedBundle) ([]DependencyLock, error) { + v2, err := bun.ReadDependenciesV2() + if err != nil { + return nil, fmt.Errorf("error reading dependencies v2 for %s", bun.Name) + } + + q := make([]DependencyLock, 0, len(v2.Requires)) + for name, d := range v2.Requires { + d.Name = name + + if d.Sharing.Mode && d.Sharing.Group.Name == "" { + return nil, fmt.Errorf("empty sharing group, sharing group name needs to be specified to be active") + } + if !d.Sharing.Mode && d.Sharing.Group.Name != "" { + return nil, fmt.Errorf("empty sharing mode, sharing mode boolean set to `true` to be active") + } + + ref, err := b.ResolveVersionv2(d.Name, d) + if err != nil { + return nil, err + } + + lock := DependencyLock{ + Alias: d.Name, + Reference: ref.String(), + SharingMode: d.Sharing.Mode, + SharingGroup: d.Sharing.Group.Name, + } + q = append(q, lock) + } + return q, nil +} + +// ResolveVersion returns the bundle name, its version and any error. +func (b *ExtendedBundle) ResolveVersion(name string, dep depsv1ext.Dependency) (OCIReference, error) { + ref, err := ParseOCIReference(dep.Bundle) + if err != nil { + return 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 + if dep.Version == nil || len(dep.Version.Ranges) == 0 { + // Check if they specified an explicit tag in referenced bundle already + if ref.HasTag() { + return ref, nil + } + + tag, err := b.determineDefaultTag(dep) + if err != nil { + return OCIReference{}, err + } + + return ref.WithTag(tag) + } + + return OCIReference{}, fmt.Errorf("not implemented: dependency version range specified for %s: %w", name, err) +} + +func (b *ExtendedBundle) 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) + } + + allowPrereleases := false + if dep.Version != nil && dep.Version.AllowPrereleases { + allowPrereleases = true + } + + var hasLatest bool + versions := make(semver.Collection, 0, len(tags)) + for _, tag := range tags { + if tag == "latest" { + hasLatest = true + continue + } + + version, err := semver.NewVersion(tag) + if err == nil { + if !allowPrereleases && version.Prerelease() != "" { + continue + } + versions = append(versions, version) + } + } + + if len(versions) == 0 { + if hasLatest { + return "latest", nil + } else { + return "", fmt.Errorf("no tag was specified for %s and none of the tags defined in the registry meet the criteria: semver formatted or 'latest'", dep.Bundle) + } + } + + sort.Sort(sort.Reverse(versions)) + + return versions[0].Original(), nil +} + +// BuildPrerequisiteInstallationName generates the name of a prerequisite dependency installation. +func (b *ExtendedBundle) BuildPrerequisiteInstallationName(installation string, dependency string) string { + return fmt.Sprintf("%s-%s", installation, dependency) +} + +// this is all copied v2 stuff +// todo(schristoff): in the future, we should clean this up + +// ResolveVersion returns the bundle name, its version and any error. +func (b *ExtendedBundle) ResolveVersionv2(name string, dep v2.Dependency) (OCIReference, error) { + ref, err := ParseOCIReference(dep.Bundle) + if err != nil { + return OCIReference{}, fmt.Errorf("error parsing dependency (%s) bundle %q as OCI reference: %w", name, dep.Bundle, err) + } + + if dep.Version == "" { + // Check if they specified an explicit tag in referenced bundle already + if ref.HasTag() { + return ref, nil + } + + tag, err := b.determineDefaultTagv2(dep) + if err != nil { + return OCIReference{}, err + } + + return ref.WithTag(tag) + } + //I think this is going to need to be smarter + if dep.Version != "" { + return ref, nil + } + + return OCIReference{}, fmt.Errorf("not implemented: dependency version range specified for %s: %w", name, err) +} + +func (b *ExtendedBundle) determineDefaultTagv2(dep v2.Dependency) (string, error) { + tags, err := crane.ListTags(dep.Bundle) + if err != nil { + return "", fmt.Errorf("error listing tags for %s: %w", dep.Bundle, err) + } + + var hasLatest bool + versions := make(semver.Collection, 0, len(tags)) + for _, tag := range tags { + if tag == "latest" { + hasLatest = true + continue + } + + } + if len(versions) == 0 { + if hasLatest { + return "latest", nil + } else { + return "", fmt.Errorf("no tag was specified for %s and none of the tags defined in the registry meet the criteria: semver formatted or 'latest'", dep.Bundle) + } + } + + return versions[0].Original(), nil +} diff --git a/pkg/cnab/extended_bundle_test.go b/pkg/cnab/extended_bundle_test.go index 8323d4667..820316236 100644 --- a/pkg/cnab/extended_bundle_test.go +++ b/pkg/cnab/extended_bundle_test.go @@ -3,6 +3,7 @@ package cnab import ( "testing" + depsv1ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v1" "get.porter.sh/porter/pkg/portercontext" porterschema "get.porter.sh/porter/pkg/schema" "github.com/cnabio/cnab-go/bundle" @@ -241,3 +242,92 @@ func TestValidate(t *testing.T) { }) } } + +func TestExtendedBundle_ResolveDependencies(t *testing.T) { + t.Parallel() + + bun := NewBundle(bundle.Bundle{ + Custom: map[string]interface{}{ + DependenciesV1ExtensionKey: depsv1ext.Dependencies{ + Requires: map[string]depsv1ext.Dependency{ + "mysql": { + Bundle: "getporter/mysql:5.7", + }, + "nginx": { + Bundle: "localhost:5000/nginx:1.19", + }, + }, + }, + }, + }) + + eb := ExtendedBundle{} + locks, err := eb.ResolveDependencies(bun) + require.NoError(t, err) + require.Len(t, locks, 2) + + var mysql DependencyLock + var nginx DependencyLock + for _, lock := range locks { + switch lock.Alias { + case "mysql": + mysql = lock + case "nginx": + nginx = lock + } + } + + assert.Equal(t, "getporter/mysql:5.7", mysql.Reference) + assert.Equal(t, "localhost:5000/nginx:1.19", nginx.Reference) +} + +func TestExtendedBundle_ResolveVersion(t *testing.T) { + t.Parallel() + + testcases := []struct { + name string + dep depsv1ext.Dependency + wantVersion string + wantError string + }{ + {name: "pinned version", + dep: depsv1ext.Dependency{Bundle: "mysql:5.7"}, + wantVersion: "5.7"}, + {name: "unimplemented range", + dep: depsv1ext.Dependency{Bundle: "mysql", Version: &depsv1ext.DependencyVersion{Ranges: []string{"1 - 1.5"}}}, + wantError: "not implemented"}, + {name: "default tag to latest", + dep: depsv1ext.Dependency{Bundle: "getporterci/porter-test-only-latest"}, + wantVersion: "latest"}, + {name: "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: 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: 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: depsv1ext.Dependency{Bundle: "getporterci/porter-test-with-versions"}, + wantVersion: "v1.2"}, + } + + for _, tc := range testcases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + eb := ExtendedBundle{} + version, err := eb.ResolveVersion("mysql", tc.dep) + if tc.wantError != "" { + require.Error(t, err, "ResolveVersion should have returned an error") + assert.Contains(t, err.Error(), tc.wantError) + } else { + require.NoError(t, err, "ResolveVersion should not have returned an error") + + assert.Equal(t, tc.wantVersion, version.Tag(), "incorrect version resolved") + } + }) + } +} diff --git a/pkg/cnab/extensions/dependencies/v1/doc.go b/pkg/cnab/extensions/dependencies/v1/doc.go deleted file mode 100644 index b739e8b5c..000000000 --- a/pkg/cnab/extensions/dependencies/v1/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package v1 defines the v1 CNAB Dependency specification, io.cnab.dependencies. -package v1 diff --git a/pkg/cnab/extensions/dependencies/v2/types.go b/pkg/cnab/extensions/dependencies/v2/types.go index 3d3a48ff4..6152d7116 100644 --- a/pkg/cnab/extensions/dependencies/v2/types.go +++ b/pkg/cnab/extensions/dependencies/v2/types.go @@ -164,9 +164,9 @@ func (s DependencySource) WiringSuffix() string { // SharingCriteria is a set of rules for sharing a dependency with other bundles. type SharingCriteria struct { // Mode defines how a dependency can be shared. - // * none: The dependency cannot be shared, even within the same dependency graph. - // * group: The dependency is shared with other bundles who defined the dependency with the same sharing group. - Mode string `json:"mode,omitempty" mapstructure:"mode,omitempty"` + // * false: The dependency cannot be shared, even within the same dependency graph. + // * true: The dependency is shared with other bundles who defined the dependency with the same sharing group. + Mode bool `json:"mode,omitempty" mapstructure:"mode,omitempty"` // Group defines matching criteria for determining if two dependencies are in the same sharing group. Group SharingGroup `json:"group,omitempty" mapstructure:"group,omitempty"` diff --git a/pkg/cnab/extensions_test.go b/pkg/cnab/extensions_test.go index 493be0e84..7c8469d7f 100644 --- a/pkg/cnab/extensions_test.go +++ b/pkg/cnab/extensions_test.go @@ -24,10 +24,10 @@ func TestProcessRequiredExtensions(t *testing.T) { "sh.porter.file-parameters": nil, "io.cnab.dependencies": depsv1ext.Dependencies{ Requires: map[string]depsv1ext.Dependency{ - "storage": depsv1ext.Dependency{ + "storage": { Bundle: "somecloud/blob-storage", }, - "mysql": depsv1ext.Dependency{ + "mysql": { Bundle: "somecloud/mysql", Version: &depsv1ext.DependencyVersion{ AllowPrereleases: true, diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index 235c39b94..3adb1e889 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -12,8 +12,6 @@ import ( "sort" "strings" - depsv2ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" - "get.porter.sh/porter/pkg/cnab" "get.porter.sh/porter/pkg/config" "get.porter.sh/porter/pkg/experimental" @@ -806,10 +804,10 @@ type BundleInterfaceDocument struct { // SharingCriteria is a set of rules for sharing a dependency with other bundles. type SharingCriteria struct { // Mode defines how a dependency can be shared. - // - none: The dependency cannot be shared, even within the same dependency graph. - // - group: The dependency is shared with other bundles who defined the dependency + // - false: The dependency cannot be shared, even within the same dependency graph. + // - true: The dependency is shared with other bundles who defined the dependency // with the same sharing group. This is the default mode. - Mode string `yaml:"mode"` + Mode bool `yaml:"mode"` // Group defines matching criteria for determining if two dependencies are in the same sharing group. Group SharingGroup `yaml:"group,omitempty"` @@ -817,11 +815,7 @@ type SharingCriteria struct { // GetEffectiveMode returns the mode, taking into account the default value when // no mode is specified. -func (s SharingCriteria) GetEffectiveMode() string { - if s.Mode == "" { - return depsv2ext.SharingModeGroup - } - +func (s SharingCriteria) GetEffectiveMode() bool { return s.Mode } diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index bf8e0b9fa..3ced60a95 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -6,8 +6,6 @@ import ( "strings" "testing" - depsv2ext "get.porter.sh/porter/pkg/cnab/extensions/dependencies/v2" - "get.porter.sh/porter/pkg/cnab" "get.porter.sh/porter/pkg/config" "get.porter.sh/porter/pkg/experimental" @@ -1033,7 +1031,7 @@ func TestManifest_DetermineDependenciesExtensionUsed(t *testing.T) { { Name: "mysql", Bundle: BundleCriteria{Reference: "mysql:5.7", Version: "5.7 - 6"}, - Sharing: SharingCriteria{Mode: depsv2ext.SharingModeGroup, Group: SharingGroup{Name: "myapp"}}, + Sharing: SharingCriteria{Mode: true, Group: SharingGroup{Name: "myapp"}}, }, }}, } diff --git a/pkg/porter/dependencies.go b/pkg/porter/dependencies.go index a6ed1283b..2c09a2251 100644 --- a/pkg/porter/dependencies.go +++ b/pkg/porter/dependencies.go @@ -7,7 +7,6 @@ import ( "strings" "get.porter.sh/porter/pkg/cnab" - depsv1 "get.porter.sh/porter/pkg/cnab/dependencies/v1" cnabprovider "get.porter.sh/porter/pkg/cnab/provider" "get.porter.sh/porter/pkg/config" "get.porter.sh/porter/pkg/manifest" @@ -32,6 +31,9 @@ type dependencyExecutioner struct { // These are populated by Prepare, call it or perish in inevitable errors parentArgs cnabprovider.ActionArguments deps []*queuedDependency + + // this should maybe go somewhere else + depArgs cnabprovider.ActionArguments } func newDependencyExecutioner(p *Porter, installation storage.Installation, action BundleAction) *dependencyExecutioner { @@ -52,7 +54,7 @@ func newDependencyExecutioner(p *Porter, installation storage.Installation, acti } type queuedDependency struct { - depsv1.DependencyLock + cnab.DependencyLock BundleReference cnab.BundleReference Parameters map[string]string @@ -95,6 +97,9 @@ func (e *dependencyExecutioner) Execute(ctx context.Context) error { // executeDependency the requested action against all the dependencies for _, dep := range e.deps { + if !e.sharedActionResolver(ctx, dep) { + return nil + } err := e.executeDependency(ctx, dep) if err != nil { return err @@ -118,15 +123,50 @@ func (e *dependencyExecutioner) PrepareRootActionArguments(ctx context.Context) // Define files necessary for dependencies that need to be copied into the bundle // args.Files is a map of target path to file contents + // This creates what goes in /cnab/app/dependencies/DEP.NAME for _, dep := range e.deps { // Copy the dependency bundle.json - target := runtime.GetDependencyDefinitionPath(dep.Alias) + e.checkSharedOutputs(ctx, dep) + target := runtime.GetDependencyDefinitionPath(dep.DependencyLock.Alias) args.Files[target] = string(dep.cnabFileContents) } - return args, nil } +func (e *dependencyExecutioner) checkSharedOutputs(ctx context.Context, dep *queuedDependency) { + if !e.sharedActionResolver(ctx, dep) && e.parentAction.GetAction() == "install" { + e.getActionArgs(ctx, dep) + } +} + +// sharedActionResolver tries to localize if v2, and shared deps +// then what actions should we take based off labels/action type/state +// true means continue, false means stop +func (e *dependencyExecutioner) sharedActionResolver(ctx context.Context, dep *queuedDependency) bool { + depInstallation, err := e.Installations.GetInstallation(ctx, e.parentOpts.Namespace, dep.Alias) + if err != nil { + if errors.Is(err, storage.ErrNotFound{}) { + return true + } + } + e.depArgs.Installation = depInstallation + + //We're real, let's check if this is in the installation the parent + // is referencing + if dep.SharingGroup == depInstallation.Labels["sh.porter.SharingGroup"] { + if e.parentAction.GetAction() == "install" { + return false + } + if e.parentAction.GetAction() == "upgrade" { + return true + } + if e.parentAction.GetAction() == "uninstall" { + return false + } + } + return true +} + func (e *dependencyExecutioner) identifyDependencies(ctx context.Context) error { ctx, span := tracing.StartSpan(ctx) defer span.EndSpan() @@ -146,6 +186,7 @@ func (e *dependencyExecutioner) identifyDependencies(ctx context.Context) error } bun = cachedBundle.Definition + } else if e.parentOpts.Name != "" { c, err := e.Installations.GetLastRun(ctx, e.parentOpts.Namespace, e.parentOpts.Name) if err != nil { @@ -157,9 +198,7 @@ func (e *dependencyExecutioner) identifyDependencies(ctx context.Context) error // If we hit here, there is a bug somewhere 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 := &depsv1.DependencySolver{} - locks, err := solver.ResolveDependencies(bun) + locks, err := bun.ResolveDependencies(bun) if err != nil { return span.Error(err) } @@ -178,7 +217,6 @@ func (e *dependencyExecutioner) identifyDependencies(ctx context.Context) error func (e *dependencyExecutioner) prepareDependency(ctx context.Context, dep *queuedDependency) error { ctx, span := tracing.StartSpan(ctx) defer span.EndSpan() - // Pull the dependency var err error pullOpts := BundlePullOptions{ @@ -277,12 +315,21 @@ func (e *dependencyExecutioner) executeDependency(ctx context.Context, dep *queu ctx, span := tracing.StartSpan(ctx) defer span.EndSpan() - depName := depsv1.BuildPrerequisiteInstallationName(e.parentOpts.Name, dep.Alias) + if dep.SharingMode { + err := e.runDependencyv2(ctx, dep) + return err + } + + eb := cnab.ExtendedBundle{} + //this expects depv1 style dependency to be installed as parentName+depName + depName := eb.BuildPrerequisiteInstallationName(e.parentOpts.Name, dep.Alias) depInstallation, err := e.Installations.GetInstallation(ctx, e.parentOpts.Namespace, depName) + if err != nil { if errors.Is(err, storage.ErrNotFound{}) { depInstallation = storage.NewInstallation(e.parentOpts.Namespace, depName) depInstallation.SetLabel("sh.porter.parentInstallation", e.parentArgs.Installation.String()) + // For now, assume it's okay to give the dependency the same credentials as the parent depInstallation.CredentialSets = e.parentInstallation.CredentialSets if err = e.Installations.InsertInstallation(ctx, depInstallation); err != nil { @@ -293,21 +340,93 @@ func (e *dependencyExecutioner) executeDependency(ctx context.Context, dep *queu } } - finalParams, err := e.porter.finalizeParameters(ctx, depInstallation, dep.BundleReference.Definition, e.parentArgs.Action, dep.Parameters) + e.depArgs.Installation = depInstallation + + if err = e.getActionArgs(ctx, dep); err != nil { + return err + } + + if err = e.finalizeExecute(ctx, dep); err != nil { + return err + } + + return nil +} + +// runDependencyv2 will see if the child dependency is already installed +// and if so, use sharingmode && group to resolve what to do +func (e *dependencyExecutioner) runDependencyv2(ctx context.Context, dep *queuedDependency) error { + depInstallation, err := e.Installations.GetInstallation(ctx, e.parentOpts.Namespace, dep.Alias) if err != nil { - return span.Error(fmt.Errorf("error resolving parameters for dependency %s: %w", dep.Alias, err)) + if errors.Is(err, storage.ErrNotFound{}) { + depInstallation = storage.NewInstallation(e.parentOpts.Namespace, dep.Alias) + depInstallation.SetLabel("sh.porter.parentInstallation", e.parentArgs.Installation.String()) + depInstallation.SetLabel("sh.porter.SharingGroup", dep.SharingGroup) + + // For now, assume it's okay to give the dependency the same credentials as the parent + depInstallation.CredentialSets = e.parentInstallation.CredentialSets + if err = e.Installations.InsertInstallation(ctx, depInstallation); err != nil { + return err + } + + return err + } + } + //We save the installation + e.depArgs.Installation = depInstallation + + // Installed: Return + // Uninstalled: Error (delete or else) + // Upgrade: Unsupported + // Invoke: At your own risk + //todo(schristoff): this is kind of icky, can be it less so? + if dep.SharingGroup == depInstallation.Labels["sh.porter.SharingGroup"] { + if depInstallation.IsInstalled() { + + action := e.parentAction.GetAction() + if action == "upgrade" || action == "uninstall" { + return nil + } + } + if depInstallation.Uninstalled { + return fmt.Errorf("error executing dependency, dependency must be in installed status or deleted, %s is in status %s", dep.Alias, depInstallation.Status) + } + + } + + if err = e.getActionArgs(ctx, dep); err != nil { + return err + } + + if err = e.finalizeExecute(ctx, dep); err != nil { + return err } - depArgs := cnabprovider.ActionArguments{ + return nil +} + +func (e *dependencyExecutioner) getActionArgs(ctx context.Context, + dep *queuedDependency) error { + finalParams, err := e.porter.finalizeParameters(ctx, e.depArgs.Installation, dep.BundleReference.Definition, e.parentArgs.Action, dep.Parameters) + if err != nil { + return fmt.Errorf("error resolving parameters for dependency %s: %w", dep.Alias, err) + } + e.depArgs = cnabprovider.ActionArguments{ BundleReference: dep.BundleReference, Action: e.parentArgs.Action, - Installation: depInstallation, + Installation: e.depArgs.Installation, Driver: e.parentArgs.Driver, AllowDockerHostAccess: e.parentOpts.AllowDockerHostAccess, Params: finalParams, PersistLogs: e.parentArgs.PersistLogs, } + return nil +} +// finalizeExecute handles some Uninstall logic that is carried out +// right before calling CNAB execute. +func (e *dependencyExecutioner) finalizeExecute(ctx context.Context, dep *queuedDependency) error { + ctx, span := tracing.StartSpan(ctx) // Determine if we're working with UninstallOptions, to inform deletion and // error handling, etc. var uninstallOpts UninstallOptions @@ -317,7 +436,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, e.depArgs) if err != nil { executeErrs = multierror.Append(executeErrs, fmt.Errorf("error executing dependency %s: %w", dep.Alias, err)) @@ -332,8 +451,8 @@ func (e *dependencyExecutioner) executeDependency(ctx context.Context, dep *queu // If uninstallOpts is an empty struct (i.e., action not Uninstall), this // will resolve to false and thus be a no-op if uninstallOpts.shouldDelete() { - span.Infof(installationDeleteTmpl, depArgs.Installation) - return e.Installations.RemoveInstallation(ctx, depArgs.Installation.Namespace, depArgs.Installation.Name) + span.Infof(installationDeleteTmpl, e.depArgs.Installation) + return e.Installations.RemoveInstallation(ctx, e.depArgs.Installation.Namespace, e.depArgs.Installation.Name) } return nil } diff --git a/pkg/porter/explain.go b/pkg/porter/explain.go index 69ac2898e..a5d52d12c 100644 --- a/pkg/porter/explain.go +++ b/pkg/porter/explain.go @@ -7,8 +7,6 @@ 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" @@ -193,8 +191,7 @@ func generatePrintable(bun cnab.ExtendedBundle, action string) (*PrintableBundle stamp = configadapter.Stamp{} } - solver := &depsv1.DependencySolver{} - deps, err := solver.ResolveDependencies(bun) + deps, err := bun.ResolveDependencies(bun) if err != nil { return nil, fmt.Errorf("error resolving bundle dependencies: %w", err) } diff --git a/pkg/porter/parameters.go b/pkg/porter/parameters.go index ff6b11d86..c309b593f 100644 --- a/pkg/porter/parameters.go +++ b/pkg/porter/parameters.go @@ -11,8 +11,6 @@ import ( "strings" "time" - 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" @@ -665,7 +663,7 @@ func (p *Porter) resolveParameterSources(ctx context.Context, bun cnab.ExtendedB outputName = source.OutputName case cnab.DependencyOutputParameterSource: // TODO(carolynvs): does this need to take namespace into account - installationName = depsv1.BuildPrerequisiteInstallationName(installation.Name, source.Dependency) + installationName = bun.BuildPrerequisiteInstallationName(installation.Name, source.Dependency) outputName = source.OutputName } diff --git a/pkg/porter/uninstall.go b/pkg/porter/uninstall.go index 3086cbe1e..109795b04 100644 --- a/pkg/porter/uninstall.go +++ b/pkg/porter/uninstall.go @@ -119,7 +119,12 @@ func (p *Porter) UninstallBundle(ctx context.Context, opts UninstallOptions) err } } - // TODO: See https://github.com/getporter/porter/issues/465 for flag to allow keeping around the dependencies + // TODO(PEP-003): See https://github.com/getporter/porter/issues/465 for flag to allow keeping around the dependencies + // Note(schristoff): For now we check if the parentLabel is on the dep + // to decide if we delete. We only add the parentLabel on the dep + // if they were installed *together* + // Users can add a label (for now) if they want to delete it + // Label is: sh.porter.parentInstallation: $INSTALLATIONNAME err = opts.handleUninstallErrs(p.Out, deperator.Execute(ctx)) if err != nil { return err diff --git a/pkg/storage/installation_store.go b/pkg/storage/installation_store.go index 7ab3006e5..3fcc324b0 100644 --- a/pkg/storage/installation_store.go +++ b/pkg/storage/installation_store.go @@ -152,7 +152,9 @@ func (s InstallationStore) GetInstallation(ctx context.Context, namespace string "name": name, }, } + err := s.store.FindOne(ctx, CollectionInstallations, opts, &out) + return out, err } diff --git a/tests/integration/dependencies_test.go b/tests/integration/dependenciesv1_test.go similarity index 100% rename from tests/integration/dependencies_test.go rename to tests/integration/dependenciesv1_test.go diff --git a/tests/integration/dependenciesv2_test.go b/tests/integration/dependenciesv2_test.go new file mode 100644 index 000000000..328dcb4e8 --- /dev/null +++ b/tests/integration/dependenciesv2_test.go @@ -0,0 +1,235 @@ +//go:build integration + +package integration + +import ( + "context" + "os" + "path/filepath" + "testing" + + "get.porter.sh/porter/pkg/cnab" + "get.porter.sh/porter/pkg/experimental" + "get.porter.sh/porter/pkg/porter" + "get.porter.sh/porter/pkg/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSharedDependencies(t *testing.T) { + t.Parallel() + + p := porter.NewTestPorter(t) + ctx := p.SetupIntegrationTest() + + p.Config.SetExperimentalFlags(experimental.FlagDependenciesV2) + + namespace := p.RandomString(10) + + bunDir, err := os.MkdirTemp("", "porter-mysql-") + require.NoError(p.T(), err, "could not create temp directory at all") + defer os.RemoveAll(bunDir) + + setupWordpress_v2(ctx, p, namespace, bunDir) + upgradeWordpressBundle_v2(ctx, p, namespace) + invokeWordpressBundle_v2(ctx, p, namespace) + uninstallWordpressBundle_v2(ctx, p, namespace) + defer cleanupWordpressBundle_v2(ctx, p, namespace) + +} + +func setupMysql(ctx context.Context, p *porter.TestPorter, namespace string, bunDir string) { + p.TestConfig.TestContext.AddTestDirectory(filepath.Join(p.RepoRoot, "build/testdata/bundles/mysql"), bunDir+"/mysql") + + p.Chdir(bunDir + "/mysql") + + publishOpts := porter.PublishOptions{} + publishOpts.Force = true + err := publishOpts.Validate(p.Config) + require.NoError(p.T(), err, "validation of publish opts for dependent bundle failed") + + err = p.Publish(ctx, publishOpts) + require.NoError(p.T(), err, "publish of dependent bundle failed") + installOpts := porter.NewInstallOptions() + + installOpts.Namespace = namespace + installOpts.CredentialIdentifiers = []string{"ci"} // Use the ci credential set, porter should remember this for later + + err = installOpts.Validate(ctx, []string{}, p.Porter) + require.NoError(p.T(), err, "validation of install opts for shared mysql bundle failed") + + err = p.InstallBundle(ctx, installOpts) + require.NoError(p.T(), err, "install of shared mysql bundle failed namespace %s", namespace) + + mysqlinst, err := p.Installations.GetInstallation(ctx, namespace, "mysql") + require.NoError(p.T(), err, "could not fetch installation status for the dependency") + + //Set the label on the installaiton so Porter knows to grab it + mysqlinst.SetLabel("sh.porter.SharingGroup", "myapp") + err = p.Installations.UpdateInstallation(ctx, mysqlinst) + require.NoError(p.T(), err, "could not add label to mysql inst") + +} + +func setupWordpress_v2(ctx context.Context, p *porter.TestPorter, namespace string, bunDir string) { + setupMysql(ctx, p, namespace, bunDir) + p.CopyDirectory(filepath.Join(p.RepoRoot, "build/testdata/bundles/wordpressv2"), ".", false) + + publishOpts := porter.PublishOptions{} + publishOpts.Force = true + err := publishOpts.Validate(p.Config) + require.NoError(p.T(), err, "validation of publish opts for dependent bundle failed") + + err = p.Publish(ctx, publishOpts) + require.NoError(p.T(), err, "publish of dependent bundle failed") + + installOpts := porter.NewInstallOptions() + installOpts.Namespace = namespace + installOpts.CredentialIdentifiers = []string{"ci"} // Use the ci credential set, porter should remember this for later + installOpts.Params = []string{ + "wordpress-password=mypassword", + "namespace=" + namespace, + } + + err = installOpts.Validate(ctx, []string{}, p.Porter) + require.NoError(p.T(), err, "validation of install opts for root bundle failed") + + err = p.InstallBundle(ctx, installOpts) + require.NoError(p.T(), err, "install of root bundle failed namespace %s", namespace) + + numInst, err := p.Installations.ListInstallations(ctx, storage.ListOptions{Namespace: namespace}) + assert.Equal(p.T(), 2, len(numInst)) + + i, err := p.Installations.GetInstallation(ctx, namespace, "mysql") + require.NoError(p.T(), err, "could not fetch installation status for the dependency") + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the dependency wasn't recorded as being installed successfully") + + // Verify that the bundle claim is present + i, err = p.Installations.GetInstallation(ctx, namespace, "wordpress") + require.NoError(p.T(), err, "could not fetch claim for the root bundle") + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the root bundle wasn't recorded as being installed successfully") +} + +func cleanupWordpressBundle_v2(ctx context.Context, p *porter.TestPorter, namespace string) { + uninstallOptions := porter.NewUninstallOptions() + uninstallOptions.Namespace = namespace + uninstallOptions.CredentialIdentifiers = []string{"ci"} + uninstallOptions.Delete = true + err := uninstallOptions.Validate(ctx, []string{}, p.Porter) + require.NoError(p.T(), err, "validation of uninstall opts for root bundle failed") + + err = p.UninstallBundle(ctx, uninstallOptions) + require.NoError(p.T(), err, "uninstall of root bundle failed") + + // This shouldn't get deleted, it existed before, and it should exist after + i, err := p.Installations.GetInstallation(ctx, namespace, "mysql") + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the dependency wasn't recorded as being installed successfully") + + // Verify that the root installation is deleted + i, err = p.Installations.GetInstallation(ctx, namespace, "wordpress") + require.ErrorIs(p.T(), err, storage.ErrNotFound{}) + require.Equal(p.T(), storage.Installation{}, i) +} + +func upgradeWordpressBundle_v2(ctx context.Context, p *porter.TestPorter, namespace string) { + upgradeOpts := porter.NewUpgradeOptions() + upgradeOpts.Namespace = namespace + // do not specify credential sets, porter should reuse what was specified from install + upgradeOpts.Params = []string{ + "wordpress-password=mypassword", + "namespace=" + namespace, + "mysql#namespace=" + namespace, + } + err := upgradeOpts.Validate(ctx, []string{}, p.Porter) + require.NoError(p.T(), err, "validation of upgrade opts for root bundle failed") + + err = p.UpgradeBundle(ctx, upgradeOpts) + require.NoError(p.T(), err, "upgrade of root bundle failed") + + // Verify that the dependency claim is still installed + // upgrade should not change our status + i, err := p.Installations.GetInstallation(ctx, namespace, "mysql") + require.NoError(p.T(), err, "could not fetch claim for the dependency") + + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the dependency wasn't recorded as being upgraded successfully") + + // Verify that the bundle claim is upgraded + i, err = p.Installations.GetInstallation(ctx, namespace, "wordpress") + require.NoError(p.T(), err, "could not fetch claim for the root bundle") + c, err := p.Installations.GetLastRun(ctx, i.Namespace, i.Name) + require.NoError(p.T(), err, "GetLastClaim failed") + assert.Equal(p.T(), cnab.ActionUpgrade, c.Action, "the root bundle wasn't recorded as being upgraded") + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the root bundle wasn't recorded as being upgraded successfully") + + // Check that we are using the original credential set specified during install + require.Len(p.T(), i.CredentialSets, 1, "expected only one credential set associated to the installation") + assert.Equal(p.T(), "ci", i.CredentialSets[0], "expected to use the alternate credential set") +} + +func invokeWordpressBundle_v2(ctx context.Context, p *porter.TestPorter, namespace string) { + invokeOpts := porter.NewInvokeOptions() + invokeOpts.Namespace = namespace + invokeOpts.Action = "ping" + // Use a different set of creds to run this rando command + invokeOpts.CredentialIdentifiers = []string{"ci2"} + invokeOpts.Params = []string{ + "wordpress-password=mypassword", + "namespace=" + namespace, + } + err := invokeOpts.Validate(ctx, []string{}, p.Porter) + require.NoError(p.T(), err, "validation of invoke opts for root bundle failed") + + err = p.InvokeBundle(ctx, invokeOpts) + require.NoError(p.T(), err, "invoke of root bundle failed") + + // Verify that the dependency claim is invoked + + i, err := p.Installations.GetInstallation(ctx, namespace, "mysql") + require.NoError(p.T(), err, "could not fetch claim for the dependency") + c, err := p.Installations.GetLastRun(ctx, i.Namespace, i.Name) + require.NoError(p.T(), err, "GetLastClaim failed") + assert.Equal(p.T(), "ping", c.Action, "the dependency wasn't recorded as being invoked") + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the dependency wasn't recorded as being invoked successfully") + + // Verify that the bundle claim is invoked + i, err = p.Installations.GetInstallation(ctx, namespace, "wordpress") + require.NoError(p.T(), err, "could not fetch claim for the root bundle") + c, err = p.Installations.GetLastRun(ctx, i.Namespace, i.Name) + require.NoError(p.T(), err, "GetLastClaim failed") + assert.Equal(p.T(), "ping", c.Action, "the root bundle wasn't recorded as being invoked") + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the root bundle wasn't recorded as being invoked successfully") + + // Check that we are now using the alternate credentials with the bundle + require.Len(p.T(), i.CredentialSets, 1, "expected only one credential set associated to the installation") + assert.Equal(p.T(), "ci2", i.CredentialSets[0], "expected to use the alternate credential set") +} + +func uninstallWordpressBundle_v2(ctx context.Context, p *porter.TestPorter, namespace string) { + + uninstallOptions := porter.NewUninstallOptions() + uninstallOptions.Namespace = namespace + // Now go back to using the original set of credentials + uninstallOptions.CredentialIdentifiers = []string{"ci"} + uninstallOptions.Params = []string{ + "namespace=" + namespace, + "mysql#namespace=" + namespace, + } + err := uninstallOptions.Validate(ctx, []string{}, p.Porter) + require.NoError(p.T(), err, "validation of uninstall opts for root bundle failed") + + err = p.UninstallBundle(ctx, uninstallOptions) + require.NoError(p.T(), err, "uninstall of root bundle failed") + + // Verify that the bundle claim is uninstalled + i, err := p.Installations.GetInstallation(ctx, namespace, "wordpress") + require.NoError(p.T(), err, "could not fetch installation for the root bundle") + c, err := p.Installations.GetLastRun(ctx, i.Namespace, i.Name) + require.NoError(p.T(), err, "GetLastClaim failed") + assert.Equal(p.T(), cnab.ActionUninstall, c.Action, "the root bundle wasn't recorded as being uninstalled") + assert.Equal(p.T(), cnab.StatusSucceeded, i.Status.ResultStatus, "the root bundle wasn't recorded as being uninstalled successfully") + + // Check that we are now using the original credentials with the bundle + require.Len(p.T(), i.CredentialSets, 1, "expected only one credential set associated to the installation") + assert.Equal(p.T(), "ci", i.CredentialSets[0], "expected to use the alternate credential set") + +}