diff --git a/README.md b/README.md index 8ee87c1..f34f5df 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,25 @@ This plugin allows to deploy a [Helm](https://github.com/kubernetes/helm) chart * Current `helm` version: 2.5.1 * Current `kubectl` version: 1.6.6 +## Drone Pipeline Usage + +### Simple Usage + For example, this configuration will deploy my-app using a chart located in the repo called `my-chart` ```YAML pipeline: helm_deploy: - image: quay.io/ipedrazas/drone-helm - skip_tls_verify: true - chart: ./charts/my-chart - release: ${DRONE_BRANCH} - values: secret.password=${SECRET_PASSWORD},image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} - prefix: STAGING - debug: true - wait: true - when: - branch: [master] + image: quay.io/ipedrazas/drone-helm + skip_tls_verify: true + chart: ./charts/my-chart + release: ${DRONE_BRANCH} + values: secret.password=${SECRET_PASSWORD},image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} + prefix: STAGING + debug: true + wait: true + when: + branch: [master] ``` Last update of Drone expect you to declare the secrets you want to use: @@ -36,6 +40,8 @@ Last update of Drone expect you to declare the secrets you want to use: branch: [master] ``` +### Using Values and Value files + Values can be passed using the `values_files` key. Use this option to define your values in a set of files and pass them to `helm`. This option trigger the `-f` or ``--values`` flag in `helm`: @@ -48,15 +54,36 @@ For example: ```YAML pipeline: helm_deploy: - image: quay.io/ipedrazas/drone-helm - skip_tls_verify: true - chart: ./charts/my-chart - release: ${DRONE_BRANCH} - values_files: ["global-values.yaml", "myenv-values.yaml"] - when: - branch: [master] + image: quay.io/ipedrazas/drone-helm + skip_tls_verify: true + chart: ./charts/my-chart + release: ${DRONE_BRANCH} + values_files: ["global-values.yaml", "myenv-values.yaml"] + when: + branch: [master] ``` +### Using private Repositories + +Charts can also be fetched from your own private Chart Repository. `helm_repos` accepts a comma separated list of key value pairs where the key is the repository name and the value is the repository url. + +For Example: +``` +helm_deploy_staging: + image: quay.io/ipedrazas/drone-helm + skip_tls_verify: true + helm_repos: hb-charts=http://helm-charts.honestbee.com + chart: hb-charts/hello-world + values: image.repository=quay.io/honestbee/hello-drone-helm,image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} + release: ${DRONE_REPO_NAME}-${DRONE_BRANCH} + prefix: STAGING + when: + branch: + exclude: [ master ] +``` + +## Drone Secrets + There are two secrets you have to create (Note that if you specify the prefix, your secrets have to be created using that prefix): ```Bash @@ -74,44 +101,66 @@ drone secret add --image=quay.io/ipedrazas/drone-helm \ ```YAML pipeline: helm_deploy_staging: - image: quay.io/ipedrazas/drone-helm - skip_tls_verify: true - chart: ./charts/my-chart - release: ${DRONE_BRANCH} - values: secret.password=${SECRET_PASSWORD},image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} - prefix: STAGING - debug: true - wait: true - when: - branch: - exclude: [ master ] + image: quay.io/ipedrazas/drone-helm + skip_tls_verify: true + chart: ./charts/my-chart + release: ${DRONE_BRANCH} + values: secret.password=${SECRET_PASSWORD},image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} + prefix: STAGING + debug: true + wait: true + when: + branch: + exclude: [ master ] pipeline_production: helm_deploy: - image: quay.io/ipedrazas/drone-helm - skip_tls_verify: true - chart: ./charts/my-chart - release: ${DRONE_BRANCH} - values: secret.password=${SECRET_PASSWORD},image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} - prefix: PROD - debug: true - wait: true - when: - branch: [master] + image: quay.io/ipedrazas/drone-helm + skip_tls_verify: true + chart: ./charts/my-chart + release: ${DRONE_BRANCH} + values: secret.password=${SECRET_PASSWORD},image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} + prefix: PROD + debug: true + wait: true + when: + branch: [master] ``` This last block defines how the plugin will deploy +## Testing with Minikube + To test the plugin, you can run `minikube` and just run the docker image as follows: + +By using the docker daemon of minikube we can test local builds without having to push to a registry: + +```bash +eval $(minikube docker-env) +``` + +Build the image locally + +```bash +./build.sh +``` + +Get the token for the default service account in the default namespace: + +```bash +KUBERNETES_TOKEN=$(kubectl get secret $(kubectl get sa default -o jsonpath='{.secrets[].name}{"\n"}') -o jsonpath="{.data.token}" | base64 -D) +``` + +Run the local image (or replace `drone-helm` with `quay.io/honestbee/drone-helm`: ```Bash docker run --rm \ - -e PLUGIN_API_SERVER=https://192.168.64.5:8443 \ - -e PLUGIN_TOKEN="" \ + -e API_SERVER="https://$(minikube ip):8443" \ + -e KUBERNETES_TOKEN="${KUBERNETES_TOKEN}" \ -e PLUGIN_NAMESPACE=default \ -e PLUGIN_SKIP_TLS_VERIFY=true \ -e PLUGIN_RELEASE=my-release \ - -e PLUGIMN_CHART=stable/redis \ + -e PLUGIN_CHART=stable/redis \ -e PLUGIN_VALUES="tag=TAG,api=API" \ -e PLUGIN_DEBUG=true \ -e PLUGIN_DRY_RUN=true \ @@ -119,21 +168,23 @@ docker run --rm \ quay.io/ipedrazas/drone-helm ``` +## Advanced customisations and debugging + This plugin installs [Tiller](https://github.com/kubernetes/helm/blob/master/docs/architecture.md) in the cluster, if you want to specify the namespace where `tiller` ins installed, use the `tiller_ns` attribute. The following example will install `tiller` in the `operations` namespace: ```YAML pipeline_production: helm_deploy: - image: quay.io/ipedrazas/drone-helm - skip_tls_verify: true - chart: ./charts/my-chart - release: ${DRONE_BRANCH} - values: image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} - prefix: PROD - tiller_ns: operations - when: - branch: [master] + image: quay.io/ipedrazas/drone-helm + skip_tls_verify: true + chart: ./charts/my-chart + release: ${DRONE_BRANCH} + values: image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} + prefix: PROD + tiller_ns: operations + when: + branch: [master] ``` There's an option to do a `dry-run` in case you want to verify that the secrets and envvars are replaced correctly. Just add the attribute `dry-run` to true: @@ -141,14 +192,14 @@ There's an option to do a `dry-run` in case you want to verify that the secrets ```YAML pipeline_production: helm_deploy: - image: quay.io/ipedrazas/drone-helm - skip_tls_verify: true - chart: ./charts/my-chart - release: ${DRONE_BRANCH} - values: image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} - prefix: STAGING - dry-run:true - when: - branch: [master] + image: quay.io/ipedrazas/drone-helm + skip_tls_verify: true + chart: ./charts/my-chart + release: ${DRONE_BRANCH} + values: image.tag=${DRONE_BRANCH}-${DRONE_COMMIT_SHA:0:7} + prefix: STAGING + dry-run:true + when: + branch: [master] ``` Happy Helming! diff --git a/main.go b/main.go index f93f070..9015707 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,11 @@ func main() { Usage: "Kubernetes helm release", EnvVar: "PLUGIN_RELEASE,RELEASE", }, + cli.StringSliceFlag{ + Name: "helm_repos", + Usage: "Repos helm should add", + EnvVar: "PLUGIN_HELM_REPOS,HELM_REPOS", + }, cli.StringFlag{ Name: "chart", Usage: "Kubernetes helm chart name", @@ -146,6 +151,7 @@ func run(c *cli.Context) error { Values: c.String("values"), ValuesFiles: c.String("values_files"), Release: c.String("release"), + HelmRepos: c.StringSlice("helm_repos"), Chart: c.String("chart"), Version: c.String("chart-version"), Debug: c.Bool("debug"), diff --git a/plugin.go b/plugin.go index 11791ad..0d97e47 100644 --- a/plugin.go +++ b/plugin.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "regexp" + "strconv" "strings" "github.com/alecthomas/template" @@ -43,6 +44,7 @@ type ( ReuseValues bool `json:"reuse_values"` Timeout string `json:"timeout"` Force bool `json:"force"` + HelmRepos []string `json:"helm_repos"` } // Plugin default Plugin struct { @@ -135,6 +137,37 @@ func setHelmCommand(p *Plugin) { } +var repoExp = regexp.MustCompile(`^(?P[\w-]+)=(?P(http|https)://[\w-./:]+)`) + +// parseRepo returns map of regex capture groups (name, url) +func parseRepo(repo string) (map[string]string, error) { + matches := repoExp.FindStringSubmatch(repo) + if len(matches) < 1 { + return nil, fmt.Errorf("Invalid repo definition: %s", repo) + } + result := make(map[string]string) + for i, name := range repoExp.SubexpNames() { + if i != 0 { + result[name] = matches[i] + } + } + return result, nil +} + +func doHelmRepoAdd(repo string) ([]string, error) { + repoMap, err := parseRepo(unQuote(repo)) + if err != nil { + return nil, err + } + repoAdd := []string{ + "repo", + "add", + repoMap["name"], + repoMap["url"], + } + return repoAdd, nil +} + func doHelmInit(p *Plugin) []string { init := make([]string, 1) init[0] = "init" @@ -179,6 +212,23 @@ func (p *Plugin) Exec() error { if err != nil { return fmt.Errorf("Error running helm command: " + strings.Join(init[:], " ")) } + + if len(p.Config.HelmRepos) > 0 { + for _, repo := range p.Config.HelmRepos { + repoAdd, err := doHelmRepoAdd(repo) + if err == nil { + if p.Config.Debug { + log.Println("adding helm repo: " + strings.Join(repoAdd[:], " ")) + } + if err = runCommand(repoAdd); err != nil { + return fmt.Errorf("Error adding helm repo: " + err.Error()) + } + } else { + return err + } + } + } + setHelmCommand(p) if p.Config.Debug { @@ -271,6 +321,7 @@ func (p *Plugin) debug() { fmt.Printf("Api server: %s \n", p.Config.APIServer) fmt.Printf("Values: %s \n", p.Config.Values) fmt.Printf("Secrets: %s \n", p.Config.Secrets) + fmt.Printf("Helm Repos: %s \n", p.Config.HelmRepos) fmt.Printf("ValuesFiles: %s \n", p.Config.ValuesFiles) kubeconfig, err := ioutil.ReadFile(KUBECONFIG) diff --git a/plugin_test.go b/plugin_test.go index 7d027d3..63f6a47 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io/ioutil" "os" "strings" @@ -74,15 +75,71 @@ func TestGetHelmCommand(t *testing.T) { } func TestResolveSecrets(t *testing.T) { - tag := "v0.1.1" - api := "http://apiserver" - token := "12345" - account := "helm" - os.Setenv("MY_TAG", tag) - os.Setenv("MY_API_SERVER", api) - os.Setenv("MY_KUBERNETES_TOKEN", token) - os.Setenv("MY_SERVICE_ACCOUNT", "helm") + testEnvs := []struct { + prefix string + tag string + api string + token string + account string + }{ + {prefix: "PROD", tag: "v0.1.1", api: "http://apiserver", token: "12345", account: "helm"}, + {prefix: "STAGING", tag: "12345678", api: "http://apiserver", token: "12345", account: "helm"}, + } + for _, env := range testEnvs { + envMap := map[string]string{ + "TAG": env.tag, + "API_SERVER": env.api, + "KUBERNETES_TOKEN": env.token, + "SERVICE_ACCOUNT": env.account, + } + + for envKey, envValue := range envMap { + os.Setenv(fmt.Sprintf("%s_%s", env.prefix, envKey), envValue) + } + + plugin := &Plugin{ + Config: Config{ + HelmCommand: nil, + Namespace: "default", + SkipTLSVerify: true, + Debug: true, + DryRun: true, + Chart: "./chart/test", + Release: "test-release", + Prefix: env.prefix, + Values: "image.tag=$TAG,api=${API_SERVER},nameOverride=my-over-app,second.tag=${TAG}", + }, + } + + resolveSecrets(plugin) + // test that the subsitution works + fmt.Println(plugin.Config.Values) + if !strings.Contains(plugin.Config.Values, env.tag) { + t.Errorf("env var ${TAG} not resolved %s", env.tag) + } + if strings.Contains(plugin.Config.Values, "${TAG}") { + t.Errorf("env var ${TAG} not resolved %s", env.tag) + } + + if plugin.Config.APIServer != env.api { + t.Errorf("env var ${API_SERVER} not resolved %s", env.api) + } + if plugin.Config.Token != env.token { + t.Errorf("env var ${KUBERNETES_TOKEN} not resolved %s", env.token) + } + if plugin.Config.ServiceAccount != env.account { + t.Errorf("env var ${SERVICE_ACCOUNT} not resolved %s", env.account) + } + + // clean up + for envKey, _ := range envMap { + os.Unsetenv(fmt.Sprintf("%s_%s", env.prefix, envKey)) + } + } +} + +func TestDetHelmRepoAdd(t *testing.T) { plugin := &Plugin{ Config: Config{ HelmCommand: nil, @@ -94,26 +151,37 @@ func TestResolveSecrets(t *testing.T) { Release: "test-release", Prefix: "MY", Values: "image.tag=$TAG,api=${API_SERVER},nameOverride=my-over-app,second.tag=${TAG}", + ClientOnly: true, + HelmRepos: []string{ + `"r1=http://r1.example.com"`, //handle quoted strings + `r2=http://r2.example.com`, //and unquoted strings + }, }, } - - resolveSecrets(plugin) - // test that the subsitution works - if !strings.Contains(plugin.Config.Values, tag) { - t.Errorf("env var ${TAG} not resolved %s", tag) - } - if strings.Contains(plugin.Config.Values, "${TAG}") { - t.Errorf("env var ${TAG} not resolved %s", tag) + expected := []string{ + "repo add r1 http://r1.example.com", + "repo add r2 http://r2.example.com", } - if plugin.Config.APIServer != api { - t.Errorf("env var ${API_SERVER} not resolved %s", api) + for i, r := range plugin.Config.HelmRepos { + repos, err := doHelmRepoAdd(r) + if err != nil { + t.Error(err) + } + result := strings.Join(repos, " ") + if expected[i] != result { + t.Errorf("Helm cannot add remote repositories - expected %q - got %q", + expected[i], + result, + ) + } } - if plugin.Config.Token != token { - t.Errorf("env var ${KUBERNETES_TOKEN} not resolved %s", token) - } - if plugin.Config.ServiceAccount != account { - t.Errorf("env var ${SERVICE_ACCOUNT} not resolved %s", account) +} + +func TestHelmAddRepositoryError(t *testing.T) { + _, err := doHelmRepoAdd("drone-helm=bad://drone-helm.example.com:443/stable") + if err == nil { + t.Errorf("Expect to see error when repo URL is invalid") } }