diff --git a/Makefile b/Makefile index b4d91906..a23784d3 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,10 @@ build: test: go test ./... +.PHONY: integration-test +integration-test: + go test -tags integration ./... + .PHONY: lint lint: golangci-lint run --config ./golangci.yml diff --git a/README.md b/README.md index 5e97ae37..0a72b057 100644 --- a/README.md +++ b/README.md @@ -43,15 +43,23 @@ Compile all the code: $ make build ``` -Run tests: +Run unit tests: ```bash $ make test ``` +Run integration tests: + +Note: The integration tests use a local docker daemon to create containers. Please ensure you have one configured before running the tests. + +```bash +$ make integration-test +``` + This will only run the linter to ensure the code meet the standard. *It does not format the code* ```bash $ make lint -``` \ No newline at end of file +``` diff --git a/action/action.go b/action/action.go index 9b9957bf..af4a16d4 100644 --- a/action/action.go +++ b/action/action.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "io" + "math" "strings" "github.com/deislabs/cnab-go/bundle" + "github.com/deislabs/cnab-go/bundle/definition" "github.com/deislabs/cnab-go/claim" "github.com/deislabs/cnab-go/credentials" "github.com/deislabs/cnab-go/driver" @@ -29,6 +31,129 @@ type Action interface { Run(*claim.Claim, credentials.Set, io.Writer) error } +func golangTypeToJSONType(value interface{}) (string, error) { + switch v := value.(type) { + case nil: + return "null", nil + case bool: + return "boolean", nil + case float64: + // All numeric values are parsed by JSON into float64s. When a value could be an integer, it could also be a number, so give the more specific answer. + if math.Trunc(v) == v { + return "integer", nil + } + return "number", nil + case string: + return "string", nil + case map[string]interface{}: + return "object", nil + case []interface{}: + return "array", nil + default: + return fmt.Sprintf("%T", value), fmt.Errorf("unsupported type: %T", value) + } +} + +// allowedTypes takes an output Schema and returns a map of the allowed types (to true) +// or an error (if reading the allowed types from the schema failed). +func allowedTypes(outputSchema definition.Schema) (map[string]bool, error) { + var outputTypes []string + mapOutputTypes := map[string]bool{} + + // Get list of one or more allowed types for this output + outputType, ok, err1 := outputSchema.GetType() + if !ok { // there are multiple types + var err2 error + outputTypes, ok, err2 = outputSchema.GetTypes() + if !ok { + return mapOutputTypes, fmt.Errorf("Getting a single type errored with %q and getting multiple types errored with %q", err1, err2) + } + } else { + outputTypes = []string{outputType} + } + + // Turn allowed outputs into map for easier membership checking + for _, v := range outputTypes { + mapOutputTypes[v] = true + } + + // All integers make acceptable numbers, and our helper function provides the most specific type. + if mapOutputTypes["number"] { + mapOutputTypes["integer"] = true + } + + return mapOutputTypes, nil +} + +// keys takes a map and returns the keys joined into a comma-separate string. +func keys(stringMap map[string]bool) string { + var keys []string + for k := range stringMap { + keys = append(keys, k) + } + return strings.Join(keys, ",") +} + +// isTypeOK uses the content and allowedTypes arguments to make sure the content of an output file matches one of the allowed types. +// The other arguments (name and allowedTypesList) are used when assembling error messages. +func isTypeOk(name, content string, allowedTypes map[string]bool) error { + if !allowedTypes["string"] { // String output types are always passed through as the escape hatch for non-JSON bundle outputs. + var value interface{} + if err := json.Unmarshal([]byte(content), &value); err != nil { + return fmt.Errorf("failed to parse %q: %s", name, err) + } + + v, err := golangTypeToJSONType(value) + if err != nil { + return fmt.Errorf("%q is not a known JSON type. Expected %q to be one of: %s", name, v, keys(allowedTypes)) + } + if !allowedTypes[v] { + return fmt.Errorf("%q is not any of the expected types (%s) because it is %q", name, keys(allowedTypes), v) + } + } + return nil +} + +func setOutputsOnClaim(claim *claim.Claim, outputs map[string]string) error { + var outputErrors []error + claim.Outputs = map[string]interface{}{} + + if claim.Bundle.Outputs == nil { + return nil + } + + for outputName, v := range claim.Bundle.Outputs.Fields { + name := v.Definition + if name == "" { + return fmt.Errorf("invalid bundle: no definition set for output %q", outputName) + } + + outputSchema := claim.Bundle.Definitions[name] + if outputSchema == nil { + return fmt.Errorf("invalid bundle: output %q references definition %q, which was not found", outputName, name) + } + outputTypes, err := allowedTypes(*outputSchema) + if err != nil { + return err + } + + content := outputs[v.Path] + if content != "" { + err := isTypeOk(outputName, content, outputTypes) + if err != nil { + outputErrors = append(outputErrors, err) + } + claim.Outputs[outputName] = outputs[v.Path] + } + } + + if len(outputErrors) > 0 { + return fmt.Errorf("error: %s", outputErrors) + } + + return nil +} + func selectInvocationImage(d driver.Driver, c *claim.Claim) (bundle.InvocationImage, error) { if len(c.Bundle.InvocationImages) == 0 { return bundle.InvocationImage{}, errors.New("no invocationImages are defined in the bundle") @@ -96,6 +221,13 @@ func opFromClaim(action string, stateless bool, c *claim.Claim, ii bundle.Invoca env["CNAB_BUNDLE_NAME"] = c.Bundle.Name env["CNAB_BUNDLE_VERSION"] = c.Bundle.Version + var outputs []string + if c.Bundle.Outputs != nil { + for _, v := range c.Bundle.Outputs.Fields { + outputs = append(outputs, v.Path) + } + } + return &driver.Operation{ Action: action, Installation: c.Name, @@ -105,6 +237,7 @@ func opFromClaim(action string, stateless bool, c *claim.Claim, ii bundle.Invoca Revision: c.Revision, Environment: env, Files: files, + Outputs: outputs, Out: w, }, nil } diff --git a/action/action_test.go b/action/action_test.go index 7029786b..2997f594 100644 --- a/action/action_test.go +++ b/action/action_test.go @@ -19,8 +19,17 @@ import ( "github.com/stretchr/testify/require" ) -type mockFailingDriver struct { +type mockDriver struct { shouldHandle bool + Result driver.OperationResult + Error error +} + +func (d *mockDriver) Handles(imageType string) bool { + return d.shouldHandle +} +func (d *mockDriver) Run(op *driver.Operation) (driver.OperationResult, error) { + return d.Result, d.Error } var mockSet = credentials.Set{ @@ -28,11 +37,16 @@ var mockSet = credentials.Set{ "secret_two": "I'm also a secret", } -func (d *mockFailingDriver) Handles(imageType string) bool { - return d.shouldHandle -} -func (d *mockFailingDriver) Run(op *driver.Operation) error { - return errors.New("I always fail") +func newClaim() *claim.Claim { + now := time.Now() + return &claim.Claim{ + Created: now, + Modified: now, + Name: "name", + Revision: "revision", + Bundle: mockBundle(), + Parameters: map[string]interface{}{}, + } } func mockBundle() *bundle.Bundle { @@ -71,6 +85,44 @@ func mockBundle() *bundle.Bundle { Type: "string", Default: "three", }, + "NullParam": { + Type: "null", + Default: true, + }, + "BooleanParam": { + Type: "boolean", + Default: true, + }, + "ObjectParam": { + Type: "object", + Default: true, + }, + "ArrayParam": { + Type: "array", + Default: true, + }, + "NumberParam": { + Type: "number", + Default: true, + }, + "IntegerParam": { + Type: "integer", + Default: true, + }, + "BooleanAndIntegerParam": { + Type: []interface{}{"boolean", "integer"}, + }, + "StringAndBooleanParam": { + Type: []interface{}{"string", "boolean"}, + }, + }, + Outputs: &bundle.OutputsDefinition{ + Fields: map[string]bundle.OutputDefinition{ + "some-output": { + Path: "/tmp/some/path", + Definition: "ParamOne", + }, + }, }, Parameters: map[string]bundle.Parameter{ "param_one": { @@ -101,22 +153,14 @@ func mockBundle() *bundle.Bundle { }, }, } - } func TestOpFromClaim(t *testing.T) { - now := time.Now() - c := &claim.Claim{ - Created: now, - Modified: now, - Name: "name", - Revision: "revision", - Bundle: mockBundle(), - Parameters: map[string]interface{}{ - "param_one": "oneval", - "param_two": "twoval", - "param_three": "threeval", - }, + c := newClaim() + c.Parameters = map[string]interface{}{ + "param_one": "oneval", + "param_two": "twoval", + "param_three": "threeval", } invocImage := c.Bundle.InvocationImages[0] @@ -137,6 +181,7 @@ func TestOpFromClaim(t *testing.T) { is.Equal(op.Files["/secret/two"], "I'm also a secret") is.Equal(op.Files["/param/three"], "threeval") is.Contains(op.Files, "/cnab/app/image-map.json") + is.Contains(op.Outputs, "/tmp/some/path") var imgMap map[string]bundle.Image is.NoError(json.Unmarshal([]byte(op.Files["/cnab/app/image-map.json"]), &imgMap)) is.Equal(c.Bundle.Images, imgMap) @@ -144,17 +189,37 @@ func TestOpFromClaim(t *testing.T) { is.Equal(os.Stdout, op.Out) } -func TestOpFromClaim_NoParameter(t *testing.T) { - now := time.Now() - b := mockBundle() - b.Parameters = nil - c := &claim.Claim{ - Created: now, - Modified: now, - Name: "name", - Revision: "revision", - Bundle: b, +func TestOpFromClaim_NoOutputsOnBundle(t *testing.T) { + c := newClaim() + c.Bundle = mockBundle() + c.Bundle.Outputs = nil + invocImage := c.Bundle.InvocationImages[0] + + op, err := opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) + if err != nil { + t.Fatal(err) } + + is := assert.New(t) + + is.Equal(c.Name, op.Installation) + is.Equal(c.Revision, op.Revision) + is.Equal(invocImage.Image, op.Image) + is.Equal(driver.ImageTypeDocker, op.ImageType) + is.Equal(op.Environment["SECRET_ONE"], "I'm a secret") + is.Equal(op.Files["/secret/two"], "I'm also a secret") + is.Contains(op.Files, "/cnab/app/image-map.json") + var imgMap map[string]bundle.Image + is.NoError(json.Unmarshal([]byte(op.Files["/cnab/app/image-map.json"]), &imgMap)) + is.Equal(c.Bundle.Images, imgMap) + is.Len(op.Parameters, 0) + is.Equal(os.Stdout, op.Out) +} + +func TestOpFromClaim_NoParameter(t *testing.T) { + c := newClaim() + c.Bundle = mockBundle() + c.Bundle.Parameters = nil invocImage := c.Bundle.InvocationImages[0] op, err := opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) @@ -177,20 +242,14 @@ func TestOpFromClaim_NoParameter(t *testing.T) { is.Len(op.Parameters, 0) is.Equal(os.Stdout, op.Out) } + func TestOpFromClaim_UndefinedParams(t *testing.T) { - now := time.Now() - c := &claim.Claim{ - Created: now, - Modified: now, - Name: "name", - Revision: "revision", - Bundle: mockBundle(), - Parameters: map[string]interface{}{ - "param_one": "oneval", - "param_two": "twoval", - "param_three": "threeval", - "param_one_million": "this is not a valid parameter", - }, + c := newClaim() + c.Parameters = map[string]interface{}{ + "param_one": "oneval", + "param_two": "twoval", + "param_three": "threeval", + "param_one_million": "this is not a valid parameter", } invocImage := c.Bundle.InvocationImages[0] @@ -199,68 +258,236 @@ func TestOpFromClaim_UndefinedParams(t *testing.T) { } func TestOpFromClaim_MissingRequiredParameter(t *testing.T) { - now := time.Now() - b := mockBundle() - b.Parameters["param_one"] = bundle.Parameter{ - Required: true, - } - - c := &claim.Claim{ - Created: now, - Modified: now, - Name: "name", - Revision: "revision", - Bundle: b, - Parameters: map[string]interface{}{ - "param_two": "twoval", - "param_three": "threeval", - }, + c := newClaim() + c.Parameters = map[string]interface{}{ + "param_two": "twoval", + "param_three": "threeval", } + c.Bundle = mockBundle() + c.Bundle.Parameters["param_one"] = bundle.Parameter{Required: true} invocImage := c.Bundle.InvocationImages[0] - // missing required parameter fails - _, err := opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) - assert.EqualError(t, err, `missing required parameter "param_one" for action "install"`) + t.Run("missing required parameter fails", func(t *testing.T) { + _, err := opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) + assert.EqualError(t, err, `missing required parameter "param_one" for action "install"`) + }) - // fill the missing parameter - c.Parameters["param_one"] = "oneval" - _, err = opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) - assert.Nil(t, err) + t.Run("fill the missing parameter", func(t *testing.T) { + c.Parameters["param_one"] = "oneval" + _, err := opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) + assert.Nil(t, err) + }) } func TestOpFromClaim_MissingRequiredParamSpecificToAction(t *testing.T) { - now := time.Now() - b := mockBundle() + c := newClaim() + c.Parameters = map[string]interface{}{ + "param_one": "oneval", + "param_two": "twoval", + "param_three": "threeval", + } + c.Bundle = mockBundle() // Add a required parameter only defined for the test action - b.Parameters["param_test"] = bundle.Parameter{ - ApplyTo: []string{"test"}, + c.Bundle.Parameters["param_test"] = bundle.Parameter{ Required: true, - } - c := &claim.Claim{ - Created: now, - Modified: now, - Name: "name", - Revision: "revision", - Bundle: b, - Parameters: map[string]interface{}{ - "param_one": "oneval", - "param_two": "twoval", - "param_three": "threeval", - }, + ApplyTo: []string{"test"}, } invocImage := c.Bundle.InvocationImages[0] - // calling install action without the test required parameter for test action is ok - _, err := opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) - assert.Nil(t, err) + t.Run("if param is not required for this action, succeed", func(t *testing.T) { + _, err := opFromClaim(claim.ActionInstall, stateful, c, invocImage, mockSet, os.Stdout) + assert.Nil(t, err) + }) + + t.Run("if param is required for this action and is missing, error", func(t *testing.T) { + _, err := opFromClaim("test", stateful, c, invocImage, mockSet, os.Stdout) + assert.EqualError(t, err, `missing required parameter "param_test" for action "test"`) + }) + + t.Run("if param is required for this action and is set, succeed", func(t *testing.T) { + c.Parameters["param_test"] = "only for test action" + _, err := opFromClaim("test", stateful, c, invocImage, mockSet, os.Stdout) + assert.Nil(t, err) + }) +} + +func TestSetOutputsOnClaim(t *testing.T) { + c := newClaim() + c.Bundle = mockBundle() + + t.Run("any text in a file is a valid string", func(t *testing.T) { + output := map[string]string{ + "/tmp/some/path": "a valid output", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("a non-string JSON value is still a string", func(t *testing.T) { + output := map[string]string{ + "/tmp/some/path": "2", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + // Types to check here: "null", "boolean", "object", "array", "number", or "integer" + + // Non strings given a good type should also work + t.Run("null succeeds", func(t *testing.T) { + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "NullParam" + c.Bundle.Outputs.Fields["some-output"] = field + output := map[string]string{ + "/tmp/some/path": "null", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("boolean succeeds", func(t *testing.T) { + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "BooleanParam" + c.Bundle.Outputs.Fields["some-output"] = field + output := map[string]string{ + "/tmp/some/path": "true", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("object succeeds", func(t *testing.T) { + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "ObjectParam" + c.Bundle.Outputs.Fields["some-output"] = field + output := map[string]string{ + "/tmp/some/path": "{}", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("array succeeds", func(t *testing.T) { + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "ArrayParam" + c.Bundle.Outputs.Fields["some-output"] = field + output := map[string]string{ + "/tmp/some/path": "[]", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("number succeeds", func(t *testing.T) { + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "NumberParam" + c.Bundle.Outputs.Fields["some-output"] = field + output := map[string]string{ + "/tmp/some/path": "3.14", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("integer as number succeeds", func(t *testing.T) { + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "NumberParam" + c.Bundle.Outputs.Fields["some-output"] = field + output := map[string]string{ + "/tmp/some/path": "372", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("integer succeeds", func(t *testing.T) { + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "IntegerParam" + c.Bundle.Outputs.Fields["some-output"] = field + output := map[string]string{ + "/tmp/some/path": "372", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) +} + +func TestSetOutputsOnClaim_MultipleTypes(t *testing.T) { + c := newClaim() + c.Bundle = mockBundle() + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "BooleanAndIntegerParam" + c.Bundle.Outputs.Fields["some-output"] = field + + t.Run("BooleanOrInteger, so boolean succeeds", func(t *testing.T) { + output := map[string]string{ + "/tmp/some/path": "false", + } + + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("BooleanOrInteger, so integer succeeds", func(t *testing.T) { + output := map[string]string{ + "/tmp/some/path": "5", + } + + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) +} + +// Tests that strings accept anything even as part of a list of types. +func TestSetOutputsOnClaim_MultipleTypesWithString(t *testing.T) { + c := newClaim() + c.Bundle = mockBundle() + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "StringAndBooleanParam" + c.Bundle.Outputs.Fields["some-output"] = field + + t.Run("null succeeds", func(t *testing.T) { + output := map[string]string{ + "/tmp/some/path": "null", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) + + t.Run("non-json string succeeds", func(t *testing.T) { + output := map[string]string{ + "/tmp/some/path": "XYZ is not a JSON value", + } + outputErrors := setOutputsOnClaim(c, output) + assert.NoError(t, outputErrors) + }) +} + +func TestSetOutputsOnClaim_MismatchType(t *testing.T) { + c := newClaim() + c.Bundle = mockBundle() + + field := c.Bundle.Outputs.Fields["some-output"] + field.Definition = "BooleanParam" + c.Bundle.Outputs.Fields["some-output"] = field + + t.Run("error case: content type does not match output definition", func(t *testing.T) { + invalidParsableOutput := map[string]string{ + "/tmp/some/path": "2", + } + + outputErrors := setOutputsOnClaim(c, invalidParsableOutput) + assert.EqualError(t, outputErrors, `error: ["some-output" is not any of the expected types (boolean) because it is "integer"]`) + }) - // test action needs the required parameter - _, err = opFromClaim("test", stateful, c, invocImage, mockSet, os.Stdout) - assert.EqualError(t, err, `missing required parameter "param_test" for action "test"`) + t.Run("error case: content is not valid JSON and definition is not string", func(t *testing.T) { + invalidNonParsableOutput := map[string]string{ + "/tmp/some/path": "Not a boolean", + } - c.Parameters["param_test"] = "only for test action" - _, err = opFromClaim("test", stateful, c, invocImage, mockSet, os.Stdout) - assert.Nil(t, err) + outputErrors := setOutputsOnClaim(c, invalidNonParsableOutput) + assert.EqualError(t, outputErrors, `error: [failed to parse "some-output": invalid character 'N' looking for beginning of value]`) + }) } func TestSelectInvocationImage_EmptyInvocationImages(t *testing.T) { @@ -282,7 +509,7 @@ func TestSelectInvocationImage_DriverIncompatible(t *testing.T) { c := &claim.Claim{ Bundle: mockBundle(), } - _, err := selectInvocationImage(&mockFailingDriver{}, c) + _, err := selectInvocationImage(&mockDriver{Error: errors.New("I always fail")}, c) if err == nil { t.Fatal("expected an error") } diff --git a/action/install.go b/action/install.go index e6c84594..ddee71fe 100644 --- a/action/install.go +++ b/action/install.go @@ -24,13 +24,18 @@ func (i *Install) Run(c *claim.Claim, creds credentials.Set, w io.Writer) error if err != nil { return err } - if err := i.Driver.Run(op); err != nil { + + opResult, err := i.Driver.Run(op) + + // update outputs in claim even if there were errors so users can see the output files. + outputErrors := setOutputsOnClaim(c, opResult.Outputs) + + if err != nil { c.Update(claim.ActionInstall, claim.StatusFailure) c.Result.Message = err.Error() return err } - - // Update claim: c.Update(claim.ActionInstall, claim.StatusSuccess) - return nil + + return outputErrors } diff --git a/action/install_test.go b/action/install_test.go index 49c9f4f3..87181cf3 100644 --- a/action/install_test.go +++ b/action/install_test.go @@ -1,9 +1,9 @@ package action import ( + "errors" "io/ioutil" "testing" - "time" "github.com/deislabs/cnab-go/claim" "github.com/deislabs/cnab-go/driver" @@ -17,21 +17,66 @@ var _ Action = &Install{} func TestInstall_Run(t *testing.T) { out := ioutil.Discard - c := &claim.Claim{ - Created: time.Time{}, - Modified: time.Time{}, - Name: "name", - Revision: "revision", - Bundle: mockBundle(), - Parameters: map[string]interface{}{}, - } + t.Run("happy-path", func(t *testing.T) { + c := newClaim() + inst := &Install{Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: nil, + }} + assert.NoError(t, inst.Run(c, mockSet, out)) + assert.Equal(t, claim.StatusSuccess, c.Result.Status) + assert.Equal(t, claim.ActionInstall, c.Result.Action) + assert.Equal(t, map[string]interface{}{"some-output": "SOME CONTENT"}, c.Outputs) + }) - inst := &Install{Driver: &driver.DebugDriver{}} - assert.NoError(t, inst.Run(c, mockSet, out)) + t.Run("when the bundle has no outputs", func(t *testing.T) { + c := newClaim() + c.Bundle.Outputs = nil + inst := &Install{ + Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{}, + Error: nil, + }, + } + assert.NoError(t, inst.Run(c, mockSet, out)) + assert.Equal(t, claim.StatusSuccess, c.Result.Status) + assert.Equal(t, claim.ActionInstall, c.Result.Action) + assert.Empty(t, c.Outputs) + }) - inst = &Install{Driver: &mockFailingDriver{}} - assert.Error(t, inst.Run(c, mockSet, out)) + t.Run("error case: driver can't handle image", func(t *testing.T) { + c := newClaim() + inst := &Install{ + Driver: &mockDriver{ + shouldHandle: false, + Error: errors.New("I always fail"), + }, + } + assert.Error(t, inst.Run(c, mockSet, out)) + }) - inst = &Install{Driver: &mockFailingDriver{shouldHandle: true}} - assert.Error(t, inst.Run(c, mockSet, out)) + t.Run("error case: driver returns error", func(t *testing.T) { + c := newClaim() + inst := &Install{ + Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: errors.New("I always fail"), + }, + } + assert.Error(t, inst.Run(c, mockSet, out)) + assert.Equal(t, claim.StatusFailure, c.Result.Status) + assert.Equal(t, claim.ActionInstall, c.Result.Action) + assert.Equal(t, map[string]interface{}{"some-output": "SOME CONTENT"}, c.Outputs) + }) } diff --git a/action/run_custom.go b/action/run_custom.go index 3a0d641a..b55f7df0 100644 --- a/action/run_custom.go +++ b/action/run_custom.go @@ -48,7 +48,7 @@ func (i *RunCustom) Run(c *claim.Claim, creds credentials.Set, w io.Writer) erro return err } - err = i.Driver.Run(op) + opResult, err := i.Driver.Run(op) // If this action says it does not modify the release, then we don't track // it in the claim. Otherwise, we do. @@ -56,12 +56,15 @@ func (i *RunCustom) Run(c *claim.Claim, creds credentials.Set, w io.Writer) erro return err } - status := claim.StatusSuccess + // update outputs in claim even if there were errors so users can see the output files. + outputErrors := setOutputsOnClaim(c, opResult.Outputs) + if err != nil { + c.Update(i.Action, claim.StatusFailure) c.Result.Message = err.Error() - status = claim.StatusFailure + return err } + c.Update(i.Action, claim.StatusSuccess) - c.Update(i.Action, status) - return err + return outputErrors } diff --git a/action/run_custom_test.go b/action/run_custom_test.go index 3c6442b3..e2236c13 100644 --- a/action/run_custom_test.go +++ b/action/run_custom_test.go @@ -1,9 +1,9 @@ package action import ( + "errors" "io/ioutil" "testing" - "time" "github.com/deislabs/cnab-go/claim" "github.com/deislabs/cnab-go/driver" @@ -17,35 +17,109 @@ var _ Action = &RunCustom{} func TestRunCustom(t *testing.T) { out := ioutil.Discard - is := assert.New(t) - rc := &RunCustom{ - Driver: &driver.DebugDriver{}, + Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: nil, + }, Action: "test", } - c := &claim.Claim{ - Created: time.Time{}, - Modified: time.Time{}, - Name: "runcustom", - Revision: "revision", - Bundle: mockBundle(), - Parameters: map[string]interface{}{}, - } - if err := rc.Run(c, mockSet, out); err != nil { - t.Fatal(err) - } - is.Equal(claim.StatusSuccess, c.Result.Status) - is.Equal("test", c.Result.Action) - - // Make sure we don't allow forbidden custom actions - rc.Action = "install" - is.Error(rc.Run(c, mockSet, out)) - - // Get rid of custom actions, and this should fail - rc.Action = "test" - c.Bundle.Actions = map[string]bundle.Action{} - if err := rc.Run(c, mockSet, out); err == nil { - t.Fatal("Unknown action should fail") - } + t.Run("happy-path", func(t *testing.T) { + c := newClaim() + err := rc.Run(c, mockSet, out) + assert.NoError(t, err) + assert.Equal(t, claim.StatusSuccess, c.Result.Status) + assert.Equal(t, "test", c.Result.Action) + assert.Equal(t, map[string]interface{}{"some-output": "SOME CONTENT"}, c.Outputs) + }) + + t.Run("when there are no outputs in the bundle", func(t *testing.T) { + c := newClaim() + c.Bundle.Outputs = nil + rc.Driver = &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{}, + Error: nil, + } + err := rc.Run(c, mockSet, out) + assert.NoError(t, err) + assert.NotEqual(t, c.Created, c.Modified, "Claim was not updated with modified timestamp after custom action") + assert.Equal(t, claim.StatusSuccess, c.Result.Status) + assert.Equal(t, "test", c.Result.Action) + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: driver doesn't handle image", func(t *testing.T) { + c := newClaim() + rc.Driver = &mockDriver{ + Error: errors.New("I always fail"), + shouldHandle: false, + } + err := rc.Run(c, mockSet, out) + assert.Error(t, err) + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: driver returns error", func(t *testing.T) { + c := newClaim() + rc.Driver = &mockDriver{ + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: errors.New("I always fail"), + shouldHandle: true, + } + err := rc.Run(c, mockSet, out) + assert.Error(t, err) + assert.NotEqual(t, "", c.Result.Message, "Expected error message in claim result message") + assert.Equal(t, "test", c.Result.Action) + assert.Equal(t, claim.StatusFailure, c.Result.Status) + assert.Equal(t, map[string]interface{}{"some-output": "SOME CONTENT"}, c.Outputs) + }) + + t.Run("error case: driver returns an error but the action does not modify", func(t *testing.T) { + c := newClaim() + action := c.Bundle.Actions["test"] + action.Modifies = false + c.Bundle.Actions["test"] = action + + rc.Driver = &mockDriver{ + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: errors.New("I always fail"), + shouldHandle: true, + } + err := rc.Run(c, mockSet, out) + assert.Error(t, err) + assert.Empty(t, c.Result, "Expected claim results not to be tracked when the action does not modify") + assert.Empty(t, c.Outputs, "Expected output results not to be tracked with the action does not modify") + }) + + t.Run("error case: forbidden custom actions should fail", func(t *testing.T) { + c := newClaim() + rc.Action = "install" + err := rc.Run(c, mockSet, out) + assert.Error(t, err) + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: unknown actions should fail", func(t *testing.T) { + c := newClaim() + rc.Action = "test" + c.Bundle.Actions = map[string]bundle.Action{} + err := rc.Run(c, mockSet, out) + assert.Error(t, err, "Unknown action should fail") + assert.Empty(t, c.Outputs) + }) } diff --git a/action/status.go b/action/status.go index 5e839476..ba420cc0 100644 --- a/action/status.go +++ b/action/status.go @@ -24,5 +24,8 @@ func (i *Status) Run(c *claim.Claim, creds credentials.Set, w io.Writer) error { if err != nil { return err } - return i.Driver.Run(op) + + // Ignore OperationResult because non-modifying actions don't have outputs to save. + _, err = i.Driver.Run(op) + return err } diff --git a/action/status_test.go b/action/status_test.go index 86165b5d..218827cb 100644 --- a/action/status_test.go +++ b/action/status_test.go @@ -1,11 +1,10 @@ package action import ( + "errors" "io/ioutil" "testing" - "time" - "github.com/deislabs/cnab-go/claim" "github.com/deislabs/cnab-go/driver" "github.com/stretchr/testify/assert" @@ -17,20 +16,29 @@ var _ Action = &Status{} func TestStatus_Run(t *testing.T) { out := ioutil.Discard - st := &Status{Driver: &driver.DebugDriver{}} - c := &claim.Claim{ - Created: time.Time{}, - Modified: time.Time{}, - Name: "name", - Revision: "revision", - Bundle: mockBundle(), - Parameters: map[string]interface{}{}, - } - - if err := st.Run(c, mockSet, out); err != nil { - t.Fatal(err) - } - - st = &Status{Driver: &mockFailingDriver{}} - assert.Error(t, st.Run(c, mockSet, out)) + t.Run("happy-path", func(t *testing.T) { + st := &Status{ + Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: nil, + }, + } + c := newClaim() + err := st.Run(c, mockSet, out) + assert.NoError(t, err) + // Status is not a modifying action + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: driver doesn't handle image", func(t *testing.T) { + c := newClaim() + st := &Status{Driver: &mockDriver{Error: errors.New("I always fail")}} + err := st.Run(c, mockSet, out) + assert.Error(t, err) + }) } diff --git a/action/uninstall.go b/action/uninstall.go index a78c59e4..db96d690 100644 --- a/action/uninstall.go +++ b/action/uninstall.go @@ -24,12 +24,16 @@ func (u *Uninstall) Run(c *claim.Claim, creds credentials.Set, w io.Writer) erro if err != nil { return err } - if err := u.Driver.Run(op); err != nil { + + opResult, err := u.Driver.Run(op) + outputErrors := setOutputsOnClaim(c, opResult.Outputs) + + if err != nil { c.Update(claim.ActionUninstall, claim.StatusFailure) c.Result.Message = err.Error() return err } - c.Update(claim.ActionUninstall, claim.StatusSuccess) - return nil + + return outputErrors } diff --git a/action/uninstall_test.go b/action/uninstall_test.go index 8efd9f0f..5dce0e54 100644 --- a/action/uninstall_test.go +++ b/action/uninstall_test.go @@ -1,9 +1,9 @@ package action import ( + "errors" "io/ioutil" "testing" - "time" "github.com/deislabs/cnab-go/claim" "github.com/deislabs/cnab-go/driver" @@ -17,42 +17,72 @@ var _ Action = &Uninstall{} func TestUninstall_Run(t *testing.T) { out := ioutil.Discard - c := &claim.Claim{ - Created: time.Time{}, - Modified: time.Time{}, - Name: "name", - Revision: "revision", - Bundle: mockBundle(), - Parameters: map[string]interface{}{}, - } - - uninst := &Uninstall{Driver: &driver.DebugDriver{}} - assert.NoError(t, uninst.Run(c, mockSet, out)) - if c.Created == c.Modified { - t.Error("Claim was not updated with modified time stamp during uninstallafter uninstall action") - } - - if c.Result.Action != claim.ActionUninstall { - t.Errorf("Claim result action not successfully updated. Expected %v, got %v", claim.ActionUninstall, c.Result.Action) - } - if c.Result.Status != claim.StatusSuccess { - t.Errorf("Claim result status not successfully updated. Expected %v, got %v", claim.StatusSuccess, c.Result.Status) - } - - uninst = &Uninstall{Driver: &mockFailingDriver{}} - assert.Error(t, uninst.Run(c, mockSet, out)) - - uninst = &Uninstall{Driver: &mockFailingDriver{shouldHandle: true}} - assert.Error(t, uninst.Run(c, mockSet, out)) - if c.Result.Message == "" { - t.Error("Expected error message in claim result message") - } - - if c.Result.Action != claim.ActionUninstall { - t.Errorf("Expected claim result action to be %v, got %v", claim.ActionUninstall, c.Result.Action) - } - - if c.Result.Status != claim.StatusFailure { - t.Errorf("Expected claim result status to be %v, got %v", claim.StatusFailure, c.Result.Status) - } + t.Run("happy-path", func(t *testing.T) { + c := newClaim() + uninst := &Uninstall{ + Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: nil, + }, + } + err := uninst.Run(c, mockSet, out) + assert.NoError(t, err) + assert.NotEqual(t, c.Created, c.Modified, "Claim was not updated with modified time stamp during uninstall after uninstall action") + assert.Equal(t, claim.ActionUninstall, c.Result.Action, "Claim result action not successfully updated.") + assert.Equal(t, claim.StatusSuccess, c.Result.Status, "Claim result status not successfully updated.") + assert.Equal(t, map[string]interface{}{"some-output": "SOME CONTENT"}, c.Outputs) + }) + + t.Run("when there are no outputs in the bundle", func(t *testing.T) { + c := newClaim() + c.Bundle.Outputs = nil + uninst := &Uninstall{ + Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{}, + Error: nil, + }, + } + err := uninst.Run(c, mockSet, out) + assert.NoError(t, err) + assert.NotEqual(t, c.Created, c.Modified, "Claim was not updated with modified time stamp during uninstall after uninstall action") + assert.Equal(t, claim.ActionUninstall, c.Result.Action, "Claim result action not successfully updated.") + assert.Equal(t, claim.StatusSuccess, c.Result.Status, "Claim result status not successfully updated.") + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: driver doesn't handle image", func(t *testing.T) { + c := newClaim() + uninst := &Uninstall{Driver: &mockDriver{ + Error: errors.New("I always fail"), + shouldHandle: false, + }} + err := uninst.Run(c, mockSet, out) + assert.Error(t, err) + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: driver does handle image", func(t *testing.T) { + c := newClaim() + uninst := &Uninstall{Driver: &mockDriver{ + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: errors.New("I always fail"), + shouldHandle: true, + }} + err := uninst.Run(c, mockSet, out) + assert.Error(t, err) + assert.NotEqual(t, "", c.Result.Message, "Expected error message in claim result message") + assert.Equal(t, claim.ActionUninstall, c.Result.Action) + assert.Equal(t, claim.StatusFailure, c.Result.Status) + assert.Equal(t, map[string]interface{}{"some-output": "SOME CONTENT"}, c.Outputs) + }) } diff --git a/action/upgrade.go b/action/upgrade.go index 50bc7568..0655d4d9 100644 --- a/action/upgrade.go +++ b/action/upgrade.go @@ -24,12 +24,16 @@ func (u *Upgrade) Run(c *claim.Claim, creds credentials.Set, w io.Writer) error if err != nil { return err } - if err := u.Driver.Run(op); err != nil { + + opResult, err := u.Driver.Run(op) + outputErrors := setOutputsOnClaim(c, opResult.Outputs) + + if err != nil { c.Update(claim.ActionUpgrade, claim.StatusFailure) c.Result.Message = err.Error() return err } - c.Update(claim.ActionUpgrade, claim.StatusSuccess) - return nil + + return outputErrors } diff --git a/action/upgrade_test.go b/action/upgrade_test.go index 3d258476..95445067 100644 --- a/action/upgrade_test.go +++ b/action/upgrade_test.go @@ -1,9 +1,9 @@ package action import ( + "errors" "io/ioutil" "testing" - "time" "github.com/deislabs/cnab-go/claim" "github.com/deislabs/cnab-go/driver" @@ -17,42 +17,63 @@ var _ Action = &Upgrade{} func TestUpgrade_Run(t *testing.T) { out := ioutil.Discard - c := &claim.Claim{ - Created: time.Time{}, - Modified: time.Time{}, - Name: "name", - Revision: "revision", - Bundle: mockBundle(), - Parameters: map[string]interface{}{}, - } - - upgr := &Upgrade{Driver: &driver.DebugDriver{}} - assert.NoError(t, upgr.Run(c, mockSet, out)) - if c.Created == c.Modified { - t.Error("Claim was not updated with modified time stamp during upgrade action") - } - - if c.Result.Action != claim.ActionUpgrade { - t.Errorf("Claim result action not successfully updated. Expected %v, got %v", claim.ActionUninstall, c.Result.Action) - } - if c.Result.Status != claim.StatusSuccess { - t.Errorf("Claim result status not successfully updated. Expected %v, got %v", claim.StatusSuccess, c.Result.Status) - } - - upgr = &Upgrade{Driver: &mockFailingDriver{}} - assert.Error(t, upgr.Run(c, mockSet, out)) - - upgr = &Upgrade{Driver: &mockFailingDriver{shouldHandle: true}} - assert.Error(t, upgr.Run(c, mockSet, out)) - if c.Result.Message == "" { - t.Error("Expected error message in claim result message") - } - - if c.Result.Action != claim.ActionUpgrade { - t.Errorf("Expected claim result action to be %v, got %v", claim.ActionUpgrade, c.Result.Action) - } - - if c.Result.Status != claim.StatusFailure { - t.Errorf("Expected claim result status to be %v, got %v", claim.StatusFailure, c.Result.Status) - } + t.Run("happy-path", func(t *testing.T) { + c := newClaim() + upgr := &Upgrade{Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{ + Outputs: map[string]string{ + "/tmp/some/path": "SOME CONTENT", + }, + }, + Error: nil, + }} + err := upgr.Run(c, mockSet, out) + assert.NoError(t, err) + assert.NotEqual(t, c.Created, c.Modified, "Claim was not updated with modified time stamp during upgrade action") + assert.Equal(t, claim.ActionUpgrade, c.Result.Action) + assert.Equal(t, claim.StatusSuccess, c.Result.Status) + assert.Equal(t, map[string]interface{}{"some-output": "SOME CONTENT"}, c.Outputs) + }) + + t.Run("when there are no outputs in the bundle", func(t *testing.T) { + c := newClaim() + c.Bundle.Outputs = nil + upgr := &Upgrade{Driver: &mockDriver{ + shouldHandle: true, + Result: driver.OperationResult{}, + Error: nil, + }} + err := upgr.Run(c, mockSet, out) + assert.NoError(t, err) + assert.NotEqual(t, c.Created, c.Modified, "Claim was not updated with modified time stamp during upgrade action") + assert.Equal(t, claim.ActionUpgrade, c.Result.Action) + assert.Equal(t, claim.StatusSuccess, c.Result.Status) + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: driver doesn't handle image", func(t *testing.T) { + c := newClaim() + upgr := &Upgrade{Driver: &mockDriver{ + Error: errors.New("I always fail"), + shouldHandle: false, + }} + err := upgr.Run(c, mockSet, out) + assert.Error(t, err) + assert.Empty(t, c.Outputs) + }) + + t.Run("error case: driver does handle image", func(t *testing.T) { + c := newClaim() + upgr := &Upgrade{Driver: &mockDriver{ + Error: errors.New("I always fail"), + shouldHandle: true, + }} + err := upgr.Run(c, mockSet, out) + assert.Error(t, err) + assert.NotEmpty(t, c.Result.Message, "Expected error message in claim result message") + assert.Equal(t, claim.ActionUpgrade, c.Result.Action) + assert.Equal(t, claim.StatusFailure, c.Result.Status) + assert.Empty(t, c.Outputs) + }) } diff --git a/bundle/definition/schema.go b/bundle/definition/schema.go index 19abbb06..49182e80 100644 --- a/bundle/definition/schema.go +++ b/bundle/definition/schema.go @@ -13,50 +13,51 @@ type Definitions map[string]*Schema // Schema represents a JSON Schema compatible CNAB Definition type Schema struct { - Comment string `json:"$comment,omitempty" mapstructure:"$comment,omitempty"` - ID string `json:"$id,omitempty" mapstructure:"$ref,omitempty"` - Ref string `json:"$ref,omitempty" mapstructure:"$ref,omitempty"` - AdditionalItems interface{} `json:"additionalItems,omitempty" mapstructure:"additionalProperties,omitempty"` - AdditionalProperties interface{} `json:"additionalProperties,omitempty" mapstructure:"additionalProperties,omitempty"` - AllOf []*Schema `json:"allOf,omitempty" mapstructure:"allOf,omitempty"` - Const interface{} `json:"const,omitempty" mapstructure:"const,omitempty"` - Contains *Schema `json:"contains,omitempty" mapstructure:"contains,omitempty"` - ContentEncoding string `json:"contentEncoding,omitempty" mapstructure:"contentEncoding,omitempty"` - ContentMediaType string `json:"contentMediaType,omitempty" mapstructure:"contentMediaType,omitempty"` - Default interface{} `json:"default,omitempty" mapstructure:"default,omitempty"` - Definitions Definitions `json:"definitions,omitempty" mapstructure:"definitions,omitempty"` - Dependencies map[string]interface{} `json:"dependencies,omitempty" mapstructure:"dependencies,omitempty"` - Description string `json:"description,omitempty" mapstructure:"description,omitempty"` - Else *Schema `json:"else,omitempty" mapstructure:"else,omitempty"` - Enum []interface{} `json:"enum,omitempty" mapstructure:"enum,omitempty"` - Examples []interface{} `json:"examples,omitempty" mapstructure:"examples,omitempty"` - ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty" mapstructure:"exclusiveMaximum,omitempty"` - ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty" mapstructure:"exclusiveMinimum,omitempty"` - Format string `json:"format,omitempty" mapstructure:"format,omitempty"` - If *Schema `json:"if,omitempty" mapstructure:"if,omitempty"` + Schema string `json:"$schema,omitempty" yaml:"$schema,omitempty" mapstructure:"$schema,omitempty"` + Comment string `json:"$comment,omitempty" yaml:"$comment,omitempty" mapstructure:"$comment,omitempty"` + ID string `json:"$id,omitempty" yaml:"$id,omitempty" mapstructure:"$id,omitempty"` + Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty" mapstructure:"$ref,omitempty"` + AdditionalItems interface{} `json:"additionalItems,omitempty" yaml:"additionalItems,omitempty" mapstructure:"additionalItems,omitempty"` + AdditionalProperties interface{} `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty" mapstructure:"additionalProperties,omitempty"` + AllOf []*Schema `json:"allOf,omitempty" yaml:"allOf,omitempty" mapstructure:"allOf,omitempty"` + Const interface{} `json:"const,omitempty" yaml:"const,omitempty" mapstructure:"const,omitempty"` + Contains *Schema `json:"contains,omitempty" yaml:"contains,omitempty" mapstructure:"contains,omitempty"` + ContentEncoding string `json:"contentEncoding,omitempty" yaml:"contentEncoding,omitempty" mapstructure:"contentEncoding,omitempty"` + ContentMediaType string `json:"contentMediaType,omitempty" yaml:"contentMediaType,omitempty" mapstructure:"contentMediaType,omitempty"` + Default interface{} `json:"default,omitempty" yaml:"default,omitempty" mapstructure:"default,omitempty"` + Definitions Definitions `json:"definitions,omitempty" yaml:"definitions,omitempty" mapstructure:"definitions,omitempty"` + Dependencies map[string]interface{} `json:"dependencies,omitempty" yaml:"dependencies,omitempty" mapstructure:"dependencies,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` + Else *Schema `json:"else,omitempty" yaml:"else,omitempty" mapstructure:"else,omitempty"` + Enum []interface{} `json:"enum,omitempty" yaml:"enum,omitempty" mapstructure:"enum,omitempty"` + Examples []interface{} `json:"examples,omitempty" yaml:"examples,omitempty" mapstructure:"examples,omitempty"` + ExclusiveMaximum *float64 `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty" mapstructure:"exclusiveMaximum,omitempty"` + ExclusiveMinimum *float64 `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty" mapstructure:"exclusiveMinimum,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty" mapstructure:"format,omitempty"` + If *Schema `json:"if,omitempty" yaml:"if,omitempty" mapstructure:"if,omitempty"` //Items can be a Schema or an Array of Schema :( - Items interface{} `json:"items,omitempty" mapstructure:"items,omitempty"` - Maximum *float64 `json:"maximum,omitempty" mapstructure:"maximum,omitempty"` - MaxLength *float64 `json:"maxLength,omitempty" mapstructure:"maxLength,omitempty"` - MinItems *float64 `json:"minItems,omitempty" mapstructure:"minItems,omitempty"` - MinLength *float64 `json:"minLength,omitempty" mapstructure:"minLength,omitempty"` - MinProperties *float64 `json:"minProperties,omitempty" mapstructure:"minProperties,omitempty"` - Minimum *float64 `json:"minimum,omitempty" mapstructure:"minimum,omitempty"` - MultipleOf *float64 `json:"multipleOf,omitempty" mapstructure:"multipleOf,omitempty"` - Not *Schema `json:"not,omitempty" mapstructure:"not,omitempty"` - OneOf *Schema `json:"oneOf,omitempty" mapstructure:"oneOf,omitempty"` + Items interface{} `json:"items,omitempty" yaml:"items,omitempty" mapstructure:"items,omitempty"` + Maximum *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty" mapstructure:"maximum,omitempty"` + MaxLength *float64 `json:"maxLength,omitempty" yaml:"maxLength,omitempty" mapstructure:"maxLength,omitempty"` + MinItems *float64 `json:"minItems,omitempty" yaml:"minItems,omitempty" mapstructure:"minItems,omitempty"` + MinLength *float64 `json:"minLength,omitempty" yaml:"minLength,omitempty" mapstructure:"minLength,omitempty"` + MinProperties *float64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty" mapstructure:"minProperties,omitempty"` + Minimum *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty" mapstructure:"minimum,omitempty"` + MultipleOf *float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty" mapstructure:"multipleOf,omitempty"` + Not *Schema `json:"not,omitempty" yaml:"not,omitempty" mapstructure:"not,omitempty"` + OneOf *Schema `json:"oneOf,omitempty" yaml:"oneOf,omitempty" mapstructure:"oneOf,omitempty"` - PatternProperties map[string]*Schema `json:"patternProperties,omitempty" mapstructure:"patternProperties,omitempty"` + PatternProperties map[string]*Schema `json:"patternProperties,omitempty" yaml:"patternProperties,omitempty" mapstructure:"patternProperties,omitempty"` - Properties map[string]*Schema `json:"properties,omitempty" mapstructure:"properties,omitempty"` - PropertyNames *Schema `json:"propertyNames,omitempty" mapstructure:"propertyNames,omitempty"` - ReadOnly *bool `json:"readOnly,omitempty" mapstructure:"readOnly,omitempty"` - Required []string `json:"required,omitempty" mapstructure:"required,omitempty"` - Then *Schema `json:"then,omitempty" mapstructure:"then,omitempty"` - Title string `json:"title,omitempty" mapstructure:"title,omitempty"` - Type interface{} `json:"type,omitempty" mapstructure:"type,omitempty"` - UniqueItems *bool `json:"uniqueItems,omitempty" mapstructure:"uniqueItems,omitempty"` - WriteOnly *bool `json:"writeOnly,omitempty" mapstructure:"writeOnly,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty" yaml:"properties,omitempty" mapstructure:"properties,omitempty"` + PropertyNames *Schema `json:"propertyNames,omitempty" yaml:"propertyNames,omitempty" mapstructure:"propertyNames,omitempty"` + ReadOnly *bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty" mapstructure:"readOnly,omitempty"` + Required []string `json:"required,omitempty" yaml:"required,omitempty" mapstructure:"required,omitempty"` + Then *Schema `json:"then,omitempty" yaml:"then,omitempty" mapstructure:"then,omitempty"` + Title string `json:"title,omitempty" yaml:"title,omitempty" mapstructure:"title,omitempty"` + Type interface{} `json:"type,omitempty" yaml:"type,omitempty" mapstructure:"type,omitempty"` + UniqueItems *bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty" mapstructure:"uniqueItems,omitempty"` + WriteOnly *bool `json:"writeOnly,omitempty" yaml:"writeOnly,omitempty" mapstructure:"writeOnly,omitempty"` } // GetType will return the singular type for a given schema and a success boolean. If the diff --git a/bundle/definition/schema_test.go b/bundle/definition/schema_test.go index 05a15a8d..3d665dc6 100644 --- a/bundle/definition/schema_test.go +++ b/bundle/definition/schema_test.go @@ -7,42 +7,55 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + yaml "gopkg.in/yaml.v2" ) -func TestSimpleUnMarshallDefinition(t *testing.T) { +func TestSimpleUnMarshalDefinition(t *testing.T) { def := `{ + "$comment": "schema comment", + "$id": "schema id", + "$ref": "schema ref", "$schema": "http://json-schema.org/draft-07/schema#", "type": "array", - "items": { - "type": "object", - "required": ["description", "schema", "tests"], - "properties": { - "description": {"type": "string"}, - "schema": {}, - "tests": { - "type": "array", - "items": { - "type": "object", - "required": ["description", "data", "valid"], - "properties": { - "description": {"type": "string"}, - "data": {}, - "valid": {"type": "boolean"} + "items": [ + { + "type": "object", + "required": ["description", "schema", "tests"], + "properties": { + "description": {"type": "string"}, + "schema": {}, + "tests": { + "type": "array", + "items": { + "type": "object", + "required": ["description", "data", "valid"], + "properties": { + "description": {"type": "string"}, + "data": {}, + "valid": {"type": "boolean"} + }, + "additionalProperties": false }, - "additionalProperties": false - }, - "minItems": 1 - } - }, - "additionalProperties": false, - "minItems": 1 - } + "minItems": 1 + } + }, + "additionalProperties": false, + "minItems": 1 + } + ], + "additionalItems": { + "type": "string" + } }` definition := new(Schema) err := json.Unmarshal([]byte(def), definition) - require.NoError(t, err, "should have been able to marshall definition") + require.NoError(t, err, "should have been able to json.Marshal definition") assert.Equal(t, "array", definition.Type, "type should have been an array") + + err = yaml.UnmarshalStrict([]byte(def), definition) + require.NoError(t, err, "should have been able to yaml.Marshal definition") } func TestSimpleSchema(t *testing.T) { diff --git a/claim/claim.go b/claim/claim.go index 989803b4..57fc709d 100644 --- a/claim/claim.go +++ b/claim/claim.go @@ -35,13 +35,14 @@ const ( // provide the necessary data to upgrade, uninstall, and downgrade // a CNAB package. type Claim struct { - Name string `json:"name"` - Revision string `json:"revision"` - Created time.Time `json:"created"` - Modified time.Time `json:"modified"` - Bundle *bundle.Bundle `json:"bundle"` - Result Result `json:"result"` - Parameters map[string]interface{} `json:"parameters"` + Name string `json:"name"` + Revision string `json:"revision"` + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` + Bundle *bundle.Bundle `json:"bundle"` + Result Result `json:"result"` + Parameters map[string]interface{} `json:"parameters"` + // Outputs is a map from the names of outputs (defined in the bundle) to the contents of the files. Outputs map[string]interface{} `json:"outputs"` RelocationMap bundle.ImageRelocationMap `json:"relocationMap"` } diff --git a/driver/command/command.go b/driver/command/command.go index 59ef22ba..d2689004 100644 --- a/driver/command/command.go +++ b/driver/command/command.go @@ -18,7 +18,7 @@ type Driver struct { } // Run executes the command -func (d *Driver) Run(op *driver.Operation) error { +func (d *Driver) Run(op *driver.Operation) (driver.OperationResult, error) { return d.exec(op) } @@ -42,7 +42,7 @@ func (d *Driver) cliName() string { return "cnab-" + strings.ToLower(d.Name) } -func (d *Driver) exec(op *driver.Operation) error { +func (d *Driver) exec(op *driver.Operation) (driver.OperationResult, error) { // We need to do two things here: We need to make it easier for the // command to access data, and we need to make it easy for the command // to pass that data on to the image it invokes. So we do some data @@ -62,13 +62,13 @@ func (d *Driver) exec(op *driver.Operation) error { data, err := json.Marshal(op) if err != nil { - return err + return driver.OperationResult{}, err } args := []string{} cmd := exec.Command(d.cliName(), args...) cmd.Dir, err = os.Getwd() if err != nil { - return err + return driver.OperationResult{}, err } cmd.Env = pairs cmd.Stdin = bytes.NewBuffer(data) @@ -77,7 +77,7 @@ func (d *Driver) exec(op *driver.Operation) error { stdout, err := cmd.StdoutPipe() if err != nil { - return fmt.Errorf("Setting up output handling for driver (%s) failed: %v", d.Name, err) + return driver.OperationResult{}, fmt.Errorf("Setting up output handling for driver (%s) failed: %v", d.Name, err) } go func() { @@ -88,7 +88,7 @@ func (d *Driver) exec(op *driver.Operation) error { }() stderr, err := cmd.StderrPipe() if err != nil { - return fmt.Errorf("Setting up error output handling for driver (%s) failed: %v", d.Name, err) + return driver.OperationResult{}, fmt.Errorf("Setting up error output handling for driver (%s) failed: %v", d.Name, err) } go func() { @@ -98,8 +98,8 @@ func (d *Driver) exec(op *driver.Operation) error { }() if err = cmd.Start(); err != nil { - return fmt.Errorf("Start of driver (%s) failed: %v", d.Name, err) + return driver.OperationResult{}, fmt.Errorf("Start of driver (%s) failed: %v", d.Name, err) } - return cmd.Wait() + return driver.OperationResult{}, cmd.Wait() } diff --git a/driver/docker/docker.go b/driver/docker/docker.go index 83fc3d8b..134d75ea 100644 --- a/driver/docker/docker.go +++ b/driver/docker/docker.go @@ -34,7 +34,7 @@ type Driver struct { } // Run executes the Docker driver -func (d *Driver) Run(op *driver.Operation) error { +func (d *Driver) Run(op *driver.Operation) (driver.OperationResult, error) { return d.exec(op) } @@ -54,6 +54,7 @@ func (d *Driver) Config() map[string]string { "VERBOSE": "Increase verbosity. true, false are supported values", "PULL_ALWAYS": "Always pull image, even if locally available (0|1)", "DOCKER_DRIVER_QUIET": "Make the Docker driver quiet (only print container stdout/stderr)", + "OUTPUTS_MOUNT_PATH": "Absolute path to where Docker driver can create temporary directories to bundle outputs. Defaults to temp dir.", } } @@ -124,20 +125,20 @@ func (d *Driver) initializeDockerCli() (command.Cli, error) { return cli, nil } -func (d *Driver) exec(op *driver.Operation) error { +func (d *Driver) exec(op *driver.Operation) (driver.OperationResult, error) { ctx := context.Background() cli, err := d.initializeDockerCli() if err != nil { - return err + return driver.OperationResult{}, err } if d.Simulate { - return nil + return driver.OperationResult{}, nil } if d.config["PULL_ALWAYS"] == "1" { if err := pullImage(ctx, cli, op.Image); err != nil { - return err + return driver.OperationResult{}, err } } var env []string @@ -153,11 +154,10 @@ func (d *Driver) exec(op *driver.Operation) error { AttachStdout: true, } - hostCfg := &container.HostConfig{AutoRemove: true} - + hostCfg := &container.HostConfig{} for _, opt := range d.dockerConfigurationOptions { if err := opt(cfg, hostCfg); err != nil { - return err + return driver.OperationResult{}, err } } @@ -166,18 +166,20 @@ func (d *Driver) exec(op *driver.Operation) error { case client.IsErrNotFound(err): fmt.Fprintf(cli.Err(), "Unable to find image '%s' locally\n", op.Image) if err := pullImage(ctx, cli, op.Image); err != nil { - return err + return driver.OperationResult{}, err } if resp, err = cli.Client().ContainerCreate(ctx, cfg, hostCfg, nil, ""); err != nil { - return fmt.Errorf("cannot create container: %v", err) + return driver.OperationResult{}, fmt.Errorf("cannot create container: %v", err) } case err != nil: - return fmt.Errorf("cannot create container: %v", err) + return driver.OperationResult{}, fmt.Errorf("cannot create container: %v", err) } + defer cli.Client().ContainerRemove(ctx, resp.ID, types.ContainerRemoveOptions{}) + tarContent, err := generateTar(op.Files) if err != nil { - return fmt.Errorf("error staging files: %s", err) + return driver.OperationResult{}, fmt.Errorf("error staging files: %s", err) } options := types.CopyToContainerOptions{ AllowOverwriteDirWithFile: false, @@ -186,7 +188,7 @@ func (d *Driver) exec(op *driver.Operation) error { // path from the given file, starting at the /. err = cli.Client().CopyToContainer(ctx, resp.ID, "/", tarContent, options) if err != nil { - return fmt.Errorf("error copying to / in container: %s", err) + return driver.OperationResult{}, fmt.Errorf("error copying to / in container: %s", err) } attach, err := cli.Client().ContainerAttach(ctx, resp.ID, types.ContainerAttachOptions{ @@ -196,7 +198,7 @@ func (d *Driver) exec(op *driver.Operation) error { Logs: true, }) if err != nil { - return fmt.Errorf("unable to retrieve logs: %v", err) + return driver.OperationResult{}, fmt.Errorf("unable to retrieve logs: %v", err) } var ( stdout io.Writer = os.Stdout @@ -218,25 +220,82 @@ func (d *Driver) exec(op *driver.Operation) error { } }() - statusc, errc := cli.Client().ContainerWait(ctx, resp.ID, container.WaitConditionRemoved) + statusc, errc := cli.Client().ContainerWait(ctx, resp.ID, container.WaitConditionNextExit) if err = cli.Client().ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { - return fmt.Errorf("cannot start container: %v", err) + return driver.OperationResult{}, fmt.Errorf("cannot start container: %v", err) } select { case err := <-errc: if err != nil { - return fmt.Errorf("error in container: %v", err) + opResult, fetchErr := d.fetchOutputs(ctx, resp.ID) + return opResult, containerError("error in container", err, fetchErr) } case s := <-statusc: if s.StatusCode == 0 { - return nil + return d.fetchOutputs(ctx, resp.ID) } if s.Error != nil { - return fmt.Errorf("container exit code: %d, message: %v", s.StatusCode, s.Error.Message) + opResult, fetchErr := d.fetchOutputs(ctx, resp.ID) + return opResult, containerError(fmt.Sprintf("container exit code: %d, message", s.StatusCode), err, fetchErr) + } + opResult, fetchErr := d.fetchOutputs(ctx, resp.ID) + return opResult, containerError(fmt.Sprintf("container exit code: %d, message", s.StatusCode), err, fetchErr) + } + opResult, fetchErr := d.fetchOutputs(ctx, resp.ID) + if fetchErr != nil { + return opResult, fmt.Errorf("fetching outputs failed: %s", fetchErr) + } + return opResult, err +} + +func containerError(containerMessage string, containerErr, fetchErr error) error { + if fetchErr != nil { + return fmt.Errorf("%s: %v. fetching outputs failed: %s", containerMessage, containerErr, fetchErr) + } + + return fmt.Errorf("%s: %v", containerMessage, containerErr) +} + +// fetchOutputs takes a context and a container ID; it copies the /cnab/app/outputs directory from that container. +// The goal is to collect all the files in the directory (recursively) and put them in a flat map of path to contents. +// This map will be inside the OperationResult. When fetchOutputs returns an error, it may also return partial results. +func (d *Driver) fetchOutputs(ctx context.Context, container string) (driver.OperationResult, error) { + opResult := driver.OperationResult{ + Outputs: map[string]string{}, + } + ioReader, _, err := d.dockerCli.Client().CopyFromContainer(ctx, container, "/cnab/app/outputs") + if err != nil { + return opResult, fmt.Errorf("error copying outputs from container: %s", err) + } + + tarReader := tar.NewReader(ioReader) + header, err := tarReader.Next() + + // io.EOF pops us out of loop on successful run. + for err == nil { + // skip directories because we're gathering file contents + if header.FileInfo().IsDir() { + header, err = tarReader.Next() + continue + } + + var contents []byte + // CopyFromContainer strips prefix above outputs directory. + pathInContainer := unix_path.Join("/cnab", "app", header.Name) + + contents, err = ioutil.ReadAll(tarReader) + if err != nil { + return opResult, fmt.Errorf("error while reading %q from outputs tar: %s", pathInContainer, err) } - return fmt.Errorf("container exit code: %d", s.StatusCode) + opResult.Outputs[pathInContainer] = string(contents) + header, err = tarReader.Next() } - return err + + if err != io.EOF { + return opResult, err + } + + return opResult, nil } func generateTar(files map[string]string) (io.Reader, error) { diff --git a/driver/docker/docker_integration_test.go b/driver/docker/docker_integration_test.go new file mode 100644 index 00000000..0150d02c --- /dev/null +++ b/driver/docker/docker_integration_test.go @@ -0,0 +1,45 @@ +// +build integration + +package docker + +import ( + "bytes" + "os" + "testing" + + "github.com/deislabs/cnab-go/driver" + "github.com/stretchr/testify/assert" +) + +func TestDriver_Run(t *testing.T) { + image := os.Getenv("DOCKER_INTEGRATION_TEST_IMAGE") + if image == "" { + image = "pvtlmc/example-outputs@sha256:568461508c8d220742add8abd226b33534d4269868df4b3178fae1cba3818a6e" + } + + op := &driver.Operation{ + Installation: "example", + Action: "install", + Image: image, + Outputs: []string{"/cnab/app/outputs/output1", "/cnab/app/outputs/output2"}, + } + + var output bytes.Buffer + op.Out = &output + op.Environment = map[string]string{ + "CNAB_ACTION": op.Action, + "CNAB_INSTALLATION_NAME": op.Installation, + } + + docker := &Driver{} + docker.SetContainerOut(op.Out) // Docker driver writes container stdout to driver.containerOut. + opResult, err := docker.Run(op) + + assert.NoError(t, err) + assert.Equal(t, "Install action\nAction install complete for example\n", output.String()) + assert.Equal(t, 2, len(opResult.Outputs), "Expecting two output files") + assert.Equal(t, map[string]string{ + "/cnab/app/outputs/output1": "SOME INSTALL CONTENT 1\n", + "/cnab/app/outputs/output2": "SOME INSTALL CONTENT 2\n", + }, opResult.Outputs) +} diff --git a/driver/driver.go b/driver/driver.go index 3aef2e84..3039b416 100644 --- a/driver/driver.go +++ b/driver/driver.go @@ -33,6 +33,8 @@ type Operation struct { Environment map[string]string `json:"environment"` // Files contains files that should be injected into the invocation image. Files map[string]string `json:"files"` + // Outputs is a list of paths starting with `/cnab/app/outputs` that the driver should return the contents of in the OperationResult. + Outputs []string `json:"outputs"` // Output stream for log messages from the driver Out io.Writer `json:"-"` } @@ -44,10 +46,16 @@ type ResolvedCred struct { Value string `json:"value"` } +// OperationResult is the output of the Driver running an Operation. +type OperationResult struct { + // Outputs is a map from the container path of an output file to its contents (i.e. /cnab/app/outputs/...). + Outputs map[string]string +} + // Driver is capable of running a invocation image type Driver interface { // Run executes the operation inside of the invocation image - Run(*Operation) error + Run(*Operation) (OperationResult, error) // Handles receives an ImageType* and answers whether this driver supports that type Handles(string) bool } @@ -69,13 +77,13 @@ type DebugDriver struct { } // Run executes the operation on the Debug driver -func (d *DebugDriver) Run(op *Operation) error { +func (d *DebugDriver) Run(op *Operation) (OperationResult, error) { data, err := json.MarshalIndent(op, "", " ") if err != nil { - return err + return OperationResult{}, err } fmt.Fprintln(op.Out, string(data)) - return nil + return OperationResult{}, nil } // Handles always returns true, effectively claiming to work for any image type diff --git a/driver/driver_test.go b/driver/driver_test.go index 4e32f3d1..c6de56bc 100644 --- a/driver/driver_test.go +++ b/driver/driver_test.go @@ -31,7 +31,9 @@ func TestDebugDriver_Run(t *testing.T) { ImageType: "oci", Out: ioutil.Discard, } - is.NoError(d.Run(op)) + + _, err := d.Run(op) + is.NoError(err) } func TestOperation_Unmarshall(t *testing.T) { diff --git a/driver/kubernetes/kubernetes.go b/driver/kubernetes/kubernetes.go index 66a2e5ff..ef18cd6c 100644 --- a/driver/kubernetes/kubernetes.go +++ b/driver/kubernetes/kubernetes.go @@ -120,9 +120,9 @@ func (k *Driver) setClient(conf *rest.Config) error { } // Run executes the operation inside of the invocation image. -func (k *Driver) Run(op *driver.Operation) error { +func (k *Driver) Run(op *driver.Operation) (driver.OperationResult, error) { if k.Namespace == "" { - return fmt.Errorf("KUBE_NAMESPACE is required") + return driver.OperationResult{}, fmt.Errorf("KUBE_NAMESPACE is required") } labelMap := generateLabels(op) meta := metav1.ObjectMeta{ @@ -171,7 +171,7 @@ func (k *Driver) Run(op *driver.Operation) error { secret.ObjectMeta.GenerateName += "env-" envsecret, err := k.secrets.Create(secret) if err != nil { - return err + return driver.OperationResult{}, err } if !k.SkipCleanup { defer k.deleteSecret(envsecret.ObjectMeta.Name) @@ -197,7 +197,7 @@ func (k *Driver) Run(op *driver.Operation) error { } secret, err := k.secrets.Create(secret) if err != nil { - return err + return driver.OperationResult{}, err } if !k.SkipCleanup { defer k.deleteSecret(secret.ObjectMeta.Name) @@ -217,7 +217,7 @@ func (k *Driver) Run(op *driver.Operation) error { job.Spec.Template.Spec.Containers = []v1.Container{container} job, err := k.jobs.Create(job) if err != nil { - return err + return driver.OperationResult{}, err } if !k.SkipCleanup { defer k.deleteJob(job.ObjectMeta.Name) @@ -226,14 +226,14 @@ func (k *Driver) Run(op *driver.Operation) error { // Return early for unit testing purposes (the fake k8s client implementation just // hangs during watch because no events are ever created on the Job) if k.skipJobStatusCheck { - return nil + return driver.OperationResult{}, nil } selector := metav1.ListOptions{ LabelSelector: labels.Set(job.ObjectMeta.Labels).String(), } - return k.watchJobStatusAndLogs(selector, op.Out) + return driver.OperationResult{}, k.watchJobStatusAndLogs(selector, op.Out) } func (k *Driver) watchJobStatusAndLogs(selector metav1.ListOptions, out io.Writer) error { diff --git a/driver/kubernetes/kubernetes_integration_test.go b/driver/kubernetes/kubernetes_integration_test.go index 79859a24..820a81f8 100644 --- a/driver/kubernetes/kubernetes_integration_test.go +++ b/driver/kubernetes/kubernetes_integration_test.go @@ -49,7 +49,7 @@ func TestDriver_Run_Integration(t *testing.T) { tc.op.Environment["CNAB_ACTION"] = tc.op.Action tc.op.Environment["CNAB_INSTALLATION_NAME"] = tc.op.Installation - err := k.Run(tc.op) + _, err := k.Run(tc.op) if tc.err != nil { assert.EqualError(t, err, tc.err.Error()) diff --git a/driver/kubernetes/kubernetes_test.go b/driver/kubernetes/kubernetes_test.go index 51613882..a388faa6 100644 --- a/driver/kubernetes/kubernetes_test.go +++ b/driver/kubernetes/kubernetes_test.go @@ -29,7 +29,7 @@ func TestDriver_Run(t *testing.T) { }, } - err := k.Run(&op) + _, err := k.Run(&op) assert.NoError(t, err) jobList, _ := k.jobs.List(metav1.ListOptions{}) diff --git a/testdata/bundles/example-outputs/build.sh b/testdata/bundles/example-outputs/build.sh new file mode 100755 index 00000000..7ff116f3 --- /dev/null +++ b/testdata/bundles/example-outputs/build.sh @@ -0,0 +1,15 @@ +#!/bin/bash -eu + +readonly PROGDIR="$(cd "$(dirname "${0}")" && pwd)" +readonly IMAGE="pvtlmc/example-outputs" + +function main() { + docker build -f "${PROGDIR}/cnab/Dockerfile" -t "${IMAGE}:latest" "${PROGDIR}/cnab" + + local image + image=$(docker inspect --format='{{index .RepoDigests 0}}' ${IMAGE}) + + printf "\nSet DOCKER_INTEGRATION_TEST_IMAGE=%s\nto override the image used in the docker integration tests\n" "${image}" +} + +main diff --git a/testdata/bundles/example-outputs/bundle.json b/testdata/bundles/example-outputs/bundle.json new file mode 100644 index 00000000..04b92554 --- /dev/null +++ b/testdata/bundles/example-outputs/bundle.json @@ -0,0 +1,45 @@ +{ + "name": "outputs", + "version": "0.1.1", + "schemaVersion": "1.0.0-WD", + "description": "An example bundle for showing outputs", + "invocationImages": [ + { + "image": "pvtlmc/example-outputs:latest", + "imageType":"docker" + } + ], + "keywords": [ + "outputs", + "cnab", + "example", + "test" + ], + "maintainers": [ + { + "email": "john.doe@example.com", + "name": "John Doe", + "url": "https://example.com" + } + ], + "images": null, + "definitions": { + "StringParam" : { + "type": "string" + } + }, + "parameters": null, + "credentials": null, + "outputs": { + "fields": { + "output1": { + "path": "/cnab/app/outputs/output1", + "definition": "StringParam" + }, + "output2": { + "path": "/cnab/app/outputs/output2", + "definition": "StringParam" + } + } + } +} diff --git a/testdata/bundles/example-outputs/cnab/Dockerfile b/testdata/bundles/example-outputs/cnab/Dockerfile new file mode 100644 index 00000000..b1ad79bc --- /dev/null +++ b/testdata/bundles/example-outputs/cnab/Dockerfile @@ -0,0 +1,11 @@ +FROM alpine:latest + +RUN apk update +RUN apk add -u bash + + +COPY Dockerfile /cnab/Dockerfile +COPY app /cnab/app +RUN mkdir /cnab/app/outputs + +CMD ["/cnab/app/run"] diff --git a/testdata/bundles/example-outputs/cnab/app/run b/testdata/bundles/example-outputs/cnab/app/run new file mode 100755 index 00000000..650d474a --- /dev/null +++ b/testdata/bundles/example-outputs/cnab/app/run @@ -0,0 +1,33 @@ +#!/bin/sh + +#set -eo pipefail + +action=$CNAB_ACTION +name=$CNAB_INSTALLATION_NAME + +case $action in + install) + echo "Install action" + echo >/cnab/app/outputs/output1 "SOME INSTALL CONTENT 1" + echo >/cnab/app/outputs/output2 "SOME INSTALL CONTENT 2" + ;; + uninstall) + echo "uninstall action" + echo >/cnab/app/outputs/output1 "SOME UNINSTALL CONTENT 1" + ;; + upgrade) + echo "Upgrade action" + echo >/cnab/app/outputs/output2 "SOME UPGRADE CONTENT 2" + ;; + status) + echo "Status action" + echo >/cnab/app/outputs/output1 "SOME STATUS CONTENT 1" + echo >/cnab/app/outputs/output2 "SOME STATUS CONTENT 2" + ;; + *) + echo "No action for $action" + echo >/cnab/app/outputs/output1 "SOME NOPE CONTENT 1" + echo >/cnab/app/outputs/output2 "SOME NOPE CONTENT 2" + ;; +esac +echo "Action $action complete for $name"