diff --git a/Dockerfile b/Dockerfile index 711f8478d..0cc03c550 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,16 +6,31 @@ RUN go get -u github.com/golang/dep/cmd/dep RUN go get github.com/golang/mock/gomock RUN go install github.com/golang/mock/mockgen + + RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ apt-get update || true && \ apt-get install -y apt-transport-https && \ - apt-get update && apt-get install -y yarn + apt-get update && apt-get install -y yarn curl RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \ apt-get install -y nodejs +ENV HELM_VERSION=v2.9.1 +ENV HELM_URL=https://storage.googleapis.com/kubernetes-helm/helm-v2.9.1-linux-amd64.tar.gz +ENV HELM_TGZ=helm-v2.9.1-linux-amd64.tar.gz +ENV HELM=linux-amd64/helm +ENV HELM_SHA256SUM=56ae2d5d08c68d6e7400d462d6ed10c929effac929fedce18d2636a9b4e166ba + +RUN curl -fsSLO "${HELM_URL}" \ + && echo "${HELM_SHA256SUM} ${HELM_TGZ}" | sha256sum -c - \ + && tar xvf "$HELM_TGZ" \ + && mv "$HELM" "/usr/local/bin/helm-${HELM_VERSION}" \ + && ln -s "/usr/local/bin/helm-${HELM_VERSION}" /usr/local/bin/helm + ENV PROJECTPATH=/go/src/github.com/replicatedhq/ship + WORKDIR $PROJECTPATH CMD ["/bin/bash"] diff --git a/Makefile b/Makefile index 5de418236..253d952f1 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,21 @@ _mockgen: -package docker \ github.com/replicatedhq/ship/pkg/lifecycle/render/docker \ PullURLResolver + mockgen \ + -destination pkg/test-mocks/helm/chart_fetcher_mock.go \ + -package helm \ + github.com/replicatedhq/ship/pkg/lifecycle/render/helm \ + ChartFetcher + mockgen \ + -destination pkg/test-mocks/helm/templater_mock.go \ + -package helm \ + github.com/replicatedhq/ship/pkg/lifecycle/render/helm \ + Templater + mockgen \ + -destination pkg/test-mocks/helm/renderer_mock.go \ + -package helm \ + github.com/replicatedhq/ship/pkg/lifecycle/render/helm \ + Renderer mockgen: _mockgen fmt diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 0883d014e..78b1e00a9 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,21 @@ FROM alpine:latest -RUN apk add --no-cache ca-certificates && update-ca-certificates +RUN apk add --no-cache ca-certificates curl && update-ca-certificates COPY ship /ship ENV IN_CONTAINER 1 + +ENV HELM_VERSION=v2.9.1 +ENV HELM_URL=https://storage.googleapis.com/kubernetes-helm/helm-v2.9.1-linux-amd64.tar.gz +ENV HELM_TGZ=helm-v2.9.1-linux-amd64.tar.gz +ENV HELM=linux-amd64/helm +ENV HELM_SHA256SUM=56ae2d5d08c68d6e7400d462d6ed10c929effac929fedce18d2636a9b4e166ba + +RUN curl -fsSLO "${HELM_URL}" \ + && echo "${HELM_SHA256SUM} ${HELM_TGZ}" | sha256sum -c - \ + && tar xvf "$HELM_TGZ" \ + && mv "$HELM" "/usr/local/bin/helm-${HELM_VERSION}" \ + && ln -s "/usr/local/bin/helm-${HELM_VERSION}" /usr/local/bin/helm + + LABEL "com.replicated.ship"="true" WORKDIR /out ENTRYPOINT [ "/ship" ] diff --git a/examples/helm/nginx.yml b/examples/helm/nginx.yml new file mode 100644 index 000000000..d90436d4a --- /dev/null +++ b/examples/helm/nginx.yml @@ -0,0 +1,209 @@ +# nginx helm chart rendering example, using k8s.io/helm/docs/examples/nginx +--- +assets: + v1: + - inline: + dest: charts/src/nginx/Chart.yaml + contents: | + name: nginx + description: A basic NGINX HTTP server + version: 0.1.0 + kubeVersion: ">=1.2.0" + keywords: + - http + - nginx + - www + - web + home: https://github.com/kubernetes/helm + sources: + - https://hub.docker.com/_/nginx/ + maintainers: + - name: technosophos + email: mbutcher@deis.com + - inline: + dest: charts/src/nginx/templates/configmap.yaml + contents: | + # This is a simple example of using a config map to create a single page static site. + apiVersion: v1 + kind: ConfigMap + metadata: + name: {{ template "nginx.fullname" . }} + labels: + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + app: {{ template "nginx.name" . }} + data: + # When the config map is mounted as a volume, these will be created as files. + index.html: {{ .Values.index | quote }} + test.txt: test + + - inline: + dest: charts/src/nginx/templates/deployment.yaml + contents: | + apiVersion: extensions/v1beta1 + kind: Deployment + metadata: + # This uses a "fullname" template (see _helpers) + # Basing names on .Release.Name means that the same chart can be installed + # multiple times into the same namespace. + name: {{ template "nginx.fullname" . }} + labels: + # The "heritage" label is used to track which tool deployed a given chart. + # It is useful for admins who want to see what releases a particular tool + # is responsible for. + heritage: {{ .Release.Service }} + # The "release" convention makes it easy to tie a release to all of the + # Kubernetes resources that were created as part of that release. + release: {{ .Release.Name }} + # This makes it easy to audit chart usage. + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + app: {{ template "nginx.name" . }} + spec: + replicas: {{ .Values.replicaCount }} + template: + metadata: + {{- if .Values.podAnnotations }} + # Allows custom annotations to be specified + annotations: + {{ toYaml .Values.podAnnotations | indent 8 }} + {{- end }} + labels: + app: {{ template "nginx.name" . }} + release: {{ .Release.Name }} + spec: + containers: + - name: {{ template "nginx.name" . }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 80 + protocol: TCP + # This (and the volumes section below) mount the config map as a volume. + volumeMounts: + - mountPath: /usr/share/nginx/html + name: wwwdata-volume + resources: + # Allow chart users to specify resources. Usually, no default should be set, so this is left to be a conscious + # choice to the chart users and avoids that charts don't run out of the box on, e. g., Minikube when high resource + # requests are specified by default. + {{ toYaml .Values.resources | indent 12 }} + {{- if .Values.nodeSelector }} + nodeSelector: + # Node selectors can be important on mixed Windows/Linux clusters. + {{ toYaml .Values.nodeSelector | indent 8 }} + {{- end }} + volumes: + - name: wwwdata-volume + configMap: + name: {{ template "nginx.fullname" . }} + - inline: + dest: charts/src/nginx/templates/_helpers.tpl + contents: | + {{/* vim: set filetype=mustache: */}} + {{/* + Expand the name of the chart. + */}} + {{- define "nginx.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 "nginx.fullname" -}} + {{- $name := default .Chart.Name .Values.nameOverride -}} + {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} + {{- end -}} + + + - inline: + dest: charts/src/nginx/templates/service.yaml + contents: | + apiVersion: v1 + kind: Service + metadata: + {{- if .Values.service.annotations }} + annotations: + {{ toYaml .Values.service.annotations | indent 4 }} + {{- end }} + labels: + app: {{ template "nginx.name" . }} + chart: {{ .Chart.Name }}-{{ .Chart.Version }} + heritage: {{ .Release.Service }} + release: {{ .Release.Name }} + name: {{ template "nginx.fullname" . }} + spec: + # Provides options for the service so chart users have the full choice + type: "{{ .Values.service.type }}" + clusterIP: "{{ .Values.service.clusterIP }}" + {{- if .Values.service.externalIPs }} + externalIPs: + {{ toYaml .Values.service.externalIPs | indent 4 }} + {{- end }} + {{- if .Values.service.loadBalancerIP }} + loadBalancerIP: "{{ .Values.service.loadBalancerIP }}" + {{- end }} + {{- if .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{ toYaml .Values.service.loadBalancerSourceRanges | indent 4 }} + {{- end }} + ports: + - name: http + port: {{ .Values.service.port }} + protocol: TCP + targetPort: http + {{- if (and (eq .Values.service.type "NodePort") (not (empty .Values.service.nodePort))) }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + app: {{ template "nginx.name" . }} + release: {{ .Release.Name }} + + - inline: + dest: charts/src/nginx/values.yaml + contents: | + # Default values for nginx. + # This is a YAML-formatted file. + # Declare name/value pairs to be passed into your templates. + + replicaCount: 1 + restartPolicy: Never + + # Evaluated by the post-install hook + sleepyTime: "10" + + index: >- +
This is a test
+ + image: + repository: nginx + tag: 1.11.0 + pullPolicy: IfNotPresent + + service: + annotations: {} + clusterIP: "" + externalIPs: [] + loadBalancerIP: "" + loadBalancerSourceRanges: [] + type: ClusterIP + port: 8888 + nodePort: "" + + podAnnotations: {} + + resources: {} + + nodeSelector: {} + - helm: + dest: charts/rendered/ + local: + chart_root: charts/src/nginx + +lifecycle: + v1: + - render: {} diff --git a/hack/docs/README.md b/hack/docs/README.md index a6a6b9b77..252895bb5 100644 --- a/hack/docs/README.md +++ b/hack/docs/README.md @@ -4,7 +4,7 @@ hack/docs ### NOTE -This is totally copy-pasted from [the support bundle docs generation](https://github.com/replicatedcom/support-bundle) so there may be some extra stuff that doesn't make sense but didn't yet get removed. +This is totally copy-pasted from [the support bundle docs generation](https://github.com/replicatedhq/support-bundle) so there may be some extra stuff that doesn't make sense but didn't yet get removed. #### TLDR: diff --git a/pkg/api/asset.go b/pkg/api/asset.go index 22ee8545c..7ddb6e9f3 100644 --- a/pkg/api/asset.go +++ b/pkg/api/asset.go @@ -21,7 +21,8 @@ type AssetShared struct { type Asset struct { Inline *InlineAsset `json:"inline,omitempty" yaml:"inline,omitempty" hcl:"inline,omitempty"` Docker *DockerAsset `json:"docker,omitempty" yaml:"docker,omitempty" hcl:"docker,omitempty"` - Github *GithubAsset `json:"github,omitempty" yaml:"github,omitempty" hcl:"github,omitempty"` + GitHub *GitHubAsset `json:"github,omitempty" yaml:"github,omitempty" hcl:"github,omitempty"` + Helm *HelmAsset `json:"helm,omitempty" yaml:"helm,omitempty" hcl:"helm,omitempty"` } // InlineAsset is an asset whose contents are specified directly in the Spec @@ -30,18 +31,35 @@ type InlineAsset struct { Contents string `json:"contents" yaml:"contents" hcl:"contents"` } -// DockerAsset is an asset whose contents are specified directly in the Spec +// DockerAsset is an asset that declares a docker image type DockerAsset struct { AssetShared `json:",inline" yaml:",inline" hcl:",inline"` Image string `json:"image" yaml:"image" hcl:"image"` Source string `json:"source" yaml:"source" hcl:"source"` } -// GithubAsset is an asset whose contents are specified directly in the Spec -type GithubAsset struct { +// GitHubAsset is an asset whose contents are specified directly in the Spec +type GitHubAsset struct { AssetShared `json:",inline" yaml:",inline" hcl:",inline"` Repo string `json:"repo" yaml:"repo" hcl:"repo"` Ref string `json:"ref" yaml:"ref" hcl:"ref"` Path string `json:"path" yaml:"path" hcl:"path"` Source string `json:"source" yaml:"source" hcl:"source"` } + +// HelmAsset is an asset that declares a helm chart on github +type HelmAsset struct { + AssetShared `json:",inline" yaml:",inline" hcl:",inline"` + Values map[string]string `json:"values" yaml:"values" hcl:"values"` + HelmOpts []string `json:"helm_opts" yaml:"helm_opts" hcl:"helm_opts"` + // Local is an escape hatch, most impls will use github or some sort of ChartMuseum thing + Local *LocalHelmOpts `json:"local,omitempty" yaml:"local,omitempty" hcl:"local,omitempty"` + // coming soon + //GitHub *GitHubAsset `json:"github" yaml:"github" hcl:"github"` +} + +// LocalHelmOpts specifies a helm chart that should be templated +// using other assets that are already present at `ChartRoot` +type LocalHelmOpts struct { + ChartRoot string `json:"chart_root" yaml:"chart_root" hcl:"chart_root"` +} diff --git a/pkg/api/lifecycle.go b/pkg/api/lifecycle.go index 01561f5eb..6e9b45295 100644 --- a/pkg/api/lifecycle.go +++ b/pkg/api/lifecycle.go @@ -11,14 +11,12 @@ type Step struct { Render *Render `json:"render,omitempty" yaml:"render,omitempty" hcl:"render,omitempty"` } -// Message is a lifeycle step to +// Message is a lifeycle step to print a message type Message struct { Contents string `json:"contents" yaml:"contents" hcl:"contents"` Level string `json:"level,omitempty" yaml:"level,omitempty" hcl:"level,omitempty"` } -// Render is a lifeycle step to +// Render is a lifeycle step to collect config and render assets type Render struct { - SkipPlan bool `json:"skip_plan" yaml:"skip_plan" hcl:"skip_plan"` - SkipStateWarning bool `json:"skip_state_warning" yaml:"skip_state_warning" hcl:"skip_state_warning"` } diff --git a/pkg/lifecycle/render/config/daemonresolver.go b/pkg/lifecycle/render/config/daemonresolver.go index 7dfdb7ca3..29aa7189b 100644 --- a/pkg/lifecycle/render/config/daemonresolver.go +++ b/pkg/lifecycle/render/config/daemonresolver.go @@ -44,7 +44,7 @@ func (d *DaemonResolver) ResolveConfig( } } - return nil, errors.New("couldn't find current render Step") + return nil, errors.New("couldn't find current render Execute") } func (d *DaemonResolver) awaitConfigSaved(ctx context.Context, daemonExitedChan chan error) (map[string]interface{}, error) { diff --git a/pkg/lifecycle/render/helm/fetch.go b/pkg/lifecycle/render/helm/fetch.go new file mode 100644 index 000000000..cefe563dc --- /dev/null +++ b/pkg/lifecycle/render/helm/fetch.go @@ -0,0 +1,43 @@ +package helm + +import ( + "path" + + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" + "github.com/pkg/errors" + "github.com/replicatedhq/ship/pkg/api" +) + +// ChartFetcher fetches a chart based on an asset. it returns +// the location that the chart was unpacked to, usually a temporary directory +type ChartFetcher interface { + FetchChart(asset api.HelmAsset, meta api.ReleaseMetadata) (string, error) +} + +// ClientFetcher is a ChartFetcher that does all the pulling/cloning client side +type ClientFetcher struct { + Logger log.Logger +} + +func (f *ClientFetcher) FetchChart(asset api.HelmAsset, meta api.ReleaseMetadata) (string, error) { + debug := log.With(level.Debug(f.Logger), "fetcher", "client") + + if asset.Local != nil { + debug.Log("event", "chart.fetch", "source", "local", "root", asset.Local.ChartRoot) + // this is not great but it'll do -- prepend `installer` since this is off of inline assets + chartRootPath := path.Join("installer", asset.Local.ChartRoot) + + return chartRootPath, nil + } + + debug.Log("event", "chart.fetch.fail", "reason", "unsupported") + return "", errors.New("only 'local' chart rendering is supported") +} + +// NewFetcher makes a new chart fetcher +func NewFetcher(logger log.Logger) ChartFetcher { + return &ClientFetcher{ + Logger: logger, + } +} diff --git a/pkg/lifecycle/render/helm/fetch_test.go b/pkg/lifecycle/render/helm/fetch_test.go new file mode 100644 index 000000000..5ef3cb564 --- /dev/null +++ b/pkg/lifecycle/render/helm/fetch_test.go @@ -0,0 +1,57 @@ +package helm + +import ( + "testing" + + "github.com/replicatedhq/ship/pkg/api" + "github.com/replicatedhq/ship/pkg/test-mocks/logger" + "github.com/stretchr/testify/require" +) + +func TestFetch(t *testing.T) { + tests := []struct { + name string + asset api.HelmAsset + expect string + expectError string + }{ + { + name: "nil local fails", + asset: api.HelmAsset{ + Local: nil, + }, + expect: "", + expectError: "only 'local' chart rendering is supported", + }, + { + name: "local returns pre-configured location", + asset: api.HelmAsset{ + Local: &api.LocalHelmOpts{ + ChartRoot: "charts/nginx", + }, + }, + expect: "installer/charts/nginx", + expectError: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + fetcher := &ClientFetcher{ + Logger: &logger.TestLogger{T: t}, + } + + dest, err := fetcher.FetchChart(test.asset, api.ReleaseMetadata{}) + + if test.expectError == "" { + req.NoError(err) + } else { + req.Error(err, "expected error "+test.expectError) + req.Equal(test.expectError, err.Error()) + } + + req.Equal(test.expect, dest) + }) + } +} diff --git a/pkg/lifecycle/render/helm/render.go b/pkg/lifecycle/render/helm/render.go new file mode 100644 index 000000000..7ab600431 --- /dev/null +++ b/pkg/lifecycle/render/helm/render.go @@ -0,0 +1,54 @@ +package helm + +import ( + "context" + + "github.com/pkg/errors" + "github.com/replicatedhq/ship/pkg/api" +) + +// Renderer is something that can render a helm asset as part of a planner.Plan +type Renderer interface { + Execute( + asset api.HelmAsset, + meta api.ReleaseMetadata, + templateContext map[string]interface{}, + ) func(ctx context.Context) error +} + +var _ Renderer = &LocalRenderer{} + +// LocalRenderer can add a helm step to the plan, the step will fetch the +// chart to a temporary location and then run a local operation to run the helm templating +type LocalRenderer struct { + Templater Templater + Fetcher ChartFetcher +} + +// NewRenderer makes a new renderer +func NewRenderer(cloner ChartFetcher, templater Templater) Renderer { + return &LocalRenderer{ + Fetcher: cloner, + Templater: templater, + } +} + +func (r *LocalRenderer) Execute( + asset api.HelmAsset, + meta api.ReleaseMetadata, + templateContext map[string]interface{}, +) func(ctx context.Context) error { + return func(ctx context.Context) error { + chartLocation, err := r.Fetcher.FetchChart(asset, meta) + if err != nil { + return errors.Wrap(err, "fetch chart") + } + + err = r.Templater.Template(chartLocation, asset, meta) + if err != nil { + return errors.Wrap(err, "execute templating") + } + return nil + } + +} diff --git a/pkg/lifecycle/render/helm/render_test.go b/pkg/lifecycle/render/helm/render_test.go new file mode 100644 index 000000000..86a86eac1 --- /dev/null +++ b/pkg/lifecycle/render/helm/render_test.go @@ -0,0 +1,80 @@ +package helm + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/replicatedhq/ship/pkg/api" + "github.com/replicatedhq/ship/pkg/test-mocks/helm" + "github.com/stretchr/testify/require" +) + +func TestRender(t *testing.T) { + tests := []struct { + name string + fetchPath string + fetchErr error + templateErr error + expectErr error + }{ + { + name: "fetch fails", + fetchPath: "/etc/kfbr", + fetchErr: errors.New("fetch failed"), + templateErr: nil, + expectErr: errors.New("fetch chart: fetch failed"), + }, + { + name: "template fails", + fetchPath: "/etc/kfbr", + fetchErr: nil, + templateErr: errors.New("template failed"), + expectErr: errors.New("execute templating: template failed"), + }, + { + name: "all good", + fetchPath: "/etc/kfbr", + fetchErr: nil, + templateErr: nil, + expectErr: nil, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mc := gomock.NewController(t) + mockFetcher := helm.NewMockChartFetcher(mc) + mockTemplater := helm.NewMockTemplater(mc) + req := require.New(t) + renderer := &LocalRenderer{ + Fetcher: mockFetcher, + Templater: mockTemplater, + } + + asset := api.HelmAsset{} + metadata := api.ReleaseMetadata{} + templateContext := map[string]interface{}{} + + mockFetcher.EXPECT().FetchChart(asset, metadata).Return(test.fetchPath, test.fetchErr) + + if test.fetchErr == nil { + mockTemplater.EXPECT().Template(test.fetchPath, asset, metadata).Return(test.templateErr) + } + + err := renderer.Execute( + asset, + metadata, + templateContext, + )(context.Background()) + + if test.expectErr == nil { + req.NoError(err) + } else { + req.Error(err, "expected error "+test.expectErr.Error()) + req.Equal(test.expectErr.Error(), err.Error()) + } + + }) + } +} diff --git a/pkg/lifecycle/render/helm/template.go b/pkg/lifecycle/render/helm/template.go new file mode 100644 index 000000000..68c1ec37e --- /dev/null +++ b/pkg/lifecycle/render/helm/template.go @@ -0,0 +1,134 @@ +package helm + +import ( + "fmt" + "os/exec" + "strings" + + "io/ioutil" + + "github.com/go-kit/kit/log" + "github.com/go-kit/kit/log/level" + "github.com/pkg/errors" + "github.com/replicatedhq/ship/pkg/api" + "github.com/spf13/afero" +) + +// Templater is something that can consume and render a helm chart pulled by ship. +// the chart should already be present at the specified path. +type Templater interface { + Template( + chartRoot string, + asset api.HelmAsset, + meta api.ReleaseMetadata, + ) error +} + +// ForkTemplater implements Templater by forking out to an embedded helm binary +// and creating the chart in place +type ForkTemplater struct { + Helm func() *exec.Cmd + Logger log.Logger + FS afero.Afero +} + +func (f *ForkTemplater) Template( + chartRoot string, + asset api.HelmAsset, + meta api.ReleaseMetadata, +) error { + debug := level.Debug(log.With(f.Logger, "step.type", "render", "render.phase", "execute", "asset.type", "helm", "dest", asset.Dest, "description", asset.Description)) + + debug.Log("event", "mkdirall.attempt", "dest", asset.Dest, "basePath", asset.Dest) + if err := f.FS.MkdirAll(asset.Dest, 0755); err != nil { + debug.Log("event", "mkdirall.fail", "err", err, "basePath", asset.Dest) + return errors.Wrapf(err, "write directory to %s", asset.Dest) + } + + releaseName := strings.ToLower(fmt.Sprintf("%s-%s", meta.ChannelName, meta.Semver)) + debug.Log("event", "releasename.resolve", "releasename", releaseName) + + // initialize command + cmd := f.Helm() + cmd.Args = append( + cmd.Args, + "template", chartRoot, + "--output-dir", asset.Dest, + "--name", releaseName, + ) + + if asset.HelmOpts != nil { + cmd.Args = append(cmd.Args, asset.HelmOpts...) + } + + stdout, stderr, err := f.fork(cmd) + + if err != nil { + debug.Log("event", "cmd.err") + if exitError, ok := err.(*exec.ExitError); ok && !exitError.Success() { + return errors.New(fmt.Sprintf(`execute helm: %s: stdout: "%s"; stderr: "%s";`, exitError.Error(), stdout, stderr)) + } + return errors.Wrap(err, "execute helm") + } + + // todo link up stdout/stderr debug logs + return nil +} + +func (f *ForkTemplater) fork(cmd *exec.Cmd) ([]byte, []byte, error) { + debug := level.Debug(log.With(f.Logger, "step.type", "render", "render.phase", "execute", "asset.type", "helm")) + debug.Log("event", "cmd.run", "base", cmd.Path, "args", strings.Join(cmd.Args, " ")) + + var stdout, stderr []byte + stdoutReader, err := cmd.StdoutPipe() + if err != nil { + return stdout, stderr, errors.Wrapf(err, "pipe stdout") + } + stderrReader, err := cmd.StderrPipe() + if err != nil { + return stdout, stderr, errors.Wrapf(err, "pipe stderr") + } + + debug.Log("event", "cmd.start") + err = cmd.Start() + if err != nil { + return stdout, stderr, errors.Wrap(err, "start cmd") + } + debug.Log("event", "cmd.started") + + stdout, err = ioutil.ReadAll(stdoutReader) + if err != nil { + debug.Log("event", "stdout.read.fail", "err", err) + return stdout, stderr, errors.Wrap(err, "read stdout") + } + debug.Log("event", "stdout.read", "value", string(stdout)) + + stderr, err = ioutil.ReadAll(stderrReader) + if err != nil { + debug.Log("event", "stderr.read.fail", "err", err) + return stdout, stderr, errors.Wrap(err, "read stderr") + } + debug.Log("event", "stderr.read", "value", string(stderr)) + + debug.Log("event", "cmd.wait") + err = cmd.Wait() + debug.Log("event", "cmd.waited") + + debug.Log("event", "cmd.streams.read.done") + + return stdout, stderr, err +} + +// NewTemplater returns a configured Templater. For now we just always fork +func NewTemplater( + logger log.Logger, + fs afero.Afero, +) Templater { + return &ForkTemplater{ + Helm: func() *exec.Cmd { + return exec.Command("/usr/local/bin/helm") + }, + Logger: logger, + FS: fs, + } +} diff --git a/pkg/lifecycle/render/helm/template_test.go b/pkg/lifecycle/render/helm/template_test.go new file mode 100644 index 000000000..352fcb780 --- /dev/null +++ b/pkg/lifecycle/render/helm/template_test.go @@ -0,0 +1,141 @@ +package helm + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "reflect" + "strings" + + "github.com/replicatedhq/ship/pkg/api" + "github.com/replicatedhq/ship/pkg/test-mocks/logger" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" +) + +func TestForkTemplater(t *testing.T) { + tests := []struct { + name string + describe string + helmForkEnv []string + expectError string + helmOpts []string + }{ + { + name: "helm crashes", + describe: "ensure that we bubble up an informative error if the forked process crashes", + helmForkEnv: []string{ + "GOTEST_SUBPROCESS_MOCK=1", + "CRASHING_HELM_ERROR=I am helm and I crashed", + }, + expectError: `execute helm: exit status 1: stdout: "I am helm and I crashed"; stderr: "";`, + }, + { + // + name: "helm bad args", + describe: "this is more of a negative test of our exec-mocking framework -- to make sure that we can properly validate that proper args were passed", + helmForkEnv: []string{ + "GOTEST_SUBPROCESS_MOCK=1", + // this is janky, but works for our purposes, use pipe | for separator, since its unlikely to be in argv + "EXPECT_HELM_ARGV=--foo|bar|--output-dir|fake", + }, + expectError: "execute helm: exit status 2: stdout: \"\"; stderr: \"expected args [--foo bar --output-dir fake], got args [template /tmp/chartroot --output-dir k8s/ --name frobnitz-1.0.0]; FAIL\";", + }, + { + name: "helm test proper args", + describe: "test that helm is invoked with the proper args. The subprocess will fail if its not called with the args set in EXPECT_HELM_ARGV", + helmForkEnv: []string{ + "GOTEST_SUBPROCESS_MOCK=1", + "EXPECT_HELM_ARGV=" + + "template|" + + "/tmp/chartroot|" + + "--output-dir|k8s/|" + + "--name|frobnitz-1.0.0", + }, + expectError: "", + }, + { + name: "helm with set value", + describe: "ensure any helm.helm_opts are forwarded down to the call to `helm template`", + helmForkEnv: []string{ + "GOTEST_SUBPROCESS_MOCK=1", + "EXPECT_HELM_ARGV=" + + "template|" + + "/tmp/chartroot|" + + "--output-dir|k8s/|" + + "--name|frobnitz-1.0.0|" + + "--set|service.clusterIP=10.3.9.2", + }, + expectError: "", + helmOpts: []string{"--set", "service.clusterIP=10.3.9.2"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + + tpl := &ForkTemplater{ + Helm: func() *exec.Cmd { + cmd := exec.Command(os.Args[0], "-test.run=TestMockHelm") + cmd.Env = append(os.Environ(), test.helmForkEnv...) + return cmd + }, + Logger: &logger.TestLogger{T: t}, + FS: afero.Afero{Fs: afero.NewMemMapFs()}, + } + + err := tpl.Template( + "/tmp/chartroot", + api.HelmAsset{ + AssetShared: api.AssetShared{ + Dest: "k8s/", + }, + HelmOpts: test.helmOpts, + }, api.ReleaseMetadata{ + Semver: "1.0.0", + ChannelName: "Frobnitz", + }) + + t.Logf("checking error %v", err) + if test.expectError == "" { + req.NoError(err) + } else { + req.Error(err, "expected error "+test.expectError) + req.Equal(test.expectError, err.Error()) + } + + }) + } +} + +// thanks andrewG / hashifolks +func TestMockHelm(t *testing.T) { + // this test does nothing when run normally, only when + // invoked by other tests. Those tests should set this + // env var in order to get the behavior + if os.Getenv("GOTEST_SUBPROCESS_MOCK") == "" { + return + } + + if os.Getenv("CRASHING_HELM_ERROR") != "" { + fmt.Fprintf(os.Stdout, os.Getenv("CRASHING_HELM_ERROR")) + os.Exit(1) + } + + if os.Getenv("EXPECT_HELM_ARGV") != "" { + // this is janky, but works for our purposes, use pipe | for separator, since its unlikely to be in argv + expectedArgs := strings.Split(os.Getenv("EXPECT_HELM_ARGV"), "|") + receivedArgs := os.Args[2:] + + fmt.Fprintf(os.Stderr, "expected args %v, got args %v", expectedArgs, receivedArgs) + if !reflect.DeepEqual(receivedArgs, expectedArgs) { + fmt.Fprint(os.Stderr, "; FAIL") + os.Exit(2) + } + + os.Exit(0) + } + +} diff --git a/pkg/lifecycle/render/planner/build.go b/pkg/lifecycle/render/planner/build.go index adf4ccd8c..8a87ed655 100644 --- a/pkg/lifecycle/render/planner/build.go +++ b/pkg/lifecycle/render/planner/build.go @@ -45,6 +45,10 @@ func (p *CLIPlanner) Build(assets []api.Asset, configGroups []libyaml.ConfigGrou asset.Docker.Dest = filepath.Join("installer", asset.Docker.Dest) debug.Log("event", "asset.resolve", "asset.type", "docker") plan = append(plan, p.dockerStep(asset.Docker, meta)) + } else if asset.Helm != nil { + asset.Helm.Dest = filepath.Join("installer", asset.Helm.Dest) + debug.Log("event", "asset.resolve", "asset.type", "helm") + plan = append(plan, p.helmStep(*asset.Helm, meta, templateContext)) } else { debug.Log("event", "asset.resolve.fail", "asset", fmt.Sprintf("%#v", asset)) } @@ -184,3 +188,15 @@ func (p *CLIPlanner) watchProgress(ch chan interface{}, debug log.Logger) error } return saveError } + +func (p *CLIPlanner) helmStep( + asset api.HelmAsset, + meta api.ReleaseMetadata, + templateContext map[string]interface{}, +) Step { + return Step{ + Dest: asset.Dest, + Description: asset.Description, + Execute: p.Helm.Execute(asset, meta, templateContext), + } +} diff --git a/pkg/lifecycle/render/planner/planner.go b/pkg/lifecycle/render/planner/planner.go index 8fb1ce4d6..d5898e770 100644 --- a/pkg/lifecycle/render/planner/planner.go +++ b/pkg/lifecycle/render/planner/planner.go @@ -12,13 +12,14 @@ import ( "github.com/replicatedhq/ship/pkg/lifecycle/render/config" "github.com/replicatedhq/ship/pkg/lifecycle/render/docker" + "github.com/replicatedhq/ship/pkg/lifecycle/render/helm" "github.com/replicatedhq/ship/pkg/templates" ) // A Plan is a list of PlanSteps to execute type Plan []Step -// A Step describes a single unit of work that Ship will do +// A Execute describes a single unit of work that Ship will do // to render the application type Step struct { Description string `json:"description" yaml:"description" hcl:"description"` @@ -51,6 +52,7 @@ type CLIPlanner struct { BuilderBuilder *templates.BuilderBuilder Saver docker.ImageSaver URLResolver docker.PullURLResolver + Helm helm.Renderer } func NewPlanner( @@ -61,6 +63,7 @@ func NewPlanner( builderBuilder *templates.BuilderBuilder, saver docker.ImageSaver, urlResolver docker.PullURLResolver, + helmRenderer helm.Renderer, ) Planner { return &CLIPlanner{ Logger: logger, @@ -70,6 +73,7 @@ func NewPlanner( BuilderBuilder: builderBuilder, Saver: saver, URLResolver: urlResolver, + Helm: helmRenderer, } } diff --git a/pkg/lifecycle/render/render.go b/pkg/lifecycle/render/render.go index 1ddba5fdd..d7f676e6a 100644 --- a/pkg/lifecycle/render/render.go +++ b/pkg/lifecycle/render/render.go @@ -66,7 +66,7 @@ func (r *Renderer) Execute(ctx context.Context, release *api.Release, step *api. defer r.Daemon.ClearProgress() debug := level.Debug(log.With(r.Logger, "step.type", "render")) - debug.Log("event", "step.execute", "step.skipPlan", step.SkipPlan) + debug.Log("event", "step.execute") r.Daemon.SetProgress(ProgressLoad) previousTemplateContext, err := r.StateManager.TryLoad() diff --git a/pkg/lifecycle/runner.go b/pkg/lifecycle/runner.go index c7503faec..8d450d089 100644 --- a/pkg/lifecycle/runner.go +++ b/pkg/lifecycle/runner.go @@ -18,39 +18,6 @@ type Runner struct { Executor *StepExecutor } -/* - - // this needs to be pulled up more, but this is enough for now - executor := &lifecycle.StepExecutor{ - Logger: s.Logger, - Renderer: &render.Renderer{ - Fs: s.Fs, - Logger: s.Logger, - Release: s.Release, - UI: s.UI, - ConfigResolver: &config.CLIResolver{ - Logger: s.Logger, - Release: s.Release, - UI: s.UI, - Viper: s.Viper, - }, - Planner: &plan.CLIPlanner{ - Logger: s.Logger, - Fs: s.Fs, - UI: s.UI, - }, - StateManager: &state.StateManager{ - Logger: s.Logger, - }, - }, - messenger: &message.CLIMessenger{ - Logger: s.Logger, - UI: s.UI, - Viper: s.Viper, - }, - } -*/ - func NewRunner(logger log.Logger, executor StepExecutor) *Runner { return &Runner{ Logger: logger, diff --git a/pkg/ship/dig.go b/pkg/ship/dig.go index 3abc9b83d..33215d75c 100644 --- a/pkg/ship/dig.go +++ b/pkg/ship/dig.go @@ -11,6 +11,7 @@ import ( "github.com/replicatedhq/ship/pkg/lifecycle/render" "github.com/replicatedhq/ship/pkg/lifecycle/render/config" "github.com/replicatedhq/ship/pkg/lifecycle/render/docker" + "github.com/replicatedhq/ship/pkg/lifecycle/render/helm" "github.com/replicatedhq/ship/pkg/lifecycle/render/planner" "github.com/replicatedhq/ship/pkg/lifecycle/render/state" "github.com/replicatedhq/ship/pkg/logger" @@ -47,6 +48,10 @@ func buildInjector() (*dig.Container, error) { docker.SaverFromViper, dockercli.NewEnvClient, + helm.NewRenderer, + helm.NewFetcher, + helm.NewTemplater, + NewShip, } diff --git a/pkg/test-mocks/helm/chart_fetcher_mock.go b/pkg/test-mocks/helm/chart_fetcher_mock.go new file mode 100644 index 000000000..585f94c69 --- /dev/null +++ b/pkg/test-mocks/helm/chart_fetcher_mock.go @@ -0,0 +1,48 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/replicatedhq/ship/pkg/lifecycle/render/helm (interfaces: ChartFetcher) + +// Package helm is a generated GoMock package. +package helm + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + api "github.com/replicatedhq/ship/pkg/api" +) + +// MockChartFetcher is a mock of ChartFetcher interface +type MockChartFetcher struct { + ctrl *gomock.Controller + recorder *MockChartFetcherMockRecorder +} + +// MockChartFetcherMockRecorder is the mock recorder for MockChartFetcher +type MockChartFetcherMockRecorder struct { + mock *MockChartFetcher +} + +// NewMockChartFetcher creates a new mock instance +func NewMockChartFetcher(ctrl *gomock.Controller) *MockChartFetcher { + mock := &MockChartFetcher{ctrl: ctrl} + mock.recorder = &MockChartFetcherMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockChartFetcher) EXPECT() *MockChartFetcherMockRecorder { + return m.recorder +} + +// FetchChart mocks base method +func (m *MockChartFetcher) FetchChart(arg0 api.HelmAsset, arg1 api.ReleaseMetadata) (string, error) { + ret := m.ctrl.Call(m, "FetchChart", arg0, arg1) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FetchChart indicates an expected call of FetchChart +func (mr *MockChartFetcherMockRecorder) FetchChart(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchChart", reflect.TypeOf((*MockChartFetcher)(nil).FetchChart), arg0, arg1) +} diff --git a/pkg/test-mocks/helm/renderer_mock.go b/pkg/test-mocks/helm/renderer_mock.go new file mode 100644 index 000000000..8c4d65e57 --- /dev/null +++ b/pkg/test-mocks/helm/renderer_mock.go @@ -0,0 +1,48 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/replicatedhq/ship/pkg/lifecycle/render/helm (interfaces: Renderer) + +// Package helm is a generated GoMock package. +package helm + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + api "github.com/replicatedhq/ship/pkg/api" +) + +// MockRenderer is a mock of Renderer interface +type MockRenderer struct { + ctrl *gomock.Controller + recorder *MockRendererMockRecorder +} + +// MockRendererMockRecorder is the mock recorder for MockRenderer +type MockRendererMockRecorder struct { + mock *MockRenderer +} + +// NewMockRenderer creates a new mock instance +func NewMockRenderer(ctrl *gomock.Controller) *MockRenderer { + mock := &MockRenderer{ctrl: ctrl} + mock.recorder = &MockRendererMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockRenderer) EXPECT() *MockRendererMockRecorder { + return m.recorder +} + +// Execute mocks base method +func (m *MockRenderer) Execute(arg0 api.HelmAsset, arg1 api.ReleaseMetadata, arg2 map[string]interface{}) func(context.Context) error { + ret := m.ctrl.Call(m, "Execute", arg0, arg1, arg2) + ret0, _ := ret[0].(func(context.Context) error) + return ret0 +} + +// Execute indicates an expected call of Execute +func (mr *MockRendererMockRecorder) Execute(arg0, arg1, arg2 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockRenderer)(nil).Execute), arg0, arg1, arg2) +} diff --git a/pkg/test-mocks/helm/templater_mock.go b/pkg/test-mocks/helm/templater_mock.go new file mode 100644 index 000000000..ccddd24d3 --- /dev/null +++ b/pkg/test-mocks/helm/templater_mock.go @@ -0,0 +1,47 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/replicatedhq/ship/pkg/lifecycle/render/helm (interfaces: Templater) + +// Package helm is a generated GoMock package. +package helm + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + api "github.com/replicatedhq/ship/pkg/api" +) + +// MockTemplater is a mock of Templater interface +type MockTemplater struct { + ctrl *gomock.Controller + recorder *MockTemplaterMockRecorder +} + +// MockTemplaterMockRecorder is the mock recorder for MockTemplater +type MockTemplaterMockRecorder struct { + mock *MockTemplater +} + +// NewMockTemplater creates a new mock instance +func NewMockTemplater(ctrl *gomock.Controller) *MockTemplater { + mock := &MockTemplater{ctrl: ctrl} + mock.recorder = &MockTemplaterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockTemplater) EXPECT() *MockTemplaterMockRecorder { + return m.recorder +} + +// Template mocks base method +func (m *MockTemplater) Template(arg0 string, arg1 api.HelmAsset, arg2 api.ReleaseMetadata) error { + ret := m.ctrl.Call(m, "Template", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// Template indicates an expected call of Template +func (mr *MockTemplaterMockRecorder) Template(arg0, arg1, arg2 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Template", reflect.TypeOf((*MockTemplater)(nil).Template), arg0, arg1, arg2) +}