From c5259545b7715a32ac9334c18f37712ab4d74963 Mon Sep 17 00:00:00 2001 From: Vaughn Dice Date: Wed, 19 Aug 2020 07:56:49 -0600 Subject: [PATCH] feat(*): add installation delete options/flags to relevant commands (#1189) * feat(*): add installation delete options/flags to relevant commands Signed-off-by: Vaughn Dice * Apply suggestions from code review Co-authored-by: Carolyn Van Slyck * incorporate add'l review feedback Signed-off-by: Vaughn Dice * add'l review feedback Signed-off-by: Vaughn Dice * refactor the parent/dep uninstall scenarios Signed-off-by: Vaughn Dice * update dependencies_test.go Signed-off-by: Vaughn Dice * use BundleAction interface to enable action-specific options when executing dependencies Signed-off-by: Vaughn Dice * ref(tests/dependencies_test.go): set mysql dep namespace param as well Signed-off-by: Vaughn Dice * fix cmd example spacing Signed-off-by: Vaughn Dice * add comments around uninstallOpts when empty Signed-off-by: Vaughn Dice * remove ToActionArgs from BundleAction interface Signed-off-by: Vaughn Dice Co-authored-by: Carolyn Van Slyck --- cmd/porter/bundle.go | 6 ++ cmd/porter/installations.go | 29 +++++- docs/content/cli/bundles_uninstall.md | 4 + docs/content/cli/installations.md | 1 + docs/content/cli/installations_delete.md | 44 +++++++++ docs/content/cli/uninstall.md | 4 + pkg/cnab/provider/helpers.go | 4 +- pkg/context/helpers.go | 45 +++++++++ pkg/porter/delete.go | 61 ++++++++++++ pkg/porter/delete_test.go | 80 +++++++++++++++ pkg/porter/dependencies.go | 68 ++++++++----- pkg/porter/helpers.go | 13 +++ pkg/porter/install.go | 4 +- pkg/porter/invoke.go | 4 +- pkg/porter/lifecycle.go | 15 ++- pkg/porter/uninstall.go | 76 ++++++++++++-- pkg/porter/uninstall_test.go | 55 +++++++++++ pkg/porter/upgrade.go | 4 +- tests/dependencies_test.go | 47 +++++---- tests/testdata/drivers/exit-driver-fail.sh | 8 ++ tests/testdata/drivers/exit-driver.sh | 8 ++ tests/uninstall_test.go | 109 +++++++++++++++++++++ 22 files changed, 633 insertions(+), 56 deletions(-) create mode 100644 docs/content/cli/installations_delete.md create mode 100644 pkg/porter/delete.go create mode 100644 pkg/porter/delete_test.go create mode 100644 pkg/porter/uninstall_test.go create mode 100644 tests/testdata/drivers/exit-driver-fail.sh create mode 100644 tests/testdata/drivers/exit-driver.sh create mode 100644 tests/uninstall_test.go diff --git a/cmd/porter/bundle.go b/cmd/porter/bundle.go index cab8a17a2..a794699ed 100644 --- a/cmd/porter/bundle.go +++ b/cmd/porter/bundle.go @@ -249,6 +249,8 @@ For example, the 'debug' driver may be specified, which simply logs the info giv porter bundle uninstall --parameter-set azure --param test-mode=true --param header-color=blue porter bundle uninstall --cred azure --cred kubernetes porter bundle uninstall --driver debug + porter bundle uninstall --delete + porter bundle uninstall --force-delete `, PreRunE: func(cmd *cobra.Command, args []string) error { return opts.Validate(args, p) @@ -273,6 +275,10 @@ For example, the 'debug' driver may be specified, which simply logs the info giv "Credential to use when uninstalling the bundle. May be either a named set of credentials or a filepath, and specified multiple times.") f.StringVarP(&opts.Driver, "driver", "d", porter.DefaultDriver, "Specify a driver to use. Allowed values: docker, debug") + f.BoolVar(&opts.Delete, "delete", false, + "Delete all records associated with the installation, assuming the uninstall action succeeds") + f.BoolVar(&opts.ForceDelete, "force-delete", false, + "UNSAFE. Delete all records associated with the installation, even if uninstall fails. This is intended for cleaning up test data and is not recommended for production environments.") addBundlePullFlags(f, &opts.BundlePullOptions) return cmd diff --git a/cmd/porter/installations.go b/cmd/porter/installations.go index 9022c12d4..d0444f2bf 100644 --- a/cmd/porter/installations.go +++ b/cmd/porter/installations.go @@ -19,6 +19,7 @@ func buildInstallationCommands(p *porter.Porter) *cobra.Command { cmd.AddCommand(buildInstallationsListCommand(p)) cmd.AddCommand(buildInstallationShowCommand(p)) cmd.AddCommand(buildInstallationOutputsCommands(p)) + cmd.AddCommand(buildInstallationDeleteCommand(p)) return cmd } @@ -59,7 +60,7 @@ func buildInstallationShowCommand(p *porter.Porter) *cobra.Command { Short: "Show an installation of a bundle", Long: "Displays info relating to an installation of a bundle, including status and a listing of outputs.", Example: ` porter installation show -porter installation show another-bundle + porter installation show another-bundle Optional output formats include json and yaml. `, @@ -77,3 +78,29 @@ Optional output formats include json and yaml. return &cmd } + +func buildInstallationDeleteCommand(p *porter.Porter) *cobra.Command { + opts := porter.DeleteOptions{} + + cmd := cobra.Command{ + Use: "delete [INSTALLATION]", + Short: "Delete an installation", + Long: "Deletes all records and outputs associated with an installation", + Example: ` porter installation delete + porter installation delete wordpress + porter installation delete --force +`, + PreRunE: func(cmd *cobra.Command, args []string) error { + return opts.Validate(args, p.Context) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return p.DeleteInstallation(opts) + }, + } + + f := cmd.Flags() + f.BoolVar(&opts.Force, "force", false, + "Force a delete the installation, regardless of last completed action") + + return &cmd +} diff --git a/docs/content/cli/bundles_uninstall.md b/docs/content/cli/bundles_uninstall.md index bdae7fb3a..4cf08e186 100644 --- a/docs/content/cli/bundles_uninstall.md +++ b/docs/content/cli/bundles_uninstall.md @@ -30,6 +30,8 @@ porter bundles uninstall [INSTALLATION] [flags] porter bundle uninstall --parameter-set azure --param test-mode=true --param header-color=blue porter bundle uninstall --cred azure --cred kubernetes porter bundle uninstall --driver debug + porter bundle uninstall --delete + porter bundle uninstall --force-delete ``` @@ -39,9 +41,11 @@ porter bundles uninstall [INSTALLATION] [flags] --allow-docker-host-access Controls if the bundle should have access to the host's Docker daemon with elevated privileges. See https://porter.sh/configuration/#allow-docker-host-access for the full implications of this flag. --cnab-file string Path to the CNAB bundle.json file. -c, --cred strings Credential to use when uninstalling the bundle. May be either a named set of credentials or a filepath, and specified multiple times. + --delete Delete all records associated with the installation, assuming the uninstall action succeeds -d, --driver string Specify a driver to use. Allowed values: docker, debug (default "docker") -f, --file string Path to the porter manifest file. Defaults to the bundle in the current directory. Optional unless a newer version of the bundle should be used to uninstall the bundle. --force Force a fresh pull of the bundle + --force-delete UNSAFE. Delete all records associated with the installation, even if uninstall fails. This is intended for cleaning up test data and is not recommended for production environments. -h, --help help for uninstall --insecure-registry Don't require TLS for the registry --param strings Define an individual parameter in the form NAME=VALUE. Overrides parameters otherwise set via --parameter-set. May be specified multiple times. diff --git a/docs/content/cli/installations.md b/docs/content/cli/installations.md index 4e55225e6..569e46967 100644 --- a/docs/content/cli/installations.md +++ b/docs/content/cli/installations.md @@ -27,6 +27,7 @@ Commands for working with installations of a bundle ### SEE ALSO * [porter](/cli/porter/) - I am porter 👩🏽‍✈️, the friendly neighborhood CNAB authoring tool +* [porter installations delete](/cli/porter_installations_delete/) - Delete an installation * [porter installations list](/cli/porter_installations_list/) - List installed bundles * [porter installations output](/cli/porter_installations_output/) - Output commands * [porter installations show](/cli/porter_installations_show/) - Show an installation of a bundle diff --git a/docs/content/cli/installations_delete.md b/docs/content/cli/installations_delete.md new file mode 100644 index 000000000..f967acce1 --- /dev/null +++ b/docs/content/cli/installations_delete.md @@ -0,0 +1,44 @@ +--- +title: "porter installations delete" +slug: porter_installations_delete +url: /cli/porter_installations_delete/ +--- +## porter installations delete + +Delete an installation + +### Synopsis + +Deletes all records and outputs associated with an installation + +``` +porter installations delete [INSTALLATION] [flags] +``` + +### Examples + +``` + porter installation delete +porter installation delete wordpress +porter installation delete --force + +``` + +### Options + +``` + --force Force a delete the installation, regardless of last completed action + -h, --help help for delete +``` + +### Options inherited from parent commands + +``` + --debug Enable debug logging + --debug-plugins Enable plugin debug logging +``` + +### SEE ALSO + +* [porter installations](/cli/porter_installations/) - Installation commands + diff --git a/docs/content/cli/uninstall.md b/docs/content/cli/uninstall.md index df22d3984..ae7f9270d 100644 --- a/docs/content/cli/uninstall.md +++ b/docs/content/cli/uninstall.md @@ -30,6 +30,8 @@ porter uninstall [INSTALLATION] [flags] porter uninstall --parameter-set azure --param test-mode=true --param header-color=blue porter uninstall --cred azure --cred kubernetes porter uninstall --driver debug + porter uninstall --delete + porter uninstall --force-delete ``` @@ -39,9 +41,11 @@ porter uninstall [INSTALLATION] [flags] --allow-docker-host-access Controls if the bundle should have access to the host's Docker daemon with elevated privileges. See https://porter.sh/configuration/#allow-docker-host-access for the full implications of this flag. --cnab-file string Path to the CNAB bundle.json file. -c, --cred strings Credential to use when uninstalling the bundle. May be either a named set of credentials or a filepath, and specified multiple times. + --delete Delete all records associated with the installation, assuming the uninstall action succeeds -d, --driver string Specify a driver to use. Allowed values: docker, debug (default "docker") -f, --file string Path to the porter manifest file. Defaults to the bundle in the current directory. Optional unless a newer version of the bundle should be used to uninstall the bundle. --force Force a fresh pull of the bundle + --force-delete UNSAFE. Delete all records associated with the installation, even if uninstall fails. This is intended for cleaning up test data and is not recommended for production environments. -h, --help help for uninstall --insecure-registry Don't require TLS for the registry --param strings Define an individual parameter in the form NAME=VALUE. Overrides parameters otherwise set via --parameter-set. May be specified multiple times. diff --git a/pkg/cnab/provider/helpers.go b/pkg/cnab/provider/helpers.go index cf61ecb36..2c6b6c3b3 100644 --- a/pkg/cnab/provider/helpers.go +++ b/pkg/cnab/provider/helpers.go @@ -45,6 +45,8 @@ func (t *TestRuntime) LoadBundle(bundleFile string) (bundle.Bundle, error) { } func (t *TestRuntime) Execute(args ActionArguments) error { - args.Driver = debugDriver + if args.Driver == "" { + args.Driver = debugDriver + } return t.Runtime.Execute(args) } diff --git a/pkg/context/helpers.go b/pkg/context/helpers.go index 3db861eb3..5f784a825 100644 --- a/pkg/context/helpers.go +++ b/pkg/context/helpers.go @@ -135,6 +135,51 @@ func (c *TestContext) AddTestDirectory(srcDir, destDir string) { } } +func (c *TestContext) AddTestDriver(src, name string) string { + c.T.Helper() + + data, err := ioutil.ReadFile(src) + if err != nil { + c.T.Fatal(err) + } + + dirname, err := c.FileSystem.TempDir("", "porter") + if err != nil { + c.T.Fatal(err) + } + + // filename in accordance with cnab-go's command driver expectations + filename := fmt.Sprintf("%s/cnab-%s", dirname, name) + + newfile, err := c.FileSystem.Create(filename) + if err != nil { + c.T.Fatal(err) + } + + if len(data) > 0 { + _, err := newfile.Write(data) + if err != nil { + c.T.Fatal(err) + } + } + + err = c.FileSystem.Chmod(newfile.Name(), os.ModePerm) + if err != nil { + c.T.Fatal(err) + } + err = newfile.Close() + if err != nil { + c.T.Fatal(err) + } + + path := os.Getenv("PATH") + pathlist := []string{dirname, path} + newpath := strings.Join(pathlist, string(os.PathListSeparator)) + os.Setenv("PATH", newpath) + + return dirname +} + // GetOutput returns all text printed to stdout. func (c *TestContext) GetOutput() string { return string(c.capturedOut.Bytes()) diff --git a/pkg/porter/delete.go b/pkg/porter/delete.go new file mode 100644 index 000000000..20df9e98e --- /dev/null +++ b/pkg/porter/delete.go @@ -0,0 +1,61 @@ +package porter + +import ( + "fmt" + + "get.porter.sh/porter/pkg/context" + claims "github.com/cnabio/cnab-go/claim" + "github.com/pkg/errors" +) + +const installationDeleteTmpl = "deleting installation records for %s...\n" + +var ( + // ErrUnsafeInstallationDelete warns the user that deletion of an unsuccessfully uninstalled installation is unsafe + ErrUnsafeInstallationDelete = errors.New("it is unsafe to delete an installation when the last action wasn't a successful uninstall") + + // ErrUnsafeInstallationDeleteRetryForce presents the ErrUnsafeInstallationDelete error and provides a retry option of --force + ErrUnsafeInstallationDeleteRetryForce = fmt.Errorf("%s; if you are sure it should be deleted, retry the last command with the --force flag", ErrUnsafeInstallationDelete) +) + +// DeleteOptions represent options for Porter's installation delete command +type DeleteOptions struct { + sharedOptions + Force bool +} + +// Validate prepares for an installation delete action and validates the args/options. +func (o *DeleteOptions) Validate(args []string, cxt *context.Context) error { + // Ensure only one argument exists (installation name) if args length non-zero + err := o.sharedOptions.validateInstallationName(args) + if err != nil { + return err + } + + return o.sharedOptions.defaultBundleFiles(cxt) +} + +// DeleteInstallation handles deletion of an installation +func (p *Porter) DeleteInstallation(opts DeleteOptions) error { + err := p.applyDefaultOptions(&opts.sharedOptions) + if err != nil { + return err + } + + installation, err := p.Claims.ReadInstallationStatus(opts.Name) + if err != nil { + return errors.Wrapf(err, "unable to read status for installation %s", opts.Name) + } + + claim, err := installation.GetLastClaim() + if err != nil { + return errors.Wrapf(err, "unable to read most recent record for installation %s", opts.Name) + } + + if (claim.Action != claims.ActionUninstall || installation.GetLastStatus() != claims.StatusSucceeded) && !opts.Force { + return ErrUnsafeInstallationDeleteRetryForce + } + + fmt.Fprintf(p.Out, installationDeleteTmpl, opts.Name) + return p.Claims.DeleteInstallation(opts.Name) +} diff --git a/pkg/porter/delete_test.go b/pkg/porter/delete_test.go new file mode 100644 index 000000000..e1d1dee38 --- /dev/null +++ b/pkg/porter/delete_test.go @@ -0,0 +1,80 @@ +package porter + +import ( + "testing" + + "github.com/cnabio/cnab-go/bundle" + "github.com/cnabio/cnab-go/claim" + "github.com/stretchr/testify/require" +) + +func TestDeleteInstallation(t *testing.T) { + testcases := []struct { + name string + lastAction string + lastActionStatus string + force bool + installationRemains bool + wantError string + }{ + { + name: "not yet installed", + wantError: "unable to read status for installation test: Installation does not exist", + }, { + name: "last action not uninstall; no --force", + lastAction: "install", + lastActionStatus: claim.StatusSucceeded, + installationRemains: true, + wantError: ErrUnsafeInstallationDeleteRetryForce.Error(), + }, { + name: "last action failed uninstall; no --force", + lastAction: "uninstall", + lastActionStatus: claim.StatusFailed, + installationRemains: true, + wantError: ErrUnsafeInstallationDeleteRetryForce.Error(), + }, { + name: "last action not uninstall; --force", + lastAction: "install", + lastActionStatus: claim.StatusSucceeded, + force: true, + }, { + name: "last action failed uninstall; --force", + lastAction: "uninstall", + lastActionStatus: claim.StatusFailed, + force: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + p := NewTestPorter(t) + p.TestConfig.SetupPorterHome() + + var err error + + // Create test claim + if tc.lastAction != "" { + c := p.TestClaims.CreateClaim("test", tc.lastAction, bundle.Bundle{}, nil) + _ = p.TestClaims.CreateResult(c, tc.lastActionStatus) + } + + opts := DeleteOptions{} + opts.Name = "test" + opts.Force = tc.force + + err = p.DeleteInstallation(opts) + if tc.wantError != "" { + require.EqualError(t, err, tc.wantError) + } else { + require.NoError(t, err, "expected DeleteInstallation to succeed") + } + + _, err = p.Claims.ReadInstallation("test") + if tc.installationRemains { + require.NoError(t, err, "expected installation to exist") + } else { + require.EqualError(t, err, "Installation does not exist") + } + }) + } +} diff --git a/pkg/porter/dependencies.go b/pkg/porter/dependencies.go index 7073ba138..400eb3429 100644 --- a/pkg/porter/dependencies.go +++ b/pkg/porter/dependencies.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/cnabio/cnab-go/claim" + "github.com/hashicorp/go-multierror" "get.porter.sh/porter/pkg/cnab/extensions" cnabprovider "get.porter.sh/porter/pkg/cnab/provider" @@ -24,8 +25,9 @@ type dependencyExecutioner struct { Claims claim.Provider // These are populated by Prepare, call it or perish in inevitable errors - parentOpts BundleLifecycleOpts - deps []*queuedDependency + parentOpts BundleAction + bundleLifecycleOpts BundleLifecycleOpts + deps []*queuedDependency } func newDependencyExecutioner(p *Porter, action string) *dependencyExecutioner { @@ -56,8 +58,9 @@ type queuedDependency struct { RelocationMapping string } -func (e *dependencyExecutioner) Prepare(parentOpts BundleLifecycleOpts) error { +func (e *dependencyExecutioner) Prepare(parentOpts BundleAction) error { e.parentOpts = parentOpts + e.bundleLifecycleOpts = parentOpts.GetBundleLifecycleOptions() err := e.identifyDependencies() if err != nil { @@ -80,7 +83,7 @@ func (e *dependencyExecutioner) Execute() error { } // executeDependency the requested action against all of the dependencies - parentArgs := e.parentOpts.ToActionArgs(e) + parentArgs := e.bundleLifecycleOpts.ToActionArgs(e) for _, dep := range e.deps { err := e.executeDependency(dep, parentArgs) if err != nil { @@ -119,21 +122,21 @@ func (e *dependencyExecutioner) ApplyDependencyMappings(args *cnabprovider.Actio func (e *dependencyExecutioner) identifyDependencies() error { // Load parent CNAB bundle definition var bun bundle.Bundle - if e.parentOpts.CNABFile != "" { - bundle, err := e.CNAB.LoadBundle(e.parentOpts.CNABFile) + if e.bundleLifecycleOpts.CNABFile != "" { + bundle, err := e.CNAB.LoadBundle(e.bundleLifecycleOpts.CNABFile) if err != nil { return err } bun = bundle - } else if e.parentOpts.Tag != "" { - cachedBundle, err := e.Resolver.Resolve(e.parentOpts.BundlePullOptions) + } else if e.bundleLifecycleOpts.Tag != "" { + cachedBundle, err := e.Resolver.Resolve(e.bundleLifecycleOpts.BundlePullOptions) if err != nil { return errors.Wrapf(err, "could not resolve bundle") } bun = cachedBundle.Bundle - } else if e.parentOpts.Name != "" { - c, err := e.Claims.ReadLastClaim(e.parentOpts.Name) + } else if e.bundleLifecycleOpts.Name != "" { + c, err := e.Claims.ReadLastClaim(e.bundleLifecycleOpts.Name) if err != nil { return err } @@ -168,8 +171,8 @@ func (e *dependencyExecutioner) prepareDependency(dep *queuedDependency) error { var err error pullOpts := BundlePullOptions{ Tag: dep.Tag, - InsecureRegistry: e.parentOpts.InsecureRegistry, - Force: e.parentOpts.Force, + InsecureRegistry: e.bundleLifecycleOpts.InsecureRegistry, + Force: e.bundleLifecycleOpts.Force, } cachedDep, err := e.Resolver.Resolve(pullOpts) if err != nil { @@ -216,7 +219,7 @@ func (e *dependencyExecutioner) prepareDependency(dep *queuedDependency) error { // Handle any parameter overrides for the dependency defined on the command line // --param DEP#PARAM=VALUE - for key, value := range e.parentOpts.combinedParameters { + for key, value := range e.bundleLifecycleOpts.combinedParameters { parts := strings.Split(key, "#") if len(parts) > 1 && parts[0] == dep.Alias { paramName := parts[1] @@ -230,7 +233,7 @@ func (e *dependencyExecutioner) prepareDependency(dep *queuedDependency) error { dep.Parameters = make(map[string]string, 1) } dep.Parameters[paramName] = value - delete(e.parentOpts.combinedParameters, key) + delete(e.bundleLifecycleOpts.combinedParameters, key) } } @@ -248,22 +251,41 @@ func (e *dependencyExecutioner) executeDependency(dep *queuedDependency, parentA // For now, assume it's okay to give the dependency the same credentials as the parent CredentialIdentifiers: parentArgs.CredentialIdentifiers, } + + // Determine if we're working with UninstallOptions, to inform deletion and + // error handling, etc. + var uninstallOpts UninstallOptions + if opts, ok := e.parentOpts.(UninstallOptions); ok { + uninstallOpts = opts + } + + var executeErrs error fmt.Fprintf(e.Out, "Executing dependency %s...\n", dep.Alias) err := e.CNAB.Execute(depArgs) if err != nil { - return errors.Wrapf(err, "error executing dependency %s", dep.Alias) - } + executeErrs = multierror.Append(executeErrs, errors.Wrapf(err, "error executing dependency %s", dep.Alias)) - // If action is uninstall, no claim will exist - if parentArgs.Action != claim.ActionUninstall { - // Collect expected outputs via claim - outputs, err := e.Claims.ReadLastOutputs(depArgs.Installation) - if err != nil { - return err + // Handle errors when/if the action is uninstall + // If uninstallOpts is an empty struct, executeErrs will pass through + executeErrs = uninstallOpts.handleUninstallErrs(e.Out, executeErrs) + if executeErrs != nil { + return executeErrs } + } + + // 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() { + fmt.Fprintf(e.Out, installationDeleteTmpl, depArgs.Installation) + return e.Claims.DeleteInstallation(depArgs.Installation) + } - dep.outputs = outputs + // Collect expected outputs via claim + outputs, err := e.Claims.ReadLastOutputs(depArgs.Installation) + if err != nil { + return err } + dep.outputs = outputs return nil } diff --git a/pkg/porter/helpers.go b/pkg/porter/helpers.go index f078041be..90b42158f 100644 --- a/pkg/porter/helpers.go +++ b/pkg/porter/helpers.go @@ -112,6 +112,19 @@ func (p *TestPorter) AddTestFile(src string, dest string) { p.TestConfig.TestContext.AddTestFile(src, dest) } +type TestDriver struct { + Name string + Filepath string +} + +func (p *TestPorter) AddTestDriver(driver TestDriver) string { + if !filepath.IsAbs(driver.Filepath) { + driver.Filepath = filepath.Join(p.TestDir, driver.Filepath) + } + + return p.TestConfig.TestContext.AddTestDriver(driver.Filepath, driver.Name) +} + func (p *TestPorter) CreateBundleDir() string { bundleDir, err := ioutil.TempDir("", "bundle") require.NoError(p.T(), err) diff --git a/pkg/porter/install.go b/pkg/porter/install.go index cc314cc86..a94e3dc64 100644 --- a/pkg/porter/install.go +++ b/pkg/porter/install.go @@ -7,6 +7,8 @@ import ( "github.com/pkg/errors" ) +var _ BundleAction = InstallOptions{} + // InstallOptions that may be specified when installing a bundle. // Porter handles defaulting any missing values. type InstallOptions struct { @@ -27,7 +29,7 @@ func (p *Porter) InstallBundle(opts InstallOptions) error { } deperator := newDependencyExecutioner(p, claim.ActionInstall) - err = deperator.Prepare(opts.BundleLifecycleOpts) + err = deperator.Prepare(opts) if err != nil { return err } diff --git a/pkg/porter/invoke.go b/pkg/porter/invoke.go index e5ce28443..ac250a50c 100644 --- a/pkg/porter/invoke.go +++ b/pkg/porter/invoke.go @@ -6,6 +6,8 @@ import ( "github.com/pkg/errors" ) +var _ BundleAction = InvokeOptions{} + // InvokeOptions that may be specified when invoking a bundle. // Porter handles defaulting any missing values. type InvokeOptions struct { @@ -36,7 +38,7 @@ func (p *Porter) InvokeBundle(opts InvokeOptions) error { } deperator := newDependencyExecutioner(p, opts.Action) - err = deperator.Prepare(opts.BundleLifecycleOpts) + err = deperator.Prepare(opts) if err != nil { return err } diff --git a/pkg/porter/lifecycle.go b/pkg/porter/lifecycle.go index 9fde16cbd..1dfae03e8 100644 --- a/pkg/porter/lifecycle.go +++ b/pkg/porter/lifecycle.go @@ -5,6 +5,15 @@ import ( "github.com/pkg/errors" ) +var _ BundleAction = BundleLifecycleOpts{} + +// BundleAction is an interface that defines a method for supplying +// BundleLifecycleOptions. This is useful when implementations contain +// action-specific options beyond the stock BundleLifecycleOptions. +type BundleAction interface { + GetBundleLifecycleOptions() BundleLifecycleOpts +} + type BundleLifecycleOpts struct { sharedOptions BundlePullOptions @@ -30,8 +39,12 @@ func (o *BundleLifecycleOpts) Validate(args []string, porter *Porter) error { return nil } +func (o BundleLifecycleOpts) GetBundleLifecycleOptions() BundleLifecycleOpts { + return o +} + // ToActionArgs converts this instance of user-provided action options. -func (o *BundleLifecycleOpts) ToActionArgs(deperator *dependencyExecutioner) cnabprovider.ActionArguments { +func (o BundleLifecycleOpts) ToActionArgs(deperator *dependencyExecutioner) cnabprovider.ActionArguments { args := cnabprovider.ActionArguments{ Action: deperator.Action, Installation: o.Name, diff --git a/pkg/porter/uninstall.go b/pkg/porter/uninstall.go index 9ee56e259..f40bea8cd 100644 --- a/pkg/porter/uninstall.go +++ b/pkg/porter/uninstall.go @@ -2,15 +2,58 @@ package porter import ( "fmt" + "io" + "strings" "github.com/cnabio/cnab-go/claim" + "github.com/hashicorp/go-multierror" "github.com/pkg/errors" ) +var _ BundleAction = UninstallOptions{} + +// ErrUnsafeInstallationDeleteRetryForceDelete presents the ErrUnsafeInstallationDelete error and provides a retry option of --force-delete +var ErrUnsafeInstallationDeleteRetryForceDelete = fmt.Errorf("%s; if you are sure it should be deleted, retry the last command with the --force-delete flag", ErrUnsafeInstallationDelete) + // UninstallOptions that may be specified when uninstalling a bundle. // Porter handles defaulting any missing values. type UninstallOptions struct { BundleLifecycleOpts + UninstallDeleteOptions +} + +// UninstallDeleteOptions supply options for deletion on uninstall +type UninstallDeleteOptions struct { + Delete bool + ForceDelete bool +} + +func (opts *UninstallDeleteOptions) shouldDelete() bool { + return opts.Delete || opts.ForceDelete +} + +func (opts *UninstallDeleteOptions) unsafeDelete() bool { + return opts.Delete && !opts.ForceDelete +} + +func (opts *UninstallDeleteOptions) handleUninstallErrs(out io.Writer, err error) error { + if err == nil { + return nil + } + + if opts.unsafeDelete() { + return multierror.Append(err, ErrUnsafeInstallationDeleteRetryForceDelete) + } + + if opts.ForceDelete { + fmt.Fprintf(out, "ignoring the following errors as --force-delete is true:\n %s", err.Error()) + return nil + } + return err +} + +func (opts UninstallOptions) GetBundleLifecycleOptions() BundleLifecycleOpts { + return opts.BundleLifecycleOpts } // UninstallBundle accepts a set of pre-validated UninstallOptions and uses @@ -27,21 +70,42 @@ func (p *Porter) UninstallBundle(opts UninstallOptions) error { } deperator := newDependencyExecutioner(p, claim.ActionUninstall) - err = deperator.Prepare(opts.BundleLifecycleOpts) + err = deperator.Prepare(opts) if err != nil { return err } + var uninstallErrs error fmt.Fprintf(p.Out, "uninstalling %s...\n", opts.Name) err = p.CNAB.Execute(opts.ToActionArgs(deperator)) if err != nil { - if len(deperator.deps) > 0 { - return errors.Wrapf(err, "failed to uninstall the %s bundle, the remaining dependencies were not uninstalled", opts.Name) - } else { - return err + uninstallErrs = multierror.Append(uninstallErrs, err) + + // If the installation is not found, no further action is needed + if strings.Contains(err.Error(), claim.ErrInstallationNotFound.Error()) { + return uninstallErrs + } + + if len(deperator.deps) > 0 && !opts.ForceDelete { + uninstallErrs = multierror.Append(uninstallErrs, + fmt.Errorf("failed to uninstall the %s bundle, the remaining dependencies were not uninstalled", opts.Name)) + } + + uninstallErrs = opts.handleUninstallErrs(p.Out, uninstallErrs) + if uninstallErrs != nil { + return uninstallErrs } } // TODO: See https://github.com/deislabs/porter/issues/465 for flag to allow keeping around the dependencies - return deperator.Execute() + err = opts.handleUninstallErrs(p.Out, deperator.Execute()) + if err != nil { + return err + } + + if opts.shouldDelete() { + fmt.Fprintf(p.Out, installationDeleteTmpl, opts.Name) + return p.Claims.DeleteInstallation(opts.Name) + } + return nil } diff --git a/pkg/porter/uninstall_test.go b/pkg/porter/uninstall_test.go new file mode 100644 index 000000000..6ff8c6e71 --- /dev/null +++ b/pkg/porter/uninstall_test.go @@ -0,0 +1,55 @@ +package porter + +import ( + "bytes" + "fmt" + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestPorter_HandleUninstallErrs(t *testing.T) { + testcases := []struct { + name string + opts UninstallDeleteOptions + err error + wantOut string + wantErr string + }{ + { + name: "no delete; no err", + opts: UninstallDeleteOptions{}, + }, { + name: "no delete", + opts: UninstallDeleteOptions{}, + err: errors.New("an error was encountered"), + wantErr: "an error was encountered", + }, { + name: "--delete; no --force-delete", + opts: UninstallDeleteOptions{Delete: true}, + err: errors.New("an error was encountered"), + wantErr: fmt.Sprintf("2 errors occurred:\n\t* an error was encountered\n\t* %s\n\n", ErrUnsafeInstallationDeleteRetryForceDelete), + }, { + name: "--force-delete", + opts: UninstallDeleteOptions{ForceDelete: true}, + err: errors.New("an error was encountered"), + wantOut: "ignoring the following errors as --force-delete is true:\n an error was encountered", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + out := bytes.NewBufferString("") + + err := tc.opts.handleUninstallErrs(out, tc.err) + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + + require.Equal(t, tc.wantOut, out.String()) + }) + } +} diff --git a/pkg/porter/upgrade.go b/pkg/porter/upgrade.go index b8ff4d4bb..692dbe772 100644 --- a/pkg/porter/upgrade.go +++ b/pkg/porter/upgrade.go @@ -7,6 +7,8 @@ import ( "github.com/pkg/errors" ) +var _ BundleAction = UpgradeOptions{} + // UpgradeOptions that may be specified when upgrading a bundle. // Porter handles defaulting any missing values. type UpgradeOptions struct { @@ -27,7 +29,7 @@ func (p *Porter) UpgradeBundle(opts UpgradeOptions) error { } deperator := newDependencyExecutioner(p, claim.ActionUpgrade) - err = deperator.Prepare(opts.BundleLifecycleOpts) + err = deperator.Prepare(opts) if err != nil { return err } diff --git a/tests/dependencies_test.go b/tests/dependencies_test.go index f3d307f80..26be06288 100644 --- a/tests/dependencies_test.go +++ b/tests/dependencies_test.go @@ -24,7 +24,7 @@ func TestDependenciesLifecycle(t *testing.T) { p.Debug = false namespace := installWordpressBundle(p) - defer cleanupWordpressBundle(p) + defer cleanupWordpressBundle(p, namespace) upgradeWordpressBundle(p, namespace) @@ -71,6 +71,7 @@ func installWordpressBundle(p *porter.TestPorter) (namespace string) { "wordpress-password=mypassword", "namespace=" + namespace, "wordpress-name=porter-ci-wordpress-" + namespace, + "mysql#namespace=" + namespace, "mysql#mysql-name=porter-ci-mysql-" + namespace, } // Add a supplemental parameter set to vet dep param resolution @@ -99,24 +100,29 @@ func installWordpressBundle(p *porter.TestPorter) (namespace string) { return namespace } -func cleanupWordpressBundle(p *porter.TestPorter) { - uninstallOpts := porter.UninstallOptions{} - uninstallOpts.CredentialIdentifiers = []string{"ci"} - uninstallOpts.Tag = p.Manifest.Dependencies["mysql"].Tag - err := uninstallOpts.Validate([]string{"wordpress-mysql"}, p.Porter) - assert.NoError(p.T(), err, "validation of uninstall opts failed for dependent bundle") +func cleanupWordpressBundle(p *porter.TestPorter, namespace string) { + uninstallOptions := porter.UninstallOptions{} + uninstallOptions.CredentialIdentifiers = []string{"ci"} + uninstallOptions.Delete = true + uninstallOptions.Params = []string{ + "wordpress-name=porter-ci-wordpress-" + namespace, + "mysql#mysql-name=porter-ci-mysql-" + namespace, + } + err := uninstallOptions.Validate([]string{}, p.Porter) + require.NoError(p.T(), err, "validation of uninstall opts for root bundle failed") - err = p.UninstallBundle(uninstallOpts) - assert.NoError(p.T(), err, "uninstall failed for dependent bundle") + err = p.UninstallBundle(uninstallOptions) + require.NoError(p.T(), err, "uninstall of root bundle failed") - // Uninstall the bundle - uninstallOpts = porter.UninstallOptions{} - uninstallOpts.CredentialIdentifiers = []string{"ci"} - err = uninstallOpts.Validate([]string{}, p.Porter) - assert.NoError(p.T(), err, "validation of uninstall opts failed for dependent bundle") + // Verify that the dependency installation is deleted + i, err := p.Claims.ReadInstallation("wordpress-mysql") + require.EqualError(p.T(), err, "Installation does not exist") + require.Equal(p.T(), claim.Installation{}, i) - err = p.UninstallBundle(uninstallOpts) - assert.NoError(p.T(), err, "uninstall failed for root bundle") + // Verify that the root installation is deleted + i, err = p.Claims.ReadInstallation("wordpress") + require.EqualError(p.T(), err, "Installation does not exist") + require.Equal(p.T(), claim.Installation{}, i) } func upgradeWordpressBundle(p *porter.TestPorter, namespace string) { @@ -126,6 +132,7 @@ func upgradeWordpressBundle(p *porter.TestPorter, namespace string) { "wordpress-password=mypassword", "namespace=" + namespace, "wordpress-name=porter-ci-wordpress-" + namespace, + "mysql#namespace=" + namespace, "mysql#mysql-name=porter-ci-mysql-" + namespace, } err := upgradeOpts.Validate([]string{}, p.Porter) @@ -154,10 +161,11 @@ func upgradeWordpressBundle(p *porter.TestPorter, namespace string) { func invokeWordpressBundle(p *porter.TestPorter, namespace string) { invokeOpts := porter.InvokeOptions{Action: "ping"} invokeOpts.CredentialIdentifiers = []string{"ci"} - invokeOpts.Params = []string{ // See https://github.com/deislabs/porter/issues/474 + invokeOpts.Params = []string{ "wordpress-password=mypassword", "namespace=" + namespace, "wordpress-name=porter-ci-wordpress-" + namespace, + "mysql#namespace=" + namespace, "mysql#mysql-name=porter-ci-mysql-" + namespace, } err := invokeOpts.Validate([]string{}, p.Porter) @@ -186,9 +194,7 @@ func invokeWordpressBundle(p *porter.TestPorter, namespace string) { func uninstallWordpressBundle(p *porter.TestPorter, namespace string) { uninstallOptions := porter.UninstallOptions{} uninstallOptions.CredentialIdentifiers = []string{"ci"} - uninstallOptions.Params = []string{ // See https://github.com/deislabs/porter/issues/474 - "wordpress-password=mypassword", - "namespace=" + namespace, + uninstallOptions.Params = []string{ "wordpress-name=porter-ci-wordpress-" + namespace, "mysql#mysql-name=porter-ci-mysql-" + namespace, } @@ -213,5 +219,4 @@ func uninstallWordpressBundle(p *porter.TestPorter, namespace string) { require.NoError(p.T(), err, "GetLastClaim failed") assert.Equal(p.T(), claim.ActionUninstall, c.Action, "the root bundle wasn't recorded as being uninstalled") assert.Equal(p.T(), claim.StatusSucceeded, i.GetLastStatus(), "the root bundle wasn't recorded as being uninstalled successfully") - } diff --git a/tests/testdata/drivers/exit-driver-fail.sh b/tests/testdata/drivers/exit-driver-fail.sh new file mode 100644 index 000000000..8d0ab02e8 --- /dev/null +++ b/tests/testdata/drivers/exit-driver-fail.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# stub out support for imageType of "docker" +if [[ "$@" == "--handles" ]]; then + echo "docker" +else + exit 1 +fi \ No newline at end of file diff --git a/tests/testdata/drivers/exit-driver.sh b/tests/testdata/drivers/exit-driver.sh new file mode 100644 index 000000000..ae10d42bc --- /dev/null +++ b/tests/testdata/drivers/exit-driver.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# stub out support for imageType of "docker" +if [[ "$@" == "--handles" ]]; then + echo "docker" +else + exit 0 +fi \ No newline at end of file diff --git a/tests/uninstall_test.go b/tests/uninstall_test.go new file mode 100644 index 000000000..523d12f70 --- /dev/null +++ b/tests/uninstall_test.go @@ -0,0 +1,109 @@ +// +build integration + +package tests + +import ( + "fmt" + "os" + "testing" + + "get.porter.sh/porter/pkg/porter" + "github.com/stretchr/testify/require" +) + +func TestUninstall_DeleteInstallation(t *testing.T) { + testcases := []struct { + name string + notInstalled bool + uninstallFails bool + delete bool + forceDelete bool + installationRemains bool + wantError string + }{ + { + name: "not yet installed", + notInstalled: true, + wantError: "1 error occurred:\n\t* could not load installation HELLO: Installation does not exist\n\n", + }, { + name: "no --delete", + installationRemains: true, + }, { + name: "--delete", + delete: true, + wantError: "", + }, { + name: "uninstall fails; --delete", + uninstallFails: true, + delete: true, + installationRemains: true, + wantError: fmt.Sprintf("2 errors occurred:\n\t* Command driver (uninstall) failed executing bundle: exit status 1\n\t* %s\n\n", porter.ErrUnsafeInstallationDeleteRetryForceDelete.Error()), + }, { + name: "uninstall fails; --force-delete", + uninstallFails: true, + forceDelete: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + p := porter.NewTestPorter(t) + + p.SetupIntegrationTest() + defer p.CleanupIntegrationTest() + p.Debug = false + + err := p.Create() + require.NoError(t, err) + + // Install bundle + if !tc.notInstalled { + opts := porter.InstallOptions{} + opts.Driver = "debug" + + err = opts.Validate(nil, p.Porter) + require.NoError(t, err, "Validate install options failed") + + err = p.InstallBundle(opts) + require.NoError(t, err, "InstallBundle failed") + } + + // Set up command driver + driver := porter.TestDriver{ + Name: "uninstall", + Filepath: "testdata/drivers/exit-driver.sh", + } + if tc.uninstallFails { + driver.Filepath = "testdata/drivers/exit-driver-fail.sh" + } + + path := os.Getenv("PATH") + dir := p.AddTestDriver(driver) + defer os.Setenv("PATH", path) + defer p.TestConfig.TestContext.FileSystem.RemoveAll(dir) + + // Uninstall bundle with custom command driver + opts := porter.UninstallOptions{} + opts.Delete = tc.delete + opts.ForceDelete = tc.forceDelete + opts.Driver = driver.Name + + err = opts.Validate(nil, p.Porter) + require.NoError(t, err, "Validate uninstall options failed") + + err = p.UninstallBundle(opts) + if tc.wantError != "" { + require.EqualError(t, err, tc.wantError) + } else { + require.NoError(t, err, "UninstallBundle failed") + } + + _, err = p.Claims.ReadInstallation(opts.Name) + if tc.installationRemains { + require.NoError(t, err, "Installation is expected to exist") + } else { + require.EqualError(t, err, "Installation does not exist") + } + }) + } +}