diff --git a/.circleci/config.yml b/.circleci/config.yml index 780ad780..5050643d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -16,6 +16,7 @@ jobs: - go-mod-cache-v1- - run: go env - run: make build + - run: make build-test-tools - save_cache: key: go-mod-cache-v1-{{ checksum "./go.sum" }} paths: @@ -87,7 +88,10 @@ jobs: - run: mkdir ~/build - attach_workspace: at: ~/build - - run: cp ~/build/helmfile ~/project/helmfile + - run: + command: | + cp ~/build/helmfile ~/project/helmfile + cp ~/build/diff-yamls ~/project/diff-yamls - run: name: Install helm environment: @@ -98,6 +102,16 @@ jobs: tar zxf ${HELM_FILENAME} linux-amd64/helm chmod +x linux-amd64/helm sudo mv linux-amd64/helm /usr/local/bin/ + - run: + name: Install kustomize + environment: + KUSTOMIZE_VERSION: v3.6.1 + command: | + KUSTOMIZE_FILENAME="kustomize_${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" + curl -Lo ${KUSTOMIZE_FILENAME} "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2F${KUSTOMIZE_VERSION}/${KUSTOMIZE_FILENAME}" + tar zxf ${KUSTOMIZE_FILENAME} kustomize + chmod +x kustomize + sudo mv kustomize /usr/local/bin/ - run: name: Deploy minikube environment: @@ -132,7 +146,10 @@ jobs: - run: mkdir ~/build - attach_workspace: at: ~/build - - run: cp ~/build/helmfile ~/project/helmfile + - run: + command: | + cp ~/build/helmfile ~/project/helmfile + cp ~/build/diff-yamls ~/project/diff-yamls - run: name: Install helm environment: @@ -143,6 +160,16 @@ jobs: tar zxf ${HELM_FILENAME} linux-amd64/helm chmod +x linux-amd64/helm sudo mv linux-amd64/helm /usr/local/bin/ + - run: + name: Install kustomize + environment: + KUSTOMIZE_VERSION: v3.6.1 + command: | + KUSTOMIZE_FILENAME="kustomize_${KUSTOMIZE_VERSION}_linux_amd64.tar.gz" + curl -Lo ${KUSTOMIZE_FILENAME} "https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2F${KUSTOMIZE_VERSION}/${KUSTOMIZE_FILENAME}" + tar zxf ${KUSTOMIZE_FILENAME} kustomize + chmod +x kustomize + sudo mv kustomize /usr/local/bin/ - run: name: Deploy minikube environment: diff --git a/.gitignore b/.gitignore index e3f7895a..583077f4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ dist/ .idea/ helmfile helmfile.lock +test/integration/tmp vendor/ diff --git a/Makefile b/Makefile index 0492ee43..0d2c3fce 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,10 @@ check: go vet ${PKGS} .PHONY: check +build-test-tools: + go build test/diff-yamls.go +.PHONY: build-test-tools + test: go test -v ${PKGS} -cover -race -p=1 .PHONY: test diff --git a/go.mod b/go.mod index b0d8ae31..ad39d9e0 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect github.com/pierrec/lz4 v2.3.0+incompatible // indirect github.com/r3labs/diff v0.0.0-20190801153147-a71de73c46ad + github.com/spf13/cobra v0.0.3 github.com/tatsushid/go-prettytable v0.0.0-20141013043238-ed2d14c29939 github.com/urfave/cli v1.20.0 github.com/variantdev/chartify v0.3.7 @@ -35,4 +36,5 @@ require ( gopkg.in/yaml.v2 v2.2.4 gotest.tools v2.2.0+incompatible gotest.tools/v3 v3.0.3-0.20200410202438-4e4a41b7851a + k8s.io/apimachinery v0.0.0-20190409092423-760d1845f48b ) diff --git a/go.sum b/go.sum index 36bbcdb4..ae103667 100644 --- a/go.sum +++ b/go.sum @@ -755,6 +755,7 @@ github.com/smartystreets/goconvey v0.0.0-20190710185942-9d28bd7c0945/go.mod h1:s github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= diff --git a/pkg/state/state.go b/pkg/state/state.go index 71c041cc..04643f1e 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -898,7 +898,10 @@ func (st *HelmState) TemplateReleases(helm helmexec.Interface, outputDir string, flags = append(flags, "--output-dir", releaseOutputDir) st.logger.Debugf("Generating templates to : %s\n", releaseOutputDir) - os.Mkdir(releaseOutputDir, 0755) + err = os.MkdirAll(releaseOutputDir, 0755) + if err != nil { + errs = append(errs, err) + } } if validate { diff --git a/test/diff-yamls.go b/test/diff-yamls.go new file mode 100644 index 00000000..c53bb9eb --- /dev/null +++ b/test/diff-yamls.go @@ -0,0 +1,275 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/go-test/deep" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/yaml" +) + +type diffSource string + +const ( + diffSourceLeft diffSource = "left" + diffSourceRight diffSource = "right" +) + +var ( + rootCmd = &cobra.Command{ + Use: "diff-yamls dir-with-yamls/ dir-with-yamls/", + Short: "Print any diff between the given directories", + Long: `Similar to the 'diff' command, but file contents +are compared after being parsed as a set of Kubernetes manifests.`, + Args: cobra.ExactArgs(2), + Run: run, + } +) + +func run(cmd *cobra.Command, args []string) { + left := args[0] + right := args[1] + leftYamls, err := globYamlFilenames(left) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + rightYamls, err := globYamlFilenames(right) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + exitCode := 0 + + onlyInLeft := leftYamls.Difference(rightYamls) + if onlyInLeft.Len() > 0 { + exitCode = 1 + for _, f := range onlyInLeft.List() { + fmt.Fprintf(os.Stderr, "Only in %s: %s\n", left, f) + } + } + + onlyInRight := rightYamls.Difference(leftYamls) + if onlyInRight.Len() > 0 { + exitCode = 1 + for _, f := range onlyInRight.List() { + fmt.Fprintf(os.Stderr, "Only in %s: %s\n", right, f) + } + } + + inBoth := leftYamls.Intersection(rightYamls) + for _, f := range inBoth.List() { + leftPath := filepath.Join(left, f) + leftNodes, err := readManifest(leftPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + rightPath := filepath.Join(right, f) + rightNodes, err := readManifest(rightPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + ps := pairs{} + for _, node := range leftNodes { + if err := ps.add(node, diffSourceLeft); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + } + for _, node := range rightNodes { + if err := ps.add(node, diffSourceRight); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + } + for _, p := range ps.list { + switch { + case p.left != nil && p.right == nil: + fmt.Fprintf(os.Stderr, "Only in %s: %s\n", leftPath, p.left.getId()) + exitCode = 1 + case p.left == nil && p.right != nil: + fmt.Fprintf(os.Stderr, "Only in %s: %s\n", rightPath, p.right.getId()) + exitCode = 1 + default: + diff := deep.Equal(p.left, p.right) + if diff != nil { + id := p.left.getId() + fmt.Fprintf(os.Stderr, "< %s %s\n", id, leftPath) + fmt.Fprintf(os.Stderr, "> %s %s\n", id, rightPath) + for _, d := range diff { + fmt.Fprintf(os.Stderr, "%s\n", d) + } + exitCode = 1 + } + } + } + } + + os.Exit(exitCode) +} + +func globYamlFilenames(dir string) (sets.String, error) { + matches, err := filepath.Glob(filepath.Join(dir, "*.yaml")) + if err != nil { + return nil, err + } + set := sets.String{} + for _, f := range matches { + set.Insert(filepath.Base(f)) + } + return set, nil +} + +type resource map[string]interface{} + +type meta struct { + apiVersion string + kind string + name string + namespace string +} + +func (res resource) getMeta() (meta, error) { + if len(res) == 0 { + return meta{}, nil + } + m := meta{} + apiVersion, _ := res["apiVersion"].(string) + m.apiVersion = apiVersion + kind, _ := res["kind"].(string) + m.kind = kind + metadata, _ := res["metadata"].(map[string]interface{}) + name, _ := metadata["name"].(string) + m.name = name + namespace, _ := metadata["namespace"].(string) + m.namespace = namespace + return m, nil +} + +func readManifest(path string) ([]resource, error) { + var err error + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + decoder := yaml.NewYAMLToJSONDecoder(f) + resources := []resource{} + for { + r := make(resource) + err = decoder.Decode(&r) + if err != nil { + break + } + if len(r) > 0 { + resources = append(resources, r) + } + } + if err != nil && err != io.EOF { + return nil, err + } + return resources, nil +} + +func (res resource) getId() string { + meta, err := res.getMeta() + if err != nil { + return fmt.Sprintf("%v", res) + } + ns := meta.namespace + if ns == "" { + ns = "~X" + } + nm := meta.name + if nm == "" { + nm = "~N" + } + gv := meta.apiVersion + if gv == "" { + gv = "~G_~V" + } + k := meta.kind + if k == "" { + k = "~K" + } + gvk := strings.Join([]string{gv, k}, "_") + return strings.Join([]string{gvk, ns, nm}, "|") + +} + +// lifted from kustomize/kyaml/kio/filters/merge3.go +type pairs struct { + list []*pair +} + +func (ps *pairs) isSameResource(meta1, meta2 meta) bool { + if meta1.name != meta2.name { + return false + } + if meta1.namespace != meta2.namespace { + return false + } + if meta1.apiVersion != meta2.apiVersion { + return false + } + if meta1.kind != meta2.kind { + return false + } + return true +} + +func (ps *pairs) add(node resource, source diffSource) error { + nodeMeta, err := node.getMeta() + if err != nil { + return err + } + for i := range ps.list { + p := ps.list[i] + if ps.isSameResource(p.meta, nodeMeta) { + return p.add(node, source) + } + } + p := &pair{meta: nodeMeta} + if err := p.add(node, source); err != nil { + return err + } + ps.list = append(ps.list, p) + return nil +} + +type pair struct { + meta meta + left resource + right resource +} + +func (p *pair) add(node resource, source diffSource) error { + switch source { + case diffSourceLeft: + if p.left != nil { + return fmt.Errorf("left source already specified") + } + p.left = node + case diffSourceRight: + if p.right != nil { + return fmt.Errorf("right source already specified") + } + p.right = node + default: + return fmt.Errorf("unknown diff source value: %s", source) + } + return nil + +} + +func main() { + rootCmd.Execute() +} diff --git a/test/integration/charts/helmx/.helmignore b/test/integration/charts/helmx/.helmignore new file mode 100644 index 00000000..f0c13194 --- /dev/null +++ b/test/integration/charts/helmx/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/test/integration/charts/helmx/Chart.yaml b/test/integration/charts/helmx/Chart.yaml new file mode 100644 index 00000000..9b0b7020 --- /dev/null +++ b/test/integration/charts/helmx/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Chart for testing helmx features +name: helmx +version: 0.1.0 diff --git a/test/integration/charts/helmx/templates/_helpers.tpl b/test/integration/charts/helmx/templates/_helpers.tpl new file mode 100644 index 00000000..d92e46b8 --- /dev/null +++ b/test/integration/charts/helmx/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "helmx.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "helmx.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/test/integration/charts/helmx/templates/configmaps.yaml b/test/integration/charts/helmx/templates/configmaps.yaml new file mode 100644 index 00000000..6cb00a9d --- /dev/null +++ b/test/integration/charts/helmx/templates/configmaps.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: release-name +data: + name: {{ .Release.Name }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: release-namespace +data: + namespace: {{ .Release.Namespace }} diff --git a/test/integration/charts/helmx/values.yaml b/test/integration/charts/helmx/values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/test/integration/happypath.yaml b/test/integration/happypath.yaml index 8186ed87..03fea81b 100644 --- a/test/integration/happypath.yaml +++ b/test/integration/happypath.yaml @@ -25,3 +25,17 @@ releases: values: - mysecret: {{ .Environment.Values.mysecret }} - values.yaml + + - name: helmx + chart: ./charts/helmx + namespace: helmx-system + jsonPatches: + - target: + version: v1 + kind: ConfigMap + name: release-name + patch: + - op: add + path: /metadata/annotations + value: + foo: bar diff --git a/test/integration/run.sh b/test/integration/run.sh index 14707b4c..c1292a73 100755 --- a/test/integration/run.sh +++ b/test/integration/run.sh @@ -47,12 +47,15 @@ set -e info "Using namespace: ${test_ns}" # helm v2 if helm version --client 2>/dev/null | grep '"v2\.'; then + helm_major_version=2 info "Using Helm version: $(helm version --short --client | grep -o v.*$)" ${helm} init --wait --override spec.template.spec.automountServiceAccountToken=true # helm v3 else + helm_major_version=3 info "Using Helm version: $(helm version --short | grep -o v.*$)" fi +info "Using Kustomize version: $(kustomize version --short | grep -o 'v[^ ]+')" ${helm} plugin install https://github.com/databus23/helm-diff --version v3.0.0-rc.7 ${kubectl} get namespace ${test_ns} &> /dev/null && warn "Namespace ${test_ns} exists, from a previous test run?" $kubectl create namespace ${test_ns} || fail "Could not create namespace ${test_ns}" @@ -73,9 +76,19 @@ info "Diffing ${dir}/happypath.yaml with limited context" bash -c "${helmfile} -f ${dir}/happypath.yaml diff --context 3 --detailed-exitcode; code="'$?'"; [ "'${code}'" -eq 2 ]" || fail "unexpected exit code returned by helmfile diff" info "Templating ${dir}/happypath.yaml" -${helmfile} -f ${dir}/happypath.yaml template +rm -rf ${dir}/tmp +${helmfile} -f ${dir}/happypath.yaml --debug template --output-dir tmp code=$? [ ${code} -eq 0 ] || fail "unexpected exit code returned by helmfile template: ${code}" +for output in $(ls -d ${dir}/tmp/*); do + # e.g. test/integration/tmp/happypath-877c0dd4-helmx/helmx + for release_dir in $(ls -d ${output}/*); do + release_name=$(basename ${release_dir}) + golden_dir=${dir}/templates-golden/v${helm_major_version}/${release_name} + info "Comparing template output ${release_dir}/templates with ${golden_dir}" + ./diff-yamls ${golden_dir} ${release_dir}/templates || fail "unexpected diff in template result for ${release_name}" + done +done info "Applying ${dir}/happypath.yaml" bash -c "${helmfile} -f ${dir}/happypath.yaml apply --detailed-exitcode; code="'$?'"; echo Code: "'$code'"; [ "'${code}'" -eq 2 ]" || fail "unexpected exit code returned by helmfile apply" diff --git a/test/integration/templates-golden/v2/helmx/helmx.all.yaml b/test/integration/templates-golden/v2/helmx/helmx.all.yaml new file mode 100644 index 00000000..6af9f074 --- /dev/null +++ b/test/integration/templates-golden/v2/helmx/helmx.all.yaml @@ -0,0 +1,17 @@ +--- +# Source: helmx/templates/helmx.all.yaml +apiVersion: v1 +data: + namespace: helmx-system +kind: ConfigMap +metadata: + name: release-namespace +--- +apiVersion: v1 +data: + name: helmx +kind: ConfigMap +metadata: + name: release-name + annotations: + foo: bar diff --git a/test/integration/templates-golden/v2/httpbin/deployment.yaml b/test/integration/templates-golden/v2/httpbin/deployment.yaml new file mode 100644 index 00000000..3493d178 --- /dev/null +++ b/test/integration/templates-golden/v2/httpbin/deployment.yaml @@ -0,0 +1,39 @@ +--- +# Source: httpbin/templates/deployment.yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: +metadata: + name: httpbin-httpbin + labels: + app: httpbin + chart: httpbin-0.1.0 + release: httpbin + heritage: Tiller +spec: + replicas: 1 + strategy: {} + template: + metadata: + labels: + app: httpbin + release: httpbin + spec: + containers: + - name: httpbin + image: "docker.io/citizenstig/httpbin:latest" + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: / + port: 8000 + readinessProbe: + httpGet: + path: / + port: 8000 + ports: + - containerPort: 8000 + resources: + {} + +status: {} diff --git a/test/integration/templates-golden/v2/httpbin/service.yaml b/test/integration/templates-golden/v2/httpbin/service.yaml new file mode 100644 index 00000000..0a0d86c4 --- /dev/null +++ b/test/integration/templates-golden/v2/httpbin/service.yaml @@ -0,0 +1,21 @@ +--- +# Source: httpbin/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: httpbin-httpbin + labels: + app: httpbin + chart: httpbin-0.1.0 + release: httpbin + heritage: Tiller +spec: + type: LoadBalancer + ports: + - port: 8000 + targetPort: 8000 + protocol: TCP + name: httpbin + selector: + app: httpbin + release: httpbin diff --git a/test/integration/templates-golden/v2/raw/resources.yaml b/test/integration/templates-golden/v2/raw/resources.yaml new file mode 100644 index 00000000..cce0f8f2 --- /dev/null +++ b/test/integration/templates-golden/v2/raw/resources.yaml @@ -0,0 +1,13 @@ +--- +# Source: raw/templates/resources.yaml +apiVersion: v1 +kind: Secret +metadata: + labels: + app: raw + chart: raw-0.2.3 + heritage: Tiller + release: raw + name: common-secret +stringData: + mykey: MYSECRET diff --git a/test/integration/templates-golden/v3/helmx/helmx.all.yaml b/test/integration/templates-golden/v3/helmx/helmx.all.yaml new file mode 100644 index 00000000..f036ace6 --- /dev/null +++ b/test/integration/templates-golden/v3/helmx/helmx.all.yaml @@ -0,0 +1,18 @@ +--- +# Source: helmx/templates/helmx.all.yaml +apiVersion: v1 +data: + namespace: helmx-system +kind: ConfigMap +metadata: + name: release-namespace +--- +# Source: helmx/templates/helmx.all.yaml +apiVersion: v1 +data: + name: helmx +kind: ConfigMap +metadata: + name: release-name + annotations: + foo: bar diff --git a/test/integration/templates-golden/v3/httpbin/deployment.yaml b/test/integration/templates-golden/v3/httpbin/deployment.yaml new file mode 100644 index 00000000..97ff6a02 --- /dev/null +++ b/test/integration/templates-golden/v3/httpbin/deployment.yaml @@ -0,0 +1,39 @@ +--- +# Source: httpbin/templates/deployment.yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: +metadata: + name: httpbin-httpbin + labels: + app: httpbin + chart: httpbin-0.1.0 + release: httpbin + heritage: Helm +spec: + replicas: 1 + strategy: {} + template: + metadata: + labels: + app: httpbin + release: httpbin + spec: + containers: + - name: httpbin + image: "docker.io/citizenstig/httpbin:latest" + imagePullPolicy: IfNotPresent + livenessProbe: + httpGet: + path: / + port: 8000 + readinessProbe: + httpGet: + path: / + port: 8000 + ports: + - containerPort: 8000 + resources: + {} + +status: {} diff --git a/test/integration/templates-golden/v3/httpbin/service.yaml b/test/integration/templates-golden/v3/httpbin/service.yaml new file mode 100644 index 00000000..2b0e7208 --- /dev/null +++ b/test/integration/templates-golden/v3/httpbin/service.yaml @@ -0,0 +1,21 @@ +--- +# Source: httpbin/templates/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: httpbin-httpbin + labels: + app: httpbin + chart: httpbin-0.1.0 + release: httpbin + heritage: Helm +spec: + type: LoadBalancer + ports: + - port: 8000 + targetPort: 8000 + protocol: TCP + name: httpbin + selector: + app: httpbin + release: httpbin diff --git a/test/integration/templates-golden/v3/raw/resources.yaml b/test/integration/templates-golden/v3/raw/resources.yaml new file mode 100644 index 00000000..233c55d1 --- /dev/null +++ b/test/integration/templates-golden/v3/raw/resources.yaml @@ -0,0 +1,13 @@ +--- +# Source: raw/templates/resources.yaml +apiVersion: v1 +kind: Secret +metadata: + labels: + app: raw + chart: raw-0.2.3 + heritage: Helm + release: raw + name: common-secret +stringData: + mykey: MYSECRET