From 438cec1bed0ef6e77c0b25ac4052e9bf21abcd78 Mon Sep 17 00:00:00 2001 From: Urvashi Reddy Date: Thu, 26 Sep 2019 15:08:21 -0700 Subject: [PATCH] Enable custom actions to be run on existing claims or bundles - "duffle run" requires an action and claim name - if a claim does not exist, the user can specify a new claim to create and the bundle. - the bundle can either be the path to a bundle.json file or the name of a bundle in duffle's store - add tests for run command and error cases --- cmd/duffle/export.go | 1 - cmd/duffle/run.go | 243 +++++++++++++++++++++----------- cmd/duffle/run_test.go | 143 +++++++++++++++++++ tests/testdata/bundles/foo.json | 8 ++ 4 files changed, 315 insertions(+), 80 deletions(-) create mode 100644 cmd/duffle/run_test.go diff --git a/cmd/duffle/export.go b/cmd/duffle/export.go index 6c9a9ec5..de8ad0ac 100644 --- a/cmd/duffle/export.go +++ b/cmd/duffle/export.go @@ -106,7 +106,6 @@ func (ex *exportCmd) setup() (string, loader.BundleLoader, error) { } func resolveBundleFilePath(bun, homePath string, bundleIsFile bool) (string, error) { - if bundleIsFile { return bun, nil } diff --git a/cmd/duffle/run.go b/cmd/duffle/run.go index 25c79ec0..cbec15b7 100644 --- a/cmd/duffle/run.go +++ b/cmd/duffle/run.go @@ -3,6 +3,7 @@ package main import ( "fmt" "io" + "log" "github.com/spf13/cobra" @@ -10,100 +11,184 @@ import ( "github.com/deislabs/cnab-go/claim" ) -func newRunCmd(w io.Writer) *cobra.Command { - const short = "run a target in the bundle" - const long = `Run an arbitrary target in the bundle. +type runCmd struct { + action string + claimName string + bundleName string + bundlePath string + + relocationMapping string + credentialsFiles []string + valuesFile string + setParams []string + setFiles []string + + driver string + out io.Writer + opOutFunc action.OperationConfigFunc + + home string + storage *claim.Store + claim *claim.Claim +} -Some CNAB bundles may declare custom targets in addition to install, upgrade, and uninstall. -This command can be used to execute those targets. +func newRunCmd(w io.Writer) *cobra.Command { + const short = "run an action in the bundle" + const long = `Run an arbitrary action in the bundle. -The 'run' command takes a ACTION and a RELEASE NAME: +Some CNAB bundles may declare custom actions in addition to install, upgrade, and uninstall. +This command can be used to execute those actions. - $ duffle run migrate my-release +The 'run' command takes an ACTION and a CLAIM name: -This will start the invocation image for the release in 'my-release', and then send -the action 'migrate'. If the invocation image does not have a 'migrate' action, it -may return an error. + $ duffle run migrate --claim myExistingClaim +or + $ duffle run preinstall --bundle myBundle --claim myNewClaim +or + $ duffle run preinstall --bundle-is-file path/to/bundle.json --claim myNewClaim -Custom actions can only be executed on releases (already-installed bundles). +All custom actions can be executed on claims (installed bundles). +Stateless custom actions can be executed on claims or bundles. +A new claim will be created if a bundle is specified and the action modifies. -Credentials and parameters may be passed to the bundle during a target action. +Credentials and parameters may also be passed in. ` - var ( - driver string - credentialsFiles []string - valuesFile string - setParams []string - setFiles []string - relocationMapping string - ) - + run := &runCmd{out: w} cmd := &cobra.Command{ - Use: "run ACTION RELEASE_NAME", + Use: "run ACTION --claim CLAIM [--bundle BUNDLE | --bundle-is-file path/to/bundle.json]", Aliases: []string{"exec"}, Short: short, Long: long, - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - target := args[0] - claimName := args[1] - storage := claimStorage() - c, err := storage.Read(claimName) - if err != nil { - if err == claim.ErrClaimNotFound { - return fmt.Errorf("Bundle installation '%s' not found", claimName) - } - return err - } - - creds, err := loadCredentials(credentialsFiles, c.Bundle) - if err != nil { - return err - } - - driverImpl, err := prepareDriver(driver) - if err != nil { - return err - } - - // Override parameters only if some are set. - if valuesFile != "" || len(setParams) > 0 { - c.Parameters, err = calculateParamValues(c.Bundle, valuesFile, setParams, setFiles) - if err != nil { - return err - } - } - - opRelocator, err := makeOpRelocator(relocationMapping) - if err != nil { - return err - } - - action := &action.RunCustom{ - Driver: driverImpl, - Action: target, - } - - fmt.Fprintf(w, "Executing custom action %q for release %q", target, claimName) - err = action.Run(&c, creds, setOut(cmd.OutOrStdout()), opRelocator) - if actionDef := c.Bundle.Actions[target]; !actionDef.Modifies { - // Do not store a claim for non-mutating actions. - return err - } - - err2 := storage.Store(c) - if err != nil { - return fmt.Errorf("run failed: %s", err) - } - return err2 + run.action = args[0] + run.home = homePath() + return run.run() }, } + run.opOutFunc = setOut(cmd.OutOrStdout()) + flags := cmd.Flags() - flags.StringVarP(&driver, "driver", "d", "docker", "Specify a driver name") - flags.StringVarP(&relocationMapping, "relocation-mapping", "m", "", "Path of relocation mapping JSON file") - flags.StringArrayVarP(&credentialsFiles, "credentials", "c", []string{}, "Specify a set of credentials to use inside the CNAB bundle") - flags.StringVarP(&valuesFile, "parameters", "p", "", "Specify file containing parameters. Formats: toml, MORE SOON") - flags.StringArrayVarP(&setParams, "set", "s", []string{}, "Set individual parameters as NAME=VALUE pairs") + flags.StringVarP(&run.claimName, "claim", "i", "", "Specify the name of an existing claim (required)") + flags.StringVarP(&run.bundleName, "bundle", "b", "", "Specify the name of a bundle") + flags.StringVarP(&run.bundlePath, "bundle-is-file", "f", "", "Specify the path to a bundle.json") + flags.StringVarP(&run.driver, "driver", "d", "docker", "Specify a driver name") + flags.StringVarP(&run.relocationMapping, "relocation-mapping", "m", "", "Path of relocation mapping JSON file") + flags.StringArrayVarP(&run.credentialsFiles, "credentials", "c", []string{}, "Specify a set of credentials to use inside the CNAB bundle") + flags.StringVarP(&run.valuesFile, "parameters", "p", "", "Specify file containing parameters. Formats: toml, MORE SOON") + flags.StringArrayVarP(&run.setParams, "set", "s", []string{}, "Set individual parameters as NAME=VALUE pairs") + + err := cmd.MarkFlagRequired("claim") + if err != nil { + log.Fatal("required flag \"claim\" is missing") + } return cmd } + +func (r *runCmd) run() error { + if r.storage == nil { + storage := claimStorage() + r.storage = &storage + } + + err := r.prepareClaim() + if err != nil { + return fmt.Errorf("failed to prepare claim %q: %s", r.claimName, err) + } + + creds, err := loadCredentials(r.credentialsFiles, r.claim.Bundle) + if err != nil { + return err + } + + driver, err := prepareDriver(r.driver) + if err != nil { + return fmt.Errorf("failed to prepare driver %q: %s", r.driver, err) + } + + // Override parameters only if some are set. + if r.valuesFile != "" || len(r.setParams) > 0 { + r.claim.Parameters, err = calculateParamValues(r.claim.Bundle, r.valuesFile, r.setParams, r.setFiles) + if err != nil { + return fmt.Errorf("failed to set parameters on claim: %s", err) + } + } + + opRelocator, err := makeOpRelocator(r.relocationMapping) + if err != nil { + return err + } + + action := &action.RunCustom{ + Driver: driver, + Action: r.action, + } + + fmt.Fprintf(r.out, "Executing custom action %q\n", r.action) + err = action.Run(r.claim, creds, r.opOutFunc, opRelocator) + if actionDef := r.claim.Bundle.Actions[r.action]; !actionDef.Modifies { + // Do not store a claim for non-mutating actions. + return err + } + + storageErr := r.storage.Store(*r.claim) + if err != nil { + return fmt.Errorf("run failed: %s", err) + } + + return storageErr +} + +func (r *runCmd) prepareClaim() error { + var err error + + if r.bundleName != "" && r.bundlePath != "" { + return fmt.Errorf("cannot specify both --bundle and --bundle-is-file: received bundle %q and bundle file %q", r.bundleName, r.bundlePath) + } + + if r.bundleName != "" { + r.bundlePath, err = resolveBundleFilePath(r.bundleName, r.home, false) + if err != nil { + return err + } + } + + if r.bundlePath != "" { + return r.createClaimFromBundlePath() + } + + return r.useExistingClaim() +} + +func (r *runCmd) createClaimFromBundlePath() error { + if !fileExists(r.bundlePath) { + return fmt.Errorf("bundle file %q does not exist", r.bundlePath) + } + + bundle, err := loadBundle(r.bundlePath) + if err != nil { + return fmt.Errorf("failed to parse contents in bundle file %q: %s", r.bundlePath, err) + } + + r.claim, err = claim.New(r.claimName) + if err != nil { + return fmt.Errorf("failed to create claim %q: %s", r.claimName, err) + } + + r.claim.Bundle = bundle + return nil +} + +func (r *runCmd) useExistingClaim() error { + c, err := r.storage.Read(r.claimName) + if err != nil { + if err == claim.ErrClaimNotFound { + return fmt.Errorf("claim %q not found in duffle store", r.claimName) + } + return fmt.Errorf("failed to read claim %q from duffle store: %s", r.claimName, err) + } + + r.claim = &c + return nil +} diff --git a/cmd/duffle/run_test.go b/cmd/duffle/run_test.go new file mode 100644 index 00000000..974eecb4 --- /dev/null +++ b/cmd/duffle/run_test.go @@ -0,0 +1,143 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/deislabs/cnab-go/bundle" + "github.com/deislabs/cnab-go/claim" + "github.com/stretchr/testify/assert" +) + +func TestRunCustom_ClaimOnly(t *testing.T) { + claim, err := claim.New("some-claim") + assert.NoError(t, err) + + claim.Bundle = &bundle.Bundle{ + InvocationImages: []bundle.InvocationImage{ + { + BaseImage: bundle.BaseImage{ + ImageType: "docker", + Image: "some-image", + }, + }, + }, + Name: "some-bundle", + Actions: map[string]bundle.Action{ + "some-custom-action": { + Modifies: false, + Stateless: true, + }, + }, + } + + claimStore := mockClaimStore() + err = claimStore.Store(*claim) + assert.NoError(t, err) + + buf := bytes.NewBuffer([]byte{}) + + run := &runCmd{ + claimName: "some-claim", + action: "some-custom-action", + out: buf, + driver: "debug", + storage: &claimStore, + } + + run.opOutFunc = setOut(run.out) + + err = run.run() + assert.NoError(t, err) + + data := buf.String() + if len(data) == 0 || !strings.Contains(data, "some-claim") { + t.Fatalf("Expected driver to have received claim information, recieved: %s", data) + } +} + +func TestRunCustom_BundleName(t *testing.T) { + tempDuffleHome, err := setupTempDuffleHome(t) + assert.NoError(t, err) + + defer os.Remove(tempDuffleHome) + + err = copyTestBundle(tempDuffleHome) + assert.NoError(t, err) + + claim, err := claim.New("foo-claim") + assert.NoError(t, err) + + claimStore := mockClaimStore() + err = claimStore.Store(*claim) + assert.NoError(t, err) + + buf := bytes.NewBuffer([]byte{}) + + run := &runCmd{ + bundleName: "foo", + claimName: "foo-claim", + action: "foo-action", + out: buf, + driver: "debug", + storage: &claimStore, + home: tempDuffleHome, + } + + run.opOutFunc = setOut(run.out) + + err = run.run() + assert.NoError(t, err) + + data := buf.String() + if len(data) == 0 || !strings.Contains(data, "foo-action") { + t.Fatalf("Expected driver to have received claim information, recieved: %s", data) + } +} + +func TestRunCustom_BundlePath(t *testing.T) { + fooBundlePath := filepath.Join("..", "..", "tests", "testdata", "bundles", "foo.json") + + buf := bytes.NewBuffer([]byte{}) + + run := &runCmd{ + bundlePath: fooBundlePath, + claimName: "foo-bundle", + action: "bar-action", + out: buf, + driver: "debug", + } + + run.opOutFunc = setOut(run.out) + + err := run.run() + assert.NoError(t, err) + + data := buf.String() + if len(data) == 0 || !strings.Contains(data, "bar") { + t.Fatalf("Expected driver to have received claim information, recieved: %s", data) + } +} + +func TestPrepareClaim_BundleErrorCases(t *testing.T) { + // error when both bundle path and name are specified + run := &runCmd{ + bundleName: "some-bundle-name", + bundlePath: "some-bundle-path", + } + err := run.prepareClaim() + assert.EqualError(t, err, `cannot specify both --bundle and --bundle-is-file: received bundle "some-bundle-name" and bundle file "some-bundle-path"`) + + // error when the bundle name does not exist in the duffle store + run = &runCmd{bundleName: "some-unknown-bundle"} + err = run.prepareClaim() + assert.EqualError(t, err, `could not find some-unknown-bundle:latest in repositories.json: no bundle name found`) + + // error when the bundle path does not exist + run = &runCmd{bundlePath: "non-existent-path"} + err = run.prepareClaim() + assert.EqualError(t, err, `bundle file "non-existent-path" does not exist`) +} diff --git a/tests/testdata/bundles/foo.json b/tests/testdata/bundles/foo.json index 10d42907..c7f6117f 100644 --- a/tests/testdata/bundles/foo.json +++ b/tests/testdata/bundles/foo.json @@ -40,5 +40,13 @@ "path": "pquux", "env": "equux" } + }, + "actions": { + "foo-action": { + "modifies": false + }, + "bar-action": { + "modifies": false + } } }