From 8f368bb65465bd8f414314e01cf897ef0d6fd7da Mon Sep 17 00:00:00 2001 From: Matt Butcher Date: Tue, 13 Sep 2016 13:29:58 -0600 Subject: [PATCH 1/2] feat(chartutils): add support for requirements.yaml --- cmd/helm/dependency.go | 216 +++++++++++++ cmd/helm/dependency_test.go | 71 +++++ cmd/helm/dependency_update.go | 287 ++++++++++++++++++ cmd/helm/dependency_update_test.go | 214 +++++++++++++ cmd/helm/fetch.go | 28 +- cmd/helm/helm.go | 1 + cmd/helm/helm_test.go | 20 ++ cmd/helm/init_test.go | 2 +- cmd/helm/install.go | 2 +- cmd/helm/repo_test.go | 2 +- cmd/helm/resolver/resolver.go | 87 ++++++ cmd/helm/resolver/resolver_test.go | 116 +++++++ cmd/helm/search.go | 1 + .../testdata/testcharts/reqtest-0.1.0.tgz | Bin 0 -> 911 bytes .../testdata/testcharts/reqtest/.helmignore | 21 ++ .../testdata/testcharts/reqtest/Chart.yaml | 3 + .../reqtest/charts/reqsubchart/.helmignore | 21 ++ .../reqtest/charts/reqsubchart/Chart.yaml | 3 + .../reqtest/charts/reqsubchart/values.yaml | 4 + .../reqtest/charts/reqsubchart2/.helmignore | 21 ++ .../reqtest/charts/reqsubchart2/Chart.yaml | 3 + .../reqtest/charts/reqsubchart2/values.yaml | 4 + .../testcharts/reqtest/requirements.lock | 3 + .../testcharts/reqtest/requirements.yaml | 7 + .../testdata/testcharts/reqtest/values.yaml | 4 + docs/charts.md | 56 ++++ pkg/chartutil/load_test.go | 30 +- pkg/chartutil/requirements.go | 84 +++++ pkg/chartutil/requirements_test.go | 27 ++ pkg/chartutil/testdata/frobnitz-1.2.3.tgz | Bin 3831 -> 3974 bytes .../frobnitz/charts/mariner-4.3.2.tgz | Bin 941 -> 1025 bytes .../testdata/frobnitz/requirements.yaml | 7 + .../mariner/charts/albatross-0.1.0.tgz | Bin 347 -> 347 bytes .../testdata/mariner/requirements.yaml | 4 + pkg/provenance/sign.go | 16 +- pkg/provenance/sign_test.go | 26 +- pkg/repo/index.go | 72 ++++- pkg/repo/index_test.go | 32 ++ 38 files changed, 1462 insertions(+), 33 deletions(-) create mode 100644 cmd/helm/dependency.go create mode 100644 cmd/helm/dependency_test.go create mode 100644 cmd/helm/dependency_update.go create mode 100644 cmd/helm/dependency_update_test.go create mode 100644 cmd/helm/resolver/resolver.go create mode 100644 cmd/helm/resolver/resolver_test.go create mode 100644 cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz create mode 100644 cmd/helm/testdata/testcharts/reqtest/.helmignore create mode 100755 cmd/helm/testdata/testcharts/reqtest/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore create mode 100755 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore create mode 100755 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml create mode 100755 cmd/helm/testdata/testcharts/reqtest/requirements.lock create mode 100644 cmd/helm/testdata/testcharts/reqtest/requirements.yaml create mode 100644 cmd/helm/testdata/testcharts/reqtest/values.yaml create mode 100644 pkg/chartutil/requirements.go create mode 100644 pkg/chartutil/requirements_test.go create mode 100644 pkg/chartutil/testdata/frobnitz/requirements.yaml create mode 100644 pkg/chartutil/testdata/mariner/requirements.yaml diff --git a/cmd/helm/dependency.go b/cmd/helm/dependency.go new file mode 100644 index 000000000..2da344052 --- /dev/null +++ b/cmd/helm/dependency.go @@ -0,0 +1,216 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + + "k8s.io/helm/pkg/chartutil" +) + +const dependencyDesc = ` +Manage the dependencies of a chart. + +Helm charts store their dependencies in 'charts/'. For chart developers, it is +often easier to manage a single dependency file ('requirements.yaml') +which declares all dependencies. + +The dependency commands operate on that file, making it easy to synchronize +between the desired dependencies and the actual dependencies stored in the +'charts/' directory. + +A 'requirements.yaml' file is a YAML file in which developers can declare chart +dependencies, along with the location of the chart and the desired version. +For example, this requirements file declares two dependencies: + + # requirements.yaml + dependencies: + - name: nginx + version: "1.2.3" + repository: "https://example.com/charts" + - name: memcached + version: "3.2.1" + repository: "https://another.example.com/charts" + +The 'name' should be the name of a chart, where that name must match the name +in that chart's 'Chart.yaml' file. + +The 'version' field should contain a semantic version or version range. + +The 'repository' URL should point to a Chart Repository. Helm expects that by +appending '/index.yaml' to the URL, it should be able to retrieve the chart +repository's index. Note: 'repository' cannot be a repository alias. It must be +a URL. +` + +const dependencyListDesc = ` +List all of the dependencies declared in a chart. + +This can take chart archives and chart directories as input. It will not alter +the contents of a chart. + +This will produce an error if the chart cannot be loaded. It will emit a warning +if it cannot find a requirements.yaml. +` + +func newDependencyCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "dependency update|list", + Aliases: []string{"dep", "dependencies"}, + Short: "manage a chart's dependencies", + Long: dependencyDesc, + } + + cmd.AddCommand(newDependencyListCmd(out)) + cmd.AddCommand(newDependencyUpdateCmd(out)) + + return cmd +} + +type dependencyListCmd struct { + out io.Writer + chartpath string +} + +func newDependencyListCmd(out io.Writer) *cobra.Command { + dlc := &dependencyListCmd{ + out: out, + } + cmd := &cobra.Command{ + Use: "list [flags] CHART", + Aliases: []string{"ls"}, + Short: "list the dependencies for the given chart", + Long: dependencyListDesc, + RunE: func(cmd *cobra.Command, args []string) error { + cp := "." + if len(args) > 0 { + cp = args[0] + } + + var err error + dlc.chartpath, err = filepath.Abs(cp) + if err != nil { + return err + } + return dlc.run() + }, + } + return cmd +} + +func (l *dependencyListCmd) run() error { + c, err := chartutil.Load(l.chartpath) + if err != nil { + return err + } + + r, err := chartutil.LoadRequirements(c) + if err != nil { + if err == chartutil.ErrRequirementsNotFound { + fmt.Fprintf(l.out, "WARNING: no requirements at %s/charts", l.chartpath) + return nil + } + return err + } + + l.printRequirements(r, l.out) + fmt.Fprintln(l.out) + l.printMissing(r, l.out) + return nil +} + +func (l *dependencyListCmd) dependencyStatus(dep *chartutil.Dependency) string { + filename := fmt.Sprintf("%s-%s.tgz", dep.Name, dep.Version) + archive := filepath.Join(l.chartpath, "charts", filename) + if _, err := os.Stat(archive); err == nil { + c, err := chartutil.Load(archive) + if err != nil { + return "corrupt" + } + if c.Metadata.Name == dep.Name && c.Metadata.Version == dep.Version { + return "ok" + } + return "mismatch" + } + + folder := filepath.Join(l.chartpath, "charts", dep.Name) + if fi, err := os.Stat(folder); err != nil { + return "missing" + } else if !fi.IsDir() { + return "mispackaged" + } + + c, err := chartutil.Load(folder) + if err != nil { + return "corrupt" + } + + if c.Metadata.Name != dep.Name { + return "misnamed" + } + + if c.Metadata.Version != dep.Version { + return "wrong version" + } + + return "unpacked" +} + +// printRequirements prints all of the requirements in the yaml file. +func (l *dependencyListCmd) printRequirements(reqs *chartutil.Requirements, out io.Writer) { + table := uitable.New() + table.MaxColWidth = 80 + table.AddRow("NAME", "VERSION", "REPOSITORY", "STATUS") + for _, row := range reqs.Dependencies { + table.AddRow(row.Name, row.Version, row.Repository, l.dependencyStatus(row)) + } + fmt.Fprintln(out, table) +} + +// printMissing prints warnings about charts that are present on disk, but are not in the requirements. +func (l *dependencyListCmd) printMissing(reqs *chartutil.Requirements, out io.Writer) { + folder := filepath.Join(l.chartpath, "charts/*") + files, err := filepath.Glob(folder) + if err != nil { + fmt.Fprintln(l.out, err) + return + } + + for _, f := range files { + c, err := chartutil.Load(f) + if err != nil { + fmt.Fprintf(l.out, "WARNING: %q is not a chart.\n", f) + continue + } + found := false + for _, d := range reqs.Dependencies { + if d.Name == c.Metadata.Name { + found = true + break + } + } + if !found { + fmt.Fprintf(l.out, "WARNING: %q is not in requirements.yaml.\n", f) + } + } + +} diff --git a/cmd/helm/dependency_test.go b/cmd/helm/dependency_test.go new file mode 100644 index 000000000..749d490cb --- /dev/null +++ b/cmd/helm/dependency_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "bytes" + "strings" + "testing" +) + +func TestDependencyListCmd(t *testing.T) { + + tests := []struct { + name string + args []string + expect string + err bool + }{ + { + name: "No such chart", + args: []string{"/no/such/chart"}, + err: true, + }, + { + name: "No requirements.yaml", + args: []string{"testdata/testcharts/alpine"}, + expect: "WARNING: no requirements at ", + }, + { + name: "Requirements in chart dir", + args: []string{"testdata/testcharts/reqtest"}, + expect: "NAME \tVERSION\tREPOSITORY \tSTATUS \nreqsubchart \t0.1.0 \thttps://example.com/charts\tunpacked\nreqsubchart2\t0.2.0 \thttps://example.com/charts\tunpacked\n", + }, + { + name: "Requirements in chart archive", + args: []string{"testdata/testcharts/reqtest-0.1.0.tgz"}, + expect: "NAME \tVERSION\tREPOSITORY \tSTATUS \nreqsubchart \t0.1.0 \thttps://example.com/charts\tmissing\nreqsubchart2\t0.2.0 \thttps://example.com/charts\tmissing\n", + }, + } + + for _, tt := range tests { + buf := bytes.NewBuffer(nil) + dlc := newDependencyListCmd(buf) + if err := dlc.RunE(dlc, tt.args); err != nil { + if tt.err { + continue + } + t.Errorf("Test %q: %s", tt.name, err) + continue + } + + got := buf.String() + if !strings.Contains(got, tt.expect) { + t.Errorf("Test: %q, Expected:\n%q\nGot:\n%q", tt.name, tt.expect, got) + } + } + +} diff --git a/cmd/helm/dependency_update.go b/cmd/helm/dependency_update.go new file mode 100644 index 000000000..9b9eb1d12 --- /dev/null +++ b/cmd/helm/dependency_update.go @@ -0,0 +1,287 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "errors" + "fmt" + "io" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/ghodss/yaml" + "github.com/spf13/cobra" + + "k8s.io/helm/cmd/helm/resolver" + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/repo" +) + +const dependencyUpDesc = ` +Update the on-disk dependencies to mirror the requirements.yaml file. + +This command verifies that the required charts, as expressed in 'requirements.yaml', +are present in 'charts/' and are at an acceptable version. +` + +// dependencyUpdateCmd describes a 'helm dependency update' +type dependencyUpdateCmd struct { + out io.Writer + chartpath string + repoFile string + repopath string + helmhome string + verify bool + keyring string +} + +// newDependencyUpdateCmd creates a new dependency update command. +func newDependencyUpdateCmd(out io.Writer) *cobra.Command { + duc := &dependencyUpdateCmd{ + out: out, + } + + cmd := &cobra.Command{ + Use: "update [flags] CHART", + Aliases: []string{"up"}, + Short: "update charts/ based on the contents of requirements.yaml", + Long: dependencyUpDesc, + RunE: func(cmd *cobra.Command, args []string) error { + cp := "." + if len(args) > 0 { + cp = args[0] + } + + var err error + duc.chartpath, err = filepath.Abs(cp) + if err != nil { + return err + } + + duc.helmhome = homePath() + duc.repoFile = repositoriesFile() + duc.repopath = repositoryDirectory() + + return duc.run() + }, + } + + f := cmd.Flags() + f.BoolVar(&duc.verify, "verify", false, "Verify the package against its signature.") + f.StringVar(&duc.keyring, "keyring", defaultKeyring(), "The keyring containing public keys.") + + return cmd +} + +// run runs the full dependency update process. +func (d *dependencyUpdateCmd) run() error { + if fi, err := os.Stat(d.chartpath); err != nil { + return fmt.Errorf("could not find %s: %s", d.chartpath, err) + } else if !fi.IsDir() { + return errors.New("only unpacked charts can be updated") + } + c, err := chartutil.LoadDir(d.chartpath) + if err != nil { + return err + } + + req, err := chartutil.LoadRequirements(c) + if err != nil { + if err == chartutil.ErrRequirementsNotFound { + fmt.Fprintf(d.out, "No requirements found in %s/charts.\n", d.chartpath) + return nil + } + return err + } + + // For each repo in the file, update the cached copy of that repo + if _, err := d.updateRepos(req.Dependencies); err != nil { + return err + } + + // Now we need to find out which version of a chart best satisfies the + // requirements the requirements.yaml + lock, err := d.resolve(req) + if err != nil { + return err + } + + // Now we need to fetch every package here into charts/ + if err := d.downloadAll(lock.Dependencies); err != nil { + return err + } + + // Finally, we need to write the lockfile. + return writeLock(d.chartpath, lock) +} + +// resolve takes a list of requirements and translates them into an exact version to download. +// +// This returns a lock file, which has all of the requirements normalized to a specific version. +func (d *dependencyUpdateCmd) resolve(req *chartutil.Requirements) (*chartutil.RequirementsLock, error) { + res := resolver.New(d.chartpath, d.helmhome) + return res.Resolve(req) +} + +// downloadAll takes a list of dependencies and downloads them into charts/ +func (d *dependencyUpdateCmd) downloadAll(deps []*chartutil.Dependency) error { + repos, err := loadChartRepositories(d.repopath) + if err != nil { + return err + } + + fmt.Fprintf(d.out, "Saving %d charts\n", len(deps)) + for _, dep := range deps { + fmt.Fprintf(d.out, "Downloading %s from repo %s\n", dep.Name, dep.Repository) + + target := fmt.Sprintf("%s-%s", dep.Name, dep.Version) + churl, err := findChartURL(target, dep.Repository, repos) + if err != nil { + fmt.Fprintf(d.out, "WARNING: %s (skipped)", err) + continue + } + + dest := filepath.Join(d.chartpath, "charts", target+".tgz") + data, err := downloadChart(churl, d.verify, d.keyring) + if err != nil { + fmt.Fprintf(d.out, "WARNING: Could not download %s: %s (skipped)", churl, err) + continue + } + if err := ioutil.WriteFile(dest, data.Bytes(), 0655); err != nil { + fmt.Fprintf(d.out, "WARNING: %s (skipped)", err) + continue + } + } + return nil +} + +// updateRepos updates all of the local repos to their latest. +// +// If one of the dependencies present is not in the cached repos, this will error out. The +// consequence of that is that every repository referenced in a requirements.yaml file +// must also be added with 'helm repo add'. +func (d *dependencyUpdateCmd) updateRepos(deps []*chartutil.Dependency) (*repo.RepoFile, error) { + // TODO: In the future, we could make it so that only the repositories that + // are used by this chart are updated. As it is, we're mainly doing some sanity + // checking here. + rf, err := repo.LoadRepositoriesFile(d.repoFile) + if err != nil { + return rf, err + } + repos := rf.Repositories + + // Verify that all repositories referenced in the deps are actually known + // by Helm. + missing := []string{} + for _, dd := range deps { + found := false + if dd.Repository == "" { + found = true + } else { + for _, repo := range repos { + if urlsAreEqual(repo, dd.Repository) { + found = true + } + } + } + if !found { + missing = append(missing, dd.Repository) + } + } + + if len(missing) > 0 { + return rf, fmt.Errorf("no repository definition for %s. Try 'helm repo add'", strings.Join(missing, ", ")) + } + + if len(repos) > 0 { + // This prints errors straight to out. + updateCharts(repos, flagDebug, d.out) + } + return rf, nil +} + +// urlsAreEqual normalizes two URLs and then compares for equality. +func urlsAreEqual(a, b string) bool { + au, err := url.Parse(a) + if err != nil { + return a == b + } + bu, err := url.Parse(b) + if err != nil { + return false + } + return au.String() == bu.String() +} + +// findChartURL searches the cache of repo data for a chart that has the name and the repourl specified. +// +// In this current version, name is of the form 'foo-1.2.3'. This will change when +// the repository index stucture changes. +func findChartURL(name, repourl string, repos map[string]*repo.ChartRepository) (string, error) { + for _, cr := range repos { + if urlsAreEqual(repourl, cr.URL) { + for ename, entry := range cr.IndexFile.Entries { + if ename == name { + return entry.URL, nil + } + } + } + } + return "", fmt.Errorf("chart %s not found in %s", name, repourl) +} + +// loadChartRepositories reads the repositories.yaml, and then builds a map of +// ChartRepositories. +// +// The key is the local name (which is only present in the repositories.yaml). +func loadChartRepositories(repodir string) (map[string]*repo.ChartRepository, error) { + indices := map[string]*repo.ChartRepository{} + repoyaml := repositoriesFile() + + // Load repositories.yaml file + rf, err := repo.LoadRepositoriesFile(repoyaml) + if err != nil { + return indices, fmt.Errorf("failed to load %s: %s", repoyaml, err) + } + + // localName: chartRepo + for lname, url := range rf.Repositories { + index, err := repo.LoadIndexFile(cacheIndexFile(lname)) + if err != nil { + return indices, err + } + + cr := &repo.ChartRepository{ + URL: url, + IndexFile: index, + } + indices[lname] = cr + } + return indices, nil +} + +// writeLock writes a lockfile to disk +func writeLock(chartpath string, lock *chartutil.RequirementsLock) error { + data, err := yaml.Marshal(lock) + if err != nil { + return err + } + dest := filepath.Join(chartpath, "requirements.lock") + return ioutil.WriteFile(dest, data, 0755) +} diff --git a/cmd/helm/dependency_update_test.go b/cmd/helm/dependency_update_test.go new file mode 100644 index 000000000..b38b90a21 --- /dev/null +++ b/cmd/helm/dependency_update_test.go @@ -0,0 +1,214 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "bytes" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ghodss/yaml" + + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/proto/hapi/chart" + "k8s.io/helm/pkg/provenance" + "k8s.io/helm/pkg/repo" +) + +func TestDependencyUpdateCmd(t *testing.T) { + // Set up a testing helm home + oldhome := helmHome + hh, err := tempHelmHome() + if err != nil { + t.Fatal(err) + } + helmHome = hh // Shoot me now. + defer func() { + os.RemoveAll(hh) + helmHome = oldhome + }() + + srv := newTestingRepositoryServer(hh) + defer srv.stop() + copied, err := srv.copyCharts("testdata/testcharts/*.tgz") + t.Logf("Copied charts %s", strings.Join(copied, "\n")) + t.Logf("Listening for directory %s", srv.docroot) + + chartname := "depup" + if err := createTestingChart(hh, chartname, srv.url()); err != nil { + t.Fatal(err) + } + + out := bytes.NewBuffer(nil) + duc := &dependencyUpdateCmd{out: out} + duc.helmhome = hh + duc.chartpath = filepath.Join(hh, chartname) + duc.repoFile = filepath.Join(duc.helmhome, "repository/repositories.yaml") + duc.repopath = filepath.Join(duc.helmhome, "repository") + + if err := duc.run(); err != nil { + t.Fatal(err) + } + + output := out.String() + t.Logf("Output: %s", output) + // This is written directly to stdout, so we have to capture as is. + if !strings.Contains(output, `update from the "test" chart repository`) { + t.Errorf("Repo did not get updated\n%s", output) + } + + // Make sure the actual file got downloaded. + expect := filepath.Join(hh, chartname, "charts/reqtest-0.1.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } + + hash, err := provenance.DigestFile(expect) + if err != nil { + t.Fatal(err) + } + + i, err := repo.LoadIndexFile(cacheIndexFile("test")) + if err != nil { + t.Fatal(err) + } + + if h := i.Entries["reqtest-0.1.0"].Digest; h != hash { + t.Errorf("Failed hash match: expected %s, got %s", hash, h) + } + + t.Logf("Results: %s", out.String()) +} + +// newTestingRepositoryServer creates a repository server for testing. +// +// docroot should be a temp dir managed by the caller. +// +// This will start the server, serving files off of the docroot. +// +// Use copyCharts to move charts into the repository and then index them +// for service. +func newTestingRepositoryServer(docroot string) *testingRepositoryServer { + root, err := filepath.Abs(docroot) + if err != nil { + panic(err) + } + srv := &testingRepositoryServer{ + docroot: root, + } + srv.start() + // Add the testing repository as the only repo. + if err := setTestingRepository(docroot, "test", srv.url()); err != nil { + panic(err) + } + return srv +} + +type testingRepositoryServer struct { + docroot string + srv *httptest.Server +} + +// copyCharts takes a glob expression and copies those charts to the server root. +func (s *testingRepositoryServer) copyCharts(origin string) ([]string, error) { + files, err := filepath.Glob(origin) + if err != nil { + return []string{}, err + } + copied := make([]string, len(files)) + for i, f := range files { + base := filepath.Base(f) + newname := filepath.Join(s.docroot, base) + data, err := ioutil.ReadFile(f) + if err != nil { + return []string{}, err + } + if err := ioutil.WriteFile(newname, data, 0755); err != nil { + return []string{}, err + } + copied[i] = newname + } + + // generate the index + index, err := repo.IndexDirectory(s.docroot, s.url()) + if err != nil { + return copied, err + } + + d, err := yaml.Marshal(index.Entries) + if err != nil { + return copied, err + } + + ifile := filepath.Join(s.docroot, "index.yaml") + err = ioutil.WriteFile(ifile, d, 0755) + return copied, err +} + +func (s *testingRepositoryServer) start() { + s.srv = httptest.NewServer(http.FileServer(http.Dir(s.docroot))) +} + +func (s *testingRepositoryServer) stop() { + s.srv.Close() +} + +func (s *testingRepositoryServer) url() string { + return s.srv.URL +} + +// setTestingRepository sets up a testing repository.yaml with only the given name/URL. +func setTestingRepository(helmhome, name, url string) error { + // Oddly, there is no repo.Save function for this. + data, err := yaml.Marshal(&map[string]string{name: url}) + if err != nil { + return err + } + os.MkdirAll(filepath.Join(helmhome, "repository", name), 0755) + dest := filepath.Join(helmhome, "repository/repositories.yaml") + return ioutil.WriteFile(dest, data, 0666) +} + +// createTestingChart creates a basic chart that depends on reqtest-0.1.0 +// +// The baseURL can be used to point to a particular repository server. +func createTestingChart(dest, name, baseURL string) error { + cfile := &chart.Metadata{ + Name: name, + Version: "1.2.3", + } + dir := filepath.Join(dest, name) + _, err := chartutil.Create(cfile, dest) + if err != nil { + return err + } + req := &chartutil.Requirements{ + Dependencies: []*chartutil.Dependency{ + {Name: "reqtest", Version: "0.1.0", Repository: baseURL}, + }, + } + data, err := yaml.Marshal(req) + if err != nil { + return err + } + + return ioutil.WriteFile(filepath.Join(dir, "requirements.yaml"), data, 0655) +} diff --git a/cmd/helm/fetch.go b/cmd/helm/fetch.go index 395fef175..e174bcdac 100644 --- a/cmd/helm/fetch.go +++ b/cmd/helm/fetch.go @@ -96,28 +96,36 @@ func (f *fetchCmd) run() error { pname += ".tgz" } - return downloadChart(pname, f.untar, f.untardir, f.verify, f.keyring) + return downloadAndSaveChart(pname, f.untar, f.untardir, f.verify, f.keyring) } -// downloadChart fetches a chart over HTTP, and then (if verify is true) verifies it. +// downloadAndSaveChart fetches a chart over HTTP, and then (if verify is true) verifies it. // // If untar is true, it also unpacks the file into untardir. -func downloadChart(pname string, untar bool, untardir string, verify bool, keyring string) error { - r, err := repo.LoadRepositoriesFile(repositoriesFile()) +func downloadAndSaveChart(pname string, untar bool, untardir string, verify bool, keyring string) error { + buf, err := downloadChart(pname, verify, keyring) if err != nil { return err } + return saveChart(pname, buf, untar, untardir) +} + +func downloadChart(pname string, verify bool, keyring string) (*bytes.Buffer, error) { + r, err := repo.LoadRepositoriesFile(repositoriesFile()) + if err != nil { + return bytes.NewBuffer(nil), err + } // get download url u, err := mapRepoArg(pname, r.Repositories) if err != nil { - return err + return bytes.NewBuffer(nil), err } href := u.String() buf, err := fetchChart(href) if err != nil { - return err + return buf, err } if verify { @@ -125,17 +133,17 @@ func downloadChart(pname string, untar bool, untardir string, verify bool, keyri sigref := href + ".prov" sig, err := fetchChart(sigref) if err != nil { - return fmt.Errorf("provenance data not downloaded from %s: %s", sigref, err) + return buf, fmt.Errorf("provenance data not downloaded from %s: %s", sigref, err) } if err := ioutil.WriteFile(basename+".prov", sig.Bytes(), 0755); err != nil { - return fmt.Errorf("provenance data not saved: %s", err) + return buf, fmt.Errorf("provenance data not saved: %s", err) } if err := verifyChart(basename, keyring); err != nil { - return err + return buf, err } } - return saveChart(pname, buf, untar, untardir) + return buf, nil } // verifyChart takes a path to a chart archive and a keyring, and verifies the chart. diff --git a/cmd/helm/helm.go b/cmd/helm/helm.go index 78e058b02..76d53ca21 100644 --- a/cmd/helm/helm.go +++ b/cmd/helm/helm.go @@ -99,6 +99,7 @@ func newRootCmd(out io.Writer) *cobra.Command { newVerifyCmd(out), newUpdateCmd(out), newVersionCmd(nil, out), + newDependencyCmd(out), ) return cmd } diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index 68c3ccc98..e0ce52c74 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -20,6 +20,7 @@ import ( "bytes" "fmt" "io" + "io/ioutil" "math/rand" "regexp" "testing" @@ -205,3 +206,22 @@ type releaseCase struct { err bool resp *release.Release } + +// tmpHelmHome sets up a Helm Home in a temp dir. +// +// This does not clean up the directory. You must do that yourself. +// You must also set helmHome yourself. +func tempHelmHome() (string, error) { + oldhome := helmHome + dir, err := ioutil.TempDir("", "helm_home-") + if err != nil { + return "n/", err + } + + helmHome = dir + if err := ensureHome(); err != nil { + return "n/", err + } + helmHome = oldhome + return dir, nil +} diff --git a/cmd/helm/init_test.go b/cmd/helm/init_test.go index 44bcafce2..df20e3786 100644 --- a/cmd/helm/init_test.go +++ b/cmd/helm/init_test.go @@ -28,7 +28,7 @@ import ( func TestEnsureHome(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") - fmt.Fprintln(w, "OK") + fmt.Fprintln(w, "") })) defaultRepositoryURL = ts.URL diff --git a/cmd/helm/install.go b/cmd/helm/install.go index ced11eb9c..3fab2c7fd 100644 --- a/cmd/helm/install.go +++ b/cmd/helm/install.go @@ -306,7 +306,7 @@ func locateChartPath(name string, verify bool, keyring string) (string, error) { if filepath.Ext(name) != ".tgz" { name += ".tgz" } - if err := downloadChart(name, false, ".", verify, keyring); err == nil { + if err := downloadAndSaveChart(name, false, ".", verify, keyring); err == nil { lname, err := filepath.Abs(filepath.Base(name)) if err != nil { return lname, err diff --git a/cmd/helm/repo_test.go b/cmd/helm/repo_test.go index d24f6f594..1e64719e6 100644 --- a/cmd/helm/repo_test.go +++ b/cmd/helm/repo_test.go @@ -36,7 +36,7 @@ var ( func TestRepoAdd(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") - fmt.Fprintln(w, "OK") + fmt.Fprintln(w, "") })) helmHome, _ = ioutil.TempDir("", "helm_home") diff --git a/cmd/helm/resolver/resolver.go b/cmd/helm/resolver/resolver.go new file mode 100644 index 000000000..34854b538 --- /dev/null +++ b/cmd/helm/resolver/resolver.go @@ -0,0 +1,87 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 resolver + +import ( + "bytes" + "encoding/json" + "fmt" + "time" + + "github.com/Masterminds/semver" + + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/provenance" +) + +// Resolver resolves dependencies from semantic version ranges to a particular version. +type Resolver struct { + chartpath string + helmhome string +} + +// New creates a new resolver for a given chart and a given helm home. +func New(chartpath string, helmhome string) *Resolver { + return &Resolver{ + chartpath: chartpath, + helmhome: helmhome, + } +} + +// Resolve resolves dependencies and returns a lock file with the resolution. +func (r *Resolver) Resolve(reqs *chartutil.Requirements) (*chartutil.RequirementsLock, error) { + d, err := hashReq(reqs) + if err != nil { + return nil, err + } + + // Now we clone the dependencies, locking as we go. + locked := make([]*chartutil.Dependency, len(reqs.Dependencies)) + for i, d := range reqs.Dependencies { + // Right now, we're just copying one entry to another. What we need to + // do here is parse the requirement as a SemVer range, and then look up + // whether a version in index.yaml satisfies this constraint. If so, + // we need to clone the dep, settinv Version appropriately. + // If not, we need to error out. + if _, err := semver.NewVersion(d.Version); err != nil { + return nil, fmt.Errorf("dependency %q has an invalid version: %s", d.Name, err) + } + locked[i] = &chartutil.Dependency{ + Name: d.Name, + Repository: d.Repository, + Version: d.Version, + } + } + + return &chartutil.RequirementsLock{ + Generated: time.Now(), + Digest: d, + Dependencies: locked, + }, nil +} + +// hashReq generates a hash of the requirements. +// +// This should be used only to compare against another hash generated by this +// function. +func hashReq(req *chartutil.Requirements) (string, error) { + data, err := json.Marshal(req) + if err != nil { + return "", err + } + s, err := provenance.Digest(bytes.NewBuffer(data)) + return "sha256:" + s, err +} diff --git a/cmd/helm/resolver/resolver_test.go b/cmd/helm/resolver/resolver_test.go new file mode 100644 index 000000000..0212757b8 --- /dev/null +++ b/cmd/helm/resolver/resolver_test.go @@ -0,0 +1,116 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 resolver + +import ( + "testing" + + "k8s.io/helm/pkg/chartutil" +) + +func TestResolve(t *testing.T) { + tests := []struct { + name string + req *chartutil.Requirements + expect *chartutil.RequirementsLock + err bool + }{ + { + name: "version failure", + req: &chartutil.Requirements{ + Dependencies: []*chartutil.Dependency{ + {Name: "oedipus-rex", Repository: "http://example.com", Version: ">1"}, + }, + }, + err: true, + }, + { + name: "valid lock", + req: &chartutil.Requirements{ + Dependencies: []*chartutil.Dependency{ + {Name: "antigone", Repository: "http://example.com", Version: "1.0.0"}, + }, + }, + expect: &chartutil.RequirementsLock{ + Dependencies: []*chartutil.Dependency{ + {Name: "antigone", Repository: "http://example.com", Version: "1.0.0"}, + }, + }, + }, + } + + r := New("testdata/chartpath", "testdata/helmhome") + for _, tt := range tests { + l, err := r.Resolve(tt.req) + if err != nil { + if tt.err { + continue + } + t.Fatal(err) + } + + if tt.err { + t.Fatalf("Expected error in test %q", tt.name) + } + + if h, err := hashReq(tt.req); err != nil { + t.Fatal(err) + } else if h != l.Digest { + t.Errorf("%q: hashes don't match.", tt.name) + } + + // Check fields. + if len(l.Dependencies) != len(tt.req.Dependencies) { + t.Errorf("%s: wrong number of dependencies in lock", tt.name) + } + d0 := l.Dependencies[0] + e0 := tt.expect.Dependencies[0] + if d0.Name != e0.Name { + t.Errorf("%s: expected name %s, got %s", tt.name, e0.Name, d0.Name) + } + if d0.Repository != e0.Repository { + t.Errorf("%s: expected repo %s, got %s", tt.name, e0.Repository, d0.Repository) + } + if d0.Version != e0.Version { + t.Errorf("%s: expected version %s, got %s", tt.name, e0.Version, d0.Version) + } + } +} + +func TestHashReq(t *testing.T) { + expect := "sha256:e70e41f8922e19558a8bf62f591a8b70c8e4622e3c03e5415f09aba881f13885" + req := &chartutil.Requirements{ + Dependencies: []*chartutil.Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "http://localhost:8879/charts"}, + }, + } + h, err := hashReq(req) + if err != nil { + t.Fatal(err) + } + if expect != h { + t.Errorf("Expected %q, got %q", expect, h) + } + + req = &chartutil.Requirements{Dependencies: []*chartutil.Dependency{}} + h, err = hashReq(req) + if err != nil { + t.Fatal(err) + } + if expect == h { + t.Errorf("Expected %q != %q", expect, h) + } +} diff --git a/cmd/helm/search.go b/cmd/helm/search.go index 3c06aec47..e1a465c99 100644 --- a/cmd/helm/search.go +++ b/cmd/helm/search.go @@ -45,6 +45,7 @@ func search(cmd *cobra.Command, args []string) error { return errors.New("This command needs at least one argument (search string)") } + // TODO: This needs to be refactored to use loadChartRepositories results, err := searchCacheForPattern(cacheDirectory(), args[0]) if err != nil { return err diff --git a/cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz b/cmd/helm/testdata/testcharts/reqtest-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..356bc93030395fa1c045c8aaefb60c9d80a079a5 GIT binary patch literal 911 zcmV;A191EwiwG0|32ul0|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PI=cZ=^O5zGwc5>E)%Zy8PMzIo?i5rB?kZ<#bY2Rh5AWxB)|L zlU(}Hzr6$8=1yd@=(ZfIWc?{JtYZ)M?tGtT28n-bRN6T&nAG+itI8L%!zDyP&|eAT ztLwSp{e9o>`N6680_I=I7PLw;Nss@(cE+1~BFIpsk~f;yB8J!S9hMcOoiD&uE#ZeY zK`D?t#1gE+7~Z>!b%Rp%Q(W7#UF*=hFxVFx{@<{&MfG_EV2b~~=a2axMS5P4G z`RApkwULSQx~j;)+w)7vNN6lO=i2GpVfmJw{3D&d-E0rq>P9#p3?;O`w&?{; zSzp`gwxKp**VO8Y?*FBsZ<*wEtKj>KZ|Q-JtpCDPTQ<*-Im0;WdWsUZ;XhqlF0n$P zm0i~9^^DJ$jQ`i}i2tWPr35hJ5+28q^FPA|MTR2fsABm24=dwDbsfXcwFXW{b?*|G zSvd-nbZ%!c_^ubO+*d1a{l<%8KZw1^4qmOJv$N{fxe{Wp>4@1wK|BK+U`rpP2ObzgPV+a3dD+x~V|6%FjW9B008H7+U)=U literal 0 HcmV?d00001 diff --git a/cmd/helm/testdata/testcharts/reqtest/.helmignore b/cmd/helm/testdata/testcharts/reqtest/.helmignore new file mode 100644 index 000000000..f0c131944 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/reqtest/Chart.yaml b/cmd/helm/testdata/testcharts/reqtest/Chart.yaml new file mode 100755 index 000000000..e2fbe4b01 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/Chart.yaml @@ -0,0 +1,3 @@ +description: A Helm chart for Kubernetes +name: reqtest +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore new file mode 100644 index 000000000..f0c131944 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml new file mode 100755 index 000000000..c3813bc8c --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/Chart.yaml @@ -0,0 +1,3 @@ +description: A Helm chart for Kubernetes +name: reqsubchart +version: 0.1.0 diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml new file mode 100644 index 000000000..0f0b63f2a --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqsubchart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore new file mode 100644 index 000000000..f0c131944 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml new file mode 100755 index 000000000..9f7c22a71 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/Chart.yaml @@ -0,0 +1,3 @@ +description: A Helm chart for Kubernetes +name: reqsubchart2 +version: 0.2.0 diff --git a/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml new file mode 100644 index 000000000..0f0b63f2a --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/charts/reqsubchart2/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqsubchart. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/cmd/helm/testdata/testcharts/reqtest/requirements.lock b/cmd/helm/testdata/testcharts/reqtest/requirements.lock new file mode 100755 index 000000000..ab1ae8cc0 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/requirements.lock @@ -0,0 +1,3 @@ +dependencies: [] +digest: Not implemented +generated: 2016-09-13T17:25:17.593788787-06:00 diff --git a/cmd/helm/testdata/testcharts/reqtest/requirements.yaml b/cmd/helm/testdata/testcharts/reqtest/requirements.yaml new file mode 100644 index 000000000..4b0b8c2db --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/requirements.yaml @@ -0,0 +1,7 @@ +dependencies: + - name: reqsubchart + version: 0.1.0 + repository: "https://example.com/charts" + - name: reqsubchart2 + version: 0.2.0 + repository: "https://example.com/charts" diff --git a/cmd/helm/testdata/testcharts/reqtest/values.yaml b/cmd/helm/testdata/testcharts/reqtest/values.yaml new file mode 100644 index 000000000..d57f76b07 --- /dev/null +++ b/cmd/helm/testdata/testcharts/reqtest/values.yaml @@ -0,0 +1,4 @@ +# Default values for reqtest. +# This is a YAML-formatted file. +# Declare name/value pairs to be passed into your templates. +# name: value diff --git a/docs/charts.md b/docs/charts.md index 104c3ad18..7b5e1ee67 100644 --- a/docs/charts.md +++ b/docs/charts.md @@ -125,6 +125,7 @@ chart's `charts/` directory: ``` wordpress: Chart.yaml + requirements.yaml # ... charts/ apache/ @@ -142,6 +143,61 @@ directory. **TIP:** _To drop a dependency into your `charts/` directory, use the `helm fetch` command._ +### Managing Dependencies with `requirements.yaml` + +While Helm will allow you to manually manage your dependencies, the +preferred method of declaring dependencies is by using a +`requirements.yaml` file inside of your chart. + +A `requirements.yaml` file is a simple file for listing your +dependencies. + +```yaml +dependencies: + - name: apache + version: 1.2.3 + repository: http://example.com/charts + - name: mysql + version: 3.2.1 + repository: http://another.example.com/charts +``` + +- The `name` field is the name of the chart you want. +- The `version` field is the version of the chart you want. +- The `repository` field is the full URL to the chart repository. Note + that you must also use `helm repo add` to add that repo locally. + +Once you have a dependencies file, you can run `helm dependency update` +and it will use your dependency file to download all of the specified +charts into your `charts/` directory for you. + +```console +$ helm dep up foochart +Hang tight while we grab the latest from your chart repositories... +...Successfully got an update from the "local" chart repository +...Successfully got an update from the "stable" chart repository +...Successfully got an update from the "example" chart repository +...Successfully got an update from the "another" chart repository +Update Complete. Happy Helming! +Saving 2 charts +Downloading apache from repo http://example.com/charts +Downloading mysql from repo http://another.example.com/charts +``` + +When `helm dependency update` retrieves charts, it will store them as +chart archives in the `charts/` directory. So for the example above, one +would expect to see the following files in the charts directory: + +``` +charts/ + apache-1.2.3.tgz + mysql-3.2.1.tgz +``` + +Manging charts with `requirements.yaml` is a good way to easily keep +charts updated, and also share requirements information throughout a +team. + ## Templates and Values By default, Helm Chart templates are written in the Go template language, with the diff --git a/pkg/chartutil/load_test.go b/pkg/chartutil/load_test.go index 822e8d078..3b785d20b 100644 --- a/pkg/chartutil/load_test.go +++ b/pkg/chartutil/load_test.go @@ -29,6 +29,7 @@ func TestLoadDir(t *testing.T) { } verifyFrobnitz(t, c) verifyChart(t, c) + verifyRequirements(t, c) } func TestLoadFile(t *testing.T) { @@ -38,6 +39,7 @@ func TestLoadFile(t *testing.T) { } verifyFrobnitz(t, c) verifyChart(t, c) + verifyRequirements(t, c) } func verifyChart(t *testing.T, c *chart.Chart) { @@ -49,7 +51,7 @@ func verifyChart(t *testing.T, c *chart.Chart) { t.Errorf("Expected 1 template, got %d", len(c.Templates)) } - numfiles := 6 + numfiles := 7 if len(c.Files) != numfiles { t.Errorf("Expected %d extra files, got %d", numfiles, len(c.Files)) for _, n := range c.Files { @@ -88,6 +90,32 @@ func verifyChart(t *testing.T, c *chart.Chart) { } +func verifyRequirements(t *testing.T, c *chart.Chart) { + r, err := LoadRequirements(c) + if err != nil { + t.Fatal(err) + } + if len(r.Dependencies) != 2 { + t.Errorf("Expected 2 requirements, got %d", len(r.Dependencies)) + } + tests := []*Dependency{ + {Name: "alpine", Version: "0.1.0", Repository: "https://example.com/charts"}, + {Name: "mariner", Version: "4.3.2", Repository: "https://example.com/charts"}, + } + for i, tt := range tests { + d := r.Dependencies[i] + if d.Name != tt.Name { + t.Errorf("Expected dependency named %q, got %q", tt.Name, d.Name) + } + if d.Version != tt.Version { + t.Errorf("Expected dependency named %q to have version %q, got %q", tt.Name, tt.Version, d.Version) + } + if d.Repository != tt.Repository { + t.Errorf("Expected dependency named %q to have repository %q, got %q", tt.Name, tt.Repository, d.Repository) + } + } +} + func verifyFrobnitz(t *testing.T, c *chart.Chart) { verifyChartfile(t, c.Metadata) diff --git a/pkg/chartutil/requirements.go b/pkg/chartutil/requirements.go new file mode 100644 index 000000000..4a452f64c --- /dev/null +++ b/pkg/chartutil/requirements.go @@ -0,0 +1,84 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 chartutil + +import ( + "errors" + "time" + + "github.com/ghodss/yaml" + + "k8s.io/helm/pkg/proto/hapi/chart" +) + +// Dependency describes a chart upon which another chart depends. +// +// Dependencies can be used to express developer intent, or to capture the state +// of a chart. +type Dependency struct { + // Name is the name of the dependency. + // + // This must mach the name in the dependency's Chart.yaml. + Name string `json:"name"` + // Version is the version (range) of this chart. + // + // A lock file will always produce a single version, while a dependency + // may contain a semantic version range. + Version string `json:"version,omitempty"` + // The URL to the repository. + // + // Appending `index.yaml` to this string should result in a URL that can be + // used to fetch the repository index. + Repository string `json:"repository"` +} + +// Requirements is a list of requirements for a chart. +// +// Requirements are charts upon which this chart depends. This expresses +// developer intent. +type Requirements struct { + Dependencies []*Dependency `json:"dependencies"` +} + +// RequirementsLock is a lock file for requirements. +// +// It represents the state that the dependencies should be in. +type RequirementsLock struct { + // Genderated is the date the lock file was last generated. + Generated time.Time `json:"generated"` + // Digest is a hash of the requirements file used to generate it. + Digest string `json:"digest"` + // Dependencies is the list of dependencies that this lock file has locked. + Dependencies []*Dependency `json:"dependencies"` +} + +// ErrRequirementsNotFound indicates that a requirements.yaml is not found. +var ErrRequirementsNotFound = errors.New("requirements.yaml not found") + +// LoadRequirements loads a requirements file from an in-memory chart. +func LoadRequirements(c *chart.Chart) (*Requirements, error) { + var data []byte + for _, f := range c.Files { + if f.TypeUrl == "requirements.yaml" { + data = f.Value + } + } + if len(data) == 0 { + return nil, ErrRequirementsNotFound + } + r := &Requirements{} + return r, yaml.Unmarshal(data, r) +} diff --git a/pkg/chartutil/requirements_test.go b/pkg/chartutil/requirements_test.go new file mode 100644 index 000000000..04f1bd4a6 --- /dev/null +++ b/pkg/chartutil/requirements_test.go @@ -0,0 +1,27 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 chartutil + +import ( + "testing" +) + +func TestLoadRequirements(t *testing.T) { + c, err := Load("testdata/frobnitz") + if err != nil { + t.Fatalf("Failed to load testdata: %s", err) + } + verifyRequirements(t, c) +} diff --git a/pkg/chartutil/testdata/frobnitz-1.2.3.tgz b/pkg/chartutil/testdata/frobnitz-1.2.3.tgz index 8055a0094583776ac76723b5bd687cae2e22e36f..27bec72bd85b6a1a2df341d237ba554c6d751200 100644 GIT binary patch literal 3974 zcmV;14|(t(iwFQBI@?zO1MOW4TolI{KYRsGO(4>0N+{DcO(e?RYws=)6;SXkQ9+|N z1eUu6R_<=k-93&gD#lk6Z4yPTMx!yBs6ngsl~`lyqph{pXj-x!w9D)^Z&7YdpqCE?C!VU%s1cce4E2E**2R0wMx-)P^;BOy&j0vAW^lJkf;ox z(Hac~gN`ujH9)N)2(3l|^euQ!UN|_OWZ|h;NuJMk@TNS9ZIBPL<>Y)vIr4RQ=|h4X zkNojG%3`H+Z465_cOCG47zpA!-n0UK$h;(_NWP?{;u z#L{-2W^C~wJ&)!9{3E^TxUzs^IWZB_;96XVDf20plVQzVyb^%ex`KosoSkJ%`4lhy zFf(S-thAD}jfWd7Mam%6pPom7fw2Dp=@e;&#{r-qrzIW;ol+uINLuX{3O6xUpFC-d zm11lZNU?B~Zx$Pq&6r*FGI5N9HBoYnc|33D;#Ddi22~EjsIo~`na2nv3mo+sl(b2> z=7zEwh(ZH_+ikh<-q|UenX;K^s@}zs7CXFP@f7Rso*LKSYD_%HQg();d4_d0T!Tr_ zOwOl3E6ECX(62-S*Wuc`Dp8QXSO2ABEn)`Vz5S;*s`beJ3kJOMH#4S|k$#iquO)P_ zC(sju{NaOu5PDrHuHJCF|FQ!L6d;a&Ew@Cw7ECNQyh==fw_7K)=QrhsiOnJ1Kmp{9um@6 z%EXK8IW!CTK_M&w11|BuTs)z!E8PJ1b7+gDH)bOXT$rkU85{)*H;aX6^kOMQfKph> zEELj9xFLNWpwX6B{|BX}r6&y?it~khbJr1Y{BP9y>%Z2ZNBSQOcx`+epl$FPEf#30 zn;cfk#^a({ZnDsDAKIKQV5T{{1yUa7!5^S!7~%WFtN%j>^-D=jOKFKZ0_wk3?{EK! z{$Hg3Ee~1j04lbL%3~~MibbmZBZ61|N2Vkt4@<$V=H{;>kp63R8h`ya7_~_MgMn_K zzsF^#^sxnGZxIl^Lby`dts5A{k-1buOgI?CgFKQ0HU>BZCW$|pDxfTkU9cpS%Ki)s z%+djhg_cGR(-UwM1x*RKG4@!CkmlpJLTylx;MISYn&6;WN)Q*<%+mk?_1~b8{C|4A zmM|KD8jc*%{9kbJP&%yrK9Da-{!i%x3AF=5sf zq{4knspKe=I8h9nZ#h*+fYu!#ft-e+_WHp_1MRDh(;En=`ojBPMh&4x=O59?@%%{TCEO&kj{!@uyx?v@Tm%WQj<5@^7j| z=^4?*uPtw1z~(KOSzcD;h+H>j67$=0rqVad}*Mt6TTJSJf)~Azo?H#W0P`|YK zJC9{X+<3dT1iP|*#k0$57q8hD0_GRTf4c^|~IzF6Td zU9t9nyGPQ9;@^%-d%C}Y?H=QNV$l)zsF)2qO9#(dUv%rr;+`7!hBhPSC#Oclg?A30 z)_VD)U-xLWWzU@Kg<%y53iaUae|Zm2jRZ+_N!NG7#~ASHZweD+Q-8?uIj107XAfuy+hJ&Zu9Pv>4T6G$BXYetBB+i?fBR zzN#7eaZJa-qvtP4$b5ZjEwygu{C76joXf|$e>rZ1N|CqeNt=3XZHG;TtK36YRFO9` z+drLCoqT?5X~vUxF4yebQnsPeS=Q_IggX@%7xi?H8e98SP3GJkce)S@&KIYwDc{jE zjCdoq(@WZGm#_Zrqg@9|H{^6WYFm8upCfv$wsUa5&d6?9_7~ zy2eC)Qgh+#(ddf!ja#?oetLFkx1CXMu7CNnE$++Kz4?a<@X6+L`^#QDf}gzIxAIgJ z_fF}CliSO`sC>-2jGA28xv1)^t2dr`ebmbQ8G9`k|M?7KJ-%JL z^49LFDbDiGKeK}~#}9I+;@7`*cyi~c|DNho@zyF!{I7~HZ7yG{&AK`F!z=CfzMQb& z^0MQ_hpLW0cIOW(#@{@#?5wffi`Gj&tNvnq*->Zti!=9S{bOPNdB+;kSpEL()rXJH zE;44^ymaVf>yJ8}d9LQj@P03RQF(Rg9_P{j_2@A^=k*<5_xW;iBo?(gqq?@Rvh`IH zXA4b_wpG;L_~yi(FK^7=n^A51^Y!T5pWUb^yA*%&@T~v!Ha_`t`gCzwmjSn;w_NjZd*iC3+);h?ithKj`9ZkPXvF@*Gx6lK z%rw4PRs#m~{~CSmKVkk)qekn0f`qm|eMHepp%_M*zzb<$43HrlQ?!Sh7H<9FCj2fG zGI%+{8Y?m>J$aP2nYxdAcXb-$TU%=QzRN9r?--68=pL3G6gbCl<5?S zhRA%aWZFiip)w7VDUfNROs7axZnQ$=!AFQ(bBH`b36UKXAwZ^ycXi1K2tOLMmPnY; z{|jqC8yWv2ARzpJ5z;0(ExE74J0SQLguWKQElcLv<;mEHA<#;47?G zt0si?AL#w>mPaGwi4aUYsNcaw!|dl00Pw4vx0v ziV<2&jMYlo%pgl>&w+`hV7lCAWZ^Ic$_C7g-1`a9{P1rjp%=~MuTW- z`(LO38jT)(|05`9ME=bh4I)tculA4spw;M5`yU*16N7vT?XfTt@I-mAx?-gV0mX0l zfg@o1@7ITZpE?5Rzd`30|5Hn-jVS(SP(a%0(Vl^Pfkv56+syG`I2_(X1B^72JXsgr zrKkuWNm(e8qr_kllP1N&Ba)*?`VBcPWHx0H1Bn3u?c#2U9KGyj5nOiQ%UBk8H3H5&v~TNR^eoXFy7px zNtlweQzjuJv`XRgzz=6Ew8;PJoSPySl8{v${b)_C6edUD2!~DdW`RO2(Wx^}@VOvp2ctys@dmId4hW%5#I>=pUDPdVNXuh5mz; zrl2u5GbYI+e^o%t_2KC&(*p;F!`*6kIUDU9PSTC{UO#;E9vs_7kXcF_C{|4&?T%pb>wuRd|$kHyQ}%YS=z z_?oHz9sBXI^2a78JQ}%u<$yDh;}`B~lY!T|40D z3!lG#E2O&A&5d1JqkTX7Pe-WH_%A5%1ZRbEu0Q>gBz=%gd=NyB+7L%J#o8!1CTMu- z2Sa1UhY(HC|9hPbYO4LG(HO*U!NUIEs6qB$P|y_l->WnD?&Yr|bjbf36f{Nt_v#D| z)c+&pC7cb@H6e%ZN|uv9`ltFj&oIAiEaDH5f_7 zRRM(2YPJ#tVKke7fgni(i2-vPmMMf^5GfuO#Y2gr%`e*BG~Z-67|iMXk#yAi2)REK zoWc6X-8AcATwac+TRRWUO!N+s<0$P+$ z9Gru4Xgw6;72yx2qC`;g&vw(`5!nBLY?|`G>j2O&B%27NPAQo#raV5D#_gOZEKU~Z zp*b%NQdxlxkK*NQoFmXElEC?SJFVvE7DbaP*QX){qI7Ig-ozZO3L6v}UxkdGaag5T5v>@spg0V(Q+RH#z9jka zv|W^gI~g8^2gNW29SF$(tI3mwhSV+a*vYW$D4mxo4CwTY(-3HwxI<2aFw3csQBvV) zhm^=j>47|S7mc<;@}HiWm7Owf94;1%t(`~I@xRp=DgQ=`8OeVv2(WDx3dg9hB{{eI2gglLRW-w@Vn?8M*RDPO5Bscn%*RQWfWBlSPh zNFe!-1!}beDA;z|&9M%eN22{JA|(HlQd7o^PsKfs*3ToF{2NU~r2Jc~MkN2SU?4~f zPT6V0bOCj?2*|TSxK=xGAebyrF1o2eI7kInG>!)j zYX3|1lb%i{w8RJVLW92WHO1Wzl^aDrrv2&x$}-#PO^{H)|FW;9oS2*Ze=(uq8;`IVAmEgSYw9$xssQ}d1x2(8k>7tPAFH(P7_g<|KeL5|qEA5i zw-O}!{v#$t*1!5~QM~*$5iYu(YK1(awEu9lXKwiZk1$C3pV??Z?SCw||M||?yom>= zV(s>hAF*d&M*Lyd$^iyhVkcvtIJIbZma+rqRY>^svfvy@nURHe_|rbF0fMdkTwI_q@04 z-X$0NRQGw3K3g_!;-oM7x-Tt=>#iA5UuNIF*m4wGc&=*w_KJ#%h3g4J{j#Nt`zE~Ajjg(*^75(OAB$w&mzun9a?2NIEzKFXtRq%&^VIGm_iVi7 za@z99ocd4N4g3AOm1_o1D?fcSH)+({gI1C0`h=vZRfCeOW3598egA!5#7%qYPp>@w>IN)7 zKl$*I(J40P#Lc7A-y7Y1-V-zKA@!!NyEpFB$4{EmD;0b51iReBrQFf)-k!De(3o@gUfrI z>(#5e@5Mg<>T_{X<v{t`W+Wthl;}>!={;of=;g;~=h4TluEvvq~k?t~W?q17RD_;L{|JLi@9)8Vv z>Z>Cc{4+kkk{>v8=1NuV^!T3FoI7_^Ey=xk z|LLu+O}B3BxpD2O83of*$?C}&nfr5He>n7A?zyDuz1AV$)J~cA-n1dxYrcKq%^gFJ zdwnJGf2kRtF#YPKc2^3Q=Px_4qh`eG$IJhB_K<_mPoPg;vaVR3Up4j2hm|juoGrT$ ze`fuStC#-!oN(`g4GqPuHI3&Bxz)<4V6PuV3u-PTfZnMqWu8Fyz*@ zHTRW$fAnU0)uy`tD@ho7x@7hjU1lEow)>Q0YpvI2ugF>a6#K#6%6AS7{YUM|&$}&q zS0Fs zOM^e@yjY453el~(BvZ1pC#i?U!B~j9ljh{V^`t+V{6kgWKt{;F!D2EX`HvBr(S8Sk z$>Q7@Q(SCr(?L6{zaJ16=4#|LL`-l9cGkvmEuBP`;&#OBm>Z)oK0H255H>|3Mq?h# zhq2IGI&S2RKp+qZ1mgBl`_Rt6`Du%RKp^fyr1k-*ZnEmmQCyAcAE&zQ!{T&PU7)(j zsyjzH^hGR^2&@t2SC1b?~84bJnOM6pcDTp#iF!d|S7SK>TWG zr#RiD{$JVy)XexFfk6C%(X=0vHD)vx8W8*dsjmg_m#2o=6^z)7A`quc81}{*!U5Gy zR^2&@i;I&wVgHYXl-K{!{?FLZ$V6Eq>u+OdAIk**1#FbS z*g=XM1dL<6{$k+c91WJ>WK(8grqd*AV+9WiIK$~B^KIzM;k%h;t6qf+9wA14?V>{E`>v1U`y~y^;v~mtf-R=46rcuz3;tRHgC-H?0LXvU0q1 zS(~R6F&^e7t1@{Yw6#&IoyPfrJOH0poQfg2&+S9x7mzgA%C* z>mD;_;*)5WrUY6x7BFj8JiMYz6e;I0Bum+7Rm=n37u_QeIF_*oq&!0g5i)70!Ri36hRF5FLFCL;Wi-O5eG$cos+D%e zBR>b7B20-U$#A}WgNkCE2&U`wHpZ(LSeo|fM(7gYE2MiO7H#j3kN-WCAQE@10^a`k zpCFO_@7NH%{*CeS1m7L(1&%)cH%HXJ5$*qo5z_wOwF9S2#oE;zJC$E%=+W`(KYrKa zx!Qj&UpMjM$xrc)Gym&IYK|f2<;OogG2xXh&u^)J>4hzqie6dPZPlst7t#`PKi~AS z?R?s!T6_Po_=X2pyfkd-=qb;x%Sa!wXK5hSShMZV9|h~U~$)z~K{-+w{>pZDDO4e=rVuY{7}+o%1<+E4dw zFIiE)a=||zTUYwkv>nH4yUtGfZNI;+8hf%|{%d!XMnO zMS%`GD|>z2M*A{jMN8WMJ6#NlCjW4>AEE!5NCO)G#f0Erl!5i!c=;zq`GY+2iy+Ua zW%zA0@1^0Gpy{O_9U3eC3~7n{-|1pdOZ7j3u*iQ45cdC80_lG-p(XWyr<1|AU;if3 zgyw%^LQCrZPA7w-_5Vh~6rul{ENK2WHb{25f3SEaS39m981@$cr=^S^7Z3fU$3#2i zH8?527~0NKJT1?7DDMgIQ4B8tkpnjASP)=n#tTmaoSzRZzzI@hDH0e3baFhTJs=PW t1OkCTAP@)y0)apv5C{YUfj}S-2m}IwKp+qZ1Om}w{vW$@^HKny000GMxBmbD diff --git a/pkg/chartutil/testdata/frobnitz/charts/mariner-4.3.2.tgz b/pkg/chartutil/testdata/frobnitz/charts/mariner-4.3.2.tgz index 03de29d688dc070e04f6f391c0f7525ff172a2bc..408f3bd5ccb61041ca3abe9a7577b651a0e72660 100644 GIT binary patch literal 1025 zcmV+c1pfOUiwFQBI@?zO1MQc4Y!pQt$1j*vT_1mmpb2(zXqATU?R#|_tXQKYRUv_d z5H#uB-Q3;QeOzbe3g@dau>@=?v9XB>(Eh>8M<5lo7!M*ugHj(Th!GH9F*ZdTqlCs- zyt=pdun1a-*F(d6?vmNNot>H8@9+2h&Ds!~4#J_p6e0+MuByOqwR|f`O1>=sL`l~) zO;!a_27;(aq8I?`R4%GWicpBXrPz?taEe9}2%oc`*DV(Nn{f*FQELAu*aiI?6A)8A z4Q;FZb9m1~Q+e7t8K4jaTojQAHj~tJ z9Rth<3y@`lNG>u!%*CK76-L-W6cMfsVd8gEc$t^Dgqs^B800SW?J&-hV*dLolc^~K z=kNcjqN~jRD?w3zj9ODBMmBONDR&u|)c;i}ufM8FimrS5>zXVv|E~m5ltfMxIS~^P zg9D%r2e#3YFdY^GxRdlB)UotBS13O3wc^ zMPUA45oUwCPz|0l7vZs_l02D{QK^$^UmfT6pDhF>3M&t7{pDQ zNQM1cKwaCav!E0AkGR|f5es7E7nXdSfh06B0n`QI+%X~Ec&6k1OuH!zN>-GcKVOZ4 z{$;F&l%KQx-)kZ$;aX8I0BEutZZKK?3$l2o|4X9xVfkMPCT93V$C|Tr%?M@s_v1%&M-kl@A9gnVF z(6s;QZN_WQb}oKo-^%S*)GpckLi5`lbDyfO*}CrF^AByj+^p?d^YV#rb_{$Se)AK1 z_v7*PCyiC3YlX&a=g}u`ZP&Uoqx!+|MR)Gn&~PB#*Uod>I_>7Lhr&-d34{IH^G`PiP`HwIU2=oXZU|H#Ztwo}@YurP^^3+`_-@1WA7>i%@9pS1+0%;lJvSU4Y21J97f1RZ vwCBE9Kk;$yaYv_xE?rg|xaC3#%3v@U3Wm6jxDq!wXS2UM1phQ}PJKj8Eb z0lColcg{#GD$%P<%*~;`9l-D~Gcmy(|AvO<;P^K&Ha9mhH37!InW2d>gMtC|oq`EQ zc>L!j=B8RHKvMx%N@{U(QD#9&W`3TPf}?^*YEG^~GALyzq~#YWc$X%n7UiXuq!x3P zr4|)~6`AN6>ltxn#n+5=(`rpXZ46XfV zW@2D8TL04nC@Z+6rX`lqq+^!3pN%7OwaHANvU zGbdF~A)}K+1Nxni7h+SL^G%qs`$gRvTEmD9NMIiej?Gq)iw$bJV^#Nx252TV)GxBp%fC-?a zfEuL%q5N-PY=~O^8yN!OX#S@MK+fUFF+ZBsMg~A@|0m`oC6*NB7Z>Xq=o#u6=#`{b zQEmsJ{BLS(49fpz#%87lW`+j9{0~%bI-39K0pz>V2u!02J1KH*8g ztxKQt*{bbZzk&0=?prMu^=7@hznNe9AKs3om&0l9b(feQJ zKkLgkCh_i8h`w1Sx$XCkue&GNZg77swe3wgTlDV(^LCwTo7=nXET7qy<9;U3i%(_U z?>C;EYQIub@4V%6716KH?WZ!_`M=%ZnSAc%ay8kvtMeQ4pXe92 z{>e`~^FJXb{kEnYgZ$O{Nx!cC_J27i;D3JN3477^5?0oyH}!s=T9|rl#>=F)&L_q0 z-P{nHtFy`)IXDmGe`C^TkJMt&7^nyx1*2dTjDk@x3P!;w7zLwX6pVsVFbYP&C>RB! P00{s9M%sWT04M+eiqO^z diff --git a/pkg/chartutil/testdata/frobnitz/requirements.yaml b/pkg/chartutil/testdata/frobnitz/requirements.yaml new file mode 100644 index 000000000..5eb0bc98b --- /dev/null +++ b/pkg/chartutil/testdata/frobnitz/requirements.yaml @@ -0,0 +1,7 @@ +dependencies: + - name: alpine + version: "0.1.0" + repository: https://example.com/charts + - name: mariner + version: "4.3.2" + repository: https://example.com/charts diff --git a/pkg/chartutil/testdata/mariner/charts/albatross-0.1.0.tgz b/pkg/chartutil/testdata/mariner/charts/albatross-0.1.0.tgz index 8b1b19dce8a88243fcbdfe46415d6bcf86d1b8a9..97e0b2eb844e4a2bc6571cc215a30798605a8619 100644 GIT binary patch delta 16 Xcmcc3beoA?zMF$V$?En-_7Fw@D`W(? delta 16 Xcmcc3beoA?zMF%gBk=r2_7Fw@E_?+k diff --git a/pkg/chartutil/testdata/mariner/requirements.yaml b/pkg/chartutil/testdata/mariner/requirements.yaml new file mode 100644 index 000000000..0b21d15b7 --- /dev/null +++ b/pkg/chartutil/testdata/mariner/requirements.yaml @@ -0,0 +1,4 @@ +dependencies: + - name: albatross + repository: https://example.com/mariner/charts + version: "0.1.0" diff --git a/pkg/provenance/sign.go b/pkg/provenance/sign.go index ab0fa6ebe..ae4c6d2b6 100644 --- a/pkg/provenance/sign.go +++ b/pkg/provenance/sign.go @@ -204,7 +204,7 @@ func (s *Signatory) Verify(chartpath, sigpath string) (*Verification, error) { ver.SignedBy = by // Second, verify the hash of the tarball. - sum, err := sumArchive(chartpath) + sum, err := DigestFile(chartpath) if err != nil { return ver, err } @@ -254,7 +254,7 @@ func (s *Signatory) verifySignature(block *clearsign.Block) (*openpgp.Entity, er func messageBlock(chartpath string) (*bytes.Buffer, error) { var b *bytes.Buffer // Checksum the archive - chash, err := sumArchive(chartpath) + chash, err := DigestFile(chartpath) if err != nil { return b, err } @@ -332,20 +332,26 @@ func loadKeyRing(ringpath string) (openpgp.EntityList, error) { return openpgp.ReadKeyRing(f) } -// sumArchive calculates a SHA256 hash (like Docker) for a given file. +// DigestFile calculates a SHA256 hash (like Docker) for a given file. // // It takes the path to the archive file, and returns a string representation of // the SHA256 sum. // // The intended use of this function is to generate a sum of a chart TGZ file. -func sumArchive(filename string) (string, error) { +func DigestFile(filename string) (string, error) { f, err := os.Open(filename) if err != nil { return "", err } defer f.Close() + return Digest(f) +} +// Digest hashes a reader and returns a SHA256 digest. +// +// Helm uses SHA256 as its default hash for all non-cryptographic applications. +func Digest(in io.Reader) (string, error) { hash := crypto.SHA256.New() - io.Copy(hash, f) + io.Copy(hash, in) return hex.EncodeToString(hash.Sum(nil)), nil } diff --git a/pkg/provenance/sign_test.go b/pkg/provenance/sign_test.go index 2f66748c1..00bc5aced 100644 --- a/pkg/provenance/sign_test.go +++ b/pkg/provenance/sign_test.go @@ -127,6 +127,28 @@ func TestLoadKeyRing(t *testing.T) { } } +func TestDigest(t *testing.T) { + f, err := os.Open(testChartfile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + hash, err := Digest(f) + if err != nil { + t.Fatal(err) + } + + sig, err := readSumFile(testSumfile) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(sig, hash) { + t.Errorf("Expected %s to be in %s", hash, sig) + } +} + func TestNewFromFiles(t *testing.T) { s, err := NewFromFiles(testKeyfile, testPubfile) if err != nil { @@ -138,8 +160,8 @@ func TestNewFromFiles(t *testing.T) { } } -func TestSumArchive(t *testing.T) { - hash, err := sumArchive(testChartfile) +func TestDigestFile(t *testing.T) { + hash, err := DigestFile(testChartfile) if err != nil { t.Fatal(err) } diff --git a/pkg/repo/index.go b/pkg/repo/index.go index 5e9a08837..b7aade1dc 100644 --- a/pkg/repo/index.go +++ b/pkg/repo/index.go @@ -19,11 +19,14 @@ package repo import ( "io/ioutil" "net/http" + "path/filepath" "strings" "gopkg.in/yaml.v2" + "k8s.io/helm/pkg/chartutil" "k8s.io/helm/pkg/proto/hapi/chart" + "k8s.io/helm/pkg/provenance" ) var indexPath = "index.yaml" @@ -33,14 +36,61 @@ type IndexFile struct { Entries map[string]*ChartRef } +// NewIndexFile initializes an index. +func NewIndexFile() *IndexFile { + return &IndexFile{Entries: map[string]*ChartRef{}} +} + +// Add adds a file to the index +func (i IndexFile) Add(md *chart.Metadata, filename, baseURL, digest string) { + name := strings.TrimSuffix(filename, ".tgz") + cr := &ChartRef{ + Name: name, + URL: baseURL + "/" + filename, + Chartfile: md, + Digest: digest, + // FIXME: Need to add Created + } + i.Entries[name] = cr +} + +// Need both JSON and YAML annotations until we get rid of gopkg.in/yaml.v2 + // ChartRef represents a chart entry in the IndexFile type ChartRef struct { - Name string `yaml:"name"` - URL string `yaml:"url"` - Created string `yaml:"created,omitempty"` - Removed bool `yaml:"removed,omitempty"` - Digest string `yaml:"digest,omitempty"` - Chartfile *chart.Metadata `yaml:"chartfile"` + Name string `yaml:"name" json:"name"` + URL string `yaml:"url" json:"url"` + Created string `yaml:"created,omitempty" json:"created,omitempty"` + Removed bool `yaml:"removed,omitempty" json:"removed,omitempty"` + Digest string `yaml:"digest,omitempty" json:"digest,omitempty"` + Chartfile *chart.Metadata `yaml:"chartfile" json:"chartfile"` +} + +// IndexDirectory reads a (flat) directory and generates an index. +// +// It indexes only charts that have been packaged (*.tgz). +// +// It writes the results to dir/index.yaml. +func IndexDirectory(dir, baseURL string) (*IndexFile, error) { + archives, err := filepath.Glob(filepath.Join(dir, "*.tgz")) + if err != nil { + return nil, err + } + index := NewIndexFile() + for _, arch := range archives { + fname := filepath.Base(arch) + c, err := chartutil.Load(arch) + if err != nil { + // Assume this is not a chart. + continue + } + hash, err := provenance.DigestFile(arch) + if err != nil { + return index, err + } + index.Add(c.Metadata, fname, baseURL, hash) + } + return index, nil } // DownloadIndexFile uses @@ -72,9 +122,7 @@ func DownloadIndexFile(repoName, url, indexFilePath string) error { func (i *IndexFile) UnmarshalYAML(unmarshal func(interface{}) error) error { var refs map[string]*ChartRef if err := unmarshal(&refs); err != nil { - if _, ok := err.(*yaml.TypeError); !ok { - return err - } + return err } i.Entries = refs return nil @@ -101,11 +149,11 @@ func LoadIndexFile(path string) (*IndexFile, error) { return nil, err } - var indexfile IndexFile - err = yaml.Unmarshal(b, &indexfile) + indexfile := NewIndexFile() + err = yaml.Unmarshal(b, indexfile) if err != nil { return nil, err } - return &indexfile, nil + return indexfile, nil } diff --git a/pkg/repo/index_test.go b/pkg/repo/index_test.go index 4fd4c255b..3b248fb19 100644 --- a/pkg/repo/index_test.go +++ b/pkg/repo/index_test.go @@ -110,3 +110,35 @@ func TestLoadIndexFile(t *testing.T) { t.Errorf("alpine entry was not decoded properly") } } + +func TestIndexDirectory(t *testing.T) { + dir := "testdata/repository" + index, err := IndexDirectory(dir, "http://localhost:8080") + if err != nil { + t.Fatal(err) + } + + if l := len(index.Entries); l != 2 { + t.Fatalf("Expected 2 entries, got %d", l) + } + + // Other things test the entry generation more thoroughly. We just test a + // few fields. + cname := "frobnitz-1.2.3" + frob, ok := index.Entries[cname] + if !ok { + t.Fatalf("Could not read chart %s", cname) + } + if len(frob.Digest) == 0 { + t.Errorf("Missing digest of file %s.", frob.Name) + } + if frob.Chartfile == nil { + t.Fatalf("Chartfile %s not added to index.", cname) + } + if frob.URL != "http://localhost:8080/frobnitz-1.2.3.tgz" { + t.Errorf("Unexpected URL: %s", frob.URL) + } + if frob.Chartfile.Name != "frobnitz" { + t.Errorf("Expected frobnitz, got %q", frob.Chartfile.Name) + } +} From ba7113b0754273bc6f3eeb2363a69a9253c83597 Mon Sep 17 00:00:00 2001 From: Matt Butcher Date: Fri, 16 Sep 2016 22:19:23 -0600 Subject: [PATCH 2/2] feat(helm): add 'helm dependency' commands This also refactors significant portions of the CLI, moving much of the shared code into a library. Also in this release, a testing repository server has been added. --- cmd/helm/dependency.go | 13 + cmd/helm/dependency_build.go | 85 +++++ cmd/helm/dependency_build_test.go | 115 +++++++ cmd/helm/dependency_update.go | 225 +----------- cmd/helm/dependency_update_test.go | 114 +------ cmd/helm/downloader/chart_downloader.go | 193 +++++++++++ cmd/helm/downloader/chart_downloader_test.go | 149 ++++++++ cmd/helm/downloader/doc.go | 23 ++ cmd/helm/downloader/manager.go | 323 ++++++++++++++++++ .../downloader/testdata/helm-test-key.pub | Bin 0 -> 1243 bytes .../downloader/testdata/helm-test-key.secret | Bin 0 -> 2545 bytes .../cache/kubernetes-charts-index.yaml | 38 +++ .../repository/cache/local-index.yaml | 1 + .../helmhome/repository/local/index.yaml | 0 .../helmhome/repository/repositories.yaml | 1 + .../downloader/testdata/signtest-0.1.0.tgz | Bin 0 -> 471 bytes .../testdata/signtest-0.1.0.tgz.prov | 20 ++ .../downloader/testdata/signtest/.helmignore | 5 + .../downloader/testdata/signtest/Chart.yaml | 3 + .../testdata/signtest/alpine/Chart.yaml | 6 + .../testdata/signtest/alpine/README.md | 9 + .../signtest/alpine/templates/alpine-pod.yaml | 16 + .../testdata/signtest/alpine/values.yaml | 2 + .../testdata/signtest/templates/pod.yaml | 10 + .../downloader/testdata/signtest/values.yaml | 0 cmd/helm/fetch.go | 187 +++------- cmd/helm/fetch_test.go | 128 +++++-- cmd/helm/helmpath/helmhome.go | 61 ++++ cmd/helm/helmpath/helmhome_test.go | 36 ++ cmd/helm/install.go | 16 +- cmd/helm/resolver/resolver.go | 5 +- cmd/helm/verify.go | 5 +- pkg/chartutil/expand.go | 10 + pkg/chartutil/requirements.go | 32 +- pkg/repo/index.go | 2 +- pkg/repo/repotest/doc.go | 20 ++ pkg/repo/repotest/server.go | 130 +++++++ pkg/repo/repotest/server_test.go | 107 ++++++ .../repotest/testdata/examplechart-0.1.0.tgz | Bin 0 -> 558 bytes .../testdata/examplechart/.helmignore | 21 ++ .../repotest/testdata/examplechart/Chart.yaml | 3 + .../testdata/examplechart/values.yaml | 4 + 42 files changed, 1617 insertions(+), 501 deletions(-) create mode 100644 cmd/helm/dependency_build.go create mode 100644 cmd/helm/dependency_build_test.go create mode 100644 cmd/helm/downloader/chart_downloader.go create mode 100644 cmd/helm/downloader/chart_downloader_test.go create mode 100644 cmd/helm/downloader/doc.go create mode 100644 cmd/helm/downloader/manager.go create mode 100644 cmd/helm/downloader/testdata/helm-test-key.pub create mode 100644 cmd/helm/downloader/testdata/helm-test-key.secret create mode 100644 cmd/helm/downloader/testdata/helmhome/repository/cache/kubernetes-charts-index.yaml create mode 120000 cmd/helm/downloader/testdata/helmhome/repository/cache/local-index.yaml create mode 100644 cmd/helm/downloader/testdata/helmhome/repository/local/index.yaml create mode 100644 cmd/helm/downloader/testdata/helmhome/repository/repositories.yaml create mode 100644 cmd/helm/downloader/testdata/signtest-0.1.0.tgz create mode 100755 cmd/helm/downloader/testdata/signtest-0.1.0.tgz.prov create mode 100644 cmd/helm/downloader/testdata/signtest/.helmignore create mode 100755 cmd/helm/downloader/testdata/signtest/Chart.yaml create mode 100755 cmd/helm/downloader/testdata/signtest/alpine/Chart.yaml create mode 100755 cmd/helm/downloader/testdata/signtest/alpine/README.md create mode 100755 cmd/helm/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml create mode 100755 cmd/helm/downloader/testdata/signtest/alpine/values.yaml create mode 100644 cmd/helm/downloader/testdata/signtest/templates/pod.yaml create mode 100644 cmd/helm/downloader/testdata/signtest/values.yaml create mode 100644 cmd/helm/helmpath/helmhome.go create mode 100644 cmd/helm/helmpath/helmhome_test.go create mode 100644 pkg/repo/repotest/doc.go create mode 100644 pkg/repo/repotest/server.go create mode 100644 pkg/repo/repotest/server_test.go create mode 100644 pkg/repo/repotest/testdata/examplechart-0.1.0.tgz create mode 100644 pkg/repo/repotest/testdata/examplechart/.helmignore create mode 100755 pkg/repo/repotest/testdata/examplechart/Chart.yaml create mode 100644 pkg/repo/repotest/testdata/examplechart/values.yaml diff --git a/cmd/helm/dependency.go b/cmd/helm/dependency.go index 2da344052..38021613e 100644 --- a/cmd/helm/dependency.go +++ b/cmd/helm/dependency.go @@ -27,6 +27,11 @@ import ( "k8s.io/helm/pkg/chartutil" ) +const ( + reqLock = "requirements.lock" + reqYaml = "requirements.yaml" +) + const dependencyDesc = ` Manage the dependencies of a chart. @@ -82,6 +87,7 @@ func newDependencyCmd(out io.Writer) *cobra.Command { cmd.AddCommand(newDependencyListCmd(out)) cmd.AddCommand(newDependencyUpdateCmd(out)) + cmd.AddCommand(newDependencyBuildCmd(out)) return cmd } @@ -214,3 +220,10 @@ func (l *dependencyListCmd) printMissing(reqs *chartutil.Requirements, out io.Wr } } + +func lockpath(chartpath string) string { + return filepath.Join(chartpath, reqLock) +} +func reqpath(chartpath string) string { + return filepath.Join(chartpath, reqYaml) +} diff --git a/cmd/helm/dependency_build.go b/cmd/helm/dependency_build.go new file mode 100644 index 000000000..b482d5fc8 --- /dev/null +++ b/cmd/helm/dependency_build.go @@ -0,0 +1,85 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "io" + + "github.com/spf13/cobra" + + "k8s.io/helm/cmd/helm/downloader" + "k8s.io/helm/cmd/helm/helmpath" +) + +const dependencyBuildDesc = ` +Build out the charts/ directory from the requirements.lock file. + +Build is used to reconstruct a chart's dependencies to the state specified in +the lock file. This will not re-negotiate dependencies, as 'helm dependency update' +does. + +If no lock file is found, 'helm dependency build' will mirror the behavior +of 'helm dependency update'. +` + +type dependencyBuildCmd struct { + out io.Writer + chartpath string + verify bool + keyring string + helmhome helmpath.HelmHome +} + +func newDependencyBuildCmd(out io.Writer) *cobra.Command { + dbc := &dependencyBuildCmd{ + out: out, + } + + cmd := &cobra.Command{ + Use: "build [flags] CHART", + Short: "rebuild the charts/ directory based on the requirements.lock file", + Long: dependencyBuildDesc, + RunE: func(cmd *cobra.Command, args []string) error { + dbc.helmhome = helmpath.HelmHome(homePath()) + dbc.chartpath = "." + + if len(args) > 0 { + dbc.chartpath = args[0] + } + return dbc.run() + }, + } + + f := cmd.Flags() + f.BoolVar(&dbc.verify, "verify", false, "Verify the packages against signatures.") + f.StringVar(&dbc.keyring, "keyring", defaultKeyring(), "The keyring containing public keys.") + + return cmd +} + +func (d *dependencyBuildCmd) run() error { + man := &downloader.Manager{ + Out: d.out, + ChartPath: d.chartpath, + HelmHome: helmpath.HelmHome(d.helmhome), + Keyring: d.keyring, + } + if d.verify { + man.Verify = downloader.VerifyIfPossible + } + + return man.Build() +} diff --git a/cmd/helm/dependency_build_test.go b/cmd/helm/dependency_build_test.go new file mode 100644 index 000000000..6c057f83b --- /dev/null +++ b/cmd/helm/dependency_build_test.go @@ -0,0 +1,115 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "k8s.io/helm/cmd/helm/helmpath" + "k8s.io/helm/pkg/provenance" + "k8s.io/helm/pkg/repo" + "k8s.io/helm/pkg/repo/repotest" +) + +func TestDependencyBuildCmd(t *testing.T) { + oldhome := helmHome + hh, err := tempHelmHome() + if err != nil { + t.Fatal(err) + } + helmHome = hh + defer func() { + os.RemoveAll(hh) + helmHome = oldhome + }() + + srv := repotest.NewServer(hh) + defer srv.Stop() + _, err = srv.CopyCharts("testdata/testcharts/*.tgz") + if err != nil { + t.Fatal(err) + } + + chartname := "depbuild" + if err := createTestingChart(hh, chartname, srv.URL()); err != nil { + t.Fatal(err) + } + + out := bytes.NewBuffer(nil) + dbc := &dependencyBuildCmd{out: out} + dbc.helmhome = helmpath.HelmHome(hh) + dbc.chartpath = filepath.Join(hh, chartname) + + // In the first pass, we basically want the same results as an update. + if err := dbc.run(); err != nil { + output := out.String() + t.Logf("Output: %s", output) + t.Fatal(err) + } + + output := out.String() + if !strings.Contains(output, `update from the "test" chart repository`) { + t.Errorf("Repo did not get updated\n%s", output) + } + + // Make sure the actual file got downloaded. + expect := filepath.Join(hh, chartname, "charts/reqtest-0.1.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } + + // In the second pass, we want to remove the chart's request dependency, + // then see if it restores from the lock. + lockfile := filepath.Join(hh, chartname, "requirements.lock") + if _, err := os.Stat(lockfile); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(expect); err != nil { + t.Fatal(err) + } + + if err := dbc.run(); err != nil { + output := out.String() + t.Logf("Output: %s", output) + t.Fatal(err) + } + + // Now repeat the test that the dependency exists. + expect = filepath.Join(hh, chartname, "charts/reqtest-0.1.0.tgz") + if _, err := os.Stat(expect); err != nil { + t.Fatal(err) + } + + // Make sure that build is also fetching the correct version. + hash, err := provenance.DigestFile(expect) + if err != nil { + t.Fatal(err) + } + + i, err := repo.LoadIndexFile(cacheIndexFile("test")) + if err != nil { + t.Fatal(err) + } + + if h := i.Entries["reqtest-0.1.0"].Digest; h != hash { + t.Errorf("Failed hash match: expected %s, got %s", hash, h) + } + +} diff --git a/cmd/helm/dependency_update.go b/cmd/helm/dependency_update.go index 9b9eb1d12..1d687165f 100644 --- a/cmd/helm/dependency_update.go +++ b/cmd/helm/dependency_update.go @@ -16,21 +16,12 @@ limitations under the License. package main import ( - "errors" - "fmt" "io" - "io/ioutil" - "net/url" - "os" "path/filepath" - "strings" - "github.com/ghodss/yaml" "github.com/spf13/cobra" - - "k8s.io/helm/cmd/helm/resolver" - "k8s.io/helm/pkg/chartutil" - "k8s.io/helm/pkg/repo" + "k8s.io/helm/cmd/helm/downloader" + "k8s.io/helm/cmd/helm/helmpath" ) const dependencyUpDesc = ` @@ -38,15 +29,16 @@ Update the on-disk dependencies to mirror the requirements.yaml file. This command verifies that the required charts, as expressed in 'requirements.yaml', are present in 'charts/' and are at an acceptable version. + +On successful update, this will generate a lock file that can be used to +rebuild the requirements to an exact version. ` // dependencyUpdateCmd describes a 'helm dependency update' type dependencyUpdateCmd struct { out io.Writer chartpath string - repoFile string - repopath string - helmhome string + helmhome helmpath.HelmHome verify bool keyring string } @@ -74,16 +66,14 @@ func newDependencyUpdateCmd(out io.Writer) *cobra.Command { return err } - duc.helmhome = homePath() - duc.repoFile = repositoriesFile() - duc.repopath = repositoryDirectory() + duc.helmhome = helmpath.HelmHome(homePath()) return duc.run() }, } f := cmd.Flags() - f.BoolVar(&duc.verify, "verify", false, "Verify the package against its signature.") + f.BoolVar(&duc.verify, "verify", false, "Verify the packages against signatures.") f.StringVar(&duc.keyring, "keyring", defaultKeyring(), "The keyring containing public keys.") return cmd @@ -91,197 +81,14 @@ func newDependencyUpdateCmd(out io.Writer) *cobra.Command { // run runs the full dependency update process. func (d *dependencyUpdateCmd) run() error { - if fi, err := os.Stat(d.chartpath); err != nil { - return fmt.Errorf("could not find %s: %s", d.chartpath, err) - } else if !fi.IsDir() { - return errors.New("only unpacked charts can be updated") - } - c, err := chartutil.LoadDir(d.chartpath) - if err != nil { - return err - } - - req, err := chartutil.LoadRequirements(c) - if err != nil { - if err == chartutil.ErrRequirementsNotFound { - fmt.Fprintf(d.out, "No requirements found in %s/charts.\n", d.chartpath) - return nil - } - return err - } - - // For each repo in the file, update the cached copy of that repo - if _, err := d.updateRepos(req.Dependencies); err != nil { - return err - } - - // Now we need to find out which version of a chart best satisfies the - // requirements the requirements.yaml - lock, err := d.resolve(req) - if err != nil { - return err - } - - // Now we need to fetch every package here into charts/ - if err := d.downloadAll(lock.Dependencies); err != nil { - return err - } - - // Finally, we need to write the lockfile. - return writeLock(d.chartpath, lock) -} - -// resolve takes a list of requirements and translates them into an exact version to download. -// -// This returns a lock file, which has all of the requirements normalized to a specific version. -func (d *dependencyUpdateCmd) resolve(req *chartutil.Requirements) (*chartutil.RequirementsLock, error) { - res := resolver.New(d.chartpath, d.helmhome) - return res.Resolve(req) -} - -// downloadAll takes a list of dependencies and downloads them into charts/ -func (d *dependencyUpdateCmd) downloadAll(deps []*chartutil.Dependency) error { - repos, err := loadChartRepositories(d.repopath) - if err != nil { - return err - } - - fmt.Fprintf(d.out, "Saving %d charts\n", len(deps)) - for _, dep := range deps { - fmt.Fprintf(d.out, "Downloading %s from repo %s\n", dep.Name, dep.Repository) - - target := fmt.Sprintf("%s-%s", dep.Name, dep.Version) - churl, err := findChartURL(target, dep.Repository, repos) - if err != nil { - fmt.Fprintf(d.out, "WARNING: %s (skipped)", err) - continue - } - - dest := filepath.Join(d.chartpath, "charts", target+".tgz") - data, err := downloadChart(churl, d.verify, d.keyring) - if err != nil { - fmt.Fprintf(d.out, "WARNING: Could not download %s: %s (skipped)", churl, err) - continue - } - if err := ioutil.WriteFile(dest, data.Bytes(), 0655); err != nil { - fmt.Fprintf(d.out, "WARNING: %s (skipped)", err) - continue - } - } - return nil -} - -// updateRepos updates all of the local repos to their latest. -// -// If one of the dependencies present is not in the cached repos, this will error out. The -// consequence of that is that every repository referenced in a requirements.yaml file -// must also be added with 'helm repo add'. -func (d *dependencyUpdateCmd) updateRepos(deps []*chartutil.Dependency) (*repo.RepoFile, error) { - // TODO: In the future, we could make it so that only the repositories that - // are used by this chart are updated. As it is, we're mainly doing some sanity - // checking here. - rf, err := repo.LoadRepositoriesFile(d.repoFile) - if err != nil { - return rf, err + man := &downloader.Manager{ + Out: d.out, + ChartPath: d.chartpath, + HelmHome: d.helmhome, + Keyring: d.keyring, } - repos := rf.Repositories - - // Verify that all repositories referenced in the deps are actually known - // by Helm. - missing := []string{} - for _, dd := range deps { - found := false - if dd.Repository == "" { - found = true - } else { - for _, repo := range repos { - if urlsAreEqual(repo, dd.Repository) { - found = true - } - } - } - if !found { - missing = append(missing, dd.Repository) - } - } - - if len(missing) > 0 { - return rf, fmt.Errorf("no repository definition for %s. Try 'helm repo add'", strings.Join(missing, ", ")) - } - - if len(repos) > 0 { - // This prints errors straight to out. - updateCharts(repos, flagDebug, d.out) - } - return rf, nil -} - -// urlsAreEqual normalizes two URLs and then compares for equality. -func urlsAreEqual(a, b string) bool { - au, err := url.Parse(a) - if err != nil { - return a == b - } - bu, err := url.Parse(b) - if err != nil { - return false - } - return au.String() == bu.String() -} - -// findChartURL searches the cache of repo data for a chart that has the name and the repourl specified. -// -// In this current version, name is of the form 'foo-1.2.3'. This will change when -// the repository index stucture changes. -func findChartURL(name, repourl string, repos map[string]*repo.ChartRepository) (string, error) { - for _, cr := range repos { - if urlsAreEqual(repourl, cr.URL) { - for ename, entry := range cr.IndexFile.Entries { - if ename == name { - return entry.URL, nil - } - } - } - } - return "", fmt.Errorf("chart %s not found in %s", name, repourl) -} - -// loadChartRepositories reads the repositories.yaml, and then builds a map of -// ChartRepositories. -// -// The key is the local name (which is only present in the repositories.yaml). -func loadChartRepositories(repodir string) (map[string]*repo.ChartRepository, error) { - indices := map[string]*repo.ChartRepository{} - repoyaml := repositoriesFile() - - // Load repositories.yaml file - rf, err := repo.LoadRepositoriesFile(repoyaml) - if err != nil { - return indices, fmt.Errorf("failed to load %s: %s", repoyaml, err) - } - - // localName: chartRepo - for lname, url := range rf.Repositories { - index, err := repo.LoadIndexFile(cacheIndexFile(lname)) - if err != nil { - return indices, err - } - - cr := &repo.ChartRepository{ - URL: url, - IndexFile: index, - } - indices[lname] = cr - } - return indices, nil -} - -// writeLock writes a lockfile to disk -func writeLock(chartpath string, lock *chartutil.RequirementsLock) error { - data, err := yaml.Marshal(lock) - if err != nil { - return err + if d.verify { + man.Verify = downloader.VerifyIfPossible } - dest := filepath.Join(chartpath, "requirements.lock") - return ioutil.WriteFile(dest, data, 0755) + return man.Update() } diff --git a/cmd/helm/dependency_update_test.go b/cmd/helm/dependency_update_test.go index b38b90a21..706983ea9 100644 --- a/cmd/helm/dependency_update_test.go +++ b/cmd/helm/dependency_update_test.go @@ -18,8 +18,6 @@ package main import ( "bytes" "io/ioutil" - "net/http" - "net/http/httptest" "os" "path/filepath" "strings" @@ -27,10 +25,12 @@ import ( "github.com/ghodss/yaml" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/chartutil" "k8s.io/helm/pkg/proto/hapi/chart" "k8s.io/helm/pkg/provenance" "k8s.io/helm/pkg/repo" + "k8s.io/helm/pkg/repo/repotest" ) func TestDependencyUpdateCmd(t *testing.T) { @@ -40,36 +40,35 @@ func TestDependencyUpdateCmd(t *testing.T) { if err != nil { t.Fatal(err) } - helmHome = hh // Shoot me now. + helmHome = hh defer func() { os.RemoveAll(hh) helmHome = oldhome }() - srv := newTestingRepositoryServer(hh) - defer srv.stop() - copied, err := srv.copyCharts("testdata/testcharts/*.tgz") - t.Logf("Copied charts %s", strings.Join(copied, "\n")) - t.Logf("Listening for directory %s", srv.docroot) + srv := repotest.NewServer(hh) + defer srv.Stop() + copied, err := srv.CopyCharts("testdata/testcharts/*.tgz") + t.Logf("Copied charts:\n%s", strings.Join(copied, "\n")) + t.Logf("Listening on directory %s", srv.Root()) chartname := "depup" - if err := createTestingChart(hh, chartname, srv.url()); err != nil { + if err := createTestingChart(hh, chartname, srv.URL()); err != nil { t.Fatal(err) } out := bytes.NewBuffer(nil) duc := &dependencyUpdateCmd{out: out} - duc.helmhome = hh + duc.helmhome = helmpath.HelmHome(hh) duc.chartpath = filepath.Join(hh, chartname) - duc.repoFile = filepath.Join(duc.helmhome, "repository/repositories.yaml") - duc.repopath = filepath.Join(duc.helmhome, "repository") if err := duc.run(); err != nil { + output := out.String() + t.Logf("Output: %s", output) t.Fatal(err) } output := out.String() - t.Logf("Output: %s", output) // This is written directly to stdout, so we have to capture as is. if !strings.Contains(output, `update from the "test" chart repository`) { t.Errorf("Repo did not get updated\n%s", output) @@ -98,95 +97,6 @@ func TestDependencyUpdateCmd(t *testing.T) { t.Logf("Results: %s", out.String()) } -// newTestingRepositoryServer creates a repository server for testing. -// -// docroot should be a temp dir managed by the caller. -// -// This will start the server, serving files off of the docroot. -// -// Use copyCharts to move charts into the repository and then index them -// for service. -func newTestingRepositoryServer(docroot string) *testingRepositoryServer { - root, err := filepath.Abs(docroot) - if err != nil { - panic(err) - } - srv := &testingRepositoryServer{ - docroot: root, - } - srv.start() - // Add the testing repository as the only repo. - if err := setTestingRepository(docroot, "test", srv.url()); err != nil { - panic(err) - } - return srv -} - -type testingRepositoryServer struct { - docroot string - srv *httptest.Server -} - -// copyCharts takes a glob expression and copies those charts to the server root. -func (s *testingRepositoryServer) copyCharts(origin string) ([]string, error) { - files, err := filepath.Glob(origin) - if err != nil { - return []string{}, err - } - copied := make([]string, len(files)) - for i, f := range files { - base := filepath.Base(f) - newname := filepath.Join(s.docroot, base) - data, err := ioutil.ReadFile(f) - if err != nil { - return []string{}, err - } - if err := ioutil.WriteFile(newname, data, 0755); err != nil { - return []string{}, err - } - copied[i] = newname - } - - // generate the index - index, err := repo.IndexDirectory(s.docroot, s.url()) - if err != nil { - return copied, err - } - - d, err := yaml.Marshal(index.Entries) - if err != nil { - return copied, err - } - - ifile := filepath.Join(s.docroot, "index.yaml") - err = ioutil.WriteFile(ifile, d, 0755) - return copied, err -} - -func (s *testingRepositoryServer) start() { - s.srv = httptest.NewServer(http.FileServer(http.Dir(s.docroot))) -} - -func (s *testingRepositoryServer) stop() { - s.srv.Close() -} - -func (s *testingRepositoryServer) url() string { - return s.srv.URL -} - -// setTestingRepository sets up a testing repository.yaml with only the given name/URL. -func setTestingRepository(helmhome, name, url string) error { - // Oddly, there is no repo.Save function for this. - data, err := yaml.Marshal(&map[string]string{name: url}) - if err != nil { - return err - } - os.MkdirAll(filepath.Join(helmhome, "repository", name), 0755) - dest := filepath.Join(helmhome, "repository/repositories.yaml") - return ioutil.WriteFile(dest, data, 0666) -} - // createTestingChart creates a basic chart that depends on reqtest-0.1.0 // // The baseURL can be used to point to a particular repository server. diff --git a/cmd/helm/downloader/chart_downloader.go b/cmd/helm/downloader/chart_downloader.go new file mode 100644 index 000000000..fad20a675 --- /dev/null +++ b/cmd/helm/downloader/chart_downloader.go @@ -0,0 +1,193 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 downloader + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "k8s.io/helm/cmd/helm/helmpath" + "k8s.io/helm/pkg/provenance" + "k8s.io/helm/pkg/repo" +) + +// VerificationStrategy describes a strategy for determining whether to verify a chart. +type VerificationStrategy int + +const ( + // VerifyNever will skip all verification of a chart. + VerifyNever VerificationStrategy = iota + // VerifyIfPossible will attempt a verification, but will not stop processing + // if verification fails. + VerifyIfPossible + // VerifyAlways will always attempt a verification, and will fail if the + // verification fails. + VerifyAlways +) + +// ChartDownloader handles downloading a chart. +// +// It is capable of performing verifications on charts as well. +type ChartDownloader struct { + // Out is the location to write warning and info messages. + Out io.Writer + // Verify indicates what verification strategy to use. + Verify VerificationStrategy + // Keyring is the keyring file used for verification. + Keyring string + // HelmHome is the $HELM_HOME. + HelmHome helmpath.HelmHome +} + +// DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file. +// +// If Verify is set to VerifyNever, the verification will be nil. +// If Verify is set to VerifyIfPossible, this will return a verification (or nil on failure), and print a warning on failure. +// If Verify is set to VerifyAlways, this will return a verification or an error if the verification fails. +func (c *ChartDownloader) DownloadTo(ref string, dest string) (*provenance.Verification, error) { + // resolve URL + u, err := c.ResolveChartRef(ref) + if err != nil { + return nil, err + } + data, err := download(u.String()) + if err != nil { + return nil, err + } + + name := filepath.Base(u.Path) + destfile := filepath.Join(dest, name) + if err := ioutil.WriteFile(destfile, data.Bytes(), 0655); err != nil { + return nil, err + } + + // If provenance is requested, verify it. + var ver *provenance.Verification + if c.Verify > VerifyNever { + + body, err := download(u.String() + ".prov") + if err != nil { + if c.Verify == VerifyAlways { + return ver, fmt.Errorf("Failed to fetch provenance %q", u.String()+".prov") + } + fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s", ref, err) + } + provfile := destfile + ".prov" + if err := ioutil.WriteFile(provfile, body.Bytes(), 0655); err != nil { + return nil, err + } + + ver, err = VerifyChart(destfile, c.Keyring) + if err != nil { + // Fail always in this case, since it means the verification step + // failed. + return ver, err + } + } + return ver, nil +} + +// ResolveChartRef resolves a chart reference to a URL. +// +// A reference may be an HTTP URL, a 'reponame/chartname' reference, or a local path. +func (c *ChartDownloader) ResolveChartRef(ref string) (*url.URL, error) { + // See if it's already a full URL. + u, err := url.ParseRequestURI(ref) + if err == nil { + // If it has a scheme and host and path, it's a full URL + if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 { + return u, nil + } + return u, fmt.Errorf("Invalid chart url format: %s", ref) + } + + r, err := repo.LoadRepositoriesFile(c.HelmHome.RepositoryFile()) + if err != nil { + return u, err + } + + // See if it's of the form: repo/path_to_chart + p := strings.Split(ref, "/") + if len(p) > 1 { + if baseURL, ok := r.Repositories[p[0]]; ok { + if !strings.HasSuffix(baseURL, "/") { + baseURL = baseURL + "/" + } + return url.ParseRequestURI(baseURL + strings.Join(p[1:], "/")) + } + return u, fmt.Errorf("No such repo: %s", p[0]) + } + return u, fmt.Errorf("Invalid chart url format: %s", ref) +} + +// VerifyChart takes a path to a chart archive and a keyring, and verifies the chart. +// +// It assumes that a chart archive file is accompanied by a provenance file whose +// name is the archive file name plus the ".prov" extension. +func VerifyChart(path string, keyring string) (*provenance.Verification, error) { + // For now, error out if it's not a tar file. + if fi, err := os.Stat(path); err != nil { + return nil, err + } else if fi.IsDir() { + return nil, errors.New("unpacked charts cannot be verified") + } else if !isTar(path) { + return nil, errors.New("chart must be a tgz file") + } + + provfile := path + ".prov" + if _, err := os.Stat(provfile); err != nil { + return nil, fmt.Errorf("could not load provenance file %s: %s", provfile, err) + } + + sig, err := provenance.NewFromKeyring(keyring, "") + if err != nil { + return nil, fmt.Errorf("failed to load keyring: %s", err) + } + return sig.Verify(path, provfile) +} + +// download performs a simple HTTP Get and returns the body. +func download(href string) (*bytes.Buffer, error) { + buf := bytes.NewBuffer(nil) + + resp, err := http.Get(href) + if err != nil { + return buf, err + } + if resp.StatusCode != 200 { + return buf, fmt.Errorf("Failed to fetch %s : %s", href, resp.Status) + } + + _, err = io.Copy(buf, resp.Body) + resp.Body.Close() + return buf, err +} + +// isTar tests whether the given file is a tar file. +// +// Currently, this simply checks extension, since a subsequent function will +// untar the file and validate its binary format. +func isTar(filename string) bool { + return strings.ToLower(filepath.Ext(filename)) == ".tgz" +} diff --git a/cmd/helm/downloader/chart_downloader_test.go b/cmd/helm/downloader/chart_downloader_test.go new file mode 100644 index 000000000..41bde281d --- /dev/null +++ b/cmd/helm/downloader/chart_downloader_test.go @@ -0,0 +1,149 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 downloader + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "k8s.io/helm/cmd/helm/helmpath" + "k8s.io/helm/pkg/repo/repotest" +) + +func TestResolveChartRef(t *testing.T) { + tests := []struct { + name, ref, expect string + fail bool + }{ + {name: "full URL", ref: "http://example.com/foo-1.2.3.tgz", expect: "http://example.com/foo-1.2.3.tgz"}, + {name: "full URL, HTTPS", ref: "https://example.com/foo-1.2.3.tgz", expect: "https://example.com/foo-1.2.3.tgz"}, + {name: "reference, testing repo", ref: "testing/foo-1.2.3.tgz", expect: "http://example.com/foo-1.2.3.tgz"}, + {name: "full URL, file", ref: "file:///foo-1.2.3.tgz", fail: true}, + {name: "invalid", ref: "invalid-1.2.3", fail: true}, + {name: "not found", ref: "nosuchthing/invalid-1.2.3", fail: true}, + } + + c := ChartDownloader{ + HelmHome: helmpath.HelmHome("testdata/helmhome"), + Out: os.Stderr, + } + + for _, tt := range tests { + u, err := c.ResolveChartRef(tt.ref) + if err != nil { + if tt.fail { + continue + } + t.Errorf("%s: failed with error %s", tt.name, err) + continue + } + if got := u.String(); got != tt.expect { + t.Errorf("%s: expected %s, got %s", tt.name, tt.expect, got) + } + } +} + +func TestVerifyChart(t *testing.T) { + v, err := VerifyChart("testdata/signtest-0.1.0.tgz", "testdata/helm-test-key.pub") + if err != nil { + t.Fatal(err) + } + // The verification is tested at length in the provenance package. Here, + // we just want a quick sanity check that the v is not empty. + if len(v.FileHash) == 0 { + t.Error("Digest missing") + } +} + +func TestDownload(t *testing.T) { + expect := "Call me Ishmael" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, expect) + })) + defer srv.Close() + + got, err := download(srv.URL) + if err != nil { + t.Fatal(err) + } + + if got.String() != expect { + t.Errorf("Expected %q, got %q", expect, got.String()) + } +} + +func TestIsTar(t *testing.T) { + tests := map[string]bool{ + "foo.tgz": true, + "foo/bar/baz.tgz": true, + "foo-1.2.3.4.5.tgz": true, + "foo.tar.gz": false, // for our purposes + "foo.tgz.1": false, + "footgz": false, + } + + for src, expect := range tests { + if isTar(src) != expect { + t.Errorf("%q should be %t", src, expect) + } + } +} + +func TestDownloadTo(t *testing.T) { + hh, err := ioutil.TempDir("", "helm-downloadto-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(hh) + + dest := filepath.Join(hh, "dest") + os.MkdirAll(dest, 0755) + + // Set up a fake repo + srv := repotest.NewServer(hh) + defer srv.Stop() + if _, err := srv.CopyCharts("testdata/*.tgz*"); err != nil { + t.Error(err) + return + } + + c := ChartDownloader{ + HelmHome: helmpath.HelmHome("testdata/helmhome"), + Out: os.Stderr, + Verify: VerifyAlways, + Keyring: "testdata/helm-test-key.pub", + } + cname := "/signtest-0.1.0.tgz" + v, err := c.DownloadTo(srv.URL()+cname, dest) + if err != nil { + t.Error(err) + return + } + + if v.FileHash == "" { + t.Error("File hash was empty, but verification is required.") + } + + if _, err := os.Stat(filepath.Join(dest, cname)); err != nil { + t.Error(err) + return + } +} diff --git a/cmd/helm/downloader/doc.go b/cmd/helm/downloader/doc.go new file mode 100644 index 000000000..fb54936b8 --- /dev/null +++ b/cmd/helm/downloader/doc.go @@ -0,0 +1,23 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 downloader provides a library for downloading charts. + +This package contains various tools for downloading charts from repository +servers, and then storing them in Helm-specific directory structures (like +HELM_HOME). This library contains many functions that depend on a specific +filesystem layout. +*/ +package downloader diff --git a/cmd/helm/downloader/manager.go b/cmd/helm/downloader/manager.go new file mode 100644 index 000000000..4b45f15ed --- /dev/null +++ b/cmd/helm/downloader/manager.go @@ -0,0 +1,323 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 downloader + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/ghodss/yaml" + + "k8s.io/helm/cmd/helm/helmpath" + "k8s.io/helm/cmd/helm/resolver" + "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/proto/hapi/chart" + "k8s.io/helm/pkg/repo" +) + +// Manager handles the lifecycle of fetching, resolving, and storing dependencies. +type Manager struct { + // Out is used to print warnings and notifications. + Out io.Writer + // ChartPath is the path to the unpacked base chart upon which this operates. + ChartPath string + // HelmHome is the $HELM_HOME directory + HelmHome helmpath.HelmHome + // Verification indicates whether the chart should be verified. + Verify VerificationStrategy + // Keyring is the key ring file. + Keyring string +} + +// Build rebuilds a local charts directory from a lockfile. +// +// If the lockfile is not present, this will run a Manager.Update() +func (m *Manager) Build() error { + c, err := m.loadChartDir() + if err != nil { + return err + } + + // If a lock file is found, run a build from that. Otherwise, just do + // an update. + lock, err := chartutil.LoadRequirementsLock(c) + if err != nil { + return m.Update() + } + + // TODO: If the hash in the lock file doesn't match the digest of the + // actual yaml file, return an error. + + // Check that all of the repos we're dependent on actually exist. + if err := m.hasAllRepos(lock.Dependencies); err != nil { + return err + } + + // For each repo in the file, update the cached copy of that repo + if err := m.UpdateRepositories(); err != nil { + return err + } + + // Now we need to fetch every package here into charts/ + if err := m.downloadAll(lock.Dependencies); err != nil { + return err + } + + return nil +} + +// Update updates a local charts directory. +// +// It first reads the requirements.yaml file, and then attempts to +// negotiate versions based on that. It will download the versions +// from remote chart repositories. +func (m *Manager) Update() error { + c, err := m.loadChartDir() + if err != nil { + return err + } + + // If no requirements file is found, we consider this a successful + // completion. + req, err := chartutil.LoadRequirements(c) + if err != nil { + if err == chartutil.ErrRequirementsNotFound { + fmt.Fprintf(m.Out, "No requirements found in %s/charts.\n", m.ChartPath) + return nil + } + return err + } + + // Check that all of the repos we're dependent on actually exist. + if err := m.hasAllRepos(req.Dependencies); err != nil { + return err + } + + // For each repo in the file, update the cached copy of that repo + if err := m.UpdateRepositories(); err != nil { + return err + } + + // Now we need to find out which version of a chart best satisfies the + // requirements the requirements.yaml + lock, err := m.resolve(req) + if err != nil { + return err + } + + // Now we need to fetch every package here into charts/ + if err := m.downloadAll(lock.Dependencies); err != nil { + return err + } + + // Finally, we need to write the lockfile. + return writeLock(m.ChartPath, lock) +} + +func (m *Manager) loadChartDir() (*chart.Chart, error) { + if fi, err := os.Stat(m.ChartPath); err != nil { + return nil, fmt.Errorf("could not find %s: %s", m.ChartPath, err) + } else if !fi.IsDir() { + return nil, errors.New("only unpacked charts can be updated") + } + return chartutil.LoadDir(m.ChartPath) +} + +// resolve takes a list of requirements and translates them into an exact version to download. +// +// This returns a lock file, which has all of the requirements normalized to a specific version. +func (m *Manager) resolve(req *chartutil.Requirements) (*chartutil.RequirementsLock, error) { + res := resolver.New(m.ChartPath, m.HelmHome) + return res.Resolve(req) +} + +// downloadAll takes a list of dependencies and downloads them into charts/ +func (m *Manager) downloadAll(deps []*chartutil.Dependency) error { + repos, err := m.loadChartRepositories() + if err != nil { + return err + } + + dl := ChartDownloader{ + Out: m.Out, + Verify: m.Verify, + Keyring: m.Keyring, + HelmHome: m.HelmHome, + } + + fmt.Fprintf(m.Out, "Saving %d charts\n", len(deps)) + for _, dep := range deps { + fmt.Fprintf(m.Out, "Downloading %s from repo %s\n", dep.Name, dep.Repository) + + target := fmt.Sprintf("%s-%s", dep.Name, dep.Version) + churl, err := findChartURL(target, dep.Repository, repos) + if err != nil { + fmt.Fprintf(m.Out, "WARNING: %s (skipped)", err) + continue + } + + dest := filepath.Join(m.ChartPath, "charts") + if _, err := dl.DownloadTo(churl, dest); err != nil { + fmt.Fprintf(m.Out, "WARNING: Could not download %s: %s (skipped)", churl, err) + continue + } + } + return nil +} + +// hasAllRepos ensures that all of the referenced deps are in the local repo cache. +func (m *Manager) hasAllRepos(deps []*chartutil.Dependency) error { + rf, err := repo.LoadRepositoriesFile(m.HelmHome.RepositoryFile()) + if err != nil { + return err + } + repos := rf.Repositories + // Verify that all repositories referenced in the deps are actually known + // by Helm. + missing := []string{} + for _, dd := range deps { + found := false + if dd.Repository == "" { + found = true + } else { + for _, repo := range repos { + if urlsAreEqual(repo, dd.Repository) { + found = true + } + } + } + if !found { + missing = append(missing, dd.Repository) + } + } + if len(missing) > 0 { + return fmt.Errorf("no repository definition for %s. Try 'helm repo add'", strings.Join(missing, ", ")) + } + return nil +} + +// UpdateRepositories updates all of the local repos to the latest. +func (m *Manager) UpdateRepositories() error { + rf, err := repo.LoadRepositoriesFile(m.HelmHome.RepositoryFile()) + if err != nil { + return err + } + repos := rf.Repositories + if len(repos) > 0 { + // This prints warnings straight to out. + m.parallelRepoUpdate(repos) + } + return nil +} + +func (m *Manager) parallelRepoUpdate(repos map[string]string) { + out := m.Out + fmt.Fprintln(out, "Hang tight while we grab the latest from your chart repositories...") + var wg sync.WaitGroup + for name, url := range repos { + wg.Add(1) + go func(n, u string) { + err := repo.DownloadIndexFile(n, u, m.HelmHome.CacheIndex(n)) + if err != nil { + updateErr := fmt.Sprintf("...Unable to get an update from the %q chart repository: %s", n, err) + fmt.Fprintln(out, updateErr) + } else { + fmt.Fprintf(out, "...Successfully got an update from the %q chart repository\n", n) + } + wg.Done() + }(name, url) + } + wg.Wait() + fmt.Fprintln(out, "Update Complete. Happy Helming!") +} + +// urlsAreEqual normalizes two URLs and then compares for equality. +func urlsAreEqual(a, b string) bool { + au, err := url.Parse(a) + if err != nil { + return a == b + } + bu, err := url.Parse(b) + if err != nil { + return false + } + return au.String() == bu.String() +} + +// findChartURL searches the cache of repo data for a chart that has the name and the repourl specified. +// +// In this current version, name is of the form 'foo-1.2.3'. This will change when +// the repository index stucture changes. +func findChartURL(name, repourl string, repos map[string]*repo.ChartRepository) (string, error) { + for _, cr := range repos { + if urlsAreEqual(repourl, cr.URL) { + for ename, entry := range cr.IndexFile.Entries { + if ename == name { + return entry.URL, nil + } + } + } + } + return "", fmt.Errorf("chart %s not found in %s", name, repourl) +} + +// loadChartRepositories reads the repositories.yaml, and then builds a map of +// ChartRepositories. +// +// The key is the local name (which is only present in the repositories.yaml). +func (m *Manager) loadChartRepositories() (map[string]*repo.ChartRepository, error) { + indices := map[string]*repo.ChartRepository{} + repoyaml := m.HelmHome.RepositoryFile() + + // Load repositories.yaml file + rf, err := repo.LoadRepositoriesFile(repoyaml) + if err != nil { + return indices, fmt.Errorf("failed to load %s: %s", repoyaml, err) + } + + // localName: chartRepo + for lname, url := range rf.Repositories { + cacheindex := m.HelmHome.CacheIndex(lname) + index, err := repo.LoadIndexFile(cacheindex) + if err != nil { + return indices, err + } + + cr := &repo.ChartRepository{ + URL: url, + IndexFile: index, + } + indices[lname] = cr + } + return indices, nil +} + +// writeLock writes a lockfile to disk +func writeLock(chartpath string, lock *chartutil.RequirementsLock) error { + data, err := yaml.Marshal(lock) + if err != nil { + return err + } + dest := filepath.Join(chartpath, "requirements.lock") + return ioutil.WriteFile(dest, data, 0755) +} diff --git a/cmd/helm/downloader/testdata/helm-test-key.pub b/cmd/helm/downloader/testdata/helm-test-key.pub new file mode 100644 index 0000000000000000000000000000000000000000..38714f25adaf701b08e11fd559a587074bbde0e4 GIT binary patch literal 1243 zcmV<11SI>J0SyFKmTjH^2mr{k15wFPQdpTAAEclY4oV^-7nTy5x$&xF;PC4il}o-Pk4@#$knlp#(|J0GE?qli_lr;7-o zyY8vBsN_GJe;#w<`JdR7riNL&RJlcS)FG+W=91;dYS6NZ2tY?kZ8Sw9{r=e4|L3E{ zRod|EPC!PWgW&pe&4qiqKQAijj;G~fyjcC^m%0p54Tn{h%YFKd0VC4n=#~SRc@BVd znj66*)Om%*SEQfX{1*Tb0RRECT}WkYZ6H)-b98BLXCNq4XlZjGYh`&Lb7*gMY-AvB zZftoVVr3w8b7f>8W^ZyJbY*jNX>MmOAVg0fPES-IR8mz_R4yqXJZNQXZ7p6uVakV zT7GGV$jaKjyjfI_a~N1!Hk?5C$0wa&4)R=i$v7t&ZMycW#RkavpF%A?>MTT2anNDzOQUm<++zEOykJ9-@&c2QXq3owqf7fek`=L@+7iF zv;IW2Q>Q&r+V@cWDF&hAUUsCKlDinerKgvJUJCl$5gjb7NhM{mBP%!M^mX-iS8xFf zuLB{@MDqvtZzF#Bxd9CXSC(y_0SExW>8~h=U8|!do4*OJj2u#!KDe3v+1T+aVzU5di=Ji2)x37y$|Z2?YXImTjH_8w>yn2@r%kznCAv zhhp0`2mpxVj5j%o&5i)?`r7iES|8dA@p2kk@+XS(tjBGN)6>tm^=gayCn`gTEC*K74Y~{I_PREk) z)PstIMx1RxB@cK8%Mey%;nVnKriAKUk2Ky?dBMG3uXItKL$3N(#3P^pQa*K$l)wUy F^>pMLK0g2e literal 0 HcmV?d00001 diff --git a/cmd/helm/downloader/testdata/helm-test-key.secret b/cmd/helm/downloader/testdata/helm-test-key.secret new file mode 100644 index 0000000000000000000000000000000000000000..a966aef93ed97d01d764f29940738df6df2d9d24 GIT binary patch literal 2545 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 N|NqlRq0#^l007yS;m800 literal 0 HcmV?d00001 diff --git a/cmd/helm/downloader/testdata/signtest-0.1.0.tgz.prov b/cmd/helm/downloader/testdata/signtest-0.1.0.tgz.prov new file mode 100755 index 000000000..94235399a --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest-0.1.0.tgz.prov @@ -0,0 +1,20 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 + +... +files: + signtest-0.1.0.tgz: sha256:dee72947753628425b82814516bdaa37aef49f25e8820dd2a6e15a33a007823b +-----BEGIN PGP SIGNATURE----- + +wsBcBAEBCgAQBQJXomNHCRCEO7+YH8GHYgAALywIAG1Me852Fpn1GYu8Q1GCcw4g +l2k7vOFchdDwDhdSVbkh4YyvTaIO3iE2Jtk1rxw+RIJiUr0eLO/rnIJuxZS8WKki +DR1LI9J1VD4dxN3uDETtWDWq7ScoPsRY5mJvYZXC8whrWEt/H2kfqmoA9LloRPWp +flOE0iktA4UciZOblTj6nAk3iDyjh/4HYL4a6tT0LjjKI7OTw4YyHfjHad1ywVCz +9dMUc1rPgTnl+fnRiSPSrlZIWKOt1mcQ4fVrU3nwtRUwTId2k8FtygL0G6M+Y6t0 +S6yaU7qfk9uTxkdkUF7Bf1X3ukxfe+cNBC32vf4m8LY4NkcYfSqK2fGtQsnVr6s= +=NyOM +-----END PGP SIGNATURE----- \ No newline at end of file diff --git a/cmd/helm/downloader/testdata/signtest/.helmignore b/cmd/helm/downloader/testdata/signtest/.helmignore new file mode 100644 index 000000000..435b756d8 --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest/.helmignore @@ -0,0 +1,5 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +.git diff --git a/cmd/helm/downloader/testdata/signtest/Chart.yaml b/cmd/helm/downloader/testdata/signtest/Chart.yaml new file mode 100755 index 000000000..90964b44a --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest/Chart.yaml @@ -0,0 +1,3 @@ +description: A Helm chart for Kubernetes +name: signtest +version: 0.1.0 diff --git a/cmd/helm/downloader/testdata/signtest/alpine/Chart.yaml b/cmd/helm/downloader/testdata/signtest/alpine/Chart.yaml new file mode 100755 index 000000000..6fbb27f18 --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest/alpine/Chart.yaml @@ -0,0 +1,6 @@ +description: Deploy a basic Alpine Linux pod +home: https://k8s.io/helm +name: alpine +sources: +- https://github.com/kubernetes/helm +version: 0.1.0 diff --git a/cmd/helm/downloader/testdata/signtest/alpine/README.md b/cmd/helm/downloader/testdata/signtest/alpine/README.md new file mode 100755 index 000000000..5bd595747 --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest/alpine/README.md @@ -0,0 +1,9 @@ +This example was generated using the command `helm create alpine`. + +The `templates/` directory contains a very simple pod resource with a +couple of parameters. + +The `values.yaml` file contains the default values for the +`alpine-pod.yaml` template. + +You can install this example using `helm install docs/examples/alpine`. diff --git a/cmd/helm/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml b/cmd/helm/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml new file mode 100755 index 000000000..08cf3c2c1 --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest/alpine/templates/alpine-pod.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{.Release.Name}}-{{.Chart.Name}} + labels: + heritage: {{.Release.Service}} + chartName: {{.Chart.Name}} + chartVersion: {{.Chart.Version | quote}} + annotations: + "helm.sh/created": "{{.Release.Time.Seconds}}" +spec: + restartPolicy: {{default "Never" .restart_policy}} + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/downloader/testdata/signtest/alpine/values.yaml b/cmd/helm/downloader/testdata/signtest/alpine/values.yaml new file mode 100755 index 000000000..bb6c06ae4 --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest/alpine/values.yaml @@ -0,0 +1,2 @@ +# The pod name +name: my-alpine diff --git a/cmd/helm/downloader/testdata/signtest/templates/pod.yaml b/cmd/helm/downloader/testdata/signtest/templates/pod.yaml new file mode 100644 index 000000000..9b00ccaf7 --- /dev/null +++ b/cmd/helm/downloader/testdata/signtest/templates/pod.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Pod +metadata: + name: signtest +spec: + restartPolicy: Never + containers: + - name: waiter + image: "alpine:3.3" + command: ["/bin/sleep","9000"] diff --git a/cmd/helm/downloader/testdata/signtest/values.yaml b/cmd/helm/downloader/testdata/signtest/values.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/cmd/helm/fetch.go b/cmd/helm/fetch.go index e174bcdac..22e3fccc5 100644 --- a/cmd/helm/fetch.go +++ b/cmd/helm/fetch.go @@ -17,21 +17,16 @@ limitations under the License. package main import ( - "bytes" - "errors" "fmt" "io" "io/ioutil" - "net/http" - "net/url" "os" "path/filepath" - "strings" "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/downloader" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/chartutil" - "k8s.io/helm/pkg/provenance" - "k8s.io/helm/pkg/repo" ) const fetchDesc = ` @@ -53,6 +48,7 @@ type fetchCmd struct { untar bool untardir string chartRef string + destdir string verify bool keyring string @@ -83,9 +79,10 @@ func newFetchCmd(out io.Writer) *cobra.Command { f := cmd.Flags() f.BoolVar(&fch.untar, "untar", false, "If set to true, will untar the chart after downloading it.") - f.StringVar(&fch.untardir, "untardir", ".", "If untar is specified, this flag specifies where to untar the chart.") + f.StringVar(&fch.untardir, "untardir", ".", "If untar is specified, this flag specifies the name of the directory into which the chart is expanded.") f.BoolVar(&fch.verify, "verify", false, "Verify the package against its signature.") f.StringVar(&fch.keyring, "keyring", defaultKeyring(), "The keyring containing public keys.") + f.StringVarP(&fch.destdir, "destination", "d", ".", "The location to write the chart. If this and tardir are specified, tardir is appended to this.") return cmd } @@ -96,162 +93,60 @@ func (f *fetchCmd) run() error { pname += ".tgz" } - return downloadAndSaveChart(pname, f.untar, f.untardir, f.verify, f.keyring) -} - -// downloadAndSaveChart fetches a chart over HTTP, and then (if verify is true) verifies it. -// -// If untar is true, it also unpacks the file into untardir. -func downloadAndSaveChart(pname string, untar bool, untardir string, verify bool, keyring string) error { - buf, err := downloadChart(pname, verify, keyring) - if err != nil { - return err + c := downloader.ChartDownloader{ + HelmHome: helmpath.HelmHome(homePath()), + Out: f.out, + Keyring: f.keyring, + Verify: downloader.VerifyNever, } - return saveChart(pname, buf, untar, untardir) -} -func downloadChart(pname string, verify bool, keyring string) (*bytes.Buffer, error) { - r, err := repo.LoadRepositoriesFile(repositoriesFile()) - if err != nil { - return bytes.NewBuffer(nil), err + if f.verify { + c.Verify = downloader.VerifyAlways } - // get download url - u, err := mapRepoArg(pname, r.Repositories) - if err != nil { - return bytes.NewBuffer(nil), err - } - - href := u.String() - buf, err := fetchChart(href) - if err != nil { - return buf, err - } - - if verify { - basename := filepath.Base(pname) - sigref := href + ".prov" - sig, err := fetchChart(sigref) + // If untar is set, we fetch to a tempdir, then untar and copy after + // verification. + dest := f.destdir + if f.untar { + var err error + dest, err = ioutil.TempDir("", "helm-") if err != nil { - return buf, fmt.Errorf("provenance data not downloaded from %s: %s", sigref, err) - } - if err := ioutil.WriteFile(basename+".prov", sig.Bytes(), 0755); err != nil { - return buf, fmt.Errorf("provenance data not saved: %s", err) - } - if err := verifyChart(basename, keyring); err != nil { - return buf, err + return fmt.Errorf("Failed to untar: %s", err) } + defer os.RemoveAll(dest) } - return buf, nil -} - -// verifyChart takes a path to a chart archive and a keyring, and verifies the chart. -// -// It assumes that a chart archive file is accompanied by a provenance file whose -// name is the archive file name plus the ".prov" extension. -func verifyChart(path string, keyring string) error { - // For now, error out if it's not a tar file. - if fi, err := os.Stat(path); err != nil { + v, err := c.DownloadTo(pname, dest) + if err != nil { return err - } else if fi.IsDir() { - return errors.New("unpacked charts cannot be verified") - } else if !isTar(path) { - return errors.New("chart must be a tgz file") } - provfile := path + ".prov" - if _, err := os.Stat(provfile); err != nil { - return fmt.Errorf("could not load provenance file %s: %s", provfile, err) + if f.verify { + fmt.Fprintf(f.out, "Verification: %v", v) } - sig, err := provenance.NewFromKeyring(keyring, "") - if err != nil { - return fmt.Errorf("failed to load keyring: %s", err) - } - ver, err := sig.Verify(path, provfile) - if flagDebug { - for name := range ver.SignedBy.Identities { - fmt.Printf("Signed by %q\n", name) + // After verification, untar the chart into the requested directory. + if f.untar { + ud := f.untardir + if !filepath.IsAbs(ud) { + ud = filepath.Join(f.destdir, ud) } + if fi, err := os.Stat(ud); err != nil { + if err := os.MkdirAll(ud, 0755); err != nil { + return fmt.Errorf("Failed to untar (mkdir): %s", err) + } + + } else if !fi.IsDir() { + return fmt.Errorf("Failed to untar: %s is not a directory", ud) + } + + from := filepath.Join(dest, filepath.Base(pname)) + return chartutil.ExpandFile(ud, from) } - return err + return nil } // defaultKeyring returns the expanded path to the default keyring. func defaultKeyring() string { return os.ExpandEnv("$HOME/.gnupg/pubring.gpg") } - -// isTar tests whether the given file is a tar file. -// -// Currently, this simply checks extension, since a subsequent function will -// untar the file and validate its binary format. -func isTar(filename string) bool { - return strings.ToLower(filepath.Ext(filename)) == ".tgz" -} - -// saveChart saves a chart locally. -func saveChart(name string, buf *bytes.Buffer, untar bool, untardir string) error { - if untar { - return chartutil.Expand(untardir, buf) - } - - p := strings.Split(name, "/") - return saveChartFile(p[len(p)-1], buf) -} - -// fetchChart retrieves a chart over HTTP. -func fetchChart(href string) (*bytes.Buffer, error) { - buf := bytes.NewBuffer(nil) - - resp, err := http.Get(href) - if err != nil { - return buf, err - } - if resp.StatusCode != 200 { - return buf, fmt.Errorf("Failed to fetch %s : %s", href, resp.Status) - } - - _, err = io.Copy(buf, resp.Body) - resp.Body.Close() - return buf, err -} - -// mapRepoArg figures out which format the argument is given, and creates a fetchable -// url from it. -func mapRepoArg(arg string, r map[string]string) (*url.URL, error) { - // See if it's already a full URL. - u, err := url.ParseRequestURI(arg) - if err == nil { - // If it has a scheme and host and path, it's a full URL - if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 { - return u, nil - } - return nil, fmt.Errorf("Invalid chart url format: %s", arg) - } - // See if it's of the form: repo/path_to_chart - p := strings.Split(arg, "/") - if len(p) > 1 { - if baseURL, ok := r[p[0]]; ok { - if !strings.HasSuffix(baseURL, "/") { - baseURL = baseURL + "/" - } - return url.ParseRequestURI(baseURL + strings.Join(p[1:], "/")) - } - return nil, fmt.Errorf("No such repo: %s", p[0]) - } - return nil, fmt.Errorf("Invalid chart url format: %s", arg) -} - -func saveChartFile(c string, r io.Reader) error { - // Grab the chart name that we'll use for the name of the file to download to. - out, err := os.Create(c) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, r) - return err -} diff --git a/cmd/helm/fetch_test.go b/cmd/helm/fetch_test.go index be548ee0c..3dd241a1f 100644 --- a/cmd/helm/fetch_test.go +++ b/cmd/helm/fetch_test.go @@ -17,49 +17,109 @@ limitations under the License. package main import ( - "fmt" - + "bytes" + "os" + "path/filepath" "testing" + + "k8s.io/helm/pkg/repo/repotest" ) -type testCase struct { - in string - expectedErr error - expectedOut string -} +func TestFetchCmd(t *testing.T) { + hh, err := tempHelmHome() + if err != nil { + t.Fatal(err) + } + old := homePath() + helmHome = hh + defer func() { + helmHome = old + os.RemoveAll(hh) + }() -var repos = map[string]string{ - "local": "http://localhost:8879/charts", - "someother": "http://storage.googleapis.com/mycharts", -} + // all flags will get "--home=TMDIR -d outdir" appended. + tests := []struct { + name string + chart string + flags []string + fail bool + failExpect string + expectFile string + expectDir bool + }{ + { + name: "Basic chart fetch", + chart: "test/signtest-0.1.0", + expectFile: "./signtest-0.1.0.tgz", + }, + { + name: "Fail fetching non-existent chart", + chart: "test/nosuchthing-0.1.0", + failExpect: "Failed to fetch", + fail: true, + }, + { + name: "Fetch and verify", + chart: "test/signtest-0.1.0", + flags: []string{"--verify", "--keyring", "testdata/helm-test-key.pub"}, + expectFile: "./signtest-0.1.0.tgz", + }, + { + name: "Fetch and fail verify", + chart: "test/reqtest-0.1.0", + flags: []string{"--verify", "--keyring", "testdata/helm-test-key.pub"}, + failExpect: "Failed to fetch provenance", + fail: true, + }, + { + name: "Fetch and untar", + chart: "test/signtest-0.1.0", + flags: []string{"--verify", "--keyring", "testdata/helm-test-key.pub", "--untar", "--untardir", "signtest"}, + expectFile: "./signtest", + expectDir: true, + }, + { + name: "Fetch, verify, untar", + chart: "test/signtest-0.1.0", + flags: []string{"--verify", "--keyring", "testdata/helm-test-key.pub", "--untar", "--untardir", "signtest"}, + expectFile: "./signtest", + expectDir: true, + }, + } -var testCases = []testCase{ - {"bad", fmt.Errorf("Invalid chart url format: bad"), ""}, - {"http://", fmt.Errorf("Invalid chart url format: http://"), ""}, - {"http://example.com", fmt.Errorf("Invalid chart url format: http://example.com"), ""}, - {"http://example.com/foo/bar", nil, "http://example.com/foo/bar"}, - {"local/nginx-2.0.0.tgz", nil, "http://localhost:8879/charts/nginx-2.0.0.tgz"}, - {"nonexistentrepo/nginx-2.0.0.tgz", fmt.Errorf("No such repo: nonexistentrepo"), ""}, -} + srv := repotest.NewServer(hh) + defer srv.Stop() -func testRunner(t *testing.T, tc testCase) { - u, err := mapRepoArg(tc.in, repos) - if (tc.expectedErr == nil && err != nil) || - (tc.expectedErr != nil && err == nil) || - (tc.expectedErr != nil && err != nil && tc.expectedErr.Error() != err.Error()) { - t.Errorf("Expected mapRepoArg to fail with input %s %v but got %v", tc.in, tc.expectedErr, err) + if _, err := srv.CopyCharts("testdata/testcharts/*.tgz*"); err != nil { + t.Fatal(err) } - if (u == nil && len(tc.expectedOut) != 0) || - (u != nil && len(tc.expectedOut) == 0) || - (u != nil && tc.expectedOut != u.String()) { - t.Errorf("Expected %s to map to fetch url %v but got %v", tc.in, tc.expectedOut, u) - } + t.Logf("HELM_HOME=%s", homePath()) -} + for _, tt := range tests { + outdir := filepath.Join(hh, "testout") + os.RemoveAll(outdir) + os.Mkdir(outdir, 0755) + + buf := bytes.NewBuffer(nil) + cmd := newFetchCmd(buf) + tt.flags = append(tt.flags, "-d", outdir) + cmd.ParseFlags(tt.flags) + if err := cmd.RunE(cmd, []string{tt.chart}); err != nil { + if tt.fail { + continue + } + t.Errorf("%q reported error: %s", tt.name, err) + continue + } -func TestMappings(t *testing.T) { - for _, tc := range testCases { - testRunner(t, tc) + ef := filepath.Join(outdir, tt.expectFile) + fi, err := os.Stat(ef) + if err != nil { + t.Errorf("%q: expected a file at %s. %s", tt.name, ef, err) + } + if fi.IsDir() != tt.expectDir { + t.Errorf("%q: expected directory=%t, but it's not.", tt.name, tt.expectDir) + } } } diff --git a/cmd/helm/helmpath/helmhome.go b/cmd/helm/helmpath/helmhome.go new file mode 100644 index 000000000..4751fb844 --- /dev/null +++ b/cmd/helm/helmpath/helmhome.go @@ -0,0 +1,61 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 helmpath + +import ( + "fmt" + "path/filepath" +) + +// HelmHome describes the location of a CLI configuration. +// +// This helper builds paths relative to a Helm Home directory. +type HelmHome string + +// String returns HelmHome as a string. +// +// Implements fmt.Stringer. +func (h HelmHome) String() string { + return string(h) +} + +// Repository returns the path to the local repository. +func (h HelmHome) Repository() string { + return filepath.Join(string(h), "repository") +} + +// RepositoryFile returns the path to the repositories.yaml file. +func (h HelmHome) RepositoryFile() string { + return filepath.Join(string(h), "repository/repositories.yaml") +} + +// Cache returns the path to the local cache. +func (h HelmHome) Cache() string { + return filepath.Join(string(h), "repository/cache") +} + +// CacheIndex returns the path to an index for the given named repository. +func (h HelmHome) CacheIndex(name string) string { + target := fmt.Sprintf("repository/cache/%s-index.yaml", name) + return filepath.Join(string(h), target) +} + +// LocalRepository returns the location to the local repo. +// +// The local repo is the one used by 'helm serve' +func (h HelmHome) LocalRepository() string { + return filepath.Join(string(h), "repository/local") +} diff --git a/cmd/helm/helmpath/helmhome_test.go b/cmd/helm/helmpath/helmhome_test.go new file mode 100644 index 000000000..3bfb176dd --- /dev/null +++ b/cmd/helm/helmpath/helmhome_test.go @@ -0,0 +1,36 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 helmpath + +import ( + "testing" +) + +func TestHelmHome(t *testing.T) { + hh := HelmHome("/r") + isEq := func(t *testing.T, a, b string) { + if a != b { + t.Errorf("Expected %q, got %q", a, b) + } + } + + isEq(t, hh.String(), "/r") + isEq(t, hh.Repository(), "/r/repository") + isEq(t, hh.RepositoryFile(), "/r/repository/repositories.yaml") + isEq(t, hh.LocalRepository(), "/r/repository/local") + isEq(t, hh.Cache(), "/r/repository/cache") + isEq(t, hh.CacheIndex("t"), "/r/repository/cache/t-index.yaml") +} diff --git a/cmd/helm/install.go b/cmd/helm/install.go index 3fab2c7fd..623f486d9 100644 --- a/cmd/helm/install.go +++ b/cmd/helm/install.go @@ -32,6 +32,8 @@ import ( "github.com/ghodss/yaml" "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/downloader" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/helm" "k8s.io/helm/pkg/proto/hapi/release" "k8s.io/helm/pkg/timeconv" @@ -286,7 +288,7 @@ func locateChartPath(name string, verify bool, keyring string) (string, error) { if fi.IsDir() { return "", errors.New("cannot verify a directory") } - if err := verifyChart(abs, keyring); err != nil { + if _, err := downloader.VerifyChart(abs, keyring); err != nil { return "", err } } @@ -306,7 +308,17 @@ func locateChartPath(name string, verify bool, keyring string) (string, error) { if filepath.Ext(name) != ".tgz" { name += ".tgz" } - if err := downloadAndSaveChart(name, false, ".", verify, keyring); err == nil { + + dl := downloader.ChartDownloader{ + HelmHome: helmpath.HelmHome(homePath()), + Out: os.Stdout, + Keyring: keyring, + } + if verify { + dl.Verify = downloader.VerifyAlways + } + + if _, err := dl.DownloadTo(name, "."); err == nil { lname, err := filepath.Abs(filepath.Base(name)) if err != nil { return lname, err diff --git a/cmd/helm/resolver/resolver.go b/cmd/helm/resolver/resolver.go index 34854b538..1a727685a 100644 --- a/cmd/helm/resolver/resolver.go +++ b/cmd/helm/resolver/resolver.go @@ -23,6 +23,7 @@ import ( "github.com/Masterminds/semver" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/chartutil" "k8s.io/helm/pkg/provenance" ) @@ -30,11 +31,11 @@ import ( // Resolver resolves dependencies from semantic version ranges to a particular version. type Resolver struct { chartpath string - helmhome string + helmhome helmpath.HelmHome } // New creates a new resolver for a given chart and a given helm home. -func New(chartpath string, helmhome string) *Resolver { +func New(chartpath string, helmhome helmpath.HelmHome) *Resolver { return &Resolver{ chartpath: chartpath, helmhome: helmhome, diff --git a/cmd/helm/verify.go b/cmd/helm/verify.go index 4e342daaf..07e1c9b77 100644 --- a/cmd/helm/verify.go +++ b/cmd/helm/verify.go @@ -20,6 +20,8 @@ import ( "io" "github.com/spf13/cobra" + + "k8s.io/helm/cmd/helm/downloader" ) const verifyDesc = ` @@ -63,5 +65,6 @@ func newVerifyCmd(out io.Writer) *cobra.Command { } func (v *verifyCmd) run() error { - return verifyChart(v.chartfile, v.keyring) + _, err := downloader.VerifyChart(v.chartfile, v.keyring) + return err } diff --git a/pkg/chartutil/expand.go b/pkg/chartutil/expand.go index 45bb9e474..30600cb61 100644 --- a/pkg/chartutil/expand.go +++ b/pkg/chartutil/expand.go @@ -71,3 +71,13 @@ func Expand(dir string, r io.Reader) error { } return nil } + +// ExpandFile expands the src file into the dest directroy. +func ExpandFile(dest, src string) error { + h, err := os.Open(src) + if err != nil { + return err + } + defer h.Close() + return Expand(dest, h) +} diff --git a/pkg/chartutil/requirements.go b/pkg/chartutil/requirements.go index 4a452f64c..8871edbb8 100644 --- a/pkg/chartutil/requirements.go +++ b/pkg/chartutil/requirements.go @@ -24,6 +24,18 @@ import ( "k8s.io/helm/pkg/proto/hapi/chart" ) +const ( + requirementsName = "requirements.yaml" + lockfileName = "requirements.lock" +) + +var ( + // ErrRequirementsNotFound indicates that a requirements.yaml is not found. + ErrRequirementsNotFound = errors.New(requirementsName + " not found") + // ErrLockfileNotFound indicates that a requirements.lock is not found. + ErrLockfileNotFound = errors.New(lockfileName + " not found") +) + // Dependency describes a chart upon which another chart depends. // // Dependencies can be used to express developer intent, or to capture the state @@ -65,14 +77,11 @@ type RequirementsLock struct { Dependencies []*Dependency `json:"dependencies"` } -// ErrRequirementsNotFound indicates that a requirements.yaml is not found. -var ErrRequirementsNotFound = errors.New("requirements.yaml not found") - // LoadRequirements loads a requirements file from an in-memory chart. func LoadRequirements(c *chart.Chart) (*Requirements, error) { var data []byte for _, f := range c.Files { - if f.TypeUrl == "requirements.yaml" { + if f.TypeUrl == requirementsName { data = f.Value } } @@ -82,3 +91,18 @@ func LoadRequirements(c *chart.Chart) (*Requirements, error) { r := &Requirements{} return r, yaml.Unmarshal(data, r) } + +// LoadRequirementsLock loads a requirements lock file. +func LoadRequirementsLock(c *chart.Chart) (*RequirementsLock, error) { + var data []byte + for _, f := range c.Files { + if f.TypeUrl == lockfileName { + data = f.Value + } + } + if len(data) == 0 { + return nil, ErrLockfileNotFound + } + r := &RequirementsLock{} + return r, yaml.Unmarshal(data, r) +} diff --git a/pkg/repo/index.go b/pkg/repo/index.go index b7aade1dc..cc2c17aee 100644 --- a/pkg/repo/index.go +++ b/pkg/repo/index.go @@ -93,7 +93,7 @@ func IndexDirectory(dir, baseURL string) (*IndexFile, error) { return index, nil } -// DownloadIndexFile uses +// DownloadIndexFile fetches the index from a repository. func DownloadIndexFile(repoName, url, indexFilePath string) error { var indexURL string diff --git a/pkg/repo/repotest/doc.go b/pkg/repo/repotest/doc.go new file mode 100644 index 000000000..34d4bc6b0 --- /dev/null +++ b/pkg/repo/repotest/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 repotest provides utilities for testing. + +The server provides a testing server that can be set up and torn down quickly. +*/ +package repotest diff --git a/pkg/repo/repotest/server.go b/pkg/repo/repotest/server.go new file mode 100644 index 000000000..eb737290c --- /dev/null +++ b/pkg/repo/repotest/server.go @@ -0,0 +1,130 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 repotest + +import ( + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + + "github.com/ghodss/yaml" + + "k8s.io/helm/pkg/repo" +) + +// NewServer creates a repository server for testing. +// +// docroot should be a temp dir managed by the caller. +// +// This will start the server, serving files off of the docroot. +// +// Use CopyCharts to move charts into the repository and then index them +// for service. +func NewServer(docroot string) *Server { + root, err := filepath.Abs(docroot) + if err != nil { + panic(err) + } + srv := &Server{ + docroot: root, + } + srv.start() + // Add the testing repository as the only repo. + if err := setTestingRepository(docroot, "test", srv.URL()); err != nil { + panic(err) + } + return srv +} + +// Server is an implementaiton of a repository server for testing. +type Server struct { + docroot string + srv *httptest.Server +} + +// Root gets the docroot for the server. +func (s *Server) Root() string { + return s.docroot +} + +// CopyCharts takes a glob expression and copies those charts to the server root. +func (s *Server) CopyCharts(origin string) ([]string, error) { + files, err := filepath.Glob(origin) + if err != nil { + return []string{}, err + } + copied := make([]string, len(files)) + for i, f := range files { + base := filepath.Base(f) + newname := filepath.Join(s.docroot, base) + data, err := ioutil.ReadFile(f) + if err != nil { + return []string{}, err + } + if err := ioutil.WriteFile(newname, data, 0755); err != nil { + return []string{}, err + } + copied[i] = newname + } + + // generate the index + index, err := repo.IndexDirectory(s.docroot, s.URL()) + if err != nil { + return copied, err + } + + d, err := yaml.Marshal(index.Entries) + if err != nil { + return copied, err + } + + ifile := filepath.Join(s.docroot, "index.yaml") + err = ioutil.WriteFile(ifile, d, 0755) + return copied, err +} + +func (s *Server) start() { + s.srv = httptest.NewServer(http.FileServer(http.Dir(s.docroot))) +} + +// Stop stops the server and closes all connections. +// +// It should be called explicitly. +func (s *Server) Stop() { + s.srv.Close() +} + +// URL returns the URL of the server. +// +// Example: +// http://localhost:1776 +func (s *Server) URL() string { + return s.srv.URL +} + +// setTestingRepository sets up a testing repository.yaml with only the given name/URL. +func setTestingRepository(helmhome, name, url string) error { + // Oddly, there is no repo.Save function for this. + data, err := yaml.Marshal(&map[string]string{name: url}) + if err != nil { + return err + } + os.MkdirAll(filepath.Join(helmhome, "repository", name), 0755) + dest := filepath.Join(helmhome, "repository/repositories.yaml") + return ioutil.WriteFile(dest, data, 0666) +} diff --git a/pkg/repo/repotest/server_test.go b/pkg/repo/repotest/server_test.go new file mode 100644 index 000000000..8437ed512 --- /dev/null +++ b/pkg/repo/repotest/server_test.go @@ -0,0 +1,107 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. +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 repotest + +import ( + "io/ioutil" + "net/http" + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v2" + + "k8s.io/helm/pkg/repo" +) + +// Young'n, in these here parts, we test our tests. + +func TestServer(t *testing.T) { + docroot, err := ioutil.TempDir("", "helm-repotest-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(docroot) + + srv := NewServer(docroot) + defer srv.Stop() + + c, err := srv.CopyCharts("testdata/*.tgz") + if err != nil { + // Some versions of Go don't correctly fire defer on Fatal. + t.Error(err) + return + } + + if len(c) != 1 { + t.Errorf("Unexpected chart count: %d", len(c)) + } + + if filepath.Base(c[0]) != "examplechart-0.1.0.tgz" { + t.Errorf("Unexpected chart: %s", c[0]) + } + + res, err := http.Get(srv.URL() + "/examplechart-0.1.0.tgz") + if err != nil { + t.Error(err) + return + } + + if res.ContentLength < 500 { + t.Errorf("Expected at least 500 bytes of data, got %d", res.ContentLength) + } + + res, err = http.Get(srv.URL() + "/index.yaml") + if err != nil { + t.Error(err) + return + } + + data, err := ioutil.ReadAll(res.Body) + res.Body.Close() + if err != nil { + t.Error(err) + return + } + + var m map[string]*repo.ChartRef + if err := yaml.Unmarshal(data, &m); err != nil { + t.Error(err) + return + } + + if l := len(m); l != 1 { + t.Errorf("Expected 1 entry, got %d", l) + return + } + + expect := "examplechart-0.1.0" + if m[expect].Name != "examplechart-0.1.0" { + t.Errorf("Unexpected chart: %s", m[expect].Name) + } + if m[expect].Chartfile.Name != "examplechart" { + t.Errorf("Unexpected chart: %s", m[expect].Chartfile.Name) + } + + res, err = http.Get(srv.URL() + "/index.yaml-nosuchthing") + if err != nil { + t.Error(err) + return + } + if res.StatusCode != 404 { + t.Errorf("Expected 404, got %d", res.StatusCode) + } +} diff --git a/pkg/repo/repotest/testdata/examplechart-0.1.0.tgz b/pkg/repo/repotest/testdata/examplechart-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..aec86c64002af0b6d3d114e15de120306ba24baa GIT binary patch literal 558 zcmV+}0@3{+iwG0|32ul0|0w_~VMtOiV@ORlOnEsqVl!4SWK%V1T2nbTPgYhoO;>Dc zVQyr3R8em|NM&qo0PL1ui_|<6#`n6P;=Ihwt7zKpS_bxRnGqBfg^>lXByG>ManmH^ z&&-Y&es)h%R%b_KL5GorJ`AU6I5|n^`8^EY^1(=KdTxEbh>`91AkU7ef;6wH^ducV zi?Y1*h5`YMzDK==6Ha2e1Y-2fiq|Gb;=d}ZU-*A9@qZG{;6p^& zs>JH}?P1%af;tG<3e^$4%?XVHY%~u!$1YD z7b|GVV=~qWpQkt;KV$V*o2Pg;(RX