From 425f7a6f6c395be401ee68d128990e5a78b3d234 Mon Sep 17 00:00:00 2001 From: Matt Butcher Date: Mon, 7 Jan 2019 17:45:14 -0700 Subject: [PATCH] feat: add 'pkg/action' for list operations (#5077) * feat: add pkg/action to encapsulate action logic Signed-off-by: Matt Butcher * feat: replace client/server internals with action package While we removed Tiller, we left the internal client/server architecture mostly intact. This replaces that architecture with the `pkg/action` package. This implements the action package for list, but nothing else. Signed-off-by: Matt Butcher * feat: Add install and refactor some tests This adds install to the action package, and then fixes up a lot of testing. Signed-off-by: Matt Butcher * fix: Move a bunch of sorters to the releaseutils package Signed-off-by: Matt Butcher * fix: updated APIs and fixed a failed test Signed-off-by: Matt Butcher * Use var for timestamper, instead of adding as a struct field Signed-off-by: Matt Butcher --- Gopkg.toml | 3 + cmd/helm/helm.go | 43 +- cmd/helm/helm_test.go | 68 ++- cmd/helm/install.go | 89 ++-- cmd/helm/install_test.go | 29 +- cmd/helm/list.go | 186 +++----- cmd/helm/list_test.go | 115 ----- cmd/helm/root.go | 9 +- .../testdata/output/install-name-template.txt | 1 + cmd/helm/testdata/output/list-with-failed.txt | 2 +- .../list-with-mulitple-flags-deleting.txt | 2 +- .../list-with-mulitple-flags-namespaced.txt | 2 +- .../list-with-mulitple-flags-pending.txt | 2 +- .../output/list-with-mulitple-flags.txt | 2 +- .../output/list-with-mulitple-flags2.txt | 2 +- .../output/list-with-old-releases.txt | 2 +- .../testdata/output/list-with-pending.txt | 4 +- cmd/helm/testdata/testcharts/empty/Chart.yaml | 6 + cmd/helm/testdata/testcharts/empty/README.md | 3 + .../testcharts/empty/templates/empty.yaml | 1 + .../testdata/testcharts/empty/values.yaml | 1 + pkg/action/action.go | 93 ++++ pkg/action/action_test.go | 188 ++++++++ .../release_testing_test.go => action/doc.go} | 19 +- pkg/action/install.go | 434 ++++++++++++++++++ pkg/action/install_test.go | 219 +++++++++ pkg/action/list.go | 189 ++++++++ pkg/action/list_test.go | 245 ++++++++++ pkg/hapi/release/release.go | 6 + pkg/helm/client.go | 13 - pkg/helm/fake.go | 5 - pkg/helm/helm_test.go | 56 --- pkg/helm/interface.go | 1 - pkg/helm/option.go | 51 -- pkg/releaseutil/kind_sorter.go | 146 ++++++ pkg/releaseutil/kind_sorter_test.go | 215 +++++++++ pkg/releaseutil/manifest_sorter.go | 220 +++++++++ pkg/releaseutil/manifest_sorter_test.go | 229 +++++++++ pkg/releaseutil/manifest_test.go | 4 +- pkg/releaseutil/sorter.go | 6 + pkg/tiller/hooks.go | 9 +- pkg/tiller/release_install.go | 8 + pkg/tiller/release_list.go | 83 ---- pkg/tiller/release_list_test.go | 191 -------- 44 files changed, 2483 insertions(+), 719 deletions(-) delete mode 100644 cmd/helm/list_test.go create mode 100644 cmd/helm/testdata/testcharts/empty/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/empty/README.md create mode 100644 cmd/helm/testdata/testcharts/empty/templates/empty.yaml create mode 100644 cmd/helm/testdata/testcharts/empty/values.yaml create mode 100644 pkg/action/action.go create mode 100644 pkg/action/action_test.go rename pkg/{tiller/release_testing_test.go => action/doc.go} (58%) create mode 100644 pkg/action/install.go create mode 100644 pkg/action/install_test.go create mode 100644 pkg/action/list.go create mode 100644 pkg/action/list_test.go create mode 100644 pkg/releaseutil/kind_sorter.go create mode 100644 pkg/releaseutil/kind_sorter_test.go create mode 100644 pkg/releaseutil/manifest_sorter.go create mode 100644 pkg/releaseutil/manifest_sorter_test.go delete mode 100644 pkg/tiller/release_list.go delete mode 100644 pkg/tiller/release_list_test.go diff --git a/Gopkg.toml b/Gopkg.toml index 8b6b18ea8..b9e90934b 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -58,3 +58,6 @@ [prune] go-tests = true unused-packages = true + +[[constraint]] + name = "github.com/stretchr/testify" diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index 7471dbaf8..ee211443f 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -26,9 +26,11 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/helm/pkg/action" "k8s.io/helm/pkg/helm" "k8s.io/helm/pkg/helm/environment" "k8s.io/helm/pkg/kube" + "k8s.io/helm/pkg/storage" "k8s.io/helm/pkg/storage/driver" ) @@ -50,7 +52,7 @@ func logf(format string, v ...interface{}) { } func main() { - cmd := newRootCmd(nil, os.Stdout, os.Args[1:]) + cmd := newRootCmd(nil, newActionConfig(false), os.Stdout, os.Args[1:]) if err := cmd.Execute(); err != nil { logf("%+v", err) os.Exit(1) @@ -89,6 +91,45 @@ func newClient(allNamespaces bool) helm.Interface { ) } +func newActionConfig(allNamespaces bool) *action.Configuration { + kc := kube.New(kubeConfig()) + kc.Log = logf + + clientset, err := kc.KubernetesClientSet() + if err != nil { + // TODO return error + log.Fatal(err) + } + var namespace string + if !allNamespaces { + namespace = getNamespace() + } + + var store *storage.Storage + switch os.Getenv("HELM_DRIVER") { + case "secret", "secrets", "": + d := driver.NewSecrets(clientset.CoreV1().Secrets(namespace)) + d.Log = logf + store = storage.Init(d) + case "configmap", "configmaps": + d := driver.NewConfigMaps(clientset.CoreV1().ConfigMaps(namespace)) + d.Log = logf + store = storage.Init(d) + case "memory": + d := driver.NewMemory() + store = storage.Init(d) + default: + // Not sure what to do here. + panic("Unknown driver in HELM_DRIVER: " + os.Getenv("HELM_DRIVER")) + } + + return &action.Configuration{ + KubeClient: kc, + Releases: store, + Discovery: clientset.Discovery(), + } +} + func kubeConfig() genericclioptions.RESTClientGetter { configOnce.Do(func() { config = kube.GetConfig(settings.KubeConfig, settings.KubeContext, settings.Namespace) diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index 60276e6a2..12920a39b 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -22,26 +22,37 @@ import ( "os" "strings" "testing" + "time" + + "k8s.io/client-go/kubernetes/fake" + "k8s.io/helm/pkg/tiller/environment" shellwords "github.com/mattn/go-shellwords" "github.com/spf13/cobra" "k8s.io/helm/internal/test" + "k8s.io/helm/pkg/action" "k8s.io/helm/pkg/hapi/release" "k8s.io/helm/pkg/helm" "k8s.io/helm/pkg/helm/helmpath" "k8s.io/helm/pkg/repo" + "k8s.io/helm/pkg/storage" + "k8s.io/helm/pkg/storage/driver" ) // base temp directory var testingDir string +func testTimestamper() time.Time { return time.Unix(242085845, 0).UTC() } + func init() { var err error testingDir, err = ioutil.TempDir(testingDir, "helm") if err != nil { panic(err) } + + action.Timestamper = testTimestamper } func TestMain(m *testing.M) { @@ -82,6 +93,54 @@ func runTestCmd(t *testing.T, tests []cmdTestCase) { } } +func runTestActionCmd(t *testing.T, tests []cmdTestCase) { + t.Helper() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer resetEnv()() + + store := storageFixture() + for _, rel := range tt.rels { + store.Create(rel) + } + _, out, err := executeActionCommandC(store, tt.cmd) + if (err != nil) != tt.wantError { + t.Errorf("expected error, got '%v'", err) + } + if tt.golden != "" { + test.AssertGoldenString(t, out, tt.golden) + } + }) + } +} + +func storageFixture() *storage.Storage { + return storage.Init(driver.NewMemory()) +} + +func executeActionCommandC(store *storage.Storage, cmd string) (*cobra.Command, string, error) { + args, err := shellwords.Parse(cmd) + if err != nil { + return nil, "", err + } + buf := new(bytes.Buffer) + + actionConfig := &action.Configuration{ + Releases: store, + KubeClient: &environment.PrintingKubeClient{Out: ioutil.Discard}, + Discovery: fake.NewSimpleClientset().Discovery(), + Log: func(format string, v ...interface{}) {}, + } + + root := newRootCmd(nil, actionConfig, buf, args) + root.SetOutput(buf) + root.SetArgs(args) + + c, err := root.ExecuteC() + + return c, buf.String(), err +} + // cmdTestCase describes a test case that works with releases. type cmdTestCase struct { name string @@ -93,18 +152,25 @@ type cmdTestCase struct { testRunStatus map[string]release.TestRunStatus } +// deprecated: Switch to executeActionCommandC func executeCommand(c helm.Interface, cmd string) (string, error) { _, output, err := executeCommandC(c, cmd) return output, err } +// deprecated: Switch to executeActionCommandC func executeCommandC(client helm.Interface, cmd string) (*cobra.Command, string, error) { args, err := shellwords.Parse(cmd) if err != nil { return nil, "", err } buf := new(bytes.Buffer) - root := newRootCmd(client, buf, args) + + actionConfig := &action.Configuration{ + Releases: storage.Init(driver.NewMemory()), + } + + root := newRootCmd(client, actionConfig, buf, args) root.SetOutput(buf) root.SetArgs(args) diff --git a/cmd/helm/install.go b/cmd/helm/install.go index 3cfa2fa58..a0b23a829 100644 --- a/cmd/helm/install.go +++ b/cmd/helm/install.go @@ -21,7 +21,9 @@ import ( "fmt" "io" "path/filepath" + "regexp" "strings" + "text/tabwriter" "text/template" "time" @@ -30,6 +32,7 @@ import ( "github.com/spf13/cobra" "k8s.io/helm/cmd/helm/require" + "k8s.io/helm/pkg/action" "k8s.io/helm/pkg/chart" "k8s.io/helm/pkg/chart/loader" "k8s.io/helm/pkg/downloader" @@ -116,11 +119,14 @@ type installOptions struct { valuesOptions chartPathOptions + cfg *action.Configuration + + // LEGACY: Here until we get upgrade converted client helm.Interface } -func newInstallCmd(c helm.Interface, out io.Writer) *cobra.Command { - o := &installOptions{client: c} +func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { + o := &installOptions{cfg: cfg} cmd := &cobra.Command{ Use: "install [NAME] [CHART]", @@ -145,7 +151,7 @@ func newInstallCmd(c helm.Interface, out io.Writer) *cobra.Command { return err } o.chartPath = cp - o.client = ensureHelmClient(o.client, false) + return o.run(out) }, } @@ -216,7 +222,7 @@ func (o *installOptions) run(out io.Writer) error { return err } // Print the final name so the user knows what the final name of the release is. - fmt.Printf("FINAL NAME: %s\n", o.name) + fmt.Fprintf(out, "FINAL NAME: %s\n", o.name) } // Check chart dependencies to make sure all are present in /charts @@ -249,37 +255,57 @@ func (o *installOptions) run(out io.Writer) error { } } - rel, err := o.client.InstallReleaseFromChart( - chartRequested, - getNamespace(), - helm.ValueOverrides(rawVals), - helm.ReleaseName(o.name), - helm.InstallDryRun(o.dryRun), - helm.InstallReuseName(o.replace), - helm.InstallDisableHooks(o.disableHooks), - helm.InstallTimeout(o.timeout), - helm.InstallWait(o.wait)) + inst := action.NewInstall(o.cfg) + inst.DryRun = o.dryRun + inst.DisableHooks = o.disableHooks + inst.Replace = o.replace + inst.Wait = o.wait + inst.Devel = o.devel + inst.Timeout = o.timeout + inst.Namespace = getNamespace() + inst.ReleaseName = o.name + rel, err := inst.Run(chartRequested, rawVals) if err != nil { return err } - if rel == nil { - return nil - } o.printRelease(out, rel) + return nil +} - // If this is a dry run, we can't display status. - if o.dryRun { - return nil +// printRelease prints info about a release +func (o *installOptions) printRelease(out io.Writer, rel *release.Release) { + if rel == nil { + return + } + fmt.Fprintf(out, "NAME: %s\n", rel.Name) + if settings.Debug { + printRelease(out, rel) + } + if !rel.Info.LastDeployed.IsZero() { + fmt.Fprintf(out, "LAST DEPLOYED: %s\n", rel.Info.LastDeployed) + } + fmt.Fprintf(out, "NAMESPACE: %s\n", rel.Namespace) + fmt.Fprintf(out, "STATUS: %s\n", rel.Info.Status.String()) + fmt.Fprintf(out, "\n") + if len(rel.Info.Resources) > 0 { + re := regexp.MustCompile(" +") + + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', tabwriter.TabIndent) + fmt.Fprintf(w, "RESOURCES:\n%s\n", re.ReplaceAllString(rel.Info.Resources, "\t")) + w.Flush() + } + if rel.Info.LastTestSuiteRun != nil { + lastRun := rel.Info.LastTestSuiteRun + fmt.Fprintf(out, "TEST SUITE:\n%s\n%s\n\n%s\n", + fmt.Sprintf("Last Started: %s", lastRun.StartedAt), + fmt.Sprintf("Last Completed: %s", lastRun.CompletedAt), + formatTestResults(lastRun.Results)) } - // Print the status like status command does - status, err := o.client.ReleaseStatus(rel.Name, 0) - if err != nil { - return err + if len(rel.Info.Notes) > 0 { + fmt.Fprintf(out, "NOTES:\n%s\n", rel.Info.Notes) } - PrintStatus(out, status) - return nil } // Merges source and destination map, preferring values from the source map @@ -309,17 +335,6 @@ func mergeValues(dest, src map[string]interface{}) map[string]interface{} { return dest } -// printRelease prints info about a release if the Debug is true. -func (o *installOptions) printRelease(out io.Writer, rel *release.Release) { - if rel == nil { - return - } - fmt.Fprintf(out, "NAME: %s\n", rel.Name) - if settings.Debug { - printRelease(out, rel) - } -} - func templateName(nameTemplate string) (string, error) { t, err := template.New("name-template").Funcs(sprig.TxtFuncMap()).Parse(nameTemplate) if err != nil { diff --git a/cmd/helm/install_test.go b/cmd/helm/install_test.go index c0d93a15c..69635aa4d 100644 --- a/cmd/helm/install_test.go +++ b/cmd/helm/install_test.go @@ -27,25 +27,20 @@ func TestInstall(t *testing.T) { // Install, base case { name: "basic install", - cmd: "install aeneas testdata/testcharts/alpine ", + cmd: "install aeneas testdata/testcharts/empty", golden: "output/install.txt", }, - // Install, no hooks - { - name: "install without hooks", - cmd: "install aeneas testdata/testcharts/alpine --no-hooks", - golden: "output/install-no-hooks.txt", - }, + // Install, values from cli { name: "install with values", - cmd: "install virgil testdata/testcharts/alpine --set foo=bar", + cmd: "install virgil testdata/testcharts/alpine --set test.Name=bar", golden: "output/install-with-values.txt", }, // Install, values from cli via multiple --set { name: "install with multiple values", - cmd: "install virgil testdata/testcharts/alpine --set foo=bar --set bar=foo", + cmd: "install virgil testdata/testcharts/alpine --set test.Color=yellow --set test.Name=banana", golden: "output/install-with-multiple-values.txt", }, // Install, values from yaml @@ -54,6 +49,12 @@ func TestInstall(t *testing.T) { cmd: "install virgil testdata/testcharts/alpine -f testdata/testcharts/alpine/extra_values.yaml", golden: "output/install-with-values-file.txt", }, + // Install, no hooks + { + name: "install without hooks", + cmd: "install aeneas testdata/testcharts/alpine --no-hooks --set test.Name=hello", + golden: "output/install-no-hooks.txt", + }, // Install, values from multiple yaml { name: "install with values", @@ -70,25 +71,25 @@ func TestInstall(t *testing.T) { // Install, re-use name { name: "install and replace release", - cmd: "install aeneas testdata/testcharts/alpine --replace", + cmd: "install aeneas testdata/testcharts/empty --replace", golden: "output/install-and-replace.txt", }, // Install, with timeout { name: "install with a timeout", - cmd: "install foobar testdata/testcharts/alpine --timeout 120", + cmd: "install foobar testdata/testcharts/empty --timeout 120", golden: "output/install-with-timeout.txt", }, // Install, with wait { name: "install with a wait", - cmd: "install apollo testdata/testcharts/alpine --wait", + cmd: "install apollo testdata/testcharts/empty --wait", golden: "output/install-with-wait.txt", }, // Install, using the name-template { name: "install with name-template", - cmd: "install testdata/testcharts/alpine --name-template '{{upper \"foobar\"}}'", + cmd: "install testdata/testcharts/empty --name-template '{{upper \"foobar\"}}'", golden: "output/install-name-template.txt", }, // Install, perform chart verification along the way. @@ -120,7 +121,7 @@ func TestInstall(t *testing.T) { }, } - runTestCmd(t, tests) + runTestActionCmd(t, tests) } type nameTemplateTestCase struct { diff --git a/cmd/helm/list.go b/cmd/helm/list.go index 1285412e4..280d4585a 100644 --- a/cmd/helm/list.go +++ b/cmd/helm/list.go @@ -25,9 +25,8 @@ import ( "github.com/spf13/cobra" "k8s.io/helm/cmd/helm/require" - "k8s.io/helm/pkg/hapi" + "k8s.io/helm/pkg/action" "k8s.io/helm/pkg/hapi/release" - "k8s.io/helm/pkg/helm" ) var listHelp = ` @@ -59,28 +58,26 @@ flag with the '--offset' flag allows you to page through results. type listOptions struct { // flags - all bool // --all - allNamespaces bool // --all-namespaces - byDate bool // --date - colWidth uint // --col-width - uninstalled bool // --uninstalled - uninstalling bool // --uninstalling - deployed bool // --deployed - failed bool // --failed - limit int // --max - offset string // --offset - pending bool // --pending - short bool // --short - sortDesc bool // --reverse - superseded bool // --superseded + all bool // --all + allNamespaces bool // --all-namespaces + byDate bool // --date + colWidth uint // --col-width + uninstalled bool // --uninstalled + uninstalling bool // --uninstalling + deployed bool // --deployed + failed bool // --failed + limit int // --max + offset int // --offset + pending bool // --pending + short bool // --short + sortDesc bool // --reverse + superseded bool // --superseded filter string - - client helm.Interface } -func newListCmd(client helm.Interface, out io.Writer) *cobra.Command { - o := &listOptions{client: client} +func newListCmd(actionConfig *action.Configuration, out io.Writer) *cobra.Command { + o := &listOptions{} cmd := &cobra.Command{ Use: "list [FILTER]", @@ -92,8 +89,41 @@ func newListCmd(client helm.Interface, out io.Writer) *cobra.Command { if len(args) > 0 { o.filter = strings.Join(args, " ") } - o.client = ensureHelmClient(o.client, o.allNamespaces) - return o.run(out) + + if o.allNamespaces { + actionConfig = newActionConfig(true) + } + + lister := action.NewList(actionConfig) + lister.All = o.limit == -1 + lister.AllNamespaces = o.allNamespaces + lister.Limit = o.limit + lister.Offset = o.offset + lister.Filter = o.filter + + // Set StateMask + lister.StateMask = o.setStateMask() + + // Set sorter + lister.Sort = action.ByNameAsc + if o.sortDesc { + lister.Sort = action.ByNameDesc + } + if o.byDate { + lister.Sort = action.ByDate + } + + results, err := lister.Run() + + if o.short { + for _, res := range results { + fmt.Fprintln(out, res.Name) + } + return err + } + + fmt.Fprintln(out, formatList(results, 90)) + return err }, } @@ -102,7 +132,7 @@ func newListCmd(client helm.Interface, out io.Writer) *cobra.Command { f.BoolVarP(&o.byDate, "date", "d", false, "sort by release date") f.BoolVarP(&o.sortDesc, "reverse", "r", false, "reverse the sort order") f.IntVarP(&o.limit, "max", "m", 256, "maximum number of releases to fetch") - f.StringVarP(&o.offset, "offset", "o", "", "next release name in the list, used to offset from start value") + f.IntVarP(&o.offset, "offset", "o", 0, "next release name in the list, used to offset from start value") f.BoolVarP(&o.all, "all", "a", false, "show all releases, not just the ones marked deployed") f.BoolVar(&o.uninstalled, "uninstalled", false, "show uninstalled releases") f.BoolVar(&o.superseded, "superseded", false, "show superseded releases") @@ -116,111 +146,35 @@ func newListCmd(client helm.Interface, out io.Writer) *cobra.Command { return cmd } -func (o *listOptions) run(out io.Writer) error { - sortBy := hapi.SortByName - if o.byDate { - sortBy = hapi.SortByLastReleased - } - - sortOrder := hapi.SortAsc - if o.sortDesc { - sortOrder = hapi.SortDesc - } - - stats := o.statusCodes() - - res, err := o.client.ListReleases( - helm.ReleaseListLimit(o.limit), - helm.ReleaseListOffset(o.offset), - helm.ReleaseListFilter(o.filter), - helm.ReleaseListSort(sortBy), - helm.ReleaseListOrder(sortOrder), - helm.ReleaseListStatuses(stats), - ) - - if err != nil { - return err - } - - if len(res) == 0 { - return nil - } - - rels := filterList(res) - - if o.short { - for _, r := range rels { - fmt.Fprintln(out, r.Name) - } - return nil - } - fmt.Fprintln(out, formatList(rels, o.colWidth)) - return nil -} - -// filterList returns a list scrubbed of old releases. -func filterList(rels []*release.Release) []*release.Release { - idx := map[string]int{} - - for _, r := range rels { - name, version := r.Name, r.Version - if max, ok := idx[name]; ok { - // check if we have a greater version already - if max > version { - continue - } - } - idx[name] = version - } - - uniq := make([]*release.Release, 0, len(idx)) - for _, r := range rels { - if idx[r.Name] == r.Version { - uniq = append(uniq, r) - } - } - return uniq -} - -// statusCodes gets the list of status codes that are to be included in the results. -func (o *listOptions) statusCodes() []release.ReleaseStatus { +// setStateMask calculates the state mask based on parameters. +func (o *listOptions) setStateMask() action.ListStates { if o.all { - return []release.ReleaseStatus{ - release.StatusUnknown, - release.StatusDeployed, - release.StatusUninstalled, - release.StatusUninstalling, - release.StatusFailed, - release.StatusPendingInstall, - release.StatusPendingUpgrade, - release.StatusPendingRollback, - } + return action.ListAll } - status := []release.ReleaseStatus{} + + state := action.ListStates(0) if o.deployed { - status = append(status, release.StatusDeployed) + state |= action.ListDeployed } if o.uninstalled { - status = append(status, release.StatusUninstalled) + state |= action.ListUninstalled } if o.uninstalling { - status = append(status, release.StatusUninstalling) - } - if o.failed { - status = append(status, release.StatusFailed) - } - if o.superseded { - status = append(status, release.StatusSuperseded) + state |= action.ListUninstalling } if o.pending { - status = append(status, release.StatusPendingInstall, release.StatusPendingUpgrade, release.StatusPendingRollback) + state |= action.ListPendingInstall | action.ListPendingRollback | action.ListPendingUpgrade + } + if o.failed { + state |= action.ListFailed } - // Default case. - if len(status) == 0 { - status = append(status, release.StatusDeployed, release.StatusFailed) + // Apply a default + if state == 0 { + return action.ListDeployed | action.ListFailed } - return status + + return state } func formatList(rels []*release.Release, colWidth uint) string { diff --git a/cmd/helm/list_test.go b/cmd/helm/list_test.go deleted file mode 100644 index 3561e6b1c..000000000 --- a/cmd/helm/list_test.go +++ /dev/null @@ -1,115 +0,0 @@ -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "testing" - - "k8s.io/helm/pkg/hapi/release" - "k8s.io/helm/pkg/helm" -) - -func TestListCmd(t *testing.T) { - tests := []cmdTestCase{{ - name: "with a release", - cmd: "list", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide"}), - }, - golden: "output/list-with-release.txt", - }, { - name: "list", - cmd: "list", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas"}), - }, - golden: "output/list.txt", - }, { - name: "list, one deployed, one failed", - cmd: "list -q", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Status: release.StatusFailed}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas-guide", Status: release.StatusDeployed}), - }, - golden: "output/list-with-failed.txt", - }, { - name: "with a release, multiple flags", - cmd: "list --uninstalled --deployed --failed -q", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Status: release.StatusUninstalled}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas-guide", Status: release.StatusDeployed}), - }, - // Note: We're really only testing that the flags parsed correctly. Which results are returned - // depends on the backend. And until pkg/helm is done, we can't mock this. - golden: "output/list-with-mulitple-flags.txt", - }, { - name: "with a release, multiple flags", - cmd: "list --all -q", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Status: release.StatusUninstalled}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas-guide", Status: release.StatusDeployed}), - }, - // See note on previous test. - golden: "output/list-with-mulitple-flags2.txt", - }, { - name: "with a release, multiple flags, deleting", - cmd: "list --all -q", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Status: release.StatusUninstalling}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas-guide", Status: release.StatusDeployed}), - }, - // See note on previous test. - golden: "output/list-with-mulitple-flags-deleting.txt", - }, { - name: "namespace defined, multiple flags", - cmd: "list --all -q --namespace test123", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Namespace: "test123"}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas-guide", Namespace: "test321"}), - }, - // See note on previous test. - golden: "output/list-with-mulitple-flags-namespaced.txt", - }, { - name: "with a pending release, multiple flags", - cmd: "list --all -q", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Status: release.StatusPendingInstall}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas-guide", Status: release.StatusDeployed}), - }, - golden: "output/list-with-mulitple-flags-pending.txt", - }, { - name: "with a pending release, pending flag", - cmd: "list --pending -q", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Status: release.StatusPendingInstall}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "wild-idea", Status: release.StatusPendingUpgrade}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "crazy-maps", Status: release.StatusPendingRollback}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "atlas-guide", Status: release.StatusDeployed}), - }, - golden: "output/list-with-pending.txt", - }, { - name: "with old releases", - cmd: "list", - rels: []*release.Release{ - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide"}), - helm.ReleaseMock(&helm.MockReleaseOptions{Name: "thomas-guide", Status: release.StatusFailed}), - }, - golden: "output/list-with-old-releases.txt", - }} - - runTestCmd(t, tests) -} diff --git a/cmd/helm/root.go b/cmd/helm/root.go index 1aef4b405..a45f1d58d 100644 --- a/cmd/helm/root.go +++ b/cmd/helm/root.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" "k8s.io/helm/cmd/helm/require" + "k8s.io/helm/pkg/action" "k8s.io/helm/pkg/helm" ) @@ -42,11 +43,13 @@ Common actions from this point include: Environment: $HELM_HOME set an alternative location for Helm files. By default, these are stored in ~/.helm + $HELM_DRIVER set the backend storage driver. Values are: configmap, secret, memory $HELM_NO_PLUGINS disable plugins. Set HELM_NO_PLUGINS=1 to disable plugins. $KUBECONFIG set an alternative Kubernetes configuration file (default "~/.kube/config") ` -func newRootCmd(c helm.Interface, out io.Writer, args []string) *cobra.Command { +// TODO: 'c helm.Interface' is deprecated in favor of actionConfig +func newRootCmd(c helm.Interface, actionConfig *action.Configuration, out io.Writer, args []string) *cobra.Command { cmd := &cobra.Command{ Use: "helm", Short: "The Helm package manager for Kubernetes.", @@ -73,8 +76,8 @@ func newRootCmd(c helm.Interface, out io.Writer, args []string) *cobra.Command { // release commands newGetCmd(c, out), newHistoryCmd(c, out), - newInstallCmd(c, out), - newListCmd(c, out), + newInstallCmd(actionConfig, out), + newListCmd(actionConfig, out), newReleaseTestCmd(c, out), newRollbackCmd(c, out), newStatusCmd(c, out), diff --git a/cmd/helm/testdata/output/install-name-template.txt b/cmd/helm/testdata/output/install-name-template.txt index 4389775ab..d52fe60c5 100644 --- a/cmd/helm/testdata/output/install-name-template.txt +++ b/cmd/helm/testdata/output/install-name-template.txt @@ -1,3 +1,4 @@ +FINAL NAME: FOOBAR NAME: FOOBAR LAST DEPLOYED: 1977-09-02 22:04:05 +0000 UTC NAMESPACE: default diff --git a/cmd/helm/testdata/output/list-with-failed.txt b/cmd/helm/testdata/output/list-with-failed.txt index 289dcac23..6cacd15a0 100644 --- a/cmd/helm/testdata/output/list-with-failed.txt +++ b/cmd/helm/testdata/output/list-with-failed.txt @@ -1,2 +1,2 @@ -thomas-guide atlas-guide +thomas-guide diff --git a/cmd/helm/testdata/output/list-with-mulitple-flags-deleting.txt b/cmd/helm/testdata/output/list-with-mulitple-flags-deleting.txt index 289dcac23..6cacd15a0 100644 --- a/cmd/helm/testdata/output/list-with-mulitple-flags-deleting.txt +++ b/cmd/helm/testdata/output/list-with-mulitple-flags-deleting.txt @@ -1,2 +1,2 @@ -thomas-guide atlas-guide +thomas-guide diff --git a/cmd/helm/testdata/output/list-with-mulitple-flags-namespaced.txt b/cmd/helm/testdata/output/list-with-mulitple-flags-namespaced.txt index 289dcac23..6cacd15a0 100644 --- a/cmd/helm/testdata/output/list-with-mulitple-flags-namespaced.txt +++ b/cmd/helm/testdata/output/list-with-mulitple-flags-namespaced.txt @@ -1,2 +1,2 @@ -thomas-guide atlas-guide +thomas-guide diff --git a/cmd/helm/testdata/output/list-with-mulitple-flags-pending.txt b/cmd/helm/testdata/output/list-with-mulitple-flags-pending.txt index 289dcac23..6cacd15a0 100644 --- a/cmd/helm/testdata/output/list-with-mulitple-flags-pending.txt +++ b/cmd/helm/testdata/output/list-with-mulitple-flags-pending.txt @@ -1,2 +1,2 @@ -thomas-guide atlas-guide +thomas-guide diff --git a/cmd/helm/testdata/output/list-with-mulitple-flags.txt b/cmd/helm/testdata/output/list-with-mulitple-flags.txt index 289dcac23..6cacd15a0 100644 --- a/cmd/helm/testdata/output/list-with-mulitple-flags.txt +++ b/cmd/helm/testdata/output/list-with-mulitple-flags.txt @@ -1,2 +1,2 @@ -thomas-guide atlas-guide +thomas-guide diff --git a/cmd/helm/testdata/output/list-with-mulitple-flags2.txt b/cmd/helm/testdata/output/list-with-mulitple-flags2.txt index 289dcac23..6cacd15a0 100644 --- a/cmd/helm/testdata/output/list-with-mulitple-flags2.txt +++ b/cmd/helm/testdata/output/list-with-mulitple-flags2.txt @@ -1,2 +1,2 @@ -thomas-guide atlas-guide +thomas-guide diff --git a/cmd/helm/testdata/output/list-with-old-releases.txt b/cmd/helm/testdata/output/list-with-old-releases.txt index 76a90e3b2..0a0d11d3f 100644 --- a/cmd/helm/testdata/output/list-with-old-releases.txt +++ b/cmd/helm/testdata/output/list-with-old-releases.txt @@ -1,3 +1,3 @@ NAME REVISION UPDATED STATUS CHART NAMESPACE thomas-guide 1 1977-09-02 22:04:05 +0000 UTC deployed foo-0.1.0-beta.1 default -thomas-guide 1 1977-09-02 22:04:05 +0000 UTC failed foo-0.1.0-beta.1 default +thomas-guide 2 1977-09-02 22:04:05 +0000 UTC failed foo-0.1.0-beta.1 default diff --git a/cmd/helm/testdata/output/list-with-pending.txt b/cmd/helm/testdata/output/list-with-pending.txt index 9a5953f48..c90e0e93d 100644 --- a/cmd/helm/testdata/output/list-with-pending.txt +++ b/cmd/helm/testdata/output/list-with-pending.txt @@ -1,4 +1,4 @@ +atlas-guide +crazy-maps thomas-guide wild-idea -crazy-maps -atlas-guide diff --git a/cmd/helm/testdata/testcharts/empty/Chart.yaml b/cmd/helm/testdata/testcharts/empty/Chart.yaml new file mode 100644 index 000000000..0e97f247c --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/Chart.yaml @@ -0,0 +1,6 @@ +description: Empty testing chart +home: https://k8s.io/helm +name: empty +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/empty/README.md b/cmd/helm/testdata/testcharts/empty/README.md new file mode 100644 index 000000000..ed73c1797 --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/README.md @@ -0,0 +1,3 @@ +#Empty + +This space intentionally left blank. diff --git a/cmd/helm/testdata/testcharts/empty/templates/empty.yaml b/cmd/helm/testdata/testcharts/empty/templates/empty.yaml new file mode 100644 index 000000000..c80812f6e --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/templates/empty.yaml @@ -0,0 +1 @@ +# This file is intentionally blank diff --git a/cmd/helm/testdata/testcharts/empty/values.yaml b/cmd/helm/testdata/testcharts/empty/values.yaml new file mode 100644 index 000000000..1f0ff00e3 --- /dev/null +++ b/cmd/helm/testdata/testcharts/empty/values.yaml @@ -0,0 +1 @@ +Name: my-empty diff --git a/pkg/action/action.go b/pkg/action/action.go new file mode 100644 index 000000000..ad417cc08 --- /dev/null +++ b/pkg/action/action.go @@ -0,0 +1,93 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "time" + + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/discovery" + + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/storage" + "k8s.io/helm/pkg/tiller/environment" + "k8s.io/helm/pkg/version" +) + +// Timestamper is a function capable of producing a timestamp.Timestamper. +// +// By default, this is a time.Time function. This can be overridden for testing, +// though, so that timestamps are predictable. +var Timestamper = time.Now + +// Configuration injects the dependencies that all actions share. +type Configuration struct { + // Discovery contains a discovery client + Discovery discovery.DiscoveryInterface + + // Releases stores records of releases. + Releases *storage.Storage + // KubeClient is a Kubernetes API client. + KubeClient environment.KubeClient + + Log func(string, ...interface{}) +} + +// capabilities builds a Capabilities from discovery information. +func (c *Configuration) capabilities() (*chartutil.Capabilities, error) { + sv, err := c.Discovery.ServerVersion() + if err != nil { + return nil, err + } + vs, err := GetVersionSet(c.Discovery) + if err != nil { + return nil, errors.Wrap(err, "could not get apiVersions from Kubernetes") + } + return &chartutil.Capabilities{ + APIVersions: vs, + KubeVersion: sv, + HelmVersion: version.GetBuildInfo(), + }, nil +} + +// Now generates a timestamp +// +// If the configuration has a Timestamper on it, that will be used. +// Otherwise, this will use time.Now(). +func (c *Configuration) Now() time.Time { + return Timestamper() +} + +// GetVersionSet retrieves a set of available k8s API versions +func GetVersionSet(client discovery.ServerGroupsInterface) (chartutil.VersionSet, error) { + groups, err := client.ServerGroups() + if err != nil { + return chartutil.DefaultVersionSet, err + } + + // FIXME: The Kubernetes test fixture for cli appears to always return nil + // for calls to Discovery().ServerGroups(). So in this case, we return + // the default API list. This is also a safe value to return in any other + // odd-ball case. + if groups.Size() == 0 { + return chartutil.DefaultVersionSet, nil + } + + versions := metav1.ExtractGroupVersions(groups) + return chartutil.NewVersionSet(versions...), nil +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go new file mode 100644 index 000000000..b793b4dbe --- /dev/null +++ b/pkg/action/action_test.go @@ -0,0 +1,188 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package action + +import ( + "flag" + "io" + "io/ioutil" + "testing" + "time" + + "github.com/pkg/errors" + "k8s.io/client-go/kubernetes/fake" + "k8s.io/helm/pkg/chart" + "k8s.io/helm/pkg/hapi/release" + "k8s.io/helm/pkg/storage" + "k8s.io/helm/pkg/storage/driver" + "k8s.io/helm/pkg/tiller/environment" +) + +var verbose = flag.Bool("test.log", false, "enable test logging") + +func actionConfigFixture(t *testing.T) *Configuration { + t.Helper() + + return &Configuration{ + Releases: storage.Init(driver.NewMemory()), + KubeClient: &environment.PrintingKubeClient{Out: ioutil.Discard}, + Discovery: fake.NewSimpleClientset().Discovery(), + Log: func(format string, v ...interface{}) { + t.Helper() + if *verbose { + t.Logf(format, v...) + } + }, + } +} + +var manifestWithHook = `kind: ConfigMap +metadata: + name: test-cm + annotations: + "helm.sh/hook": post-install,pre-delete +data: + name: value` + +var manifestWithTestHook = `kind: Pod + metadata: + name: finding-nemo, + annotations: + "helm.sh/hook": test-success + spec: + containers: + - name: nemo-test + image: fake-image + cmd: fake-command + ` + +type chartOptions struct { + *chart.Chart +} + +type chartOption func(*chartOptions) + +func buildChart(opts ...chartOption) *chart.Chart { + c := &chartOptions{ + Chart: &chart.Chart{ + // TODO: This should be more complete. + Metadata: &chart.Metadata{ + Name: "hello", + }, + // This adds a basic template and hooks. + Templates: []*chart.File{ + {Name: "templates/hello", Data: []byte("hello: world")}, + {Name: "templates/hooks", Data: []byte(manifestWithHook)}, + }, + }, + } + + for _, opt := range opts { + opt(c) + } + + return c.Chart +} + +func withNotes(notes string) chartOption { + return func(opts *chartOptions) { + opts.Templates = append(opts.Templates, &chart.File{ + Name: "templates/NOTES.txt", + Data: []byte(notes), + }) + } +} + +func withDependency(dependencyOpts ...chartOption) chartOption { + return func(opts *chartOptions) { + opts.AddDependency(buildChart(dependencyOpts...)) + } +} + +func withSampleTemplates() chartOption { + return func(opts *chartOptions) { + sampleTemplates := []*chart.File{ + // This adds basic templates and partials. + {Name: "templates/goodbye", Data: []byte("goodbye: world")}, + {Name: "templates/empty", Data: []byte("")}, + {Name: "templates/with-partials", Data: []byte(`hello: {{ template "_planet" . }}`)}, + {Name: "templates/partials/_planet", Data: []byte(`{{define "_planet"}}Earth{{end}}`)}, + } + opts.Templates = append(opts.Templates, sampleTemplates...) + } +} + +func withKube(version string) chartOption { + return func(opts *chartOptions) { + opts.Metadata.KubeVersion = version + } +} + +// releaseStub creates a release stub, complete with the chartStub as its chart. +func releaseStub() *release.Release { + return namedReleaseStub("angry-panda", release.StatusDeployed) +} + +func namedReleaseStub(name string, status release.ReleaseStatus) *release.Release { + now := time.Now() + return &release.Release{ + Name: name, + Info: &release.Info{ + FirstDeployed: now, + LastDeployed: now, + Status: status, + Description: "Named Release Stub", + }, + Chart: buildChart(withSampleTemplates()), + Config: map[string]interface{}{"name": "value"}, + Version: 1, + Hooks: []*release.Hook{ + { + Name: "test-cm", + Kind: "ConfigMap", + Path: "test-cm", + Manifest: manifestWithHook, + Events: []release.HookEvent{ + release.HookPostInstall, + release.HookPreDelete, + }, + }, + { + Name: "finding-nemo", + Kind: "Pod", + Path: "finding-nemo", + Manifest: manifestWithTestHook, + Events: []release.HookEvent{ + release.HookReleaseTestSuccess, + }, + }, + }, + } +} + +func newHookFailingKubeClient() *hookFailingKubeClient { + return &hookFailingKubeClient{ + PrintingKubeClient: environment.PrintingKubeClient{Out: ioutil.Discard}, + } +} + +type hookFailingKubeClient struct { + environment.PrintingKubeClient +} + +func (h *hookFailingKubeClient) WatchUntilReady(ns string, r io.Reader, timeout int64, shouldWait bool) error { + return errors.New("Failed watch") +} diff --git a/pkg/tiller/release_testing_test.go b/pkg/action/doc.go similarity index 58% rename from pkg/tiller/release_testing_test.go rename to pkg/action/doc.go index 06ca530b1..3c91bd618 100644 --- a/pkg/tiller/release_testing_test.go +++ b/pkg/action/doc.go @@ -14,16 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -package tiller - -// func TestRunReleaseTest(t *testing.T) { -// rs := rsFixture() -// rel := namedReleaseStub("nemo", release.Status_DEPLOYED) -// rs.env.Releases.Create(rel) - -// req := &services.TestReleaseRequest{Name: "nemo", Timeout: 2} -// err := rs.RunReleaseTest(req, mockRunReleaseTestServer{}) -// if err != nil { -// t.Fatalf("failed to run release tests on %s: %s", rel.Name, err) -// } -// } +// Package action contains the logic for each action that Helm can perform. +// +// This is a library for calling top-level Helm actions like 'install', +// 'upgrade', or 'list'. Actions approximately match the command line +// invocations that the Helm client uses. +package action diff --git a/pkg/action/install.go b/pkg/action/install.go new file mode 100644 index 000000000..2d8a46b4d --- /dev/null +++ b/pkg/action/install.go @@ -0,0 +1,434 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "bytes" + "fmt" + "io" + "path" + "sort" + "strings" + "time" + + "github.com/pkg/errors" + "k8s.io/helm/pkg/chart" + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/engine" + "k8s.io/helm/pkg/hapi/release" + "k8s.io/helm/pkg/hooks" + "k8s.io/helm/pkg/releaseutil" + "k8s.io/helm/pkg/version" +) + +// releaseNameMaxLen is the maximum length of a release name. +// +// As of Kubernetes 1.4, the max limit on a name is 63 chars. We reserve 10 for +// charts to add data. Effectively, that gives us 53 chars. +// See https://github.com/kubernetes/helm/issues/1528 +const releaseNameMaxLen = 53 + +// NOTESFILE_SUFFIX that we want to treat special. It goes through the templating engine +// but it's not a yaml file (resource) hence can't have hooks, etc. And the user actually +// wants to see this file after rendering in the status command. However, it must be a suffix +// since there can be filepath in front of it. +const notesFileSuffix = "NOTES.txt" + +// Install performs an installation operation. +type Install struct { + cfg *Configuration + + DryRun bool + DisableHooks bool + Replace bool + Wait bool + Devel bool + DepUp bool + Timeout int64 + Namespace string + ReleaseName string +} + +// NewInstall creates a new Install object with the given configuration. +func NewInstall(cfg *Configuration) *Install { + return &Install{ + cfg: cfg, + } +} + +// Run executes the installation +// +// If DryRun is set to true, this will prepare the release, but not install it +func (i *Install) Run(chrt *chart.Chart, rawValues map[string]interface{}) (*release.Release, error) { + if err := i.availableName(); err != nil { + return nil, err + } + + caps, err := i.cfg.capabilities() + if err != nil { + return nil, err + } + + options := chartutil.ReleaseOptions{ + Name: i.ReleaseName, + IsInstall: true, + } + valuesToRender, err := chartutil.ToRenderValues(chrt, rawValues, options, caps) + if err != nil { + return nil, err + } + + rel := i.createRelease(chrt, rawValues) + var manifestDoc *bytes.Buffer + rel.Hooks, manifestDoc, rel.Info.Notes, err = i.renderResources(chrt, valuesToRender, caps.APIVersions) + // Even for errors, attach this if available + if manifestDoc != nil { + rel.Manifest = manifestDoc.String() + } + // Check error from render + if err != nil { + rel.SetStatus(release.StatusFailed, fmt.Sprintf("failed to render resource: %s", err.Error())) + rel.Version = 0 // Why do we do this? + return rel, err + } + + // Mark this release as in-progress + rel.SetStatus(release.StatusPendingInstall, "Intiial install underway") + if err := i.validateManifest(manifestDoc); err != nil { + return rel, err + } + + // Bail out here if it is a dry run + if i.DryRun { + rel.Info.Description = "Dry run complete" + return rel, nil + } + + // If Replace is true, we need to supersede the last release. + if i.Replace { + if err := i.replaceRelease(rel); err != nil { + return nil, err + } + } + + // Store the release in history before continuing (new in Helm 3). We always know + // that this is a create operation. + if err := i.cfg.Releases.Create(rel); err != nil { + // We could try to recover gracefully here, but since nothing has been installed + // yet, this is probably safer than trying to continue when we know storage is + // not working. + return rel, err + } + + // pre-install hooks + if !i.DisableHooks { + if err := i.execHook(rel.Hooks, hooks.PreInstall); err != nil { + rel.SetStatus(release.StatusFailed, "failed pre-install: "+err.Error()) + i.replaceRelease(rel) + return rel, err + } + } + + // At this point, we can do the install. Note that before we were detecting whether to + // do an update, but it's not clear whether we WANT to do an update if the re-use is set + // to true, since that is basically an upgrade operation. + buf := bytes.NewBufferString(rel.Manifest) + if err := i.cfg.KubeClient.Create(i.Namespace, buf, i.Timeout, i.Wait); err != nil { + rel.SetStatus(release.StatusFailed, fmt.Sprintf("Release %q failed: %s", i.ReleaseName, err.Error())) + i.recordRelease(rel) // Ignore the error, since we have another error to deal with. + return rel, errors.Wrapf(err, "release %s failed", i.ReleaseName) + } + + if !i.DisableHooks { + if err := i.execHook(rel.Hooks, hooks.PostInstall); err != nil { + rel.SetStatus(release.StatusFailed, "failed post-install: "+err.Error()) + i.replaceRelease(rel) + return rel, err + } + } + + rel.SetStatus(release.StatusDeployed, "Install complete") + + // This is a tricky case. The release has been created, but the result + // cannot be recorded. The truest thing to tell the user is that the + // release was created. However, the user will not be able to do anything + // further with this release. + // + // One possible strategy would be to do a timed retry to see if we can get + // this stored in the future. + i.recordRelease(rel) + + return rel, nil +} + +// availableName tests whether a name is available +// +// Roughly, this will return an error if name is +// +// - empty +// - too long +// - already in use, and not deleted +// - used by a deleted release, and i.Replace is false +func (i *Install) availableName() error { + start := i.ReleaseName + if start == "" { + return errors.New("name is required") + } + + if len(start) > releaseNameMaxLen { + return errors.Errorf("release name %q exceeds max length of %d", start, releaseNameMaxLen) + } + + h, err := i.cfg.Releases.History(start) + if err != nil || len(h) < 1 { + return nil + } + releaseutil.Reverse(h, releaseutil.SortByRevision) + rel := h[0] + + if st := rel.Info.Status; i.Replace && (st == release.StatusUninstalled || st == release.StatusFailed) { + return nil + } + return errors.New("cannot re-use a name that is still in use") +} + +// createRelease creates a new release object +func (i *Install) createRelease(chrt *chart.Chart, rawVals map[string]interface{}) *release.Release { + ts := i.cfg.Now() + return &release.Release{ + Name: i.ReleaseName, + Namespace: i.Namespace, + Chart: chrt, + Config: rawVals, + Info: &release.Info{ + FirstDeployed: ts, + LastDeployed: ts, + Status: release.StatusUnknown, + }, + Version: 1, + } +} + +// recordRelease with an update operation in case reuse has been set. +func (i *Install) recordRelease(r *release.Release) error { + // This is a legacy function which has been reduced to a oneliner. Could probably + // refactor it out. + return i.cfg.Releases.Update(r) +} + +// replaceRelease replaces an older release with this one +// +// This allows us to re-use names by superseding an existing release with a new one +func (i *Install) replaceRelease(rel *release.Release) error { + hist, err := i.cfg.Releases.History(rel.Name) + if err != nil || len(hist) == 0 { + // No releases exist for this name, so we can return early + return nil + } + + releaseutil.Reverse(hist, releaseutil.SortByRevision) + last := hist[0] + + // Update version to the next available + rel.Version = last.Version + 1 + + // Do not change the status of a failed release. + if last.Info.Status == release.StatusFailed { + return nil + } + + // For any other status, mark it as superseded and store the old record + last.SetStatus(release.StatusSuperseded, "superseded by new release") + return i.recordRelease(last) +} + +// renderResources renders the templates in a chart +func (i *Install) renderResources(ch *chart.Chart, values chartutil.Values, vs chartutil.VersionSet) ([]*release.Hook, *bytes.Buffer, string, error) { + hooks := []*release.Hook{} + buf := bytes.NewBuffer(nil) + // Guard to make sure Helm is at the right version to handle this chart. + sver := version.GetVersion() + if ch.Metadata.HelmVersion != "" && + !version.IsCompatibleRange(ch.Metadata.HelmVersion, sver) { + return hooks, buf, "", errors.Errorf("chart incompatible with Helm %s", sver) + } + + if ch.Metadata.KubeVersion != "" { + cap, _ := values["Capabilities"].(*chartutil.Capabilities) + gitVersion := cap.KubeVersion.String() + k8sVersion := strings.Split(gitVersion, "+")[0] + if !version.IsCompatibleRange(ch.Metadata.KubeVersion, k8sVersion) { + return hooks, buf, "", errors.Errorf("chart requires kubernetesVersion: %s which is incompatible with Kubernetes %s", ch.Metadata.KubeVersion, k8sVersion) + } + } + + files, err := engine.New().Render(ch, values) + if err != nil { + return hooks, buf, "", err + } + + // NOTES.txt gets rendered like all the other files, but because it's not a hook nor a resource, + // pull it out of here into a separate file so that we can actually use the output of the rendered + // text file. We have to spin through this map because the file contains path information, so we + // look for terminating NOTES.txt. We also remove it from the files so that we don't have to skip + // it in the sortHooks. + notes := "" + for k, v := range files { + if strings.HasSuffix(k, notesFileSuffix) { + // Only apply the notes if it belongs to the parent chart + // Note: Do not use filePath.Join since it creates a path with \ which is not expected + if k == path.Join(ch.Name(), "templates", notesFileSuffix) { + notes = v + } + delete(files, k) + } + } + + // Sort hooks, manifests, and partials. Only hooks and manifests are returned, + // as partials are not used after renderer.Render. Empty manifests are also + // removed here. + // TODO: Can we migrate SortManifests out of pkg/tiller? + hooks, manifests, err := releaseutil.SortManifests(files, vs, releaseutil.InstallOrder) + if err != nil { + // By catching parse errors here, we can prevent bogus releases from going + // to Kubernetes. + // + // We return the files as a big blob of data to help the user debug parser + // errors. + b := bytes.NewBuffer(nil) + for name, content := range files { + if len(strings.TrimSpace(content)) == 0 { + continue + } + b.WriteString("\n---\n# Source: " + name + "\n") + b.WriteString(content) + } + return hooks, b, "", err + } + + // Aggregate all valid manifests into one big doc. + b := bytes.NewBuffer(nil) + for _, m := range manifests { + b.WriteString("\n---\n# Source: " + m.Name + "\n") + b.WriteString(m.Content) + } + + return hooks, b, notes, nil +} + +// validateManifest checks to see whether the given manifest is valid for the current Kubernetes +func (i *Install) validateManifest(manifest io.Reader) error { + _, err := i.cfg.KubeClient.BuildUnstructured(i.Namespace, manifest) + return err +} + +// execHook executes all of the hooks for the given hook event. +func (i *Install) execHook(hs []*release.Hook, hook string) error { + name := i.ReleaseName + namespace := i.Namespace + timeout := i.Timeout + executingHooks := []*release.Hook{} + + for _, h := range hs { + for _, e := range h.Events { + if string(e) == hook { + executingHooks = append(executingHooks, h) + } + } + } + + sort.Sort(hookByWeight(executingHooks)) + + for _, h := range executingHooks { + if err := i.deleteHookByPolicy(h, hooks.BeforeHookCreation, hook); err != nil { + return err + } + + b := bytes.NewBufferString(h.Manifest) + if err := i.cfg.KubeClient.Create(namespace, b, timeout, false); err != nil { + return errors.Wrapf(err, "warning: Release %s %s %s failed", name, hook, h.Path) + } + b.Reset() + b.WriteString(h.Manifest) + + if err := i.cfg.KubeClient.WatchUntilReady(namespace, b, timeout, false); err != nil { + // If a hook is failed, checkout the annotation of the hook to determine whether the hook should be deleted + // under failed condition. If so, then clear the corresponding resource object in the hook + if err := i.deleteHookByPolicy(h, hooks.HookFailed, hook); err != nil { + return err + } + return err + } + } + + // If all hooks are succeeded, checkout the annotation of each hook to determine whether the hook should be deleted + // under succeeded condition. If so, then clear the corresponding resource object in each hook + for _, h := range executingHooks { + if err := i.deleteHookByPolicy(h, hooks.HookSucceeded, hook); err != nil { + return err + } + h.LastRun = time.Now() + } + + return nil +} + +// deleteHookByPolicy deletes a hook if the hook policy instructs it to +func (i *Install) deleteHookByPolicy(h *release.Hook, policy, hook string) error { + b := bytes.NewBufferString(h.Manifest) + if hookHasDeletePolicy(h, policy) { + if errHookDelete := i.cfg.KubeClient.Delete(i.Namespace, b); errHookDelete != nil { + return errHookDelete + } + } + return nil +} + +// deletePolices represents a mapping between the key in the annotation for label deleting policy and its real meaning +// FIXME: Can we refactor this out? +var deletePolices = map[string]release.HookDeletePolicy{ + hooks.HookSucceeded: release.HookSucceeded, + hooks.HookFailed: release.HookFailed, + hooks.BeforeHookCreation: release.HookBeforeHookCreation, +} + +// hookHasDeletePolicy determines whether the defined hook deletion policy matches the hook deletion polices +// supported by helm. If so, mark the hook as one should be deleted. +func hookHasDeletePolicy(h *release.Hook, policy string) bool { + dp, ok := deletePolices[policy] + if !ok { + return false + } + for _, v := range h.DeletePolicies { + if dp == v { + return true + } + } + return false +} + +// hookByWeight is a sorter for hooks +type hookByWeight []*release.Hook + +func (x hookByWeight) Len() int { return len(x) } +func (x hookByWeight) Swap(i, j int) { x[i], x[j] = x[j], x[i] } +func (x hookByWeight) Less(i, j int) bool { + if x[i].Weight == x[j].Weight { + return x[i].Name < x[j].Name + } + return x[i].Weight < x[j].Weight +} diff --git a/pkg/action/install_test.go b/pkg/action/install_test.go new file mode 100644 index 000000000..91ad53d8f --- /dev/null +++ b/pkg/action/install_test.go @@ -0,0 +1,219 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "k8s.io/helm/pkg/hapi/release" +) + +func installAction(t *testing.T) *Install { + config := actionConfigFixture(t) + instAction := NewInstall(config) + instAction.Namespace = "spaced" + instAction.ReleaseName = "test-install-release" + + return instAction +} + +var mockEmptyVals = func() map[string]interface{} { return map[string]interface{}{} } + +func TestInstallRelease(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + res, err := instAction.Run(buildChart(), mockEmptyVals()) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + is.Equal(res.Name, "test-install-release", "Expected release name.") + is.Equal(res.Namespace, "spaced") + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + is.Len(rel.Hooks, 1) + is.Equal(rel.Hooks[0].Manifest, manifestWithHook) + is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) + is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") + + is.NotEqual(len(res.Manifest), 0) + is.NotEqual(len(rel.Manifest), 0) + is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Equal(rel.Info.Description, "Install complete") +} + +func TestInstallRelease_NoName(t *testing.T) { + instAction := installAction(t) + instAction.ReleaseName = "" + _, err := instAction.Run(buildChart(), mockEmptyVals()) + if err == nil { + t.Fatal("expected failure when no name is specified") + } + assert.Contains(t, err.Error(), "name is required") +} + +func TestInstallRelease_WithNotes(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "with-notes" + res, err := instAction.Run(buildChart(withNotes("note here")), mockEmptyVals()) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.Equal(res.Name, "with-notes") + is.Equal(res.Namespace, "spaced") + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + is.Len(rel.Hooks, 1) + is.Equal(rel.Hooks[0].Manifest, manifestWithHook) + is.Equal(rel.Hooks[0].Events[0], release.HookPostInstall) + is.Equal(rel.Hooks[0].Events[1], release.HookPreDelete, "Expected event 0 is pre-delete") + is.NotEqual(len(res.Manifest), 0) + is.NotEqual(len(rel.Manifest), 0) + is.Contains(rel.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Equal(rel.Info.Description, "Install complete") + + is.Equal(rel.Info.Notes, "note here") +} + +func TestInstallRelease_WithNotesRendered(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "with-notes" + res, err := instAction.Run(buildChart(withNotes("got-{{.Release.Name}}")), mockEmptyVals()) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.NoError(err) + + expectedNotes := fmt.Sprintf("got-%s", res.Name) + is.Equal(expectedNotes, rel.Info.Notes) + is.Equal(rel.Info.Description, "Install complete") +} + +func TestInstallRelease_WithChartAndDependencyNotes(t *testing.T) { + // Regression: Make sure that the child's notes don't override the parent's + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "with-notes" + res, err := instAction.Run(buildChart( + withNotes("parent"), + withDependency(withNotes("child"))), + mockEmptyVals(), + ) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + rel, err := instAction.cfg.Releases.Get(res.Name, res.Version) + is.Equal("with-notes", rel.Name) + is.NoError(err) + is.Equal("parent", rel.Info.Notes) + is.Equal(rel.Info.Description, "Install complete") +} + +func TestInstallRelease_DryRun(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.DryRun = true + res, err := instAction.Run(buildChart(withSampleTemplates()), mockEmptyVals()) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.Contains(res.Manifest, "---\n# Source: hello/templates/hello\nhello: world") + is.Contains(res.Manifest, "---\n# Source: hello/templates/goodbye\ngoodbye: world") + is.Contains(res.Manifest, "hello: Earth") + is.NotContains(res.Manifest, "hello: {{ template \"_planet\" . }}") + is.NotContains(res.Manifest, "empty") + + _, err = instAction.cfg.Releases.Get(res.Name, res.Version) + is.Error(err) + is.Len(res.Hooks, 1) + is.True(res.Hooks[0].LastRun.IsZero(), "expect hook to not be marked as run") + is.Equal(res.Info.Description, "Dry run complete") +} + +func TestInstallRelease_NoHooks(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.DisableHooks = true + instAction.ReleaseName = "no-hooks" + instAction.cfg.Releases.Create(releaseStub()) + + res, err := instAction.Run(buildChart(), mockEmptyVals()) + if err != nil { + t.Fatalf("Failed install: %s", err) + } + + is.True(res.Hooks[0].LastRun.IsZero(), "hooks should not run with no-hooks") +} + +func TestInstallRelease_FailedHooks(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.ReleaseName = "failed-hooks" + instAction.cfg.KubeClient = newHookFailingKubeClient() + + res, err := instAction.Run(buildChart(), mockEmptyVals()) + is.Error(err) + is.Contains(res.Info.Description, "failed post-install") + is.Equal(res.Info.Status, release.StatusFailed) +} + +func TestInstallRelease_ReplaceRelease(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + instAction.Replace = true + + rel := releaseStub() + rel.Info.Status = release.StatusUninstalled + instAction.cfg.Releases.Create(rel) + instAction.ReleaseName = rel.Name + + res, err := instAction.Run(buildChart(), mockEmptyVals()) + is.NoError(err) + + // This should have been auto-incremented + is.Equal(2, res.Version) + is.Equal(res.Name, rel.Name) + + getres, err := instAction.cfg.Releases.Get(rel.Name, res.Version) + is.NoError(err) + is.Equal(getres.Info.Status, release.StatusDeployed) +} + +func TestInstallRelease_KubeVersion(t *testing.T) { + is := assert.New(t) + instAction := installAction(t) + _, err := instAction.Run(buildChart(withKube(">=0.0.0")), mockEmptyVals()) + is.NoError(err) + + // This should fail for a few hundred years + instAction.ReleaseName = "should-fail" + _, err = instAction.Run(buildChart(withKube(">=99.0.0")), mockEmptyVals()) + is.Error(err) + is.Contains(err.Error(), "chart requires kubernetesVersion") +} diff --git a/pkg/action/list.go b/pkg/action/list.go new file mode 100644 index 000000000..db8ac3541 --- /dev/null +++ b/pkg/action/list.go @@ -0,0 +1,189 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "regexp" + + "k8s.io/helm/pkg/hapi/release" + "k8s.io/helm/pkg/releaseutil" +) + +// ListStates represents zero or more status codes that a list item may have set +// +// Because this is used as a bitmask filter, more than one one bit can be flipped +// in the ListStates. +type ListStates uint + +const ( + // ListDeployed filters on status "deployed" + ListDeployed ListStates = 1 << iota + // ListUninstalled filters on status "uninstalled" + ListUninstalled + // ListUninstalling filters on status "uninstalling" (uninstall in progress) + ListUninstalling + // ListPendingInstall filters on status "pending" (deployment in progress) + ListPendingInstall + // ListPendingUpgrade filters on status "pending_upgrade" (upgrade in progress) + ListPendingUpgrade + // ListPendingRollback filters on status "pending_rollback" (rollback in progres) + ListPendingRollback + // ListSuperseded filters on status "superseded" (historical release version that is no longer deployed) + ListSuperseded + // ListFailed filters on status "failed" (release version not deployed because of error) + ListFailed + // ListUnknown filters on an unknown status + ListUnknown +) + +// FromName takes a state name and returns a ListStates representation. +// +// Currently, there are only names for individual flipped bits, so the returned +// ListStates will only match one of the constants. However, it is possible that +// this behavior could change in the future. +func (s ListStates) FromName(str string) ListStates { + switch str { + case "deployed": + return ListDeployed + case "uninstalled": + return ListUninstalled + case "superseded": + return ListSuperseded + case "failed": + return ListFailed + case "uninstalling": + return ListUninstalling + case "pending-install": + return ListPendingInstall + case "pending-upgrade": + return ListPendingUpgrade + case "pending-rollback": + return ListPendingRollback + } + return ListUnknown +} + +// ListAll is a convenience for enabling all list filters +const ListAll = ListDeployed | ListUninstalled | ListUninstalling | ListPendingInstall | ListPendingRollback | ListPendingUpgrade | ListSuperseded | ListFailed + +// Sorter is a top-level sort +type Sorter uint + +const ( + // ByDate sorts by date + ByDate Sorter = iota + // ByNameAsc sorts by ascending lexicographic order + ByNameAsc + // ByNameDesc sorts by descending lexicographic order + ByNameDesc +) + +// List is the action for listing releases. +// +// It provides, for example, the implementation of 'helm list'. +type List struct { + // All ignores the limit/offset + All bool + // AllNamespaces searches across namespaces + AllNamespaces bool + // Sort indicates the sort to use + // + // see pkg/releaseutil for several useful sorters + Sort Sorter + // StateMask accepts a bitmask of states for items to show. + // The default is ListDeployed + StateMask ListStates + // Limit is the number of items to return per Run() + Limit int + // Offset is the starting index for the Run() call + Offset int + // Filter is a filter that is applied to the results + Filter string + + cfg *Configuration +} + +// NewList constructs a new *List +func NewList(cfg *Configuration) *List { + return &List{ + StateMask: ListDeployed | ListFailed, + cfg: cfg, + } +} + +// Run executes the list command, returning a set of matches. +func (a *List) Run() ([]*release.Release, error) { + var filter *regexp.Regexp + if a.Filter != "" { + var err error + filter, err = regexp.Compile(a.Filter) + if err != nil { + return nil, err + } + } + + results, err := a.cfg.Releases.List(func(rel *release.Release) bool { + // Skip anything that the mask doesn't cover + currentStatus := a.StateMask.FromName(rel.Info.Status.String()) + if a.StateMask¤tStatus == 0 { + return false + } + + // Skip anything that doesn't match the filter. + if filter != nil && !filter.MatchString(rel.Name) { + return false + } + return true + }) + + if results == nil { + return results, nil + } + + // Unfortunately, we have to sort before truncating, which can incur substantial overhead + a.sort(results) + + // Guard on offset + if a.Offset >= len(results) { + return []*release.Release{}, nil + } + + // Calculate the limit and offset, and then truncate results if necessary. + limit := len(results) + if a.Limit > 0 && a.Limit < limit { + limit = a.Limit + } + last := a.Offset + limit + if l := len(results); l < last { + last = l + } + results = results[a.Offset:last] + + return results, err +} + +// sort is an in-place sort where order is based on the value of a.Sort +func (a *List) sort(rels []*release.Release) { + switch a.Sort { + case ByDate: + releaseutil.SortByDate(rels) + case ByNameDesc: + releaseutil.Reverse(rels, releaseutil.SortByName) + default: + releaseutil.SortByName(rels) + } +} diff --git a/pkg/action/list_test.go b/pkg/action/list_test.go new file mode 100644 index 000000000..a80b98618 --- /dev/null +++ b/pkg/action/list_test.go @@ -0,0 +1,245 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/helm/pkg/hapi/release" + "k8s.io/helm/pkg/storage" +) + +func TestListStates(t *testing.T) { + for input, expect := range map[string]ListStates{ + "deployed": ListDeployed, + "uninstalled": ListUninstalled, + "uninstalling": ListUninstalling, + "superseded": ListSuperseded, + "failed": ListFailed, + "pending-install": ListPendingInstall, + "pending-rollback": ListPendingRollback, + "pending-upgrade": ListPendingUpgrade, + "unknown": ListUnknown, + "totally made up key": ListUnknown, + } { + if expect != expect.FromName(input) { + t.Errorf("Expected %d for %s", expect, input) + } + // This is a cheap way to verify that ListAll actually allows everything but Unknown + if got := expect.FromName(input); got != ListUnknown && got&ListAll == 0 { + t.Errorf("Expected %s to match the ListAll filter", input) + } + } + + filter := ListDeployed | ListPendingRollback + if status := filter.FromName("deployed"); filter&status == 0 { + t.Errorf("Expected %d to match mask %d", status, filter) + } + if status := filter.FromName("failed"); filter&status != 0 { + t.Errorf("Expected %d to fail to match mask %d", status, filter) + } +} + +func TestList_Empty(t *testing.T) { + lister := NewList(actionConfigFixture(t)) + list, err := lister.Run() + assert.NoError(t, err) + assert.Len(t, list, 0) +} + +func newListFixture(t *testing.T) *List { + return NewList(actionConfigFixture(t)) +} + +func TestList_OneNamespace(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) +} + +func TestList_AllNamespaces(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + makeMeSomeReleases(lister.cfg.Releases, t) + lister.AllNamespaces = true + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) +} + +func TestList_Sort(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Sort = ByNameDesc // Other sorts are tested elsewhere + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) + is.Equal("two", list[0].Name) + is.Equal("three", list[1].Name) + is.Equal("one", list[2].Name) +} + +func TestList_Limit(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 2 + // Sort because otherwise there is no guaranteed order + lister.Sort = ByNameAsc + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 2) + + // Lex order means one, three, two + is.Equal("one", list[0].Name) + is.Equal("three", list[1].Name) +} + +func TestList_BigLimit(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 20 + // Sort because otherwise there is no guaranteed order + lister.Sort = ByNameAsc + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 3) + + // Lex order means one, three, two + is.Equal("one", list[0].Name) + is.Equal("three", list[1].Name) + is.Equal("two", list[2].Name) +} + +func TestList_LimitOffset(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 2 + lister.Offset = 1 + // Sort because otherwise there is no guaranteed order + lister.Sort = ByNameAsc + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 2) + + // Lex order means one, three, two + is.Equal("three", list[0].Name) + is.Equal("two", list[1].Name) +} + +func TestList_LimitOffsetOutOfBounds(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Limit = 2 + lister.Offset = 3 // Last item is index 2 + // Sort because otherwise there is no guaranteed order + lister.Sort = ByNameAsc + makeMeSomeReleases(lister.cfg.Releases, t) + list, err := lister.Run() + is.NoError(err) + is.Len(list, 0) + + lister.Limit = 10 + lister.Offset = 1 + list, err = lister.Run() + is.NoError(err) + is.Len(list, 2) +} +func TestList_StateMask(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + // Sort because otherwise there is no guaranteed order + lister.Sort = ByNameAsc + makeMeSomeReleases(lister.cfg.Releases, t) + one, err := lister.cfg.Releases.Get("one", 1) + is.NoError(err) + one.SetStatus(release.StatusUninstalled, "uninstalled") + lister.cfg.Releases.Update(one) + + res, err := lister.Run() + is.NoError(err) + is.Len(res, 2) + is.Equal("three", res[0].Name) + is.Equal("two", res[1].Name) + + lister.StateMask = ListUninstalled + res, err = lister.Run() + is.NoError(err) + is.Len(res, 1) + is.Equal("one", res[0].Name) + + lister.StateMask |= ListDeployed + res, err = lister.Run() + is.NoError(err) + is.Len(res, 3) +} + +func TestList_Filter(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Filter = "th." + lister.Sort = ByNameAsc + makeMeSomeReleases(lister.cfg.Releases, t) + + res, err := lister.Run() + is.NoError(err) + is.Len(res, 1) + is.Equal("three", res[0].Name) +} + +func TestList_FilterFailsCompile(t *testing.T) { + is := assert.New(t) + lister := newListFixture(t) + lister.Filter = "t[h.{{{" + makeMeSomeReleases(lister.cfg.Releases, t) + + _, err := lister.Run() + is.Error(err) +} + +func makeMeSomeReleases(store *storage.Storage, t *testing.T) { + t.Helper() + one := releaseStub() + one.Name = "one" + one.Namespace = "default" + one.Version = 1 + two := releaseStub() + two.Name = "two" + two.Namespace = "default" + two.Version = 2 + three := releaseStub() + three.Name = "three" + three.Namespace = "default" + three.Version = 3 + + for _, rel := range []*release.Release{one, two, three} { + if err := store.Create(rel); err != nil { + t.Fatal(err) + } + } + + all, err := store.ListReleases() + assert.NoError(t, err) + assert.Len(t, all, 3, "sanity test: three items added") +} diff --git a/pkg/hapi/release/release.go b/pkg/hapi/release/release.go index a52e39566..1ab98b8a8 100644 --- a/pkg/hapi/release/release.go +++ b/pkg/hapi/release/release.go @@ -38,3 +38,9 @@ type Release struct { // Namespace is the kubernetes namespace of the release. Namespace string `json:"namespace,omitempty"` } + +// SetStatus is a helper for setting the status on a release. +func (r *Release) SetStatus(status ReleaseStatus, msg string) { + r.Info.Status = status + r.Info.Description = msg +} diff --git a/pkg/helm/client.go b/pkg/helm/client.go index aa4e1fab0..4ed3da2d0 100644 --- a/pkg/helm/client.go +++ b/pkg/helm/client.go @@ -52,19 +52,6 @@ func (c *Client) Option(opts ...Option) *Client { return c } -// ListReleases lists the current releases. -func (c *Client) ListReleases(opts ...ReleaseListOption) ([]*release.Release, error) { - reqOpts := c.opts - for _, opt := range opts { - opt(&reqOpts) - } - req := &reqOpts.listReq - if err := reqOpts.runBefore(req); err != nil { - return nil, err - } - return c.tiller.ListReleases(req) -} - // InstallRelease loads a chart from chstr, installs it, and returns the release response. func (c *Client) InstallRelease(chstr, ns string, opts ...InstallOption) (*release.Release, error) { // load the chart to install diff --git a/pkg/helm/fake.go b/pkg/helm/fake.go index 80b850a6b..7bc9eae60 100644 --- a/pkg/helm/fake.go +++ b/pkg/helm/fake.go @@ -46,11 +46,6 @@ func (c *FakeClient) Option(opts ...Option) Interface { var _ Interface = &FakeClient{} var _ Interface = (*FakeClient)(nil) -// ListReleases lists the current releases -func (c *FakeClient) ListReleases(opts ...ReleaseListOption) ([]*release.Release, error) { - return c.Rels, nil -} - // InstallRelease creates a new release and returns the release func (c *FakeClient) InstallRelease(chStr, ns string, opts ...InstallOption) (*release.Release, error) { chart := &chart.Chart{} diff --git a/pkg/helm/helm_test.go b/pkg/helm/helm_test.go index 50cd821ad..7bbf9701a 100644 --- a/pkg/helm/helm_test.go +++ b/pkg/helm/helm_test.go @@ -26,7 +26,6 @@ import ( cpb "k8s.io/helm/pkg/chart" "k8s.io/helm/pkg/chart/loader" "k8s.io/helm/pkg/hapi" - rls "k8s.io/helm/pkg/hapi/release" ) // Path to example charts relative to pkg/helm. @@ -36,61 +35,6 @@ const chartsDir = "../../docs/examples/" var errSkip = errors.New("test: skip") // Verify each ReleaseListOption is applied to a ListReleasesRequest correctly. -func TestListReleases_VerifyOptions(t *testing.T) { - // Options testdata - var limit = 2 - var offset = "offset" - var filter = "filter" - var sortBy = hapi.SortByLastReleased - var sortOrd = hapi.SortAsc - var codes = []rls.ReleaseStatus{ - rls.StatusFailed, - rls.StatusUninstalled, - rls.StatusDeployed, - rls.StatusSuperseded, - } - - // Expected ListReleasesRequest message - exp := &hapi.ListReleasesRequest{ - Limit: int64(limit), - Offset: offset, - Filter: filter, - SortBy: sortBy, - SortOrder: sortOrd, - StatusCodes: codes, - } - - // Options used in ListReleases - ops := []ReleaseListOption{ - ReleaseListSort(sortBy), - ReleaseListOrder(sortOrd), - ReleaseListLimit(limit), - ReleaseListOffset(offset), - ReleaseListFilter(filter), - ReleaseListStatuses(codes), - } - - // BeforeCall option to intercept Helm client ListReleasesRequest - b4c := BeforeCall(func(msg interface{}) error { - switch act := msg.(type) { - case *hapi.ListReleasesRequest: - t.Logf("ListReleasesRequest: %#+v\n", act) - assert(t, exp, act) - default: - t.Fatalf("expected message of type ListReleasesRequest, got %T\n", act) - } - return errSkip - }) - - client := NewClient(b4c) - - if _, err := client.ListReleases(ops...); err != errSkip { - t.Fatalf("did not expect error but got (%v)\n``", err) - } - - // ensure options for call are not saved to client - assert(t, "", client.opts.listReq.Filter) -} // Verify each InstallOption is applied to an InstallReleaseRequest correctly. func TestInstallRelease_VerifyOptions(t *testing.T) { diff --git a/pkg/helm/interface.go b/pkg/helm/interface.go index a66eefb23..96db762b0 100644 --- a/pkg/helm/interface.go +++ b/pkg/helm/interface.go @@ -24,7 +24,6 @@ import ( // Interface for helm client for mocking in tests type Interface interface { - ListReleases(opts ...ReleaseListOption) ([]*release.Release, error) InstallRelease(chStr, namespace string, opts ...InstallOption) (*release.Release, error) InstallReleaseFromChart(chart *chart.Chart, namespace string, opts ...InstallOption) (*release.Release, error) UninstallRelease(rlsName string, opts ...UninstallOption) (*hapi.UninstallReleaseResponse, error) diff --git a/pkg/helm/option.go b/pkg/helm/option.go index 83eaba92f..8c8dbbd25 100644 --- a/pkg/helm/option.go +++ b/pkg/helm/option.go @@ -20,7 +20,6 @@ import ( "k8s.io/client-go/discovery" "k8s.io/helm/pkg/hapi" - "k8s.io/helm/pkg/hapi/release" "k8s.io/helm/pkg/storage/driver" "k8s.io/helm/pkg/tiller/environment" ) @@ -87,56 +86,6 @@ func BeforeCall(fn func(interface{}) error) Option { } } -// ReleaseListOption allows specifying various settings -// configurable by the helm client user for overriding -// the defaults used when running the `helm list` command. -type ReleaseListOption func(*options) - -// ReleaseListOffset specifies the offset into a list of releases. -func ReleaseListOffset(offset string) ReleaseListOption { - return func(opts *options) { - opts.listReq.Offset = offset - } -} - -// ReleaseListFilter specifies a filter to apply a list of releases. -func ReleaseListFilter(filter string) ReleaseListOption { - return func(opts *options) { - opts.listReq.Filter = filter - } -} - -// ReleaseListLimit set an upper bound on the number of releases returned. -func ReleaseListLimit(limit int) ReleaseListOption { - return func(opts *options) { - opts.listReq.Limit = int64(limit) - } -} - -// ReleaseListOrder specifies how to order a list of releases. -func ReleaseListOrder(order hapi.SortOrder) ReleaseListOption { - return func(opts *options) { - opts.listReq.SortOrder = order - } -} - -// ReleaseListSort specifies how to sort a release list. -func ReleaseListSort(sort hapi.SortBy) ReleaseListOption { - return func(opts *options) { - opts.listReq.SortBy = sort - } -} - -// ReleaseListStatuses specifies which status codes should be returned. -func ReleaseListStatuses(statuses []release.ReleaseStatus) ReleaseListOption { - return func(opts *options) { - if len(statuses) == 0 { - statuses = []release.ReleaseStatus{release.StatusDeployed} - } - opts.listReq.StatusCodes = statuses - } -} - // InstallOption allows specifying various settings // configurable by the helm client user for overriding // the defaults used when running the `helm install` command. diff --git a/pkg/releaseutil/kind_sorter.go b/pkg/releaseutil/kind_sorter.go new file mode 100644 index 000000000..cbb3e4c22 --- /dev/null +++ b/pkg/releaseutil/kind_sorter.go @@ -0,0 +1,146 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseutil + +import "sort" + +// KindSortOrder is an ordering of Kinds. +type KindSortOrder []string + +// InstallOrder is the order in which manifests should be installed (by Kind). +// +// Those occurring earlier in the list get installed before those occurring later in the list. +var InstallOrder KindSortOrder = []string{ + "Namespace", + "ResourceQuota", + "LimitRange", + "Secret", + "ConfigMap", + "StorageClass", + "PersistentVolume", + "PersistentVolumeClaim", + "ServiceAccount", + "CustomResourceDefinition", + "ClusterRole", + "ClusterRoleBinding", + "Role", + "RoleBinding", + "Service", + "DaemonSet", + "Pod", + "ReplicationController", + "ReplicaSet", + "Deployment", + "StatefulSet", + "Job", + "CronJob", + "Ingress", + "APIService", +} + +// UninstallOrder is the order in which manifests should be uninstalled (by Kind). +// +// Those occurring earlier in the list get uninstalled before those occurring later in the list. +var UninstallOrder KindSortOrder = []string{ + "APIService", + "Ingress", + "Service", + "CronJob", + "Job", + "StatefulSet", + "Deployment", + "ReplicaSet", + "ReplicationController", + "Pod", + "DaemonSet", + "RoleBinding", + "Role", + "ClusterRoleBinding", + "ClusterRole", + "CustomResourceDefinition", + "ServiceAccount", + "PersistentVolumeClaim", + "PersistentVolume", + "StorageClass", + "ConfigMap", + "Secret", + "LimitRange", + "ResourceQuota", + "Namespace", +} + +// sortByKind does an in-place sort of manifests by Kind. +// +// Results are sorted by 'ordering' +func sortByKind(manifests []Manifest, ordering KindSortOrder) []Manifest { + ks := newKindSorter(manifests, ordering) + sort.Sort(ks) + return ks.manifests +} + +type kindSorter struct { + ordering map[string]int + manifests []Manifest +} + +func newKindSorter(m []Manifest, s KindSortOrder) *kindSorter { + o := make(map[string]int, len(s)) + for v, k := range s { + o[k] = v + } + + return &kindSorter{ + manifests: m, + ordering: o, + } +} + +func (k *kindSorter) Len() int { return len(k.manifests) } + +func (k *kindSorter) Swap(i, j int) { k.manifests[i], k.manifests[j] = k.manifests[j], k.manifests[i] } + +func (k *kindSorter) Less(i, j int) bool { + a := k.manifests[i] + b := k.manifests[j] + first, aok := k.ordering[a.Head.Kind] + second, bok := k.ordering[b.Head.Kind] + // if same kind (including unknown) sub sort alphanumeric + if first == second { + // if both are unknown and of different kind sort by kind alphabetically + if !aok && !bok && a.Head.Kind != b.Head.Kind { + return a.Head.Kind < b.Head.Kind + } + return a.Name < b.Name + } + // unknown kind is last + if !aok { + return false + } + if !bok { + return true + } + // sort different kinds + return first < second +} + +// SortByKind sorts manifests in InstallOrder +func SortByKind(manifests []Manifest) []Manifest { + ordering := InstallOrder + ks := newKindSorter(manifests, ordering) + sort.Sort(ks) + return ks.manifests +} diff --git a/pkg/releaseutil/kind_sorter_test.go b/pkg/releaseutil/kind_sorter_test.go new file mode 100644 index 000000000..f0b04ff0e --- /dev/null +++ b/pkg/releaseutil/kind_sorter_test.go @@ -0,0 +1,215 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseutil + +import ( + "bytes" + "testing" +) + +func TestKindSorter(t *testing.T) { + manifests := []Manifest{ + { + Name: "i", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "j", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "e", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "u", + Head: &SimpleHead{Kind: "CronJob"}, + }, + { + Name: "2", + Head: &SimpleHead{Kind: "CustomResourceDefinition"}, + }, + { + Name: "n", + Head: &SimpleHead{Kind: "DaemonSet"}, + }, + { + Name: "r", + Head: &SimpleHead{Kind: "Deployment"}, + }, + { + Name: "!", + Head: &SimpleHead{Kind: "HonkyTonkSet"}, + }, + { + Name: "v", + Head: &SimpleHead{Kind: "Ingress"}, + }, + { + Name: "t", + Head: &SimpleHead{Kind: "Job"}, + }, + { + Name: "c", + Head: &SimpleHead{Kind: "LimitRange"}, + }, + { + Name: "a", + Head: &SimpleHead{Kind: "Namespace"}, + }, + { + Name: "f", + Head: &SimpleHead{Kind: "PersistentVolume"}, + }, + { + Name: "g", + Head: &SimpleHead{Kind: "PersistentVolumeClaim"}, + }, + { + Name: "o", + Head: &SimpleHead{Kind: "Pod"}, + }, + { + Name: "q", + Head: &SimpleHead{Kind: "ReplicaSet"}, + }, + { + Name: "p", + Head: &SimpleHead{Kind: "ReplicationController"}, + }, + { + Name: "b", + Head: &SimpleHead{Kind: "ResourceQuota"}, + }, + { + Name: "k", + Head: &SimpleHead{Kind: "Role"}, + }, + { + Name: "l", + Head: &SimpleHead{Kind: "RoleBinding"}, + }, + { + Name: "d", + Head: &SimpleHead{Kind: "Secret"}, + }, + { + Name: "m", + Head: &SimpleHead{Kind: "Service"}, + }, + { + Name: "h", + Head: &SimpleHead{Kind: "ServiceAccount"}, + }, + { + Name: "s", + Head: &SimpleHead{Kind: "StatefulSet"}, + }, + { + Name: "1", + Head: &SimpleHead{Kind: "StorageClass"}, + }, + { + Name: "w", + Head: &SimpleHead{Kind: "APIService"}, + }, + } + + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + {"install", InstallOrder, "abcde1fgh2ijklmnopqrstuvw!"}, + {"uninstall", UninstallOrder, "wvmutsrqponlkji2hgf1edcba!"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + if got, want := len(test.expected), len(manifests); got != want { + t.Fatalf("Expected %d names in order, got %d", want, got) + } + defer buf.Reset() + for _, r := range sortByKind(manifests, test.order) { + buf.WriteString(r.Name) + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + }) + } +} + +// TestKindSorterSubSort verifies manifests of same kind are also sorted alphanumeric +func TestKindSorterSubSort(t *testing.T) { + manifests := []Manifest{ + { + Name: "a", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "A", + Head: &SimpleHead{Kind: "ClusterRole"}, + }, + { + Name: "0", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "1", + Head: &SimpleHead{Kind: "ConfigMap"}, + }, + { + Name: "z", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "!", + Head: &SimpleHead{Kind: "ClusterRoleBinding"}, + }, + { + Name: "u2", + Head: &SimpleHead{Kind: "Unknown"}, + }, + { + Name: "u1", + Head: &SimpleHead{Kind: "Unknown"}, + }, + { + Name: "t3", + Head: &SimpleHead{Kind: "Unknown2"}, + }, + } + for _, test := range []struct { + description string + order KindSortOrder + expected string + }{ + // expectation is sorted by kind (unknown is last) and then sub sorted alphabetically within each group + {"cm,clusterRole,clusterRoleBinding,Unknown,Unknown2", InstallOrder, "01Aa!zu1u2t3"}, + } { + var buf bytes.Buffer + t.Run(test.description, func(t *testing.T) { + defer buf.Reset() + for _, r := range sortByKind(manifests, test.order) { + buf.WriteString(r.Name) + } + if got := buf.String(); got != test.expected { + t.Errorf("Expected %q, got %q", test.expected, got) + } + }) + } +} diff --git a/pkg/releaseutil/manifest_sorter.go b/pkg/releaseutil/manifest_sorter.go new file mode 100644 index 000000000..17ffed330 --- /dev/null +++ b/pkg/releaseutil/manifest_sorter.go @@ -0,0 +1,220 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseutil + +import ( + "log" + "path" + "strconv" + "strings" + + "github.com/pkg/errors" + yaml "gopkg.in/yaml.v2" + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/hapi/release" + "k8s.io/helm/pkg/hooks" +) + +// Manifest represents a manifest file, which has a name and some content. +type Manifest struct { + Name string + Content string + Head *SimpleHead +} + +// manifestFile represents a file that contains a manifest. +type manifestFile struct { + entries map[string]string + path string + apis chartutil.VersionSet +} + +// result is an intermediate structure used during sorting. +type result struct { + hooks []*release.Hook + generic []Manifest +} + +// TODO: Refactor this out. It's here because naming conventions were not followed through. +// So fix the Test hook names and then remove this. +var events = map[string]release.HookEvent{ + hooks.PreInstall: release.HookPreInstall, + hooks.PostInstall: release.HookPostInstall, + hooks.PreDelete: release.HookPreDelete, + hooks.PostDelete: release.HookPostDelete, + hooks.PreUpgrade: release.HookPreUpgrade, + hooks.PostUpgrade: release.HookPostUpgrade, + hooks.PreRollback: release.HookPreRollback, + hooks.PostRollback: release.HookPostRollback, + hooks.ReleaseTestSuccess: release.HookReleaseTestSuccess, + hooks.ReleaseTestFailure: release.HookReleaseTestFailure, +} + +// SortManifests takes a map of filename/YAML contents, splits the file +// by manifest entries, and sorts the entries into hook types. +// +// The resulting hooks struct will be populated with all of the generated hooks. +// Any file that does not declare one of the hook types will be placed in the +// 'generic' bucket. +// +// Files that do not parse into the expected format are simply placed into a map and +// returned. +func SortManifests(files map[string]string, apis chartutil.VersionSet, sort KindSortOrder) ([]*release.Hook, []Manifest, error) { + result := &result{} + + for filePath, c := range files { + + // Skip partials. We could return these as a separate map, but there doesn't + // seem to be any need for that at this time. + if strings.HasPrefix(path.Base(filePath), "_") { + continue + } + // Skip empty files and log this. + if len(strings.TrimSpace(c)) == 0 { + log.Printf("info: manifest %q is empty. Skipping.", filePath) + continue + } + + manifestFile := &manifestFile{ + entries: SplitManifests(c), + path: filePath, + apis: apis, + } + + if err := manifestFile.sort(result); err != nil { + return result.hooks, result.generic, err + } + } + + return result.hooks, sortByKind(result.generic, sort), nil +} + +// sort takes a manifestFile object which may contain multiple resource definition +// entries and sorts each entry by hook types, and saves the resulting hooks and +// generic manifests (or non-hooks) to the result struct. +// +// To determine hook type, it looks for a YAML structure like this: +// +// kind: SomeKind +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook: pre-install +// +// To determine the policy to delete the hook, it looks for a YAML structure like this: +// +// kind: SomeKind +// apiVersion: v1 +// metadata: +// annotations: +// helm.sh/hook-delete-policy: hook-succeeded +func (file *manifestFile) sort(result *result) error { + for _, m := range file.entries { + var entry SimpleHead + if err := yaml.Unmarshal([]byte(m), &entry); err != nil { + return errors.Wrapf(err, "YAML parse error on %s", file.path) + } + + if entry.Version != "" && !file.apis.Has(entry.Version) { + return errors.Errorf("apiVersion %q in %s is not available", entry.Version, file.path) + } + + if !hasAnyAnnotation(entry) { + result.generic = append(result.generic, Manifest{ + Name: file.path, + Content: m, + Head: &entry, + }) + continue + } + + hookTypes, ok := entry.Metadata.Annotations[hooks.HookAnno] + if !ok { + result.generic = append(result.generic, Manifest{ + Name: file.path, + Content: m, + Head: &entry, + }) + continue + } + + hw := calculateHookWeight(entry) + + h := &release.Hook{ + Name: entry.Metadata.Name, + Kind: entry.Kind, + Path: file.path, + Manifest: m, + Events: []release.HookEvent{}, + Weight: hw, + DeletePolicies: []release.HookDeletePolicy{}, + } + + isUnknownHook := false + for _, hookType := range strings.Split(hookTypes, ",") { + hookType = strings.ToLower(strings.TrimSpace(hookType)) + e, ok := events[hookType] + if !ok { + isUnknownHook = true + break + } + h.Events = append(h.Events, e) + } + + if isUnknownHook { + log.Printf("info: skipping unknown hook: %q", hookTypes) + continue + } + + result.hooks = append(result.hooks, h) + + operateAnnotationValues(entry, hooks.HookDeleteAnno, func(value string) { + h.DeletePolicies = append(h.DeletePolicies, release.HookDeletePolicy(value)) + }) + } + + return nil +} + +// hasAnyAnnotation returns true if the given entry has any annotations at all. +func hasAnyAnnotation(entry SimpleHead) bool { + return entry.Metadata != nil && + entry.Metadata.Annotations != nil && + len(entry.Metadata.Annotations) != 0 +} + +// calculateHookWeight finds the weight in the hook weight annotation. +// +// If no weight is found, the assigned weight is 0 +func calculateHookWeight(entry SimpleHead) int { + hws := entry.Metadata.Annotations[hooks.HookWeightAnno] + hw, err := strconv.Atoi(hws) + if err != nil { + hw = 0 + } + return hw +} + +// operateAnnotationValues finds the given annotation and runs the operate function with the value of that annotation +func operateAnnotationValues(entry SimpleHead, annotation string, operate func(p string)) { + if dps, ok := entry.Metadata.Annotations[annotation]; ok { + for _, dp := range strings.Split(dps, ",") { + dp = strings.ToLower(strings.TrimSpace(dp)) + operate(dp) + } + } +} diff --git a/pkg/releaseutil/manifest_sorter_test.go b/pkg/releaseutil/manifest_sorter_test.go new file mode 100644 index 000000000..91b98f83f --- /dev/null +++ b/pkg/releaseutil/manifest_sorter_test.go @@ -0,0 +1,229 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseutil + +import ( + "reflect" + "testing" + + "github.com/ghodss/yaml" + + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/hapi/release" +) + +func TestSortManifests(t *testing.T) { + + data := []struct { + name []string + path string + kind []string + hooks map[string][]release.HookEvent + manifest string + }{ + { + name: []string{"first"}, + path: "one", + kind: []string{"Job"}, + hooks: map[string][]release.HookEvent{"first": {release.HookPreInstall}}, + manifest: `apiVersion: v1 +kind: Job +metadata: + name: first + labels: + doesnot: matter + annotations: + "helm.sh/hook": pre-install +`, + }, + { + name: []string{"second"}, + path: "two", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"second": {release.HookPostInstall}}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: second + annotations: + "helm.sh/hook": post-install +`, + }, { + name: []string{"third"}, + path: "three", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"third": nil}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: third + annotations: + "helm.sh/hook": no-such-hook +`, + }, { + name: []string{"fourth"}, + path: "four", + kind: []string{"Pod"}, + hooks: map[string][]release.HookEvent{"fourth": nil}, + manifest: `kind: Pod +apiVersion: v1 +metadata: + name: fourth + annotations: + nothing: here`, + }, { + name: []string{"fifth"}, + path: "five", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"fifth": {release.HookPostDelete, release.HookPostInstall}}, + manifest: `kind: ReplicaSet +apiVersion: v1beta1 +metadata: + name: fifth + annotations: + "helm.sh/hook": post-delete, post-install +`, + }, { + // Regression test: files with an underscore in the base name should be skipped. + name: []string{"sixth"}, + path: "six/_six", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"sixth": nil}, + manifest: `invalid manifest`, // This will fail if partial is not skipped. + }, { + // Regression test: files with no content should be skipped. + name: []string{"seventh"}, + path: "seven", + kind: []string{"ReplicaSet"}, + hooks: map[string][]release.HookEvent{"seventh": nil}, + manifest: "", + }, + { + name: []string{"eighth", "example-test"}, + path: "eight", + kind: []string{"ConfigMap", "Pod"}, + hooks: map[string][]release.HookEvent{"eighth": nil, "example-test": {release.HookReleaseTestSuccess}}, + manifest: `kind: ConfigMap +apiVersion: v1 +metadata: + name: eighth +data: + name: value +--- +apiVersion: v1 +kind: Pod +metadata: + name: example-test + annotations: + "helm.sh/hook": test-success +`, + }, + } + + manifests := make(map[string]string, len(data)) + for _, o := range data { + manifests[o.path] = o.manifest + } + + hs, generic, err := SortManifests(manifests, chartutil.NewVersionSet("v1", "v1beta1"), InstallOrder) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + // This test will fail if 'six' or 'seven' was added. + if len(generic) != 2 { + t.Errorf("Expected 2 generic manifests, got %d", len(generic)) + } + + if len(hs) != 4 { + t.Errorf("Expected 4 hooks, got %d", len(hs)) + } + + for _, out := range hs { + found := false + for _, expect := range data { + if out.Path == expect.path { + found = true + if out.Path != expect.path { + t.Errorf("Expected path %s, got %s", expect.path, out.Path) + } + nameFound := false + for _, expectedName := range expect.name { + if out.Name == expectedName { + nameFound = true + } + } + if !nameFound { + t.Errorf("Got unexpected name %s", out.Name) + } + kindFound := false + for _, expectedKind := range expect.kind { + if out.Kind == expectedKind { + kindFound = true + } + } + if !kindFound { + t.Errorf("Got unexpected kind %s", out.Kind) + } + + expectedHooks := expect.hooks[out.Name] + if !reflect.DeepEqual(expectedHooks, out.Events) { + t.Errorf("expected events: %v but got: %v", expectedHooks, out.Events) + } + + } + } + if !found { + t.Errorf("Result not found: %v", out) + } + } + + // Verify the sort order + sorted := []Manifest{} + for _, s := range data { + manifests := SplitManifests(s.manifest) + + for _, m := range manifests { + var sh SimpleHead + err := yaml.Unmarshal([]byte(m), &sh) + if err != nil { + // This is expected for manifests that are corrupt or empty. + t.Log(err) + continue + } + + name := sh.Metadata.Name + + //only keep track of non-hook manifests + if err == nil && s.hooks[name] == nil { + another := Manifest{ + Content: m, + Name: name, + Head: &sh, + } + sorted = append(sorted, another) + } + } + } + + sorted = sortByKind(sorted, InstallOrder) + for i, m := range generic { + if m.Content != sorted[i].Content { + t.Errorf("Expected %q, got %q", m.Content, sorted[i].Content) + } + } +} diff --git a/pkg/releaseutil/manifest_test.go b/pkg/releaseutil/manifest_test.go index 8e0793d5f..b452c29c0 100644 --- a/pkg/releaseutil/manifest_test.go +++ b/pkg/releaseutil/manifest_test.go @@ -21,7 +21,7 @@ import ( "testing" ) -const manifestFile = ` +const mockManifestFile = ` --- apiVersion: v1 @@ -50,7 +50,7 @@ spec: cmd: fake-command` func TestSplitManifest(t *testing.T) { - manifests := SplitManifests(manifestFile) + manifests := SplitManifests(mockManifestFile) if len(manifests) != 1 { t.Errorf("Expected 1 manifest, got %v", len(manifests)) } diff --git a/pkg/releaseutil/sorter.go b/pkg/releaseutil/sorter.go index 6106319df..0f27fabb0 100644 --- a/pkg/releaseutil/sorter.go +++ b/pkg/releaseutil/sorter.go @@ -27,20 +27,26 @@ type list []*rspb.Release func (s list) Len() int { return len(s) } func (s list) Swap(i, j int) { s[i], s[j] = s[j], s[i] } +// ByName sorts releases by name type ByName struct{ list } +// Less compares to releases func (s ByName) Less(i, j int) bool { return s.list[i].Name < s.list[j].Name } +// ByDate sorts releases by date type ByDate struct{ list } +// Less compares to releases func (s ByDate) Less(i, j int) bool { ti := s.list[i].Info.LastDeployed.Second() tj := s.list[j].Info.LastDeployed.Second() return ti < tj } +// ByRevision sorts releases by revision number type ByRevision struct{ list } +// Less compares to releases func (s ByRevision) Less(i, j int) bool { return s.list[i].Version < s.list[j].Version } diff --git a/pkg/tiller/hooks.go b/pkg/tiller/hooks.go index 5a85036e3..93cd8a715 100644 --- a/pkg/tiller/hooks.go +++ b/pkg/tiller/hooks.go @@ -44,13 +44,6 @@ var events = map[string]release.HookEvent{ hooks.ReleaseTestFailure: release.HookReleaseTestFailure, } -// deletePolices represents a mapping between the key in the annotation for label deleting policy and its real meaning -var deletePolices = map[string]release.HookDeletePolicy{ - hooks.HookSucceeded: release.HookSucceeded, - hooks.HookFailed: release.HookFailed, - hooks.BeforeHookCreation: release.HookBeforeHookCreation, -} - // Manifest represents a manifest file, which has a name and some content. type Manifest struct { Name string @@ -69,7 +62,7 @@ type manifestFile struct { apis chartutil.VersionSet } -// sortManifests takes a map of filename/YAML contents, splits the file +// SortManifests takes a map of filename/YAML contents, splits the file // by manifest entries, and sorts the entries into hook types. // // The resulting hooks struct will be populated with all of the generated hooks. diff --git a/pkg/tiller/release_install.go b/pkg/tiller/release_install.go index f91c8e555..e6fd82ff3 100644 --- a/pkg/tiller/release_install.go +++ b/pkg/tiller/release_install.go @@ -31,6 +31,14 @@ import ( relutil "k8s.io/helm/pkg/releaseutil" ) +// deletePolices represents a mapping between the key in the annotation for label deleting policy and its real meaning +// FIXME: Can we refactor this out? +var deletePolices = map[string]release.HookDeletePolicy{ + hooks.HookSucceeded: release.HookSucceeded, + hooks.HookFailed: release.HookFailed, + hooks.BeforeHookCreation: release.HookBeforeHookCreation, +} + // InstallRelease installs a release and stores the release record. func (s *ReleaseServer) InstallRelease(req *hapi.InstallReleaseRequest) (*release.Release, error) { s.Log("preparing install for %s", req.Name) diff --git a/pkg/tiller/release_list.go b/pkg/tiller/release_list.go deleted file mode 100644 index 222369f31..000000000 --- a/pkg/tiller/release_list.go +++ /dev/null @@ -1,83 +0,0 @@ -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package tiller - -import ( - "regexp" - - "k8s.io/helm/pkg/hapi" - "k8s.io/helm/pkg/hapi/release" - relutil "k8s.io/helm/pkg/releaseutil" -) - -// ListReleases lists the releases found by the server. -func (s *ReleaseServer) ListReleases(req *hapi.ListReleasesRequest) ([]*release.Release, error) { - if len(req.StatusCodes) == 0 { - req.StatusCodes = []release.ReleaseStatus{release.StatusDeployed} - } - - rels, err := s.Releases.ListFilterAll(func(r *release.Release) bool { - for _, sc := range req.StatusCodes { - if sc == r.Info.Status { - return true - } - } - return false - }) - if err != nil { - return nil, err - } - - if len(req.Filter) != 0 { - rels, err = filterReleases(req.Filter, rels) - if err != nil { - return nil, err - } - } - - switch req.SortBy { - case hapi.SortByName: - relutil.SortByName(rels) - case hapi.SortByLastReleased: - relutil.SortByDate(rels) - } - - if req.SortOrder == hapi.SortDesc { - ll := len(rels) - rr := make([]*release.Release, ll) - for i, item := range rels { - rr[ll-i-1] = item - } - rels = rr - } - - return rels, nil -} - -func filterReleases(filter string, rels []*release.Release) ([]*release.Release, error) { - preg, err := regexp.Compile(filter) - if err != nil { - return rels, err - } - matches := []*release.Release{} - for _, r := range rels { - if preg.MatchString(r.Name) { - matches = append(matches, r) - } - } - return matches, nil -} diff --git a/pkg/tiller/release_list_test.go b/pkg/tiller/release_list_test.go deleted file mode 100644 index abce1f569..000000000 --- a/pkg/tiller/release_list_test.go +++ /dev/null @@ -1,191 +0,0 @@ -/* -Copyright The Helm Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package tiller - -import ( - "fmt" - "testing" - - "k8s.io/helm/pkg/hapi" - "k8s.io/helm/pkg/hapi/release" -) - -func TestListReleases(t *testing.T) { - rs := rsFixture(t) - num := 7 - for i := 0; i < num; i++ { - rel := releaseStub() - rel.Name = fmt.Sprintf("rel-%d", i) - if err := rs.Releases.Create(rel); err != nil { - t.Fatalf("Could not store mock release: %s", err) - } - } - - rels, err := rs.ListReleases(&hapi.ListReleasesRequest{}) - if err != nil { - t.Fatalf("Failed listing: %s", err) - } - - if len(rels) != num { - t.Errorf("Expected %d releases, got %d", num, len(rels)) - } -} - -func TestListReleasesByStatus(t *testing.T) { - rs := rsFixture(t) - stubs := []*release.Release{ - namedReleaseStub("kamal", release.StatusDeployed), - namedReleaseStub("astrolabe", release.StatusUninstalled), - namedReleaseStub("octant", release.StatusFailed), - namedReleaseStub("sextant", release.StatusUnknown), - } - for _, stub := range stubs { - if err := rs.Releases.Create(stub); err != nil { - t.Fatalf("Could not create stub: %s", err) - } - } - - tests := []struct { - statusCodes []release.ReleaseStatus - names []string - }{ - { - names: []string{"kamal"}, - statusCodes: []release.ReleaseStatus{release.StatusDeployed}, - }, - { - names: []string{"astrolabe"}, - statusCodes: []release.ReleaseStatus{release.StatusUninstalled}, - }, - { - names: []string{"kamal", "octant"}, - statusCodes: []release.ReleaseStatus{release.StatusDeployed, release.StatusFailed}, - }, - { - names: []string{"kamal", "astrolabe", "octant", "sextant"}, - statusCodes: []release.ReleaseStatus{ - release.StatusDeployed, - release.StatusUninstalled, - release.StatusFailed, - release.StatusUnknown, - }, - }, - } - - for i, tt := range tests { - rels, err := rs.ListReleases(&hapi.ListReleasesRequest{StatusCodes: tt.statusCodes, Offset: "", Limit: 64}) - if err != nil { - t.Fatalf("Failed listing %d: %s", i, err) - } - - if len(tt.names) != len(rels) { - t.Fatalf("Expected %d releases, got %d", len(tt.names), len(rels)) - } - - for _, name := range tt.names { - found := false - for _, rel := range rels { - if rel.Name == name { - found = true - } - } - if !found { - t.Errorf("%d: Did not find name %q", i, name) - } - } - } -} - -func TestListReleasesSort(t *testing.T) { - rs := rsFixture(t) - - // Put them in by reverse order so that the mock doesn't "accidentally" - // sort. - num := 7 - for i := num; i > 0; i-- { - rel := releaseStub() - rel.Name = fmt.Sprintf("rel-%d", i) - if err := rs.Releases.Create(rel); err != nil { - t.Fatalf("Could not store mock release: %s", err) - } - } - - limit := 6 - req := &hapi.ListReleasesRequest{ - Offset: "", - Limit: int64(limit), - SortBy: hapi.SortByName, - } - rels, err := rs.ListReleases(req) - if err != nil { - t.Fatalf("Failed listing: %s", err) - } - - // if len(rels) != limit { - // t.Errorf("Expected %d releases, got %d", limit, len(rels)) - // } - - for i := 0; i < limit; i++ { - n := fmt.Sprintf("rel-%d", i+1) - if rels[i].Name != n { - t.Errorf("Expected %q, got %q", n, rels[i].Name) - } - } -} - -func TestListReleasesFilter(t *testing.T) { - rs := rsFixture(t) - names := []string{ - "axon", - "dendrite", - "neuron", - "neuroglia", - "synapse", - "nucleus", - "organelles", - } - num := 7 - for i := 0; i < num; i++ { - rel := releaseStub() - rel.Name = names[i] - if err := rs.Releases.Create(rel); err != nil { - t.Fatalf("Could not store mock release: %s", err) - } - } - - req := &hapi.ListReleasesRequest{ - Offset: "", - Limit: 64, - Filter: "neuro[a-z]+", - SortBy: hapi.SortByName, - } - rels, err := rs.ListReleases(req) - if err != nil { - t.Fatalf("Failed listing: %s", err) - } - - if len(rels) != 2 { - t.Errorf("Expected 2 releases, got %d", len(rels)) - } - - if rels[0].Name != "neuroglia" { - t.Errorf("Unexpected sort order: %v.", rels) - } - if rels[1].Name != "neuron" { - t.Errorf("Unexpected sort order: %v.", rels) - } -}