diff --git a/Dockerfile b/Dockerfile index 492dd54..c8fa2ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM alpine/helm MAINTAINER Erin Call COPY build/drone-helm /bin/drone-helm -COPY kubeconfig /root/.kube/config.tpl +COPY assets/kubeconfig.tpl /root/.kube/config.tpl LABEL description="Helm 3 plugin for Drone 3" LABEL base="alpine/helm" diff --git a/README.md b/README.md index 04f55a3..592baee 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,71 @@ # Drone plugin for Helm 3 -Dissatisfied with this empty README? Consider grabbing [the "put stuff in the README" issue](https://github.com/pelotech/drone-helm3/issues/8)! +This plugin provides an interface between [Drone](https://drone.io/) and [Helm 3](https://github.com/kubernetes/helm): + +* Lint your charts +* Deploy your service +* Delete your service + +The plugin is inpsired by [drone-helm](https://github.com/ipedrazas/drone-helm), which fills the same role for Helm 2. It provides a comparable feature-set and the configuration settings are backwards-compatible. + +## Example configuration + +The examples below give a minimal and sufficient configuration for each use-case. For a full description of each command's settings, see [docs/parameter_reference.md](docs/parameter_reference.md). + +### Linting + +```yaml +steps: + - name: lint + image: pelotech/drone-helm3 + settings: + helm_command: lint + chart: ./ +``` + +### Installation + +```yaml +steps: + - name: deploy + image: pelotech/drone-helm3 + settings: + helm_command: upgrade + chart: ./ + release: my-project + environment: + API_SERVER: https://my.kubernetes.installation/clusters/a-1234 + KUBERNETES_TOKEN: + from_secret: kubernetes_token +``` + +### Uninstallation + +```yaml +steps: + - name: uninstall + image: pelotech/drone-helm3 + settings: + helm_command: uninstall + release: my-project + environment: + API_SERVER: https://my.kubernetes.installation/clusters/a-1234 + KUBERNETES_TOKEN: + from_secret: kubernetes_token +``` + +## Upgrading from drone-helm + +drone-helm3 is largely backwards-compatible with drone-helm. There are some known differences: + +* `prefix` must be supplied via the `settings` block, not `environment`. +* Several settings no longer have any effect: + * `purge` -- this is the default behavior in Helm 3 + * `recreate_pods` + * `tiller_ns` + * `upgrade` + * `canary_image` + * `client_only` + * `stable_repo_url` + +Since helm 3 does not require Tiller, we also recommend switching to a service account with less-expansive permissions. diff --git a/kubeconfig b/assets/kubeconfig.tpl similarity index 96% rename from kubeconfig rename to assets/kubeconfig.tpl index c7b2025..92ec2c7 100644 --- a/kubeconfig +++ b/assets/kubeconfig.tpl @@ -3,7 +3,7 @@ clusters: - cluster: {{- if eq .SkipTLSVerify true }} insecure-skip-tls-verify: true -{{- else }} +{{- else if .Certificate }} certificate-authority-data: {{ .Certificate }} {{- end}} server: {{ .APIServer }} diff --git a/cmd/drone-helm/main.go b/cmd/drone-helm/main.go index 61673b2..43d2e30 100644 --- a/cmd/drone-helm/main.go +++ b/cmd/drone-helm/main.go @@ -2,22 +2,21 @@ package main import ( "fmt" - "github.com/kelseyhightower/envconfig" "os" "github.com/pelotech/drone-helm3/internal/helm" ) func main() { - var c helm.Config + cfg, err := helm.NewConfig(os.Stdout, os.Stderr) - if err := envconfig.Process("plugin", &c); err != nil { + if err != nil { fmt.Fprintf(os.Stderr, "%s\n", err.Error()) return } // Make the plan - plan, err := helm.NewPlan(c) + plan, err := helm.NewPlan(*cfg) if err != nil { fmt.Fprintf(os.Stderr, "%w\n", err) os.Exit(1) diff --git a/docs/parameter_reference.md b/docs/parameter_reference.md new file mode 100644 index 0000000..07d8107 --- /dev/null +++ b/docs/parameter_reference.md @@ -0,0 +1,129 @@ +# Parameter reference + +## Global +| Param name | Type | Purpose | +|---------------------|-----------------|---------| +| helm_command | string | Indicates the operation to perform. Recommended, but not required. Valid options are `upgrade`, `uninstall`, `lint`, and `help`. | +| update_dependencies | boolean | Calls `helm dependency update` before running the main command. **Not currently implemented**; see [#25](https://github.com/pelotech/drone-helm3/issues/25).| +| helm_repos | list\ | Calls `helm repo add $repo` before running the main command. Each string should be formatted as `repo_name=https://repo.url/`. **Not currently implemented**; see [#26](https://github.com/pelotech/drone-helm3/issues/26). | +| namespace | string | Kubernetes namespace to use for this operation. | +| prefix | string | Expect environment variables to be prefixed with the given string. For more details, see "Using the prefix setting" below. **Not currently implemented**; see [#19](https://github.com/pelotech/drone-helm3/issues/19). | +| debug | boolean | Generate debug output within drone-helm3 and pass `--debug` to all helm commands. Use with care, since the debug output may include secrets. | + +## Linting + +Linting is only triggered when the `helm_command` setting is "lint". + +| Param name | Type | Required | Purpose | +|---------------|----------------|----------|---------| +| chart | string | yes | The chart to be linted. Must be a local path. | +| values | list\ | | Chart values to use as the `--set` argument to `helm lint`. | +| string_values | list\ | | Chart values to use as the `--set-string` argument to `helm lint`. | +| values_files | list\ | | Values to use as `--values` arguments to `helm lint`. | + +## Installation + +Installations are triggered when the `helm_command` setting is "upgrade." They can also be triggered when the build was triggered by a `push`, `tag`, `deployment`, `pull_request`, `promote`, or `rollback` Drone event. + +| Param name | Type | Required | Purpose | +|------------------------|----------------|----------|---------| +| chart | string | yes | The chart to use for this installation. | +| release | string | yes | The release name for helm to use. | +| api_server | string | yes | API endpoint for the Kubernetes cluster. | +| kubernetes_token | string | yes | Token for authenticating to Kubernetes. | +| service_account | string | | Service account for authenticating to Kubernetes. Default is `helm`. | +| kubernetes_certificate | string | | Base64 encoded TLS certificate used by the Kubernetes cluster's certificate authority. | +| chart_version | string | | Specific chart version to install. | +| dry_run | boolean | | Pass `--dry-run` to `helm upgrade`. | +| wait | boolean | | Wait until kubernetes resources are in a ready state before marking the installation successful. | +| timeout | duration | | Timeout for any *individual* Kubernetes operation. The installation's full runtime may exceed this duration. | +| force | boolean | | Pass `--force` to `helm upgrade`. | +| values | list\ | | Chart values to use as the `--set` argument to `helm upgrade`. | +| string_values | list\ | | Chart values to use as the `--set-string` argument to `helm upgrade`. | +| values_files | list\ | | Values to use as `--values` arguments to `helm upgrade`. | +| reuse_values | boolean | | Reuse the values from a previous release. | +| skip_tls_verify | boolean | | Connect to the Kubernetes cluster without checking for a valid TLS certificate. Not recommended in production. | + +## Uninstallation + +Uninstallations are triggered when the `helm_command` setting is "uninstall" or "delete." They can also be triggered when the build was triggered by a `delete` Drone event. + +| Param name | Type | Required | Purpose | +|------------------------|----------|----------|---------| +| release | string | yes | The release name for helm to use. | +| api_server | string | yes | API endpoint for the Kubernetes cluster. | +| kubernetes_token | string | yes | Token for authenticating to Kubernetes. | +| service_account | string | | Service account for authenticating to Kubernetes. Default is `helm`. | +| kubernetes_certificate | string | | Base64 encoded TLS certificate used by the Kubernetes cluster's certificate authority. | +| dry_run | boolean | | Pass `--dry-run` to `helm uninstall`. | +| timeout | duration | | Timeout for any *individual* Kubernetes operation. The uninstallation's full runtime may exceed this duration. | +| skip_tls_verify | boolean | | Connect to the Kubernetes cluster without checking for a valid TLS certificate. Not recommended in production. | + +### Where to put settings + +Any setting (with the exception of `prefix`; [see below](#user-content-using-the-prefix-setting)), can go in either the `settings` or `environment` section. + +### Formatting non-string values + +* Booleans can be yaml's `true` and `false` literals or the strings `"true"` and `"false"`. +* Durations are strings formatted with the syntax accepted by [golang's ParseDuration function](https://golang.org/pkg/time/#ParseDuration) (e.g. 5m30s) +* List\s can be a yaml sequence or a comma-separated string. + +All of the following are equivalent: + +```yaml +values: "foo=1,bar=2" +values: ["foo=1", "bar=2"] +values: + - foo=1 + - bar=2 +``` + +Note that **list members must not contain commas**. Both of the following are equivalent: + +```yaml +values_files: [ "./over_9,000.yml" ] +values_files: [ "./over_9", "000.yml" ] +``` + +### Using the `prefix` setting + +Because the prefix setting is meta-configuration, it has some inherent edge-cases. Here is what it does in the cases we've thought of: + +Unlike the other settings, it must be declared in the `settings` block, not `environment`: + +```yaml +settings: + prefix: helm # drone-helm3 will look for environment variables called HELM_VARNAME +environment: + prefix: armet # no effect +``` + +It does not apply to configuration in the `settings` block, only in `environment`: + +```yaml +settings: + prefix: helm + helm_timeout: 5m # no effect +environment: + helm_timeout: 2m # timeout will be 2 minutes +``` + +If the environment contains a variable in non-prefixed form, it will still be applied: + +```yaml +settings: + prefix: helm +environment: + timeout: 2m # timeout will be 2 minutes +``` + +If the environment contains both the prefixed and non-prefixed forms, drone-helm3 will use the prefixed form: + +```yaml +settings: + prefix: helm +environment: + timeout: 5m # overridden + helm_timeout: 2m # timeout will be 2 minutes +``` diff --git a/go.mod b/go.mod index 4f4371b..c34f191 100644 --- a/go.mod +++ b/go.mod @@ -8,4 +8,5 @@ require ( github.com/stretchr/testify v1.4.0 golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f // indirect golang.org/x/tools v0.0.0-20191209225234-22774f7dae43 // indirect + gopkg.in/yaml.v2 v2.2.2 ) diff --git a/internal/helm/config.go b/internal/helm/config.go index 9795cd2..987b6de 100644 --- a/internal/helm/config.go +++ b/internal/helm/config.go @@ -2,62 +2,76 @@ package helm import ( "fmt" - "strings" + "github.com/kelseyhightower/envconfig" + "io" ) -// The Config struct captures the `settings` and `environment` blocks inthe application's drone +// The Config struct captures the `settings` and `environment` blocks in the application's drone // config. Configuration in drone's `settings` block arrives as uppercase env vars matching the -// config key, prefixed with `PLUGIN_`. Config from the `environment` block is *not* prefixed; any -// keys that are likely to be in that block (i.e. things that use `from_secret` need an explicit -// `envconfig:` tag so that envconfig will look for a non-prefixed env var. +// config key, prefixed with `PLUGIN_`. Config from the `environment` block is uppercased, but does +// not have the `PLUGIN_` prefix. It may, however, be prefixed with the value in `$PLUGIN_PREFIX`. type Config struct { // Configuration for drone-helm itself - Command helmCommand `envconfig:"HELM_COMMAND"` // Helm command to run - DroneEvent string `envconfig:"DRONE_BUILD_EVENT"` // Drone event that invoked this plugin. - UpdateDependencies bool `split_words:"true"` // call `helm dependency update` before the main command - Repos []string `envconfig:"HELM_REPOS"` // call `helm repo add` before the main command - Prefix string `` // Prefix to use when looking up secret env vars + Command string `envconfig:"HELM_COMMAND"` // Helm command to run + DroneEvent string `envconfig:"DRONE_BUILD_EVENT"` // Drone event that invoked this plugin. + UpdateDependencies bool `split_words:"true"` // Call `helm dependency update` before the main command + Repos []string `envconfig:"HELM_REPOS"` // Call `helm repo add` before the main command + Prefix string `` // Prefix to use when looking up secret env vars + Debug bool `` // Generate debug output and pass --debug to all helm commands + Values string `` // Argument to pass to --set in applicable helm commands + StringValues string `split_words:"true"` // Argument to pass to --set-string in applicable helm commands + ValuesFiles []string `split_words:"true"` // Arguments to pass to --values in applicable helm commands + Namespace string `` // Kubernetes namespace for all helm commands + KubeToken string `envconfig:"KUBERNETES_TOKEN"` // Kubernetes authentication token to put in .kube/config + SkipTLSVerify bool `envconfig:"SKIP_TLS_VERIFY"` // Put insecure-skip-tls-verify in .kube/config + Certificate string `envconfig:"KUBERNETES_CERTIFICATE"` // The Kubernetes cluster CA's self-signed certificate (must be base64-encoded) + APIServer string `envconfig:"API_SERVER"` // The Kubernetes cluster's API endpoint + ServiceAccount string `split_words:"true"` // Account to use for connecting to the Kubernetes cluster + ChartVersion string `split_words:"true"` // Specific chart version to use in `helm upgrade` + DryRun bool `split_words:"true"` // Pass --dry-run to applicable helm commands + Wait bool `` // Pass --wait to applicable helm commands + ReuseValues bool `split_words:"true"` // Pass --reuse-values to `helm upgrade` + Timeout string `` // Argument to pass to --timeout in applicable helm commands + Chart string `` // Chart argument to use in applicable helm commands + Release string `` // Release argument to use in applicable helm commands + Force bool `` // Pass --force to applicable helm commands - // Global helm config - Debug bool `` // global helm flag (also applies to drone-helm itself) - KubeConfig string `split_words:"true" default:"/root/.kube/config"` // path to the kube config file - Values string `` - StringValues string `split_words:"true"` - ValuesFiles []string `split_words:"true"` - Namespace string `` - KubeToken string `envconfig:"KUBERNETES_TOKEN"` - SkipTLSVerify bool `envconfig:"SKIP_TLS_VERIFY"` - Certificate string `envconfig:"KUBERNETES_CERTIFICATE"` - APIServer string `envconfig:"API_SERVER"` - ServiceAccount string `envconfig:"SERVICE_ACCOUNT"` // Can't just use split_words; need envconfig to find the non-prefixed form - - // Config specifically for `helm upgrade` - ChartVersion string `split_words:"true"` // - DryRun bool `split_words:"true"` // also available for `delete` - Wait bool `` // - ReuseValues bool `split_words:"true"` // - Timeout string `` // - Chart string `` // Also available for `lint`, in which case it must be a path to a chart directory - Release string `` - Force bool `` // + Stdout io.Writer `ignored:"true"` + Stderr io.Writer `ignored:"true"` } -type helmCommand string +// NewConfig creates a Config and reads environment variables into it, accounting for several possible formats. +func NewConfig(stdout, stderr io.Writer) (*Config, error) { + cfg := Config{ + Stdout: stdout, + Stderr: stderr, + } + if err := envconfig.Process("plugin", &cfg); err != nil { + return nil, err + } -// helmCommand.Decode checks the given value against the list of known commands and generates a helpful error if the command is unknown. -func (cmd *helmCommand) Decode(value string) error { - known := []string{"upgrade", "delete", "lint", "help"} - for _, c := range known { - if value == c { - *cmd = helmCommand(value) - return nil + prefix := cfg.Prefix + + if err := envconfig.Process("", &cfg); err != nil { + return nil, err + } + + if prefix != "" { + if err := envconfig.Process(cfg.Prefix, &cfg); err != nil { + return nil, err } } - if value == "" { - return nil + if cfg.Debug && cfg.Stderr != nil { + cfg.logDebug() } - known[len(known)-1] = fmt.Sprintf("or %s", known[len(known)-1]) - return fmt.Errorf("unknown command '%s'. If specified, command must be %s", - value, strings.Join(known, ", ")) + + return &cfg, nil +} + +func (cfg Config) logDebug() { + if cfg.KubeToken != "" { + cfg.KubeToken = "(redacted)" + } + fmt.Fprintf(cfg.Stderr, "Generated config: %+v\n", cfg) } diff --git a/internal/helm/config_test.go b/internal/helm/config_test.go index 1df2d4f..3ca2f91 100644 --- a/internal/helm/config_test.go +++ b/internal/helm/config_test.go @@ -2,27 +2,179 @@ package helm import ( "github.com/stretchr/testify/suite" + "os" + "strings" "testing" ) type ConfigTestSuite struct { suite.Suite + // These tests need to mutate the environment, so the suite.setenv and .unsetenv functions store the original contents of the + // relevant variable in this map. Its use of *string is so they can distinguish between "not set" and "set to empty string" + envBackup map[string]*string } func TestConfigTestSuite(t *testing.T) { suite.Run(t, new(ConfigTestSuite)) } -func (suite *ConfigTestSuite) TestHelmCommandDecodeSuccess() { - cmd := helmCommand("") - err := cmd.Decode("upgrade") - suite.Require().Nil(err) +func (suite *ConfigTestSuite) TestNewConfigWithPluginPrefix() { + suite.unsetenv("PLUGIN_PREFIX") + suite.unsetenv("HELM_COMMAND") + suite.unsetenv("UPDATE_DEPENDENCIES") + suite.unsetenv("DEBUG") - suite.EqualValues(cmd, "upgrade") + suite.setenv("PLUGIN_HELM_COMMAND", "execute order 66") + suite.setenv("PLUGIN_UPDATE_DEPENDENCIES", "true") + suite.setenv("PLUGIN_DEBUG", "true") + + cfg, err := NewConfig(&strings.Builder{}, &strings.Builder{}) + suite.Require().NoError(err) + + suite.Equal("execute order 66", cfg.Command) + suite.True(cfg.UpdateDependencies) + suite.True(cfg.Debug) } -func (suite *ConfigTestSuite) TestHelmCommandDecodeFailure() { - cmd := helmCommand("") - err := cmd.Decode("execute order 66") - suite.EqualError(err, "unknown command 'execute order 66'. If specified, command must be upgrade, delete, lint, or help") +func (suite *ConfigTestSuite) TestNewConfigWithNoPrefix() { + suite.unsetenv("PLUGIN_PREFIX") + suite.unsetenv("PLUGIN_HELM_COMMAND") + suite.unsetenv("PLUGIN_UPDATE_DEPENDENCIES") + suite.unsetenv("PLUGIN_DEBUG") + + suite.setenv("HELM_COMMAND", "execute order 66") + suite.setenv("UPDATE_DEPENDENCIES", "true") + suite.setenv("DEBUG", "true") + + cfg, err := NewConfig(&strings.Builder{}, &strings.Builder{}) + suite.Require().NoError(err) + + suite.Equal("execute order 66", cfg.Command) + suite.True(cfg.UpdateDependencies) + suite.True(cfg.Debug) +} + +func (suite *ConfigTestSuite) TestNewConfigWithConfigurablePrefix() { + suite.unsetenv("API_SERVER") + suite.unsetenv("PLUGIN_API_SERVER") + + suite.setenv("PLUGIN_PREFIX", "prix_fixe") + suite.setenv("PRIX_FIXE_API_SERVER", "your waiter this evening") + + cfg, err := NewConfig(&strings.Builder{}, &strings.Builder{}) + suite.Require().NoError(err) + + suite.Equal("prix_fixe", cfg.Prefix) + suite.Equal("your waiter this evening", cfg.APIServer) +} + +func (suite *ConfigTestSuite) TestPrefixSettingDoesNotAffectPluginPrefix() { + suite.setenv("PLUGIN_PREFIX", "IXFREP") + suite.setenv("PLUGIN_HELM_COMMAND", "wake me up") + suite.setenv("IXFREP_PLUGIN_HELM_COMMAND", "send me to sleep inside") + + cfg, err := NewConfig(&strings.Builder{}, &strings.Builder{}) + suite.Require().NoError(err) + + suite.Equal("wake me up", cfg.Command) +} + +func (suite *ConfigTestSuite) TestPrefixSettingMustHavePluginPrefix() { + suite.unsetenv("PLUGIN_PREFIX") + suite.setenv("PREFIX", "refpix") + suite.setenv("HELM_COMMAND", "gimme more") + suite.setenv("REFPIX_HELM_COMMAND", "gimme less") + + cfg, err := NewConfig(&strings.Builder{}, &strings.Builder{}) + suite.Require().NoError(err) + + suite.Equal("gimme more", cfg.Command) +} + +func (suite *ConfigTestSuite) TestNewConfigWithConflictingVariables() { + suite.setenv("PLUGIN_HELM_COMMAND", "execute order 66") + suite.setenv("HELM_COMMAND", "defend the jedi") // values from the `environment` block override those from `settings` + + suite.setenv("PLUGIN_PREFIX", "prod") + suite.setenv("TIMEOUT", "5m0s") + suite.setenv("PROD_TIMEOUT", "2m30s") // values from prefixed env vars override those from non-prefixed ones + + cfg, err := NewConfig(&strings.Builder{}, &strings.Builder{}) + suite.Require().NoError(err) + + suite.Equal("defend the jedi", cfg.Command) + suite.Equal("2m30s", cfg.Timeout) +} + +func (suite *ConfigTestSuite) TestNewConfigSetsWriters() { + stdout := &strings.Builder{} + stderr := &strings.Builder{} + cfg, err := NewConfig(stdout, stderr) + suite.Require().NoError(err) + + suite.Equal(stdout, cfg.Stdout) + suite.Equal(stderr, cfg.Stderr) +} + +func (suite *ConfigTestSuite) TestLogDebug() { + suite.setenv("DEBUG", "true") + suite.setenv("HELM_COMMAND", "upgrade") + + stderr := strings.Builder{} + stdout := strings.Builder{} + _, err := NewConfig(&stdout, &stderr) + suite.Require().NoError(err) + + suite.Equal("", stdout.String()) + + suite.Regexp(`^Generated config: \{Command:upgrade.*\}`, stderr.String()) +} + +func (suite *ConfigTestSuite) TestLogDebugCensorsKubeToken() { + stderr := &strings.Builder{} + kubeToken := "I'm shy! Don't put me in your build logs!" + cfg := Config{ + Debug: true, + KubeToken: kubeToken, + Stderr: stderr, + } + + cfg.logDebug() + + suite.Contains(stderr.String(), "KubeToken:(redacted)") + suite.Equal(kubeToken, cfg.KubeToken) // The actual config value should be left unchanged +} + +func (suite *ConfigTestSuite) setenv(key, val string) { + orig, ok := os.LookupEnv(key) + if ok { + suite.envBackup[key] = &orig + } else { + suite.envBackup[key] = nil + } + os.Setenv(key, val) +} + +func (suite *ConfigTestSuite) unsetenv(key string) { + orig, ok := os.LookupEnv(key) + if ok { + suite.envBackup[key] = &orig + } else { + suite.envBackup[key] = nil + } + os.Unsetenv(key) +} + +func (suite *ConfigTestSuite) BeforeTest(_, _ string) { + suite.envBackup = make(map[string]*string) +} + +func (suite *ConfigTestSuite) AfterTest(_, _ string) { + for key, val := range suite.envBackup { + if val == nil { + os.Unsetenv(key) + } else { + os.Setenv(key, *val) + } + } } diff --git a/internal/helm/plan.go b/internal/helm/plan.go index 1d4ced9..54de589 100644 --- a/internal/helm/plan.go +++ b/internal/helm/plan.go @@ -6,7 +6,10 @@ import ( "os" ) -const kubeConfigTemplate = "/root/.kube/config.tpl" +const ( + kubeConfigTemplate = "/root/.kube/config.tpl" + kubeConfigFile = "/root/.kube/config" +) // A Step is one step in the plan. type Step interface { @@ -27,13 +30,12 @@ func NewPlan(cfg Config) (*Plan, error) { cfg: cfg, runCfg: run.Config{ Debug: cfg.Debug, - KubeConfig: cfg.KubeConfig, Values: cfg.Values, StringValues: cfg.StringValues, ValuesFiles: cfg.ValuesFiles, Namespace: cfg.Namespace, - Stdout: os.Stdout, - Stderr: os.Stderr, + Stdout: cfg.Stdout, + Stderr: cfg.Stderr, }, } @@ -67,6 +69,7 @@ func determineSteps(cfg Config) *func(Config) []Step { return &help default: switch cfg.DroneEvent { + // Note: These events are documented in docs/upgrade_settings.yml. Any changes here should be reflected there. case "push", "tag", "deployment", "pull_request", "promote", "rollback": return &upgrade case "delete": @@ -81,11 +84,11 @@ func determineSteps(cfg Config) *func(Config) []Step { func (p *Plan) Execute() error { for i, step := range p.steps { if p.cfg.Debug { - fmt.Fprintf(os.Stderr, "calling %T.Execute (step %d)\n", step, i) + fmt.Fprintf(p.cfg.Stderr, "calling %T.Execute (step %d)\n", step, i) } if err := step.Execute(p.runCfg); err != nil { - return fmt.Errorf("in execution step %d: %w", i, err) + return fmt.Errorf("while executing %T step: %w", step, err) } } @@ -141,6 +144,7 @@ func initKube(cfg Config) []Step { ServiceAccount: cfg.ServiceAccount, Token: cfg.KubeToken, TemplateFile: kubeConfigTemplate, + ConfigFile: kubeConfigFile, }, } } diff --git a/internal/helm/plan_test.go b/internal/helm/plan_test.go index e5a1a96..4808aef 100644 --- a/internal/helm/plan_test.go +++ b/internal/helm/plan_test.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" - "os" + "strings" "testing" "github.com/pelotech/drone-helm3/internal/run" @@ -20,6 +20,7 @@ func TestPlanTestSuite(t *testing.T) { func (suite *PlanTestSuite) TestNewPlan() { ctrl := gomock.NewController(suite.T()) + defer ctrl.Finish() stepOne := NewMockStep(ctrl) stepTwo := NewMockStep(ctrl) @@ -29,25 +30,27 @@ func (suite *PlanTestSuite) TestNewPlan() { } defer func() { help = origHelp }() + stdout := strings.Builder{} + stderr := strings.Builder{} cfg := Config{ Command: "help", Debug: false, - KubeConfig: "/branch/.sfere/profig", Values: "steadfastness,forthrightness", StringValues: "tensile_strength,flexibility", ValuesFiles: []string{"/root/price_inventory.yml"}, Namespace: "outer", + Stdout: &stdout, + Stderr: &stderr, } runCfg := run.Config{ Debug: false, - KubeConfig: "/branch/.sfere/profig", Values: "steadfastness,forthrightness", StringValues: "tensile_strength,flexibility", ValuesFiles: []string{"/root/price_inventory.yml"}, Namespace: "outer", - Stdout: os.Stdout, - Stderr: os.Stderr, + Stdout: &stdout, + Stderr: &stderr, } stepOne.EXPECT(). @@ -63,6 +66,7 @@ func (suite *PlanTestSuite) TestNewPlan() { func (suite *PlanTestSuite) TestNewPlanAbortsOnError() { ctrl := gomock.NewController(suite.T()) + defer ctrl.Finish() stepOne := NewMockStep(ctrl) stepTwo := NewMockStep(ctrl) @@ -85,6 +89,51 @@ func (suite *PlanTestSuite) TestNewPlanAbortsOnError() { suite.EqualError(err, "while preparing *helm.MockStep step: I'm starry Dave, aye, cat blew that") } +func (suite *PlanTestSuite) TestExecute() { + ctrl := gomock.NewController(suite.T()) + defer ctrl.Finish() + stepOne := NewMockStep(ctrl) + stepTwo := NewMockStep(ctrl) + + runCfg := run.Config{} + + plan := Plan{ + steps: []Step{stepOne, stepTwo}, + runCfg: runCfg, + } + + stepOne.EXPECT(). + Execute(runCfg). + Times(1) + stepTwo.EXPECT(). + Execute(runCfg). + Times(1) + + suite.NoError(plan.Execute()) +} + +func (suite *PlanTestSuite) TestExecuteAbortsOnError() { + ctrl := gomock.NewController(suite.T()) + defer ctrl.Finish() + stepOne := NewMockStep(ctrl) + stepTwo := NewMockStep(ctrl) + + runCfg := run.Config{} + + plan := Plan{ + steps: []Step{stepOne, stepTwo}, + runCfg: runCfg, + } + + stepOne.EXPECT(). + Execute(runCfg). + Times(1). + Return(fmt.Errorf("oh, he'll gnaw")) + + err := plan.Execute() + suite.EqualError(err, "while executing *helm.MockStep step: oh, he'll gnaw") +} + func (suite *PlanTestSuite) TestUpgrade() { cfg := Config{ ChartVersion: "seventeen", @@ -142,6 +191,7 @@ func (suite *PlanTestSuite) TestDel() { ServiceAccount: "greathelm", Token: "b2YgbXkgYWZmZWN0aW9u", TemplateFile: kubeConfigTemplate, + ConfigFile: kubeConfigFile, } suite.Equal(expected, init) @@ -176,6 +226,7 @@ func (suite *PlanTestSuite) TestInitKube() { ServiceAccount: "helmet", Token: "cXVlZXIgY2hhcmFjdGVyCg==", TemplateFile: kubeConfigTemplate, + ConfigFile: kubeConfigFile, } suite.Equal(expected, init) } diff --git a/internal/run/config.go b/internal/run/config.go index 09b9642..4f9b99a 100644 --- a/internal/run/config.go +++ b/internal/run/config.go @@ -7,7 +7,6 @@ import ( // Config contains configuration applicable to all helm commands type Config struct { Debug bool - KubeConfig string Values string StringValues string ValuesFiles []string diff --git a/internal/run/initkube.go b/internal/run/initkube.go index 4af29af..fc0fb11 100644 --- a/internal/run/initkube.go +++ b/internal/run/initkube.go @@ -16,6 +16,7 @@ type InitKube struct { ServiceAccount string Token string TemplateFile string + ConfigFile string template *template.Template configFile io.WriteCloser @@ -34,7 +35,7 @@ type kubeValues struct { // Execute generates a kubernetes config file from drone-helm3's template. func (i *InitKube) Execute(cfg Config) error { if cfg.Debug { - fmt.Fprintf(cfg.Stderr, "writing kubeconfig file to %s\n", cfg.KubeConfig) + fmt.Fprintf(cfg.Stderr, "writing kubeconfig file to %s\n", i.ConfigFile) } defer i.configFile.Close() return i.template.Execute(i.configFile, i.values) @@ -50,9 +51,6 @@ func (i *InitKube) Prepare(cfg Config) error { if i.Token == "" { return errors.New("token is needed to deploy") } - if i.Certificate == "" && !i.SkipTLSVerify { - return errors.New("certificate is needed to deploy") - } if i.ServiceAccount == "" { i.ServiceAccount = "helm" @@ -76,16 +74,16 @@ func (i *InitKube) Prepare(cfg Config) error { } if cfg.Debug { - if _, err := os.Stat(cfg.KubeConfig); err != nil { + if _, err := os.Stat(i.ConfigFile); err != nil { // non-nil err here isn't an actual error state; the kubeconfig just doesn't exist fmt.Fprint(cfg.Stderr, "creating ") } else { fmt.Fprint(cfg.Stderr, "truncating ") } - fmt.Fprintf(cfg.Stderr, "kubeconfig file at %s\n", cfg.KubeConfig) + fmt.Fprintf(cfg.Stderr, "kubeconfig file at %s\n", i.ConfigFile) } - i.configFile, err = os.Create(cfg.KubeConfig) + i.configFile, err = os.Create(i.ConfigFile) if err != nil { return fmt.Errorf("could not open kubeconfig file for writing: %w", err) } diff --git a/internal/run/initkube_test.go b/internal/run/initkube_test.go index fb32b15..72452a8 100644 --- a/internal/run/initkube_test.go +++ b/internal/run/initkube_test.go @@ -1,12 +1,12 @@ package run import ( + "github.com/stretchr/testify/suite" + yaml "gopkg.in/yaml.v2" "io/ioutil" "os" - "text/template" - // "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" "testing" + "text/template" ) type InitKubeTestSuite struct { @@ -34,10 +34,10 @@ namespace: {{ .Namespace }} Certificate: "CCNA", Token: "Aspire virtual currency", TemplateFile: templateFile.Name(), + ConfigFile: configFile.Name(), } cfg := Config{ - Namespace: "Cisco", - KubeConfig: configFile.Name(), + Namespace: "Cisco", } err = init.Prepare(cfg) suite.Require().Nil(err) @@ -58,6 +58,59 @@ namespace: Cisco suite.Equal(want, string(conf)) } +func (suite *InitKubeTestSuite) TestExecuteGeneratesConfig() { + configFile, err := tempfile("kubeconfig********.yml", "") + defer os.Remove(configFile.Name()) + suite.Require().NoError(err) + + cfg := Config{ + Namespace: "marshmallow", + } + init := InitKube{ + ConfigFile: configFile.Name(), + TemplateFile: "../../assets/kubeconfig.tpl", // the actual kubeconfig template + APIServer: "https://kube.cluster/peanut", + ServiceAccount: "chef", + Token: "eWVhaCB3ZSB0b2tpbic=", + Certificate: "d293LCB5b3UgYXJlIHNvIGNvb2wgZm9yIHNtb2tpbmcgd2VlZCDwn5mE", + } + suite.Require().NoError(init.Prepare(cfg)) + suite.Require().NoError(init.Execute(cfg)) + + contents, err := ioutil.ReadFile(configFile.Name()) + suite.Require().NoError(err) + + // each setting should be reflected in the generated file + expectations := []string{ + "namespace: marshmallow", + "server: https://kube.cluster/peanut", + "user: chef", + "name: chef", + "token: eWVhaCB3ZSB0b2tpbic", + "certificate-authority-data: d293LCB5b3UgYXJlIHNvIGNvb2wgZm9yIHNtb2tpbmcgd2VlZCDwn5mE", + } + for _, expected := range expectations { + suite.Contains(string(contents), expected) + } + + // the generated config should be valid yaml, with no repeated keys + conf := map[string]interface{}{} + suite.NoError(yaml.UnmarshalStrict(contents, &conf)) + + // test the other branch of the certificate/SkipTLSVerify conditional + init.SkipTLSVerify = true + init.Certificate = "" + + suite.Require().NoError(init.Prepare(cfg)) + suite.Require().NoError(init.Execute(cfg)) + contents, err = ioutil.ReadFile(configFile.Name()) + suite.Require().NoError(err) + suite.Contains(string(contents), "insecure-skip-tls-verify: true") + + conf = map[string]interface{}{} + suite.NoError(yaml.UnmarshalStrict(contents, &conf)) +} + func (suite *InitKubeTestSuite) TestPrepareParseError() { templateFile, err := tempfile("kubeconfig********.yml.tpl", `{{ NonexistentFunction }}`) defer os.Remove(templateFile.Name()) @@ -95,11 +148,10 @@ func (suite *InitKubeTestSuite) TestPrepareCannotOpenDestinationFile() { Certificate: "CCNA", Token: "Aspire virtual currency", TemplateFile: templateFile.Name(), + ConfigFile: "/usr/foreign/exclude/kubeprofig", } - cfg := Config{ - KubeConfig: "/usr/foreign/exclude/kubeprofig", - } + cfg := Config{} err = init.Prepare(cfg) suite.Error(err) suite.Regexp("could not open .* for writing: .* no such file or directory", err) @@ -120,11 +172,10 @@ func (suite *InitKubeTestSuite) TestPrepareRequiredConfig() { Certificate: "CCNA", Token: "Aspire virtual currency", TemplateFile: templateFile.Name(), + ConfigFile: configFile.Name(), } - cfg := Config{ - KubeConfig: configFile.Name(), - } + cfg := Config{} suite.NoError(init.Prepare(cfg)) // consistency check; we should be starting in a happy state @@ -134,13 +185,6 @@ func (suite *InitKubeTestSuite) TestPrepareRequiredConfig() { init.APIServer = "Sysadmin" init.Token = "" suite.Error(init.Prepare(cfg), "Token should be required.") - - init.Token = "Aspire virtual currency" - init.Certificate = "" - suite.Error(init.Prepare(cfg), "Certificate should be required.") - - init.SkipTLSVerify = true - suite.NoError(init.Prepare(cfg), "Certificate should not be required if SkipTLSVerify is true") } func (suite *InitKubeTestSuite) TestPrepareDefaultsServiceAccount() { @@ -157,11 +201,10 @@ func (suite *InitKubeTestSuite) TestPrepareDefaultsServiceAccount() { Certificate: "CCNA", Token: "Aspire virtual currency", TemplateFile: templateFile.Name(), + ConfigFile: configFile.Name(), } - cfg := Config{ - KubeConfig: configFile.Name(), - } + cfg := Config{} init.Prepare(cfg) suite.Equal("helm", init.ServiceAccount) diff --git a/internal/run/lint.go b/internal/run/lint.go index e2843ca..1993b49 100644 --- a/internal/run/lint.go +++ b/internal/run/lint.go @@ -16,6 +16,8 @@ func (l *Lint) Execute(_ Config) error { } // Prepare gets the Lint ready to execute. +// Note: mandatory settings are documented in README.md, and the full list of settings is in docs/lint_settings.yml. +// Any additions or deletions here should be reflected there. func (l *Lint) Prepare(cfg Config) error { if l.Chart == "" { return fmt.Errorf("chart is required") diff --git a/internal/run/uninstall.go b/internal/run/uninstall.go index 1b9d0b2..e24daca 100644 --- a/internal/run/uninstall.go +++ b/internal/run/uninstall.go @@ -22,7 +22,7 @@ func (u *Uninstall) Prepare(cfg Config) error { return fmt.Errorf("release is required") } - args := []string{"--kubeconfig", cfg.KubeConfig} + args := make([]string, 0) if cfg.Namespace != "" { args = append(args, "--namespace", cfg.Namespace) diff --git a/internal/run/uninstall_test.go b/internal/run/uninstall_test.go index 9e826c9..fce7fee 100644 --- a/internal/run/uninstall_test.go +++ b/internal/run/uninstall_test.go @@ -58,11 +58,9 @@ func (suite *UninstallTestSuite) TestPrepareAndExecute() { Run(). Times(1) - cfg := Config{ - KubeConfig: "/root/.kube/config", - } + cfg := Config{} suite.NoError(u.Prepare(cfg)) - expected := []string{"--kubeconfig", "/root/.kube/config", "uninstall", "zayde_wølf_king"} + expected := []string{"uninstall", "zayde_wølf_king"} suite.Equal(expected, actual) u.Execute(cfg) @@ -73,15 +71,13 @@ func (suite *UninstallTestSuite) TestPrepareDryRunFlag() { Release: "firefox_ak_wildfire", DryRun: true, } - cfg := Config{ - KubeConfig: "/root/.kube/config", - } + cfg := Config{} suite.mockCmd.EXPECT().Stdout(gomock.Any()).AnyTimes() suite.mockCmd.EXPECT().Stderr(gomock.Any()).AnyTimes() suite.NoError(u.Prepare(cfg)) - expected := []string{"--kubeconfig", "/root/.kube/config", "uninstall", "--dry-run", "firefox_ak_wildfire"} + expected := []string{"uninstall", "--dry-run", "firefox_ak_wildfire"} suite.Equal(expected, suite.actualArgs) } @@ -90,16 +86,14 @@ func (suite *UninstallTestSuite) TestPrepareNamespaceFlag() { Release: "carly_simon_run_away_with_me", } cfg := Config{ - KubeConfig: "/root/.kube/config", - Namespace: "emotion", + Namespace: "emotion", } suite.mockCmd.EXPECT().Stdout(gomock.Any()).AnyTimes() suite.mockCmd.EXPECT().Stderr(gomock.Any()).AnyTimes() suite.NoError(u.Prepare(cfg)) - expected := []string{"--kubeconfig", "/root/.kube/config", - "--namespace", "emotion", "uninstall", "carly_simon_run_away_with_me"} + expected := []string{"--namespace", "emotion", "uninstall", "carly_simon_run_away_with_me"} suite.Equal(expected, suite.actualArgs) } @@ -109,9 +103,8 @@ func (suite *UninstallTestSuite) TestPrepareDebugFlag() { } stderr := strings.Builder{} cfg := Config{ - KubeConfig: "/root/.kube/config", - Debug: true, - Stderr: &stderr, + Debug: true, + Stderr: &stderr, } command = func(path string, args ...string) cmd { @@ -126,8 +119,8 @@ func (suite *UninstallTestSuite) TestPrepareDebugFlag() { suite.mockCmd.EXPECT().Stderr(&stderr).AnyTimes() suite.NoError(u.Prepare(cfg)) - suite.Equal(fmt.Sprintf("Generated command: '%s --kubeconfig /root/.kube/config "+ - "--debug uninstall just_a_band_huff_and_puff'\n", helmBin), stderr.String()) + suite.Equal(fmt.Sprintf("Generated command: '%s --debug "+ + "uninstall just_a_band_huff_and_puff'\n", helmBin), stderr.String()) } func (suite *UninstallTestSuite) TestPrepareRequiresRelease() { diff --git a/internal/run/upgrade.go b/internal/run/upgrade.go index cff2c70..fc561aa 100644 --- a/internal/run/upgrade.go +++ b/internal/run/upgrade.go @@ -25,6 +25,8 @@ func (u *Upgrade) Execute(_ Config) error { } // Prepare gets the Upgrade ready to execute. +// Note: mandatory settings are documented in README.md, and the full list of settings is in docs/upgrade_settings.yml. +// Any additions or deletions here should be reflected there. func (u *Upgrade) Prepare(cfg Config) error { if u.Chart == "" { return fmt.Errorf("chart is required") @@ -33,7 +35,7 @@ func (u *Upgrade) Prepare(cfg Config) error { return fmt.Errorf("release is required") } - args := []string{"--kubeconfig", cfg.KubeConfig} + args := make([]string, 0) if cfg.Namespace != "" { args = append(args, "--namespace", cfg.Namespace) diff --git a/internal/run/upgrade_test.go b/internal/run/upgrade_test.go index 13b2080..aae1af4 100644 --- a/internal/run/upgrade_test.go +++ b/internal/run/upgrade_test.go @@ -41,8 +41,7 @@ func (suite *UpgradeTestSuite) TestPrepareAndExecute() { command = func(path string, args ...string) cmd { suite.Equal(helmBin, path) - suite.Equal([]string{"--kubeconfig", "/root/.kube/config", "upgrade", "--install", - "jonas_brothers_only_human", "at40"}, args) + suite.Equal([]string{"upgrade", "--install", "jonas_brothers_only_human", "at40"}, args) return suite.mockCmd } @@ -55,9 +54,7 @@ func (suite *UpgradeTestSuite) TestPrepareAndExecute() { Run(). Times(1) - cfg := Config{ - KubeConfig: "/root/.kube/config", - } + cfg := Config{} err := u.Prepare(cfg) suite.Require().Nil(err) u.Execute(cfg) @@ -73,8 +70,7 @@ func (suite *UpgradeTestSuite) TestPrepareNamespaceFlag() { command = func(path string, args ...string) cmd { suite.Equal(helmBin, path) - suite.Equal([]string{"--kubeconfig", "/root/.kube/config", "--namespace", "melt", "upgrade", - "--install", "shaed_trampoline", "at40"}, args) + suite.Equal([]string{"--namespace", "melt", "upgrade", "--install", "shaed_trampoline", "at40"}, args) return suite.mockCmd } @@ -83,8 +79,7 @@ func (suite *UpgradeTestSuite) TestPrepareNamespaceFlag() { suite.mockCmd.EXPECT().Stderr(gomock.Any()) cfg := Config{ - Namespace: "melt", - KubeConfig: "/root/.kube/config", + Namespace: "melt", } err := u.Prepare(cfg) suite.Require().Nil(err) @@ -105,7 +100,6 @@ func (suite *UpgradeTestSuite) TestPrepareWithUpgradeFlags() { } cfg := Config{ - KubeConfig: "/root/.kube/config", Values: "age=35", StringValues: "height=5ft10in", ValuesFiles: []string{"/usr/local/stats", "/usr/local/grades"}, @@ -113,7 +107,7 @@ func (suite *UpgradeTestSuite) TestPrepareWithUpgradeFlags() { command = func(path string, args ...string) cmd { suite.Equal(helmBin, path) - suite.Equal([]string{"--kubeconfig", "/root/.kube/config", "upgrade", "--install", + suite.Equal([]string{"upgrade", "--install", "--version", "radio_edit", "--dry-run", "--wait", @@ -165,10 +159,9 @@ func (suite *UpgradeTestSuite) TestPrepareDebugFlag() { stdout := strings.Builder{} stderr := strings.Builder{} cfg := Config{ - Debug: true, - KubeConfig: "/root/.kube/config", - Stdout: &stdout, - Stderr: &stderr, + Debug: true, + Stdout: &stdout, + Stderr: &stderr, } command = func(path string, args ...string) cmd { @@ -186,7 +179,7 @@ func (suite *UpgradeTestSuite) TestPrepareDebugFlag() { u.Prepare(cfg) - want := fmt.Sprintf("Generated command: '%s --kubeconfig /root/.kube/config --debug upgrade "+ + want := fmt.Sprintf("Generated command: '%s --debug upgrade "+ "--install lewis_capaldi_someone_you_loved at40'\n", helmBin) suite.Equal(want, stderr.String()) suite.Equal("", stdout.String())