From 200a46122c46ba8e958b9447f9f640676b3dca3a Mon Sep 17 00:00:00 2001 From: Jeremy Rickard Date: Wed, 13 Mar 2019 09:23:56 -0600 Subject: [PATCH] Adding THE KUBERNETES MIXIN This PR adds a Kubernetes mixin. It updates the design doc a little to reflect a couple of param changes across the three verbs and harmonizes things using camelCase naming per the [style guide](https://google.github.io/styleguide/jsoncstyleguide.xml?showone=Property_Name_Format#Property_Name_Format) from the GOOG. Closes #82 --- Makefile | 6 + cmd/kubernetes/build.go | 17 ++ cmd/kubernetes/install.go | 16 ++ cmd/kubernetes/main.go | 40 ++++ cmd/kubernetes/schema.go | 17 ++ cmd/kubernetes/uninstall.go | 16 ++ cmd/kubernetes/upgrade.go | 16 ++ cmd/kubernetes/version.go | 16 ++ docs/content/design/kubernetes-mixin.md | 30 ++- pkg/kubernetes/build.go | 19 ++ pkg/kubernetes/helpers.go | 22 ++ pkg/kubernetes/install.go | 123 ++++++++++ pkg/kubernetes/install_test.go | 116 ++++++++++ pkg/kubernetes/kubernetes.go | 131 +++++++++++ pkg/kubernetes/schema.go | 20 ++ pkg/kubernetes/schema/kubernetes.json | 215 ++++++++++++++++++ pkg/kubernetes/schema_test.go | 54 +++++ pkg/kubernetes/step.go | 14 ++ .../testdata/install-input-bad-outputs.yaml | 8 + .../testdata/install-input-bad-wait-flag.yaml | 6 + .../testdata/install-input-no-manifests.yaml | 6 + pkg/kubernetes/testdata/install-input.yaml | 6 + pkg/kubernetes/testdata/schema.json | 215 ++++++++++++++++++ pkg/kubernetes/testdata/uninstall-input.yaml | 6 + pkg/kubernetes/testdata/upgrade-input.yaml | 6 + pkg/kubernetes/uninstall.go | 129 +++++++++++ pkg/kubernetes/uninstall_test.go | 118 ++++++++++ pkg/kubernetes/upgrade.go | 137 +++++++++++ pkg/kubernetes/upgrade_test.go | 184 +++++++++++++++ pkg/kubernetes/version.go | 11 + 30 files changed, 1703 insertions(+), 17 deletions(-) create mode 100644 cmd/kubernetes/build.go create mode 100644 cmd/kubernetes/install.go create mode 100644 cmd/kubernetes/main.go create mode 100644 cmd/kubernetes/schema.go create mode 100644 cmd/kubernetes/uninstall.go create mode 100644 cmd/kubernetes/upgrade.go create mode 100644 cmd/kubernetes/version.go create mode 100644 pkg/kubernetes/build.go create mode 100644 pkg/kubernetes/helpers.go create mode 100644 pkg/kubernetes/install.go create mode 100644 pkg/kubernetes/install_test.go create mode 100644 pkg/kubernetes/kubernetes.go create mode 100644 pkg/kubernetes/schema.go create mode 100644 pkg/kubernetes/schema/kubernetes.json create mode 100644 pkg/kubernetes/schema_test.go create mode 100644 pkg/kubernetes/step.go create mode 100644 pkg/kubernetes/testdata/install-input-bad-outputs.yaml create mode 100644 pkg/kubernetes/testdata/install-input-bad-wait-flag.yaml create mode 100644 pkg/kubernetes/testdata/install-input-no-manifests.yaml create mode 100644 pkg/kubernetes/testdata/install-input.yaml create mode 100644 pkg/kubernetes/testdata/schema.json create mode 100644 pkg/kubernetes/testdata/uninstall-input.yaml create mode 100644 pkg/kubernetes/testdata/upgrade-input.yaml create mode 100644 pkg/kubernetes/uninstall.go create mode 100644 pkg/kubernetes/uninstall_test.go create mode 100644 pkg/kubernetes/upgrade.go create mode 100644 pkg/kubernetes/upgrade_test.go create mode 100644 pkg/kubernetes/version.go diff --git a/Makefile b/Makefile index e5d7a53857..854bf7874c 100644 --- a/Makefile +++ b/Makefile @@ -31,10 +31,12 @@ build: build-client build-runtime azure helm build-runtime: generate $(MAKE) build-runtime MIXIN=porter -f mixin.mk BINDIR=bin $(MAKE) build-runtime MIXIN=exec -f mixin.mk + $(MAKE) build-runtime MIXIN=kubernetes -f mixin.mk build-client: generate $(MAKE) build-client MIXIN=porter -f mixin.mk BINDIR=bin $(MAKE) build-client MIXIN=exec -f mixin.mk + $(MAKE) build-client MIXIN=kubernetes -f mixin.mk generate: packr2 go generate ./... @@ -48,14 +50,17 @@ endif xbuild-all: $(MAKE) xbuild-all MIXIN=porter -f mixin.mk BINDIR=bin $(MAKE) xbuild-all MIXIN=exec -f mixin.mk + $(MAKE) xbuild-all MIXIN=kubernetes -f mixin.mk xbuild-runtime: $(MAKE) xbuild-runtime MIXIN=porter -f mixin.mk BINDIR=bin $(MAKE) xbuild-runtime MIXIN=exec -f mixin.mk + $(MAKE) xbuild-runtime MIXIN=kubernetes -f mixin.mk xbuild-client: $(MAKE) xbuild-client MIXIN=porter -f mixin.mk BINDIR=bin $(MAKE) xbuild-client MIXIN=exec -f mixin.mk + $(MAKE) xbuild-client MIXIN=kubernetes -f mixin.mk bin/mixins/helm/helm: mkdir -p bin/mixins/helm @@ -132,6 +137,7 @@ prep-install-scripts: publish: prep-install-scripts $(MAKE) publish MIXIN=exec -f mixin.mk + $(MAKE) publish MIXIN=kubernetes -f mixin.mk # AZURE_STORAGE_CONNECTION_STRING will be used for auth in the following commands if [[ "$(PERMALINK)" == "latest" ]]; then \ az storage blob upload-batch -d porter/$(VERSION) -s bin/$(VERSION); \ diff --git a/cmd/kubernetes/build.go b/cmd/kubernetes/build.go new file mode 100644 index 0000000000..dab67c19aa --- /dev/null +++ b/cmd/kubernetes/build.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/deislabs/porter/pkg/kubernetes" + "github.com/spf13/cobra" +) + +func buildBuildCommand(mixin *kubernetes.Mixin) *cobra.Command { + cmd := &cobra.Command{ + Use: "build", + Short: "Generate Dockerfile contribution for invocation image", + RunE: func(cmd *cobra.Command, args []string) error { + return mixin.Build() + }, + } + return cmd +} diff --git a/cmd/kubernetes/install.go b/cmd/kubernetes/install.go new file mode 100644 index 0000000000..1ca7c679c7 --- /dev/null +++ b/cmd/kubernetes/install.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/deislabs/porter/pkg/kubernetes" + "github.com/spf13/cobra" +) + +func buildInstallCommand(mixin *kubernetes.Mixin) *cobra.Command { + return &cobra.Command{ + Use: "install", + Short: "Use kubectl to apply manifests to cluster", + RunE: func(cmd *cobra.Command, args []string) error { + return mixin.Install() + }, + } +} diff --git a/cmd/kubernetes/main.go b/cmd/kubernetes/main.go new file mode 100644 index 0000000000..1fa49a2c52 --- /dev/null +++ b/cmd/kubernetes/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "io" + "os" + + "github.com/deislabs/porter/pkg/kubernetes" + "github.com/spf13/cobra" +) + +func main() { + cmd := buildRootCommand(os.Stdin) + if err := cmd.Execute(); err != nil { + fmt.Printf("err: %s\n", err) + os.Exit(1) + } +} + +func buildRootCommand(in io.Reader) *cobra.Command { + mixin := kubernetes.New() + mixin.In = in + cmd := &cobra.Command{ + Use: "kubernetes", + Long: "kuberetes is a porter 👩🏽‍✈️ mixin that you can you can use to leverage kubernetes manifests", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + mixin.Out = cmd.OutOrStdout() + mixin.Err = cmd.OutOrStderr() + }, + } + + cmd.PersistentFlags().BoolVar(&mixin.Debug, "debug", false, "Enable debug logging") + cmd.AddCommand(buildVersionCommand(mixin)) + cmd.AddCommand(buildBuildCommand(mixin)) + cmd.AddCommand(buildInstallCommand(mixin)) + cmd.AddCommand(buildUpgradeCommand(mixin)) + cmd.AddCommand(buildUnInstallCommand(mixin)) + cmd.AddCommand(buildSchemaCommand(mixin)) + return cmd +} diff --git a/cmd/kubernetes/schema.go b/cmd/kubernetes/schema.go new file mode 100644 index 0000000000..2aa894975f --- /dev/null +++ b/cmd/kubernetes/schema.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/deislabs/porter/pkg/kubernetes" + "github.com/spf13/cobra" +) + +func buildSchemaCommand(mixin *kubernetes.Mixin) *cobra.Command { + cmd := &cobra.Command{ + Use: "schema", + Short: "Print the json schema for the mixin", + RunE: func(cmd *cobra.Command, args []string) error { + return mixin.PrintSchema() + }, + } + return cmd +} diff --git a/cmd/kubernetes/uninstall.go b/cmd/kubernetes/uninstall.go new file mode 100644 index 0000000000..f5769c7270 --- /dev/null +++ b/cmd/kubernetes/uninstall.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/deislabs/porter/pkg/kubernetes" + "github.com/spf13/cobra" +) + +func buildUnInstallCommand(mixin *kubernetes.Mixin) *cobra.Command { + return &cobra.Command{ + Use: "uninstall", + Short: "Use kubectl to delete manifests from cluster", + RunE: func(cmd *cobra.Command, args []string) error { + return mixin.UnInstall() + }, + } +} diff --git a/cmd/kubernetes/upgrade.go b/cmd/kubernetes/upgrade.go new file mode 100644 index 0000000000..13908b3047 --- /dev/null +++ b/cmd/kubernetes/upgrade.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/deislabs/porter/pkg/kubernetes" + "github.com/spf13/cobra" +) + +func buildUpgradeCommand(mixin *kubernetes.Mixin) *cobra.Command { + return &cobra.Command{ + Use: "Upgrade", + Short: "Use kubectl to apply manifests to cluster", + RunE: func(cmd *cobra.Command, args []string) error { + return mixin.Upgrade() + }, + } +} diff --git a/cmd/kubernetes/version.go b/cmd/kubernetes/version.go new file mode 100644 index 0000000000..085ffb174d --- /dev/null +++ b/cmd/kubernetes/version.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/deislabs/porter/pkg/kubernetes" + "github.com/spf13/cobra" +) + +func buildVersionCommand(mixin *kubernetes.Mixin) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the mixin verison", + Run: func(cmd *cobra.Command, args []string) { + mixin.PrintVersion() + }, + } +} diff --git a/docs/content/design/kubernetes-mixin.md b/docs/content/design/kubernetes-mixin.md index 54def140eb..03dd67bed2 100644 --- a/docs/content/design/kubernetes-mixin.md +++ b/docs/content/design/kubernetes-mixin.md @@ -50,12 +50,8 @@ The mixin allows bundle authors to specify the following parameters on install: |-----------|------|-------------|---------| | `namespace` | string | The namespace in which to create resources | `default` | | `manifests` | string | The path to the manifests. Can be a file or directory | `/cnab/app/kubernetes` | -| `allow-missing-template-keys` | boolean | If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to golang and jsonpath output formats. | `true` | -| `output` | string | Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-file. | | -| `record` | boolean | Record current kubectl command in the resource annotation. If set to false, do not record the command. If set to true, record the command. If not set, default to updating the existing annotation value only if one already exists. | `false` | -| `save-config` | boolean | If true, the configuration of current object will be saved in its annotation. Otherwise, the annotation will be unchanged. This flag is useful when you want to perform kubectl apply on this object in the future. | `false` | +| `record` | boolean | Record current kubectl command in the resource annotation. If set to false, do not record the command. If set to true, record the command. If not set, default to updating the existing annotation value only if one already exists. | `false` | | `selector` | string | Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2) | | -| `template` | string | Template string or path to template file to use when -o=go-template, -o=go-template-file. The template format is golang templates | | | `validate` | boolean | If true, use a schema to validate the input before sending it | `true` | | `wait` | boolean | If true, wait for resources to be gone before returning. This waits for finalizers. | `true` | @@ -67,15 +63,12 @@ The mixin allows bundle authors to specify the following parameters on install: |-----------|------|-------------|---------| | `namespace` | string | The namespace in which to create resources. | `default` | | `manifests` | string | The path to the manifests. Can be a file or directory. | `/cnab/app/kubernetes` | -| `allow-missing-template-keys` | boolean | If true, ignore any errors in templates when a field or map key is missing in the template. Only applies to golang and jsonpath output formats. | `true` | -| `force` | boolean | If true, immediately remove resources from API and bypass graceful deletion. Note that immediate deletion of some resources may result in inconsistency or data loss and requires confirmation. Overrides `grace-period`. | `false`| -| `grace-period` | integer | Period of time in seconds given to the resource to terminate gracefully. Ignored if negative. Set to 1 for immediate shutdown. If `force` is true, will result in 0. | -1 | -| `output` | string | Output format. One of: json|yaml|name|go-template|go-template-file|template|templatefile|jsonpath|jsonpath-file. | | +| `force` | boolean | If true, immediately remove resources from API and bypass graceful deletion. Note that immediate deletion of some resources may result in inconsistency or data loss and requires confirmation. Overrides `gracePeriod`. | `false`| +| `gracePeriod` | integer | Period of time in seconds given to the resource to terminate gracefully. Ignored if negative. Set to 1 for immediate shutdown. If `force` is true, will result in 0. | -1 | | `overwrite` | boolean | Automatically resolve conflicts between the modified and live configuration by using values from the modified configuration. | `true` | -| `record` | boolean | Record current kubectl command in the resource annotation. If set to false, do not record the command. If set to true, record the command. If not set, default to updating the existing annotation value only if one already exists. | `false` | -| `save-config` | boolean | If true, the configuration of current object will be saved in its annotation. Otherwise, the annotation will be unchanged. This flag is useful when you want to perform kubectl apply on this object in the future. | `false` | +| `prune` | boolean | Automatically delete resource objects, including the uninitialized ones, that do not appear in the configs. | `false` | +| `record` | boolean | Record current kubectl command in the resource annotation. If set to false, do not record the command. If set to true, record the command. If not set, default to updating the existing annotation value only if one already exists. | `false` || | `selector` | string | Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2). | | -| `template` | string | Template string or path to template file to use when -o=go-template, -o=go-template-file. The template format is golang templates. | | | `timeout` | integer | The length of time (in seconds) to wait before giving up on a delete, zero means determine a timeout from the size of the object. | 0 | | `validate` | boolean | If true, use a schema to validate the input before sending it. | `true` | | `wait` | boolean | If true, wait for resources to be gone before returning. This waits for finalizers. | `true` | @@ -93,13 +86,15 @@ The mixin allows bundle authors to specify the following parameters on delete: | `namespace` | string | The namespace in which to create resources. | `default` | | `manifests` | string | The path to the manifests. Can be a file or directory. | `/cnab/app/kuberentes` | | `force` | boolean | If true, immediately remove resources from API and bypass graceful deletion. Note that immediate deletion of some resources may result in inconsistency or data loss and requires confirmation. Sets grace period to `0`. | `false` | -| `grace-period` | integer | Period of time in seconds given to the resource to terminate gracefully. Ignored if negative. Set to 1 for immediate shutdown. | `-1` | +| `gracePeriod` | integer | Period of time in seconds given to the resource to terminate gracefully. Ignored if negative. Set to 1 for immediate shutdown. | `-1` | | `timeout` | integer | The length of time (in seconds) to wait before giving up on a delete, zero means determine a timeout from the size of the object. | 0 | | `wait` | boolean | If true, wait for resources to be gone before returning. This waits for finalizers. | `true` | ### Outputs -This mixin will leverage the `kubectl get` command in order to populate outputs. Given the wide range of objects that can be created, the mixin will support JSON Path to specify how to retrieve values to populate outputs. Bundle authors will specify the object type, name and provide a JSONPath to obtain the data. For example, to obtain the ClusterIP of a a given service, consider the following porter.yaml excerpt: +This mixin will leverage the `kubectl get` command in order to populate outputs. Given the wide range of objects that can be created, the mixin will support JSON Path to specify how to retrieve values to populate outputs. Bundle authors will specify the object type, name and provide a JSONPath to obtain the data. The mixin will not attempt further processing of the data, so if a JSONPath expression is given that results in multiple items, the JSON representing that will be stuck into the output as is. Namespace will default to `default` if not specified + +For example, to obtain the ClusterIP of a a given service, consider the following porter.yaml excerpt: ```yaml install: @@ -108,7 +103,8 @@ install: manifests: "/cnab/app/manifests/super-cool-app" outputs: - name: cluster_ip - resource_type: "service" - resource_name: "super-cool-service" - jsonpath: "spec.clusterIP" + resourceType: "service" + resourceName: "super-cool-service" + namespace: "cool" + jsonPath: "spec.clusterIP" ``` \ No newline at end of file diff --git a/pkg/kubernetes/build.go b/pkg/kubernetes/build.go new file mode 100644 index 0000000000..66a8082d1f --- /dev/null +++ b/pkg/kubernetes/build.go @@ -0,0 +1,19 @@ +package kubernetes + +import ( + "fmt" +) + +const kubeVersion = "v1.13.0" +const dockerFileContents = `RUN apt-get update && \ +apt-get install -y apt-transport-https curl && \ +curl -o kubectl https://storage.googleapis.com/kubernetes-release/release/%s/bin/linux/amd64/kubectl && \ +mv kubectl /usr/local/bin && \ +chmod a+x /usr/local/bin/kubectl +` + +// Build generates the relevant Dockerfile output for this mixin +func (m *Mixin) Build() error { + _, err := fmt.Fprintf(m.Out, dockerFileContents, kubeVersion) + return err +} diff --git a/pkg/kubernetes/helpers.go b/pkg/kubernetes/helpers.go new file mode 100644 index 0000000000..5d2dbfa3ae --- /dev/null +++ b/pkg/kubernetes/helpers.go @@ -0,0 +1,22 @@ +package kubernetes + +import ( + "testing" + + "github.com/deislabs/porter/pkg/context" +) + +type TestMixin struct { + *Mixin + TestContext *context.TestContext +} + +func NewTestMixin(t *testing.T) *TestMixin { + c := context.NewTestContext(t) + m := New() + m.Context = c.Context + return &TestMixin{ + Mixin: m, + TestContext: c, + } +} diff --git a/pkg/kubernetes/install.go b/pkg/kubernetes/install.go new file mode 100644 index 0000000000..2487ed1690 --- /dev/null +++ b/pkg/kubernetes/install.go @@ -0,0 +1,123 @@ +package kubernetes + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/pkg/errors" + + yaml "gopkg.in/yaml.v2" +) + +type InstallAction struct { + Steps []InstallStep `yaml:"install"` +} + +type InstallStep struct { + *InstallArguments `yaml:"kubernetes"` +} + +type InstallArguments struct { + Step `yaml:",inline"` + + Namespace string `yaml:"namespace"` + Manifests []string `yaml:"manifests,omitempty"` + Record *bool `yaml:"record,omitempty"` + Selector string `yaml:"selector,omitempty"` + Validate *bool `yaml:"validate,omitempty"` + Wait *bool `yaml:"wait,omitempty"` +} + +func (m *Mixin) Install() error { + payload, err := m.getPayloadData() + if err != nil { + return err + } + var action InstallAction + err = yaml.Unmarshal(payload, &action) + if err != nil { + return err + } + + if len(action.Steps) != 1 { + return errors.Errorf("expected a single step, but got %d", len(action.Steps)) + } + + step := action.Steps[0] + var commands []*exec.Cmd + manifests := m.resolveManifests(step.Manifests) + + for _, manifestPath := range manifests { + commandPayload, err := m.buildInstallCommand(step.InstallArguments, manifestPath) + if err != nil { + return err + } + cmd := m.NewCommand("kubectl", commandPayload...) + commands = append(commands, cmd) + } + + for _, cmd := range commands { + cmd.Stdout = m.Out + cmd.Stderr = m.Err + + err = cmd.Start() + if err != nil { + prettyCmd := fmt.Sprintf("%s %s", cmd.Path, strings.Join(cmd.Args, " ")) + return errors.Wrap(err, fmt.Sprintf("couldn't run command %s", prettyCmd)) + } + err = cmd.Wait() + if err != nil { + prettyCmd := fmt.Sprintf("%s %s", cmd.Path, strings.Join(cmd.Args, " ")) + return errors.Wrap(err, fmt.Sprintf("error running command %s", prettyCmd)) + } + } + + err = m.handleOutputs(step.Outputs) + return err +} + +func (m *Mixin) getInstallStep(payload []byte) (*InstallStep, error) { + var step InstallStep + err := yaml.Unmarshal(payload, &step) + if err != nil { + return nil, err + } + + return &step, nil +} + +func (m *Mixin) buildInstallCommand(step *InstallArguments, manifestPath string) ([]string, error) { + command := []string{"apply", "-f", manifestPath} + if step.Namespace != "" { + command = append(command, "-n", step.Namespace) + } + + if step.Record != nil { + recordIt := *step.Record + if recordIt { + command = append(command, "--record=true") + } + } + + if step.Selector != "" { + command = append(command, fmt.Sprintf("--selector=%s", step.Selector)) + } + + if step.Validate != nil { + validateIt := *step.Validate + if !validateIt { + command = append(command, "--validate=false") + } + } + + waitForIt := true + if step.Wait != nil { + waitForIt = *step.Wait + } + if waitForIt { + command = append(command, "--wait") + } + + return command, nil +} diff --git a/pkg/kubernetes/install_test.go b/pkg/kubernetes/install_test.go new file mode 100644 index 0000000000..1c15fec7f9 --- /dev/null +++ b/pkg/kubernetes/install_test.go @@ -0,0 +1,116 @@ +package kubernetes + +import ( + "bytes" + "fmt" + "os" + "testing" + + "github.com/deislabs/porter/pkg/test" + "github.com/stretchr/testify/require" + + yaml "gopkg.in/yaml.v2" +) + +type InstallTest struct { + expectedCommand string + installStep InstallStep +} + +func TestMain(m *testing.M) { + test.TestMainWithMockedCommandHandlers(m) +} + +func TestMixin_InstallStep(t *testing.T) { + + manifestDirectory := "/cnab/app/manifesto" + + installCmd := "kubectl apply -f" + + dontWait := false + recordIt := true + validateIt := false + + namespace := "meditations" + + selector := "app=nginx" + + installTests := []InstallTest{ + { + expectedCommand: fmt.Sprintf("%s %s --wait", installCmd, manifestDirectory), + installStep: InstallStep{ + InstallArguments: &InstallArguments{ + Manifests: []string{manifestDirectory}, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --wait", installCmd, defaultManifestPath), + installStep: InstallStep{ + InstallArguments: &InstallArguments{}, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s", installCmd, defaultManifestPath), + installStep: InstallStep{ + InstallArguments: &InstallArguments{ + Wait: &dontWait, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s -n %s", installCmd, defaultManifestPath, namespace), + installStep: InstallStep{ + InstallArguments: &InstallArguments{ + Namespace: namespace, + Wait: &dontWait, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s -n %s --validate=false", installCmd, defaultManifestPath, namespace), + installStep: InstallStep{ + InstallArguments: &InstallArguments{ + Namespace: namespace, + Validate: &validateIt, + Wait: &dontWait, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s -n %s --record=true", installCmd, defaultManifestPath, namespace), + installStep: InstallStep{ + InstallArguments: &InstallArguments{ + Namespace: namespace, + Record: &recordIt, + Wait: &dontWait, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --selector=%s --wait", installCmd, defaultManifestPath, selector), + installStep: InstallStep{ + InstallArguments: &InstallArguments{ + Selector: selector, + }, + }, + }, + } + + defer os.Unsetenv(test.ExpectedCommandEnv) + for _, installTest := range installTests { + t.Run(installTest.expectedCommand, func(t *testing.T) { + os.Setenv(test.ExpectedCommandEnv, installTest.expectedCommand) + + action := InstallAction{Steps: []InstallStep{installTest.installStep}} + b, _ := yaml.Marshal(action) + + h := NewTestMixin(t) + h.In = bytes.NewReader(b) + + err := h.Install() + + require.NoError(t, err) + }) + } +} diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go new file mode 100644 index 0000000000..f616a4e0df --- /dev/null +++ b/pkg/kubernetes/kubernetes.go @@ -0,0 +1,131 @@ +//go:generate packr2 + +package kubernetes + +import ( + "bufio" + "fmt" + "io" + "io/ioutil" + "strings" + + "github.com/deislabs/porter/pkg/context" + "github.com/ghodss/yaml" + "github.com/gobuffalo/packr/v2" + "github.com/pkg/errors" + "github.com/xeipuuv/gojsonschema" +) + +const defaultManifestPath = "/cnab/app/manifests/kubernetes" + +type Mixin struct { + *context.Context + + schemas *packr.Box +} + +func New() *Mixin { + return &Mixin{ + Context: context.New(), + schemas: NewSchemaBox(), + } +} + +func NewSchemaBox() *packr.Box { + return packr.New("github.com/deislabs/porter/pkg/kubernetes/schema", "./schema") +} + +func (m *Mixin) getCommandFile(commandFile string, w io.Writer) ([]byte, error) { + if commandFile == "" { + reader := bufio.NewReader(m.In) + return ioutil.ReadAll(reader) + } + return ioutil.ReadFile(commandFile) +} + +func (m *Mixin) getPayloadData() ([]byte, error) { + reader := bufio.NewReader(m.In) + data, err := ioutil.ReadAll(reader) + return data, errors.Wrap(err, "could not read payload from STDIN") +} + +func (m *Mixin) ValidatePayload(b []byte) error { + // Load the step as a go dump + s := make(map[string]interface{}) + err := yaml.Unmarshal(b, &s) + if err != nil { + return errors.Wrap(err, "could not marshal payload as yaml") + } + manifestLoader := gojsonschema.NewGoLoader(s) + + // Load the step schema + schema, err := m.GetSchema() + if err != nil { + return err + } + schemaLoader := gojsonschema.NewStringLoader(schema) + + validator, err := gojsonschema.NewSchema(schemaLoader) + if err != nil { + return errors.Wrap(err, "unable to compile the mixin step schema") + } + + // Validate the manifest against the schema + result, err := validator.Validate(manifestLoader) + if err != nil { + return errors.Wrap(err, "unable to validate the mixin step schema") + } + if !result.Valid() { + errs := make([]string, 0, len(result.Errors())) + for _, err := range result.Errors() { + errs = append(errs, err.String()) + } + return errors.New(strings.Join(errs, "\n\t* ")) + } + + return nil +} + +// If no manifest is specified, update the empty slice to include the default path +func (m *Mixin) resolveManifests(manifests []string) []string { + if len(manifests) == 0 { + return append(manifests, defaultManifestPath) + } + return manifests +} + +func (m *Mixin) getOutput(resourceType, resourceName, namespace, jsonPath string) (string, error) { + args := []string{"get", resourceType, resourceName} + args = append(args, fmt.Sprintf("-o=jsonpath='%s'", jsonPath)) + if namespace != "" { + args = append(args, fmt.Sprintf("--namespace=%s", namespace)) + } + cmd := m.NewCommand("kubectl", args...) + cmd.Stderr = m.Err + out, err := cmd.Output() + if err != nil { + prettyCmd := fmt.Sprintf("%s %s", cmd.Path, strings.Join(cmd.Args, " ")) + return "", errors.Wrap(err, fmt.Sprintf("couldn't run command %s", prettyCmd)) + } + return string(out), nil +} + +func (m *Mixin) handleOutputs(outputs []KubernetesOutput) error { + //Now get the outputs + var lines []string + for _, output := range outputs { + val, err := m.getOutput( + output.ResourceType, + output.ResourceName, + output.Namespace, + output.JSONPath, + ) + if err != nil { + return err + } + l := fmt.Sprintf("%s=%s", output.Name, val) + lines = append(lines, l) + } + m.Context.WriteOutput(lines) + return nil +} diff --git a/pkg/kubernetes/schema.go b/pkg/kubernetes/schema.go new file mode 100644 index 0000000000..0ebbcb9ea4 --- /dev/null +++ b/pkg/kubernetes/schema.go @@ -0,0 +1,20 @@ +package kubernetes + +import ( + "fmt" +) + +func (m *Mixin) PrintSchema() error { + schema, err := m.GetSchema() + if err != nil { + return err + } + + fmt.Fprintf(m.Out, schema) + + return nil +} + +func (m *Mixin) GetSchema() (string, error) { + return m.schemas.FindString("kubernetes.json") +} diff --git a/pkg/kubernetes/schema/kubernetes.json b/pkg/kubernetes/schema/kubernetes.json new file mode 100644 index 0000000000..b7fa163563 --- /dev/null +++ b/pkg/kubernetes/schema/kubernetes.json @@ -0,0 +1,215 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "installStep": { + "type": "object", + "properties": { + "kubernetes": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 0 + }, + "manifests": { + "type": "array", + "items": { + "type": "string", + "minItems": 0 + } + }, + "record": { + "type": "boolean", + "default":"false" + }, + "selector": { + "type": "string", + "minLength": 0 + }, + "validate": { + "type": "boolean", + "default":"true" + }, + "wait": { + "type": "boolean", + "default":"true" + }, + "outputs": { + "$ref": "#/definitions/outputs" + } + }, + "additionalProperties": false, + "required": [ + "description" + ] + } + }, + "additionalProperties": false, + "required": [ + "kubernetes" + ] + }, + "upgradeStep": { + "type": "object", + "properties": { + "kubernetes": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 0 + }, + "manifests": { + "type": "array", + "items": { + "type": "string", + "minItems": 0 + } + }, + "force": { + "type": "boolean", + "default":"false" + }, + "gracePeriod" : { + "type": "integer" + }, + "overwrite": { + "type": "boolean", + "default":"true" + }, + "record": { + "type": "boolean", + "default":"false" + }, + "selector": { + "type": "string", + "minLength": 0 + }, + "timeout" : { + "type": "integer" + }, + "validate": { + "type": "boolean", + "default":"true" + }, + "wait": { + "type": "boolean", + "default":"true" + }, + "outputs": { + "$ref": "#/definitions/outputs" + } + }, + "additionalProperties": false, + "required": [ + "description" + ] + } + }, + "additionalProperties": false, + "required": [ + "kubernetes" + ] + }, + "uninstallStep": { + "type": "object", + "properties": { + "kubernetes": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 0 + }, + "manifests": { + "type": "array", + "items": { + "type": "string", + "minItems": 0 + } + }, + "force": { + "type": "boolean", + "default":"false" + }, + "gracePeriod" : { + "type": "integer" + }, + "timeout" : { + "type": "integer" + }, + "wait": { + "type": "boolean", + "default":"true" + } + }, + "additionalProperties": false, + "required": [ + "description" + ] + } + }, + "additionalProperties": false, + "required": [ + "kubernetes" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "resourceType": { + "type": "string" + }, + "resourceName": { + "type": "string" + }, + "jsonPath": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["name", "resourceType", "resourceName", "jsonPath"] + } + } + }, + "type": "object", + "properties": { + "install": { + "type": "array", + "items": { + "$ref": "#/definitions/installStep" + } + }, + "upgrade": { + "type": "array", + "items": { + "$ref": "#/definitions/upgradeStep" + } + }, + "uninstall": { + "type": "array", + "items": { + "$ref": "#/definitions/uninstallStep" + } + } + }, + "additionalProperties": false + } + \ No newline at end of file diff --git a/pkg/kubernetes/schema_test.go b/pkg/kubernetes/schema_test.go new file mode 100644 index 0000000000..2e156b04b7 --- /dev/null +++ b/pkg/kubernetes/schema_test.go @@ -0,0 +1,54 @@ +package kubernetes + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMixin_PrintSchema(t *testing.T) { + m := NewTestMixin(t) + + err := m.PrintSchema() + require.NoError(t, err) + + gotSchema := m.TestContext.GetOutput() + + wantSchema, err := ioutil.ReadFile("testdata/schema.json") + require.NoError(t, err) + + assert.Equal(t, string(wantSchema), gotSchema) +} + +func TestMixin_ValidatePayload(t *testing.T) { + testcases := []struct { + name string + step string + pass bool + error string + }{ + {"install", "testdata/install-input.yaml", true, ""}, + {"upgrade", "testdata/upgrade-input.yaml", true, ""}, + {"uninstall", "testdata/uninstall-input.yaml", true, ""}, + {"install-default-manifest-ok", "testdata/install-input-no-manifests.yaml", true, ""}, + {"install-bad-wait-flag", "testdata/install-input-bad-wait-flag.yaml", false, "install.0.kubernetes.wait: Invalid type. Expected: boolean, given: string"}, + {"install-bad-outputs", "testdata/install-input-bad-outputs.yaml", false, "install.0.kubernetes.outputs.0: resourceType is required\n\t* install.0.kubernetes.outputs.0: resourceName is required\n\t* install.0.kubernetes.outputs.0: jsonPath is required"}, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + m := NewTestMixin(t) + b, err := ioutil.ReadFile(tc.step) + require.NoError(t, err) + + err = m.ValidatePayload(b) + if tc.pass { + require.NoError(t, err) + } else { + require.EqualError(t, err, tc.error) + } + }) + } +} diff --git a/pkg/kubernetes/step.go b/pkg/kubernetes/step.go new file mode 100644 index 0000000000..a60e25e287 --- /dev/null +++ b/pkg/kubernetes/step.go @@ -0,0 +1,14 @@ +package kubernetes + +type Step struct { + Description string `yaml:"description"` + Outputs []KubernetesOutput `yaml:"outputs"` +} + +type KubernetesOutput struct { + Name string `yaml:"name"` + ResourceType string `yaml:"resourceType"` + ResourceName string `yaml:"resourceName"` + Namespace string `yaml:"namespace",omitempty` + JSONPath string `yaml:"jsonPath"` +} diff --git a/pkg/kubernetes/testdata/install-input-bad-outputs.yaml b/pkg/kubernetes/testdata/install-input-bad-outputs.yaml new file mode 100644 index 0000000000..4b649f9686 --- /dev/null +++ b/pkg/kubernetes/testdata/install-input-bad-outputs.yaml @@ -0,0 +1,8 @@ +install: + - kubernetes: + description: "Install Hello World App" + manifests: + - /cnab/app/manifests/hello + wait: true + outputs: + - name: ip-address diff --git a/pkg/kubernetes/testdata/install-input-bad-wait-flag.yaml b/pkg/kubernetes/testdata/install-input-bad-wait-flag.yaml new file mode 100644 index 0000000000..1d921dfd22 --- /dev/null +++ b/pkg/kubernetes/testdata/install-input-bad-wait-flag.yaml @@ -0,0 +1,6 @@ +install: + - kubernetes: + description: "Install Hello World App" + manifests: + - /cnab/app/manifests/hello + wait: "hello" diff --git a/pkg/kubernetes/testdata/install-input-no-manifests.yaml b/pkg/kubernetes/testdata/install-input-no-manifests.yaml new file mode 100644 index 0000000000..5629432014 --- /dev/null +++ b/pkg/kubernetes/testdata/install-input-no-manifests.yaml @@ -0,0 +1,6 @@ +install: + - kubernetes: + description: "Install Hello World App" + manifests: + - /cnab/app/manifests/hello + wait: true diff --git a/pkg/kubernetes/testdata/install-input.yaml b/pkg/kubernetes/testdata/install-input.yaml new file mode 100644 index 0000000000..5629432014 --- /dev/null +++ b/pkg/kubernetes/testdata/install-input.yaml @@ -0,0 +1,6 @@ +install: + - kubernetes: + description: "Install Hello World App" + manifests: + - /cnab/app/manifests/hello + wait: true diff --git a/pkg/kubernetes/testdata/schema.json b/pkg/kubernetes/testdata/schema.json new file mode 100644 index 0000000000..b7fa163563 --- /dev/null +++ b/pkg/kubernetes/testdata/schema.json @@ -0,0 +1,215 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "installStep": { + "type": "object", + "properties": { + "kubernetes": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 0 + }, + "manifests": { + "type": "array", + "items": { + "type": "string", + "minItems": 0 + } + }, + "record": { + "type": "boolean", + "default":"false" + }, + "selector": { + "type": "string", + "minLength": 0 + }, + "validate": { + "type": "boolean", + "default":"true" + }, + "wait": { + "type": "boolean", + "default":"true" + }, + "outputs": { + "$ref": "#/definitions/outputs" + } + }, + "additionalProperties": false, + "required": [ + "description" + ] + } + }, + "additionalProperties": false, + "required": [ + "kubernetes" + ] + }, + "upgradeStep": { + "type": "object", + "properties": { + "kubernetes": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 0 + }, + "manifests": { + "type": "array", + "items": { + "type": "string", + "minItems": 0 + } + }, + "force": { + "type": "boolean", + "default":"false" + }, + "gracePeriod" : { + "type": "integer" + }, + "overwrite": { + "type": "boolean", + "default":"true" + }, + "record": { + "type": "boolean", + "default":"false" + }, + "selector": { + "type": "string", + "minLength": 0 + }, + "timeout" : { + "type": "integer" + }, + "validate": { + "type": "boolean", + "default":"true" + }, + "wait": { + "type": "boolean", + "default":"true" + }, + "outputs": { + "$ref": "#/definitions/outputs" + } + }, + "additionalProperties": false, + "required": [ + "description" + ] + } + }, + "additionalProperties": false, + "required": [ + "kubernetes" + ] + }, + "uninstallStep": { + "type": "object", + "properties": { + "kubernetes": { + "type": "object", + "properties": { + "description": { + "type": "string", + "minLength": 1 + }, + "namespace": { + "type": "string", + "minLength": 0 + }, + "manifests": { + "type": "array", + "items": { + "type": "string", + "minItems": 0 + } + }, + "force": { + "type": "boolean", + "default":"false" + }, + "gracePeriod" : { + "type": "integer" + }, + "timeout" : { + "type": "integer" + }, + "wait": { + "type": "boolean", + "default":"true" + } + }, + "additionalProperties": false, + "required": [ + "description" + ] + } + }, + "additionalProperties": false, + "required": [ + "kubernetes" + ] + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "resourceType": { + "type": "string" + }, + "resourceName": { + "type": "string" + }, + "jsonPath": { + "type": "string" + } + }, + "additionalProperties": false, + "required": ["name", "resourceType", "resourceName", "jsonPath"] + } + } + }, + "type": "object", + "properties": { + "install": { + "type": "array", + "items": { + "$ref": "#/definitions/installStep" + } + }, + "upgrade": { + "type": "array", + "items": { + "$ref": "#/definitions/upgradeStep" + } + }, + "uninstall": { + "type": "array", + "items": { + "$ref": "#/definitions/uninstallStep" + } + } + }, + "additionalProperties": false + } + \ No newline at end of file diff --git a/pkg/kubernetes/testdata/uninstall-input.yaml b/pkg/kubernetes/testdata/uninstall-input.yaml new file mode 100644 index 0000000000..177d7d25f5 --- /dev/null +++ b/pkg/kubernetes/testdata/uninstall-input.yaml @@ -0,0 +1,6 @@ +uninstall: + - kubernetes: + description: "Install Hello World App" + manifests: + - /cnab/app/manifests/hello + wait: true diff --git a/pkg/kubernetes/testdata/upgrade-input.yaml b/pkg/kubernetes/testdata/upgrade-input.yaml new file mode 100644 index 0000000000..3009ee5bea --- /dev/null +++ b/pkg/kubernetes/testdata/upgrade-input.yaml @@ -0,0 +1,6 @@ +upgrade: + - kubernetes: + description: "Upgrade Hello World App" + manifests: + - /cnab/app/manifests/hello + wait: true diff --git a/pkg/kubernetes/uninstall.go b/pkg/kubernetes/uninstall.go new file mode 100644 index 0000000000..56d268386b --- /dev/null +++ b/pkg/kubernetes/uninstall.go @@ -0,0 +1,129 @@ +package kubernetes + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/pkg/errors" + + yaml "gopkg.in/yaml.v2" +) + +type UninstallAction struct { + Steps []UninstallStep `yaml:"uninstall"` +} + +type UninstallStep struct { + *UninstallArguments `yaml:"kubernetes"` +} + +type UninstallArguments struct { + Step `yaml:",inline"` + + Namespace string `yaml:"namespace"` + Manifests []string `yaml:"manifests,omitempty"` + + Force *bool `yaml:force,omitempty"` + GracePeriod *int `yaml:"gracePeriod,omitempty"` + Selector string `yaml:"selector,omitempty"` + Timeout *int `yaml:"timeout,omitempty"` + Wait *bool `yaml:"wait,omitempty"` +} + +// UnInstall will delete anything created during the install or upgrade step +func (m *Mixin) UnInstall() error { + payload, err := m.getPayloadData() + if err != nil { + return err + } + + var action UninstallAction + err = yaml.Unmarshal(payload, &action) + if err != nil { + return err + } + + if len(action.Steps) != 1 { + return errors.Errorf("expected a single step, but got %d", len(action.Steps)) + } + + step := action.Steps[0] + var commands []*exec.Cmd + manifests := m.resolveManifests(step.Manifests) + + for _, manifestPath := range manifests { + commandPayload, err := m.buildUninstallCommand(step.UninstallArguments, manifestPath) + if err != nil { + return err + } + cmd := m.NewCommand("kubectl", commandPayload...) + commands = append(commands, cmd) + } + + for _, cmd := range commands { + cmd.Stdout = m.Out + cmd.Stderr = m.Err + err = cmd.Start() + if err != nil { + prettyCmd := fmt.Sprintf("%s %s", cmd.Path, strings.Join(cmd.Args, " ")) + return errors.Wrap(err, fmt.Sprintf("couldn't run command %s", prettyCmd)) + } + err = cmd.Wait() + if err != nil { + prettyCmd := fmt.Sprintf("%s %s", cmd.Path, strings.Join(cmd.Args, " ")) + return errors.Wrap(err, fmt.Sprintf("error running command %s", prettyCmd)) + } + } + return nil +} + +func (m *Mixin) buildUninstallCommand(args *UninstallArguments, manifestPath string) ([]string, error) { + command := []string{"delete", "-f", manifestPath} + if args.Namespace != "" { + command = append(command, "-n", args.Namespace) + } + + if args.Force != nil { + forceIt := *args.Force + if forceIt { + command = append(command, "--force") + if args.GracePeriod != nil { + gracePeriod := *args.GracePeriod + if gracePeriod != 0 { + return nil, fmt.Errorf("grace period must be zero when force is specified: %d", gracePeriod) + } + } else { + // default to zero + command = append(command, "--grace-period=0") + } + } + } + + if args.GracePeriod != nil { + gracePeriod := *args.GracePeriod + command = append(command, fmt.Sprintf("--grace-period=%d", gracePeriod)) + if gracePeriod == 0 { + command = append(command, "--force") + } + } + + if args.Selector != "" { + command = append(command, fmt.Sprintf("--selector=%s", args.Selector)) + } + + if args.Timeout != nil { + timeout := *args.Timeout + command = append(command, fmt.Sprintf("--timeout=%ds", timeout)) + } + + waitForIt := true + if args.Wait != nil { + waitForIt = *args.Wait + } + if waitForIt { + command = append(command, "--wait") + } + + return command, nil +} diff --git a/pkg/kubernetes/uninstall_test.go b/pkg/kubernetes/uninstall_test.go new file mode 100644 index 0000000000..cc07937463 --- /dev/null +++ b/pkg/kubernetes/uninstall_test.go @@ -0,0 +1,118 @@ +package kubernetes + +import ( + "bytes" + "fmt" + "os" + "testing" + + "github.com/deislabs/porter/pkg/test" + "github.com/stretchr/testify/require" + + yaml "gopkg.in/yaml.v2" +) + +type UnInstallTest struct { + expectedCommand string + uninstallStep UninstallStep +} + +func TestMixin_UninstallStep(t *testing.T) { + + manifestDirectory := "/cnab/app/manifesto" + + deleteCmd := "kubectl delete -f" + + dontWait := false + + namespace := "meditations" + + selector := "app=nginx" + forceIt := true + withGrace := 1 + + timeout := 1 + + uninstallTests := []UnInstallTest{ + { + expectedCommand: fmt.Sprintf("%s %s --wait", deleteCmd, manifestDirectory), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{ + Manifests: []string{manifestDirectory}, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --wait", deleteCmd, defaultManifestPath), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{}, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s", deleteCmd, defaultManifestPath), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{ + Wait: &dontWait, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s -n %s", deleteCmd, defaultManifestPath, namespace), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{ + Namespace: namespace, + Wait: &dontWait, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --selector=%s --wait", deleteCmd, defaultManifestPath, selector), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{ + Selector: selector, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --force --grace-period=0 --wait", deleteCmd, defaultManifestPath), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{ + Force: &forceIt, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --grace-period=%d --wait", deleteCmd, defaultManifestPath, withGrace), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{ + GracePeriod: &withGrace, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --timeout=%ds --wait", deleteCmd, defaultManifestPath, timeout), + uninstallStep: UninstallStep{ + UninstallArguments: &UninstallArguments{ + Timeout: &timeout, + }, + }, + }, + } + + defer os.Unsetenv(test.ExpectedCommandEnv) + for _, uninstallTest := range uninstallTests { + t.Run(uninstallTest.expectedCommand, func(t *testing.T) { + os.Setenv(test.ExpectedCommandEnv, uninstallTest.expectedCommand) + + action := UninstallAction{Steps: []UninstallStep{uninstallTest.uninstallStep}} + b, _ := yaml.Marshal(action) + + h := NewTestMixin(t) + h.In = bytes.NewReader(b) + + err := h.UnInstall() + + require.NoError(t, err) + }) + } +} diff --git a/pkg/kubernetes/upgrade.go b/pkg/kubernetes/upgrade.go new file mode 100644 index 0000000000..8256728f7b --- /dev/null +++ b/pkg/kubernetes/upgrade.go @@ -0,0 +1,137 @@ +package kubernetes + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/pkg/errors" + + yaml "gopkg.in/yaml.v2" +) + +type UpgradeAction struct { + Steps []UpgradeStep `yaml:"upgrade"` +} + +type UpgradeStep struct { + *UpgradeArguments `yaml:"kubernetes"` +} + +type UpgradeArguments struct { + *InstallArguments + + // Upgrade specific arguments + Force *bool `yaml:"force,omitempty"` + GracePeriod *int `yaml:"gracePeriod,omitempty"` + Overwrite *bool `yaml:"overwrite,omitempty"` + Prune *bool `yaml:"prune,omitempty"` + Timeout *int `yaml:"timeout,omitempty"` +} + +// Upgrade will reapply manifests using kubectl +func (m *Mixin) Upgrade() error { + + payload, err := m.getPayloadData() + if err != nil { + return err + } + + var action UpgradeAction + err = yaml.Unmarshal(payload, &action) + if err != nil { + return err + } + + if len(action.Steps) != 1 { + return errors.Errorf("expected a single step, but got %d", len(action.Steps)) + } + + step := action.Steps[0] + var commands []*exec.Cmd + manifests := m.resolveManifests(step.Manifests) + + for _, manifestPath := range manifests { + commandPayload, err := m.buildUpgradeCommand(step.UpgradeArguments, manifestPath) + if err != nil { + return err + } + cmd := m.NewCommand("kubectl", commandPayload...) + commands = append(commands, cmd) + } + + for _, cmd := range commands { + cmd.Stdout = m.Out + cmd.Stderr = m.Err + + err = cmd.Start() + if err != nil { + prettyCmd := fmt.Sprintf("%s %s", cmd.Path, strings.Join(cmd.Args, " ")) + return errors.Wrap(err, fmt.Sprintf("couldn't run command %s", prettyCmd)) + } + err = cmd.Wait() + if err != nil { + prettyCmd := fmt.Sprintf("%s %s", cmd.Path, strings.Join(cmd.Args, " ")) + return errors.Wrap(err, fmt.Sprintf("error running command %s", prettyCmd)) + } + } + + err = m.handleOutputs(step.Outputs) + return err +} + +func (m *Mixin) buildUpgradeCommand(args *UpgradeArguments, manifestPath string) ([]string, error) { + command, err := m.buildInstallCommand(args.InstallArguments, manifestPath) + if err != nil { + return nil, errors.Wrap(err, "unable to create upgrade command") + } + + // // Upgrade specific arguments + // Timeout *int `yaml:"timeout,omitempty"` + + if args.Force != nil { + forceIt := *args.Force + if forceIt { + command = append(command, "--force") + if args.GracePeriod != nil { + gracePeriod := *args.GracePeriod + if gracePeriod != 0 { + return nil, fmt.Errorf("grace period must be zero when force is specified: %d", gracePeriod) + } + } else { + //set the grace period to zero + command = append(command, "--grace-period=0") + } + + } + } + + if args.GracePeriod != nil { + gracePeriod := *args.GracePeriod + command = append(command, fmt.Sprintf("--grace-period=%d", gracePeriod)) + if gracePeriod == 0 { + command = append(command, "--force") + } + } + + if args.Prune != nil { + pruneIt := *args.Prune + if pruneIt { + command = append(command, "--prune=true") + } + } + + if args.Overwrite != nil { + overwriteIt := *args.Overwrite + if !overwriteIt { + command = append(command, "--overwrite=false") + } + } + + if args.Timeout != nil { + timeout := *args.Timeout + command = append(command, fmt.Sprintf("--timeout=%ds", timeout)) + } + + return command, nil +} diff --git a/pkg/kubernetes/upgrade_test.go b/pkg/kubernetes/upgrade_test.go new file mode 100644 index 0000000000..1de3c68adc --- /dev/null +++ b/pkg/kubernetes/upgrade_test.go @@ -0,0 +1,184 @@ +package kubernetes + +import ( + "bytes" + "fmt" + "os" + "testing" + + "github.com/deislabs/porter/pkg/test" + "github.com/stretchr/testify/require" + + yaml "gopkg.in/yaml.v2" +) + +type UpgradeTest struct { + expectedCommand string + upgradeStep UpgradeStep +} + +func TestMixin_UpgradeStep(t *testing.T) { + + manifestDirectory := "/cnab/app/manifesto" + + upgradeCmd := "kubectl apply -f" + + dontWait := false + + recordIt := true + validateIt := false + + namespace := "meditations" + + selector := "app=nginx" + + forceIt := true + withGrace := 1 + + overwriteIt := false + pruneIt := true + + timeout := 1 + + upgradeTests := []UpgradeTest{ + // These tests are largely the same as the install, just testing that the embedded + // install gets handled correctly + { + expectedCommand: fmt.Sprintf("%s %s --wait", upgradeCmd, manifestDirectory), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + InstallArguments: &InstallArguments{ + Manifests: []string{manifestDirectory}, + }, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --wait", upgradeCmd, defaultManifestPath), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + InstallArguments: &InstallArguments{}, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s", upgradeCmd, defaultManifestPath), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + InstallArguments: &InstallArguments{ + Wait: &dontWait, + }, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s -n %s", upgradeCmd, defaultManifestPath, namespace), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + InstallArguments: &InstallArguments{ + Namespace: namespace, + Wait: &dontWait, + }, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s -n %s --validate=false", upgradeCmd, defaultManifestPath, namespace), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + InstallArguments: &InstallArguments{ + Namespace: namespace, + Validate: &validateIt, + Wait: &dontWait, + }, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s -n %s --record=true", upgradeCmd, defaultManifestPath, namespace), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + InstallArguments: &InstallArguments{ + Namespace: namespace, + Record: &recordIt, + Wait: &dontWait, + }, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --selector=%s --wait", upgradeCmd, defaultManifestPath, selector), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + InstallArguments: &InstallArguments{ + Selector: selector, + }, + }, + }, + }, + + // These tests exercise the upgrade options + { + expectedCommand: fmt.Sprintf("%s %s --wait --force --grace-period=0", upgradeCmd, defaultManifestPath), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + Force: &forceIt, + InstallArguments: &InstallArguments{}, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --wait --grace-period=%d", upgradeCmd, defaultManifestPath, withGrace), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + GracePeriod: &withGrace, + InstallArguments: &InstallArguments{}, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --wait --overwrite=false", upgradeCmd, defaultManifestPath), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + Overwrite: &overwriteIt, + InstallArguments: &InstallArguments{}, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --wait --prune=true", upgradeCmd, defaultManifestPath), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + Prune: &pruneIt, + InstallArguments: &InstallArguments{}, + }, + }, + }, + { + expectedCommand: fmt.Sprintf("%s %s --wait --timeout=%ds", upgradeCmd, defaultManifestPath, timeout), + upgradeStep: UpgradeStep{ + UpgradeArguments: &UpgradeArguments{ + Timeout: &timeout, + InstallArguments: &InstallArguments{}, + }, + }, + }, + } + + defer os.Unsetenv(test.ExpectedCommandEnv) + for _, upgradeTest := range upgradeTests { + t.Run(upgradeTest.expectedCommand, func(t *testing.T) { + os.Setenv(test.ExpectedCommandEnv, upgradeTest.expectedCommand) + + action := UpgradeAction{Steps: []UpgradeStep{upgradeTest.upgradeStep}} + b, _ := yaml.Marshal(action) + + h := NewTestMixin(t) + h.In = bytes.NewReader(b) + + err := h.Upgrade() + + require.NoError(t, err) + }) + } +} diff --git a/pkg/kubernetes/version.go b/pkg/kubernetes/version.go new file mode 100644 index 0000000000..3265089ebc --- /dev/null +++ b/pkg/kubernetes/version.go @@ -0,0 +1,11 @@ +package kubernetes + +import ( + "fmt" + + "github.com/deislabs/porter/pkg" +) + +func (m *Mixin) PrintVersion() { + fmt.Fprintf(m.Out, "kubernetes mixin %s (%s)\n", pkg.Version, pkg.Commit) +}