From 4f09b05613cd1c3d7f0fe1fda0b55390a24e9449 Mon Sep 17 00:00:00 2001 From: Matt Butcher Date: Wed, 28 Sep 2016 18:51:12 -0600 Subject: [PATCH] feat(helm): implement new index format This implements a new index file format for repository indices. It also implements a new format for requirements.yaml. Breaking change: This will break all previous versions of Helm, and will impact helm search, repo, serve, and fetch functions. Closes #1197 --- Makefile | 2 +- cmd/helm/dependency_build_test.go | 8 +- cmd/helm/dependency_update_test.go | 5 +- cmd/helm/downloader/chart_downloader.go | 36 ++- cmd/helm/downloader/chart_downloader_test.go | 2 +- cmd/helm/downloader/manager.go | 35 +-- .../cache/kubernetes-charts-index.yaml | 84 ++++--- .../helmhome/repository/repositories.yaml | 5 +- cmd/helm/fetch_test.go | 2 +- cmd/helm/helm_test.go | 57 ++++- cmd/helm/helmpath/helmhome.go | 7 +- cmd/helm/init.go | 56 +++-- cmd/helm/init_test.go | 35 ++- cmd/helm/package.go | 10 +- cmd/helm/package_test.go | 13 + cmd/helm/repo_add.go | 45 ++-- cmd/helm/repo_add_test.go | 65 +++-- cmd/helm/repo_list.go | 13 +- cmd/helm/repo_remove.go | 43 ++-- cmd/helm/repo_remove_test.go | 37 +-- cmd/helm/repo_update.go | 27 ++- cmd/helm/repo_update_test.go | 67 ++--- cmd/helm/search.go | 5 +- cmd/helm/search/search.go | 27 ++- cmd/helm/search/search_test.go | 90 ++++--- cmd/helm/search_test.go | 6 +- cmd/helm/serve.go | 21 +- .../repository/cache/testing-index.yaml | 100 ++++---- .../helmhome/repository/repositories.yaml | 7 +- cmd/helm/testdata/repositories.yaml | 8 +- cmd/helm/testdata/testserver/index.yaml | 1 + .../testserver/repository/repositories.yaml | 6 + docs/chart_repository.md | 59 +++-- pkg/repo/doc.go | 93 +++++++ pkg/repo/index.go | 165 +++++++++---- pkg/repo/index_test.go | 212 ++++++++++++---- pkg/repo/local.go | 21 +- pkg/repo/repo.go | 178 ++++++++------ pkg/repo/repo_test.go | 229 +++++++++++++----- pkg/repo/repotest/server.go | 26 +- pkg/repo/repotest/server_test.go | 17 +- pkg/repo/testdata/local-index.yaml | 30 ++- pkg/repo/testdata/old-repositories.yaml | 3 + pkg/repo/testdata/repositories.yaml | 11 +- 44 files changed, 1296 insertions(+), 673 deletions(-) create mode 100644 cmd/helm/testdata/testserver/index.yaml create mode 100644 cmd/helm/testdata/testserver/repository/repositories.yaml create mode 100644 pkg/repo/doc.go create mode 100644 pkg/repo/testdata/old-repositories.yaml diff --git a/Makefile b/Makefile index 705d2dbe8..9599b80a7 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ test: test-unit .PHONY: test-unit test-unit: - $(GO) test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS) + HELM_HOME=/no/such/dir $(GO) test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS) .PHONY: test-style test-style: diff --git a/cmd/helm/dependency_build_test.go b/cmd/helm/dependency_build_test.go index d382d68bb..7038e9aa1 100644 --- a/cmd/helm/dependency_build_test.go +++ b/cmd/helm/dependency_build_test.go @@ -30,7 +30,7 @@ import ( func TestDependencyBuildCmd(t *testing.T) { oldhome := helmHome - hh, err := tempHelmHome() + hh, err := tempHelmHome(t) if err != nil { t.Fatal(err) } @@ -108,8 +108,12 @@ func TestDependencyBuildCmd(t *testing.T) { t.Fatal(err) } - if h := i.Entries["reqtest-0.1.0"].Digest; h != hash { + reqver := i.Entries["reqtest"][0] + if h := reqver.Digest; h != hash { t.Errorf("Failed hash match: expected %s, got %s", hash, h) } + if v := reqver.Version; v != "0.1.0" { + t.Errorf("mismatched versions. Expected %q, got %q", "0.1.0", v) + } } diff --git a/cmd/helm/dependency_update_test.go b/cmd/helm/dependency_update_test.go index 53df11eca..becbef9a3 100644 --- a/cmd/helm/dependency_update_test.go +++ b/cmd/helm/dependency_update_test.go @@ -36,7 +36,7 @@ import ( func TestDependencyUpdateCmd(t *testing.T) { // Set up a testing helm home oldhome := helmHome - hh, err := tempHelmHome() + hh, err := tempHelmHome(t) if err != nil { t.Fatal(err) } @@ -90,7 +90,8 @@ func TestDependencyUpdateCmd(t *testing.T) { t.Fatal(err) } - if h := i.Entries["reqtest-0.1.0"].Digest; h != hash { + reqver := i.Entries["reqtest"][0] + if h := reqver.Digest; h != hash { t.Errorf("Failed hash match: expected %s, got %s", hash, h) } diff --git a/cmd/helm/downloader/chart_downloader.go b/cmd/helm/downloader/chart_downloader.go index 5019739d1..db8db771d 100644 --- a/cmd/helm/downloader/chart_downloader.go +++ b/cmd/helm/downloader/chart_downloader.go @@ -69,7 +69,7 @@ type ChartDownloader struct { // For VerifyNever and VerifyIfPossible, the Verification may be empty. func (c *ChartDownloader) DownloadTo(ref string, dest string) (*provenance.Verification, error) { // resolve URL - u, err := c.ResolveChartRef(ref) + u, err := c.ResolveChartVersion(ref) if err != nil { return nil, err } @@ -111,10 +111,10 @@ func (c *ChartDownloader) DownloadTo(ref string, dest string) (*provenance.Verif return ver, nil } -// ResolveChartRef resolves a chart reference to a URL. +// ResolveChartVersion 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) { +func (c *ChartDownloader) ResolveChartVersion(ref string) (*url.URL, error) { // See if it's already a full URL. u, err := url.ParseRequestURI(ref) if err == nil { @@ -122,7 +122,7 @@ func (c *ChartDownloader) ResolveChartRef(ref string) (*url.URL, error) { if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 { return u, nil } - return u, fmt.Errorf("Invalid chart url format: %s", ref) + return u, fmt.Errorf("invalid chart url format: %s", ref) } r, err := repo.LoadRepositoriesFile(c.HelmHome.RepositoryFile()) @@ -133,15 +133,29 @@ func (c *ChartDownloader) ResolveChartRef(ref string) (*url.URL, error) { // 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:], "/")) + rf, err := findRepoEntry(p[0], r.Repositories) + if err != nil { + return u, err + } + if rf.URL == "" { + return u, fmt.Errorf("no URL found for repository %q", p[0]) + } + baseURL := rf.URL + if !strings.HasSuffix(baseURL, "/") { + baseURL = baseURL + "/" + } + return url.ParseRequestURI(baseURL + strings.Join(p[1:], "/")) + } + return u, fmt.Errorf("invalid chart url format: %s", ref) +} + +func findRepoEntry(name string, repos []*repo.Entry) (*repo.Entry, error) { + for _, re := range repos { + if re.Name == name { + return re, nil } - return u, fmt.Errorf("No such repo: %s", p[0]) } - return u, fmt.Errorf("Invalid chart url format: %s", ref) + return nil, fmt.Errorf("no repo named %q", name) } // VerifyChart takes a path to a chart archive and a keyring, and verifies the chart. diff --git a/cmd/helm/downloader/chart_downloader_test.go b/cmd/helm/downloader/chart_downloader_test.go index 652776a51..c8bbfa8e6 100644 --- a/cmd/helm/downloader/chart_downloader_test.go +++ b/cmd/helm/downloader/chart_downloader_test.go @@ -47,7 +47,7 @@ func TestResolveChartRef(t *testing.T) { } for _, tt := range tests { - u, err := c.ResolveChartRef(tt.ref) + u, err := c.ResolveChartVersion(tt.ref) if err != nil { if tt.fail { continue diff --git a/cmd/helm/downloader/manager.go b/cmd/helm/downloader/manager.go index 204ea240d..17ef1efd2 100644 --- a/cmd/helm/downloader/manager.go +++ b/cmd/helm/downloader/manager.go @@ -175,8 +175,7 @@ func (m *Manager) downloadAll(deps []*chartutil.Dependency) error { 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) + churl, err := findChartURL(dep.Name, dep.Repository, repos) if err != nil { fmt.Fprintf(m.Out, "WARNING: %s (skipped)", err) continue @@ -207,7 +206,7 @@ func (m *Manager) hasAllRepos(deps []*chartutil.Dependency) error { found = true } else { for _, repo := range repos { - if urlsAreEqual(repo, dd.Repository) { + if urlsAreEqual(repo.URL, dd.Repository) { found = true } } @@ -236,25 +235,23 @@ func (m *Manager) UpdateRepositories() error { return nil } -func (m *Manager) parallelRepoUpdate(repos map[string]string) { +func (m *Manager) parallelRepoUpdate(repos []*repo.Entry) { 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 { + for _, re := 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) + if err := repo.DownloadIndexFile(n, u, m.HelmHome.CacheIndex(n)); err != nil { + fmt.Fprintf(out, "...Unable to get an update from the %q chart repository (%s):\n\t%s\n", n, u, err) } else { fmt.Fprintf(out, "...Successfully got an update from the %q chart repository\n", n) } wg.Done() - }(name, url) + }(re.Name, re.URL) } wg.Wait() - fmt.Fprintln(out, "Update Complete. Happy Helming!") + fmt.Fprintln(out, "Update Complete. ⎈Happy Helming!⎈") } // urlsAreEqual normalizes two URLs and then compares for equality. @@ -280,7 +277,15 @@ func findChartURL(name, repourl string, repos map[string]*repo.ChartRepository) if urlsAreEqual(repourl, cr.URL) { for ename, entry := range cr.IndexFile.Entries { if ename == name { - return entry.URL, nil + for _, verEntry := range entry { + if len(verEntry.URLs) == 0 { + // Not totally sure what to do here. Returning an + // error is the strictest option. Skipping it might + // be preferable. + return "", fmt.Errorf("chart %q has no download URL", name) + } + return verEntry.URLs[0], nil + } } } } @@ -302,8 +307,8 @@ func (m *Manager) loadChartRepositories() (map[string]*repo.ChartRepository, err return indices, fmt.Errorf("failed to load %s: %s", repoyaml, err) } - // localName: chartRepo - for lname, url := range rf.Repositories { + for _, re := range rf.Repositories { + lname := re.Name cacheindex := m.HelmHome.CacheIndex(lname) index, err := repo.LoadIndexFile(cacheindex) if err != nil { @@ -311,7 +316,7 @@ func (m *Manager) loadChartRepositories() (map[string]*repo.ChartRepository, err } cr := &repo.ChartRepository{ - URL: url, + URL: re.URL, IndexFile: index, } indices[lname] = cr diff --git a/cmd/helm/downloader/testdata/helmhome/repository/cache/kubernetes-charts-index.yaml b/cmd/helm/downloader/testdata/helmhome/repository/cache/kubernetes-charts-index.yaml index 6b27d1b84..26ce97423 100644 --- a/cmd/helm/downloader/testdata/helmhome/repository/cache/kubernetes-charts-index.yaml +++ b/cmd/helm/downloader/testdata/helmhome/repository/cache/kubernetes-charts-index.yaml @@ -1,38 +1,46 @@ -alpine-0.1.0: - name: alpine - url: http://storage.googleapis.com/kubernetes-charts/alpine-0.1.0.tgz - created: 2016-09-06 21:58:44.211261566 +0000 UTC - checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d - chartfile: - name: alpine - home: https://k8s.io/helm - sources: - - https://github.com/kubernetes/helm - version: 0.1.0 - description: Deploy a basic Alpine Linux pod - keywords: [] - maintainers: [] - engine: "" - icon: "" -mariadb-0.3.0: - name: mariadb - url: http://storage.googleapis.com/kubernetes-charts/mariadb-0.3.0.tgz - created: 2016-09-06 21:58:44.211870222 +0000 UTC - checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56 - chartfile: - name: mariadb - home: https://mariadb.org - sources: - - https://github.com/bitnami/bitnami-docker-mariadb - version: 0.3.0 - description: Chart for MariaDB - keywords: - - mariadb - - mysql - - database - - sql - maintainers: - - name: Bitnami - email: containers@bitnami.com - engine: gotpl - icon: "" +apiVersion: v1 +entries: + alpine: + - name: alpine + url: http://storage.googleapis.com/kubernetes-charts/alpine-0.1.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + home: https://k8s.io/helm + sources: + - https://github.com/kubernetes/helm + version: 0.1.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + engine: "" + icon: "" + - name: alpine + url: http://storage.googleapis.com/kubernetes-charts/alpine-0.2.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + home: https://k8s.io/helm + sources: + - https://github.com/kubernetes/helm + version: 0.2.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + engine: "" + icon: "" + mariadb: + - name: mariadb + url: http://storage.googleapis.com/kubernetes-charts/mariadb-0.3.0.tgz + checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56 + home: https://mariadb.org + sources: + - https://github.com/bitnami/bitnami-docker-mariadb + version: 0.3.0 + description: Chart for MariaDB + keywords: + - mariadb + - mysql + - database + - sql + maintainers: + - name: Bitnami + email: containers@bitnami.com + engine: gotpl + icon: "" diff --git a/cmd/helm/downloader/testdata/helmhome/repository/repositories.yaml b/cmd/helm/downloader/testdata/helmhome/repository/repositories.yaml index 29cb2d2bf..10d83457e 100644 --- a/cmd/helm/downloader/testdata/helmhome/repository/repositories.yaml +++ b/cmd/helm/downloader/testdata/helmhome/repository/repositories.yaml @@ -1 +1,4 @@ -testing: "http://example.com" +apiVersion: v1 +repositories: + - name: testing + url: "http://example.com" diff --git a/cmd/helm/fetch_test.go b/cmd/helm/fetch_test.go index 3dd241a1f..b24d255e7 100644 --- a/cmd/helm/fetch_test.go +++ b/cmd/helm/fetch_test.go @@ -26,7 +26,7 @@ import ( ) func TestFetchCmd(t *testing.T) { - hh, err := tempHelmHome() + hh, err := tempHelmHome(t) if err != nil { t.Fatal(err) } diff --git a/cmd/helm/helm_test.go b/cmd/helm/helm_test.go index ef99484ba..94dd57122 100644 --- a/cmd/helm/helm_test.go +++ b/cmd/helm/helm_test.go @@ -22,17 +22,20 @@ import ( "io" "io/ioutil" "math/rand" + "os" "regexp" "testing" "github.com/golang/protobuf/ptypes/timestamp" "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/helm" "k8s.io/helm/pkg/proto/hapi/chart" "k8s.io/helm/pkg/proto/hapi/release" rls "k8s.io/helm/pkg/proto/hapi/services" "k8s.io/helm/pkg/proto/hapi/version" + "k8s.io/helm/pkg/repo" ) var mockHookTemplate = `apiVersion: v1 @@ -211,11 +214,11 @@ type releaseCase struct { resp *release.Release } -// tmpHelmHome sets up a Helm Home in a temp dir. +// tempHelmHome 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) { +func tempHelmHome(t *testing.T) (string, error) { oldhome := helmHome dir, err := ioutil.TempDir("", "helm_home-") if err != nil { @@ -223,9 +226,57 @@ func tempHelmHome() (string, error) { } helmHome = dir - if err := ensureHome(); err != nil { + if err := ensureTestHome(helmpath.Home(helmHome), t); err != nil { return "n/", err } helmHome = oldhome return dir, nil } + +// ensureTestHome creates a home directory like ensureHome, but without remote references. +// +// t is used only for logging. +func ensureTestHome(home helmpath.Home, t *testing.T) error { + configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository()} + for _, p := range configDirectories { + if fi, err := os.Stat(p); err != nil { + if err := os.MkdirAll(p, 0755); err != nil { + return fmt.Errorf("Could not create %s: %s", p, err) + } + } else if !fi.IsDir() { + return fmt.Errorf("%s must be a directory", p) + } + } + + repoFile := home.RepositoryFile() + if fi, err := os.Stat(repoFile); err != nil { + rf := repo.NewRepoFile() + if err := rf.WriteFile(repoFile, 0644); err != nil { + return err + } + } else if fi.IsDir() { + return fmt.Errorf("%s must be a file, not a directory", repoFile) + } + if r, err := repo.LoadRepositoriesFile(repoFile); err == repo.ErrRepoOutOfDate { + t.Log("Updating repository file format...") + if err := r.WriteFile(repoFile, 0644); err != nil { + return err + } + } + + localRepoIndexFile := home.LocalRepository(localRepoIndexFilePath) + if fi, err := os.Stat(localRepoIndexFile); err != nil { + i := repo.NewIndexFile() + if err := i.WriteFile(localRepoIndexFile, 0644); err != nil { + return err + } + + //TODO: take this out and replace with helm update functionality + os.Symlink(localRepoIndexFile, cacheDirectory("local-index.yaml")) + } else if fi.IsDir() { + return fmt.Errorf("%s must be a file, not a directory", localRepoIndexFile) + } + + t.Logf("$HELM_HOME has been configured at %s.\n", helmHome) + return nil +} diff --git a/cmd/helm/helmpath/helmhome.go b/cmd/helm/helmpath/helmhome.go index d2749ef2f..798ab3d5f 100644 --- a/cmd/helm/helmpath/helmhome.go +++ b/cmd/helm/helmpath/helmhome.go @@ -56,6 +56,9 @@ func (h Home) CacheIndex(name string) string { // LocalRepository returns the location to the local repo. // // The local repo is the one used by 'helm serve' -func (h Home) LocalRepository() string { - return filepath.Join(string(h), "repository/local") +// +// If additional path elements are passed, they are appended to the returned path. +func (h Home) LocalRepository(paths ...string) string { + frag := append([]string{string(h), "repository/local"}, paths...) + return filepath.Join(frag...) } diff --git a/cmd/helm/init.go b/cmd/helm/init.go index d8f807691..c0e091041 100644 --- a/cmd/helm/init.go +++ b/cmd/helm/init.go @@ -25,7 +25,9 @@ import ( "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/cmd/helm/installer" + "k8s.io/helm/pkg/repo" ) const initDesc = ` @@ -33,15 +35,18 @@ This command installs Tiller (the helm server side component) onto your Kubernetes Cluster and sets up local configuration in $HELM_HOME (default: ~/.helm/) ` -var ( - defaultRepository = "stable" - defaultRepositoryURL = "http://storage.googleapis.com/kubernetes-charts" +const ( + stableRepository = "stable" + localRepository = "local" + stableRepositoryURL = "http://storage.googleapis.com/kubernetes-charts" + localRepositoryURL = "http://localhost:8879/charts" ) type initCmd struct { image string clientOnly bool out io.Writer + home helmpath.Home } func newInitCmd(out io.Writer) *cobra.Command { @@ -56,6 +61,7 @@ func newInitCmd(out io.Writer) *cobra.Command { if len(args) != 0 { return errors.New("This command does not accept arguments") } + i.home = helmpath.Home(homePath()) return i.run() }, } @@ -66,7 +72,7 @@ func newInitCmd(out io.Writer) *cobra.Command { // runInit initializes local config and installs tiller to Kubernetes Cluster func (i *initCmd) run() error { - if err := ensureHome(); err != nil { + if err := ensureHome(i.home, i.out); err != nil { return err } @@ -101,12 +107,11 @@ func requireHome() error { // ensureHome checks to see if $HELM_HOME exists // // If $HELM_HOME does not exist, this function will create it. -func ensureHome() error { - configDirectories := []string{homePath(), repositoryDirectory(), cacheDirectory(), localRepoDirectory()} - +func ensureHome(home helmpath.Home, out io.Writer) error { + configDirectories := []string{home.String(), home.Repository(), home.Cache(), home.LocalRepository()} for _, p := range configDirectories { if fi, err := os.Stat(p); err != nil { - fmt.Printf("Creating %s \n", p) + fmt.Fprintf(out, "Creating %s \n", p) if err := os.MkdirAll(p, 0755); err != nil { return fmt.Errorf("Could not create %s: %s", p, err) } @@ -115,24 +120,41 @@ func ensureHome() error { } } - repoFile := repositoriesFile() + repoFile := home.RepositoryFile() if fi, err := os.Stat(repoFile); err != nil { - fmt.Printf("Creating %s \n", repoFile) - if _, err := os.Create(repoFile); err != nil { + fmt.Fprintf(out, "Creating %s \n", repoFile) + r := repo.NewRepoFile() + r.Add(&repo.Entry{ + Name: stableRepository, + URL: stableRepositoryURL, + Cache: "stable-index.yaml", + }, &repo.Entry{ + Name: localRepository, + URL: localRepositoryURL, + Cache: "local-index.yaml", + }) + if err := r.WriteFile(repoFile, 0644); err != nil { return err } - if err := addRepository(defaultRepository, defaultRepositoryURL); err != nil { - return err + cif := home.CacheIndex(stableRepository) + if err := repo.DownloadIndexFile(stableRepository, stableRepositoryURL, cif); err != nil { + fmt.Fprintf(out, "WARNING: Failed to download %s: %s (run 'helm update')\n", stableRepository, err) } } else if fi.IsDir() { return fmt.Errorf("%s must be a file, not a directory", repoFile) } + if r, err := repo.LoadRepositoriesFile(repoFile); err == repo.ErrRepoOutOfDate { + fmt.Fprintln(out, "Updating repository file format...") + if err := r.WriteFile(repoFile, 0644); err != nil { + return err + } + } localRepoIndexFile := localRepoDirectory(localRepoIndexFilePath) if fi, err := os.Stat(localRepoIndexFile); err != nil { - fmt.Printf("Creating %s \n", localRepoIndexFile) - _, err := os.Create(localRepoIndexFile) - if err != nil { + fmt.Fprintf(out, "Creating %s \n", localRepoIndexFile) + i := repo.NewIndexFile() + if err := i.WriteFile(localRepoIndexFile, 0644); err != nil { return err } @@ -142,6 +164,6 @@ func ensureHome() error { return fmt.Errorf("%s must be a file, not a directory", localRepoIndexFile) } - fmt.Printf("$HELM_HOME has been configured at %s.\n", helmHome) + fmt.Fprintf(out, "$HELM_HOME has been configured at %s.\n", helmHome) return nil } diff --git a/cmd/helm/init_test.go b/cmd/helm/init_test.go index df20e3786..171ef1ac5 100644 --- a/cmd/helm/init_test.go +++ b/cmd/helm/init_test.go @@ -17,28 +17,29 @@ limitations under the License. package main import ( - "fmt" + "bytes" "io/ioutil" - "net/http" - "net/http/httptest" "os" "testing" + + "k8s.io/helm/cmd/helm/helmpath" ) 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, "") - })) - defaultRepositoryURL = ts.URL + home, err := ioutil.TempDir("", "helm_home") + if err != nil { + t.Fatal(err) + } + defer os.Remove(home) - home := createTmpHome() + b := bytes.NewBuffer(nil) + hh := helmpath.Home(home) helmHome = home - if err := ensureHome(); err != nil { - t.Errorf("%s", err) + if err := ensureHome(hh, b); err != nil { + t.Error(err) } - expectedDirs := []string{homePath(), repositoryDirectory(), cacheDirectory(), localRepoDirectory()} + expectedDirs := []string{hh.String(), hh.Repository(), hh.Cache(), hh.LocalRepository()} for _, dir := range expectedDirs { if fi, err := os.Stat(dir); err != nil { t.Errorf("%s", err) @@ -47,8 +48,8 @@ func TestEnsureHome(t *testing.T) { } } - if fi, err := os.Stat(repositoriesFile()); err != nil { - t.Errorf("%s", err) + if fi, err := os.Stat(hh.RepositoryFile()); err != nil { + t.Error(err) } else if fi.IsDir() { t.Errorf("%s should not be a directory", fi) } @@ -59,9 +60,3 @@ func TestEnsureHome(t *testing.T) { t.Errorf("%s should not be a directory", fi) } } - -func createTmpHome() string { - tmpHome, _ := ioutil.TempDir("", "helm_home") - defer os.Remove(tmpHome) - return tmpHome -} diff --git a/cmd/helm/package.go b/cmd/helm/package.go index fac2a2fef..df4583c62 100644 --- a/cmd/helm/package.go +++ b/cmd/helm/package.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/chartutil" "k8s.io/helm/pkg/helm" "k8s.io/helm/pkg/provenance" @@ -50,6 +51,7 @@ type packageCmd struct { key string keyring string out io.Writer + home helmpath.Home } func newPackageCmd(client helm.Interface, out io.Writer) *cobra.Command { @@ -61,6 +63,7 @@ func newPackageCmd(client helm.Interface, out io.Writer) *cobra.Command { Short: "package a chart directory into a chart archive", Long: packageDesc, RunE: func(cmd *cobra.Command, args []string) error { + pkg.home = helmpath.Home(homePath()) if len(args) == 0 { return fmt.Errorf("This command needs at least one argument, the path to the chart.") } @@ -113,16 +116,17 @@ func (p *packageCmd) run(cmd *cobra.Command, args []string) error { } name, err := chartutil.Save(ch, cwd) if err == nil && flagDebug { - cmd.Printf("Saved %s to current directory\n", name) + fmt.Fprintf(p.out, "Saved %s to current directory\n", name) } // Save to $HELM_HOME/local directory. This is second, because we don't want // the case where we saved here, but didn't save to the default destination. if p.save { - if err := repo.AddChartToLocalRepo(ch, localRepoDirectory()); err != nil { + lr := p.home.LocalRepository() + if err := repo.AddChartToLocalRepo(ch, lr); err != nil { return err } else if flagDebug { - cmd.Printf("Saved %s to %s\n", name, localRepoDirectory()) + fmt.Fprintf(p.out, "Saved %s to %s\n", name, lr) } } diff --git a/cmd/helm/package_test.go b/cmd/helm/package_test.go index b724ba2ea..fc2060c16 100644 --- a/cmd/helm/package_test.go +++ b/cmd/helm/package_test.go @@ -24,6 +24,8 @@ import ( "testing" "github.com/spf13/cobra" + + "k8s.io/helm/cmd/helm/helmpath" ) func TestPackage(t *testing.T) { @@ -57,6 +59,13 @@ func TestPackage(t *testing.T) { expect: "keyring is required for signing a package", err: true, }, + { + name: "package testdata/testcharts/alpine, no save", + args: []string{"testdata/testcharts/alpine"}, + flags: map[string]string{"save": "0"}, + expect: "", + hasfile: "alpine-0.1.0.tgz", + }, { name: "package testdata/testcharts/alpine", args: []string{"testdata/testcharts/alpine"}, @@ -87,7 +96,11 @@ func TestPackage(t *testing.T) { t.Fatal(err) } + ensureTestHome(helmpath.Home(tmp), t) + oldhome := homePath() + helmHome = tmp defer func() { + helmHome = oldhome os.Chdir(origDir) os.RemoveAll(tmp) }() diff --git a/cmd/helm/repo_add.go b/cmd/helm/repo_add.go index 5ca4cc799..fd31e0d33 100644 --- a/cmd/helm/repo_add.go +++ b/cmd/helm/repo_add.go @@ -17,20 +17,20 @@ limitations under the License. package main import ( - "errors" "fmt" "io" - "io/ioutil" + "path/filepath" "github.com/spf13/cobra" - "gopkg.in/yaml.v2" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/repo" ) type repoAddCmd struct { name string url string + home helmpath.Home out io.Writer } @@ -49,6 +49,7 @@ func newRepoAddCmd(out io.Writer) *cobra.Command { add.name = args[0] add.url = args[1] + add.home = helmpath.Home(homePath()) return add.run() }, @@ -57,38 +58,36 @@ func newRepoAddCmd(out io.Writer) *cobra.Command { } func (a *repoAddCmd) run() error { - if err := addRepository(a.name, a.url); err != nil { + if err := addRepository(a.name, a.url, a.home); err != nil { return err } - - fmt.Println(a.name + " has been added to your repositories") + fmt.Fprintf(a.out, "%q has been added to your repositories\n", a.name) return nil } -func addRepository(name, url string) error { - if err := repo.DownloadIndexFile(name, url, cacheIndexFile(name)); err != nil { - return errors.New("Looks like " + url + " is not a valid chart repository or cannot be reached: " + err.Error()) +func addRepository(name, url string, home helmpath.Home) error { + cif := home.CacheIndex(name) + if err := repo.DownloadIndexFile(name, url, cif); err != nil { + return fmt.Errorf("Looks like %q is not a valid chart repository or cannot be reached: %s", url, err.Error()) } - return insertRepoLine(name, url) + return insertRepoLine(name, url, home) } -func insertRepoLine(name, url string) error { - f, err := repo.LoadRepositoriesFile(repositoriesFile()) +func insertRepoLine(name, url string, home helmpath.Home) error { + cif := home.CacheIndex(name) + f, err := repo.LoadRepositoriesFile(home.RepositoryFile()) if err != nil { return err } - _, ok := f.Repositories[name] - if ok { - return fmt.Errorf("The repository name you provided (%s) already exists. Please specify a different name.", name) - } - if f.Repositories == nil { - f.Repositories = make(map[string]string) + if f.Has(name) { + return fmt.Errorf("The repository name you provided (%s) already exists. Please specify a different name.", name) } - - f.Repositories[name] = url - - b, _ := yaml.Marshal(&f.Repositories) - return ioutil.WriteFile(repositoriesFile(), b, 0666) + f.Add(&repo.Entry{ + Name: name, + URL: url, + Cache: filepath.Base(cif), + }) + return f.WriteFile(home.RepositoryFile(), 0644) } diff --git a/cmd/helm/repo_add_test.go b/cmd/helm/repo_add_test.go index be6a669ef..218353174 100644 --- a/cmd/helm/repo_add_test.go +++ b/cmd/helm/repo_add_test.go @@ -18,29 +18,35 @@ package main import ( "bytes" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" "os" - "path/filepath" "testing" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/repo" + "k8s.io/helm/pkg/repo/repotest" ) var testName = "test-name" func TestRepoAddCmd(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, "") - })) + srv := repotest.NewServer("testdata/testserver") + defer srv.Stop() + + thome, err := tempHelmHome(t) + if err != nil { + t.Fatal(err) + } + oldhome := homePath() + helmHome = thome + defer func() { + helmHome = oldhome + os.Remove(thome) + }() tests := []releaseCase{ { name: "add a repository", - args: []string{testName, ts.URL}, + args: []string{testName, srv.URL()}, expected: testName + " has been added to your repositories", }, } @@ -49,41 +55,32 @@ func TestRepoAddCmd(t *testing.T) { buf := bytes.NewBuffer(nil) c := newRepoAddCmd(buf) if err := c.RunE(c, tt.args); err != nil { - t.Errorf("%q: expected '%q', got '%q'", tt.name, tt.expected, err) + t.Errorf("%q: expected %q, got %q", tt.name, tt.expected, err) } } } 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, "") - })) - - helmHome, _ = ioutil.TempDir("", "helm_home") - defer os.Remove(helmHome) - os.Mkdir(filepath.Join(helmHome, repositoryDir), 0755) - os.Mkdir(cacheDirectory(), 0755) - - if err := ioutil.WriteFile(repositoriesFile(), []byte("example-repo: http://exampleurl.com"), 0666); err != nil { - t.Errorf("%#v", err) + ts := repotest.NewServer("testdata/testserver") + defer ts.Stop() + + thome, err := tempHelmHome(t) + if err != nil { + t.Fatal(err) } + defer os.Remove(thome) + hh := helmpath.Home(thome) - if err := addRepository(testName, ts.URL); err != nil { - t.Errorf("%s", err) + if err := addRepository(testName, ts.URL(), hh); err != nil { + t.Error(err) } - f, err := repo.LoadRepositoriesFile(repositoriesFile()) + f, err := repo.LoadRepositoriesFile(hh.RepositoryFile()) if err != nil { - t.Errorf("%s", err) - } - _, ok := f.Repositories[testName] - if !ok { - t.Errorf("%s was not successfully inserted into %s", testName, repositoriesFile()) + t.Error(err) } - if err := insertRepoLine(testName, ts.URL); err == nil { - t.Errorf("Duplicate repository name was added") + if !f.Has(testName) { + t.Errorf("%s was not successfully inserted into %s", testName, hh.RepositoryFile()) } - } diff --git a/cmd/helm/repo_list.go b/cmd/helm/repo_list.go index 21696a0aa..a3816facd 100644 --- a/cmd/helm/repo_list.go +++ b/cmd/helm/repo_list.go @@ -24,11 +24,13 @@ import ( "github.com/gosuri/uitable" "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/repo" ) type repoListCmd struct { - out io.Writer + out io.Writer + home helmpath.Home } func newRepoListCmd(out io.Writer) *cobra.Command { @@ -40,6 +42,7 @@ func newRepoListCmd(out io.Writer) *cobra.Command { Use: "list [flags]", Short: "list chart repositories", RunE: func(cmd *cobra.Command, args []string) error { + list.home = helmpath.Home(homePath()) return list.run() }, } @@ -48,7 +51,7 @@ func newRepoListCmd(out io.Writer) *cobra.Command { } func (a *repoListCmd) run() error { - f, err := repo.LoadRepositoriesFile(repositoriesFile()) + f, err := repo.LoadRepositoriesFile(a.home.RepositoryFile()) if err != nil { return err } @@ -58,9 +61,9 @@ func (a *repoListCmd) run() error { table := uitable.New() table.MaxColWidth = 50 table.AddRow("NAME", "URL") - for k, v := range f.Repositories { - table.AddRow(k, v) + for _, re := range f.Repositories { + table.AddRow(re.Name, re.URL) } - fmt.Println(table) + fmt.Fprintln(a.out, table) return nil } diff --git a/cmd/helm/repo_remove.go b/cmd/helm/repo_remove.go index f58f78212..422b545b7 100644 --- a/cmd/helm/repo_remove.go +++ b/cmd/helm/repo_remove.go @@ -19,18 +19,18 @@ package main import ( "fmt" "io" - "io/ioutil" "os" "github.com/spf13/cobra" - "gopkg.in/yaml.v2" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/repo" ) type repoRemoveCmd struct { out io.Writer name string + home helmpath.Home } func newRepoRemoveCmd(out io.Writer) *cobra.Command { @@ -47,6 +47,7 @@ func newRepoRemoveCmd(out io.Writer) *cobra.Command { return err } remove.name = args[0] + remove.home = helmpath.Home(homePath()) return remove.run() }, @@ -56,41 +57,35 @@ func newRepoRemoveCmd(out io.Writer) *cobra.Command { } func (r *repoRemoveCmd) run() error { - return removeRepoLine(r.name) + return removeRepoLine(r.out, r.name, r.home) } -func removeRepoLine(name string) error { - r, err := repo.LoadRepositoriesFile(repositoriesFile()) +func removeRepoLine(out io.Writer, name string, home helmpath.Home) error { + repoFile := home.RepositoryFile() + r, err := repo.LoadRepositoriesFile(repoFile) if err != nil { return err } - _, ok := r.Repositories[name] - if ok { - delete(r.Repositories, name) - b, err := yaml.Marshal(&r.Repositories) - if err != nil { - return err - } - if err := ioutil.WriteFile(repositoriesFile(), b, 0666); err != nil { - return err - } - if err := removeRepoCache(name); err != nil { - return err - } + if !r.Remove(name) { + return fmt.Errorf("no repo named %q found", name) + } + if err := r.WriteFile(repoFile, 0644); err != nil { + return err + } - } else { - return fmt.Errorf("The repository, %s, does not exist in your repositories list", name) + if err := removeRepoCache(name, home); err != nil { + return err } - fmt.Println(name + " has been removed from your repositories") + fmt.Fprintf(out, "%q has been removed from your repositories", name) return nil } -func removeRepoCache(name string) error { - if _, err := os.Stat(cacheIndexFile(name)); err == nil { - err = os.Remove(cacheIndexFile(name)) +func removeRepoCache(name string, home helmpath.Home) error { + if _, err := os.Stat(home.CacheIndex(name)); err == nil { + err = os.Remove(home.CacheIndex(name)) if err != nil { return err } diff --git a/cmd/helm/repo_remove_test.go b/cmd/helm/repo_remove_test.go index ea4fa6b55..77b30426a 100644 --- a/cmd/helm/repo_remove_test.go +++ b/cmd/helm/repo_remove_test.go @@ -17,45 +17,52 @@ limitations under the License. package main import ( + "bytes" "os" + "strings" "testing" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/repo" ) func TestRepoRemove(t *testing.T) { testURL := "https://test-url.com" - home := createTmpHome() - helmHome = home - if err := ensureHome(); err != nil { - t.Errorf("%s", err) - } - if err := removeRepoLine(testName); err == nil { + b := bytes.NewBuffer(nil) + + home, err := tempHelmHome(t) + defer os.Remove(home) + hh := helmpath.Home(home) + + if err := removeRepoLine(b, testName, hh); err == nil { t.Errorf("Expected error removing %s, but did not get one.", testName) } - - if err := insertRepoLine(testName, testURL); err != nil { - t.Errorf("%s", err) + if err := insertRepoLine(testName, testURL, hh); err != nil { + t.Error(err) } - mf, _ := os.Create(cacheIndexFile(testName)) + mf, _ := os.Create(hh.CacheIndex(testName)) mf.Close() - if err := removeRepoLine(testName); err != nil { + b.Reset() + if err := removeRepoLine(b, testName, hh); err != nil { t.Errorf("Error removing %s from repositories", testName) } + if !strings.Contains(b.String(), "has been removed") { + t.Errorf("Unexpected output: %s", b.String()) + } - if _, err := os.Stat(cacheIndexFile(testName)); err == nil { + if _, err := os.Stat(hh.CacheIndex(testName)); err == nil { t.Errorf("Error cache file was not removed for repository %s", testName) } - f, err := repo.LoadRepositoriesFile(repositoriesFile()) + f, err := repo.LoadRepositoriesFile(hh.RepositoryFile()) if err != nil { - t.Errorf("%s", err) + t.Error(err) } - if _, ok := f.Repositories[testName]; ok { + if f.Has(testName) { t.Errorf("%s was not successfully removed from repositories list", testName) } } diff --git a/cmd/helm/repo_update.go b/cmd/helm/repo_update.go index 865e5eae0..1a7685abe 100644 --- a/cmd/helm/repo_update.go +++ b/cmd/helm/repo_update.go @@ -24,6 +24,7 @@ import ( "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/repo" ) @@ -37,8 +38,9 @@ future releases. type repoUpdateCmd struct { repoFile string - update func(map[string]string, bool, io.Writer) + update func([]*repo.Entry, bool, io.Writer, helmpath.Home) out io.Writer + home helmpath.Home } func newRepoUpdateCmd(out io.Writer) *cobra.Command { @@ -53,6 +55,7 @@ func newRepoUpdateCmd(out io.Writer) *cobra.Command { Short: "update information on available charts in the chart repositories", Long: updateDesc, RunE: func(cmd *cobra.Command, args []string) error { + u.home = helmpath.Home(homePath()) return u.run() }, } @@ -69,29 +72,29 @@ func (u *repoUpdateCmd) run() error { return errors.New("no repositories found. You must add one before updating") } - u.update(f.Repositories, flagDebug, u.out) + u.update(f.Repositories, flagDebug, u.out, u.home) return nil } -func updateCharts(repos map[string]string, verbose bool, out io.Writer) { +func updateCharts(repos []*repo.Entry, verbose bool, out io.Writer, home helmpath.Home) { fmt.Fprintln(out, "Hang tight while we grab the latest from your chart repositories...") var wg sync.WaitGroup - for name, url := range repos { + for _, re := range repos { wg.Add(1) go func(n, u string) { defer wg.Done() - err := repo.DownloadIndexFile(n, u, cacheIndexFile(n)) + if n == localRepository { + // We skip local because the indices are symlinked. + return + } + err := repo.DownloadIndexFile(n, u, home.CacheIndex(n)) if err != nil { - updateErr := fmt.Sprintf("...Unable to get an update from the %q chart repository", n) - if verbose { - updateErr = updateErr + ": " + err.Error() - } - fmt.Fprintln(out, updateErr) + fmt.Fprintf(out, "...Unable to get an update from the %q chart repository (%s):\n\t%s\n", n, u, err) } else { fmt.Fprintf(out, "...Successfully got an update from the %q chart repository\n", n) } - }(name, url) + }(re.Name, re.URL) } wg.Wait() - fmt.Fprintln(out, "Update Complete. Happy Helming!") + fmt.Fprintln(out, "Update Complete. ⎈ Happy Helming!⎈ ") } diff --git a/cmd/helm/repo_update_test.go b/cmd/helm/repo_update_test.go index 867babd80..6ec59c410 100644 --- a/cmd/helm/repo_update_test.go +++ b/cmd/helm/repo_update_test.go @@ -19,19 +19,34 @@ import ( "bytes" "fmt" "io" - "net/http" - "net/http/httptest" + "os" "strings" "testing" + + "k8s.io/helm/cmd/helm/helmpath" + "k8s.io/helm/pkg/repo" + "k8s.io/helm/pkg/repo/repotest" ) func TestUpdateCmd(t *testing.T) { + + thome, err := tempHelmHome(t) + if err != nil { + t.Fatal(err) + } + oldhome := homePath() + helmHome = thome + defer func() { + helmHome = oldhome + os.Remove(thome) + }() + out := bytes.NewBuffer(nil) // Instead of using the HTTP updater, we provide our own for this test. // The TestUpdateCharts test verifies the HTTP behavior independently. - updater := func(repos map[string]string, verbose bool, out io.Writer) { - for name := range repos { - fmt.Fprintln(out, name) + updater := func(repos []*repo.Entry, verbose bool, out io.Writer, home helmpath.Home) { + for _, re := range repos { + fmt.Fprintln(out, re.Name) } } uc := &repoUpdateCmd{ @@ -46,36 +61,26 @@ func TestUpdateCmd(t *testing.T) { } } -const mockRepoIndex = ` -mychart-0.1.0: - name: mychart-0.1.0 - url: localhost:8879/charts/mychart-0.1.0.tgz - chartfile: - name: "" - home: "" - sources: [] - version: "" - description: "" - keywords: [] - maintainers: [] - engine: "" - icon: "" -` - func TestUpdateCharts(t *testing.T) { - // This tests the repo in isolation. It creates a mock HTTP server that simply - // returns a static YAML file in the anticipate format. - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(mockRepoIndex)) - }) - srv := httptest.NewServer(handler) - defer srv.Close() + srv := repotest.NewServer("testdata/testserver") + defer srv.Stop() + + thome, err := tempHelmHome(t) + if err != nil { + t.Fatal(err) + } + oldhome := homePath() + helmHome = thome + defer func() { + helmHome = oldhome + os.Remove(thome) + }() buf := bytes.NewBuffer(nil) - repos := map[string]string{ - "charts": srv.URL, + repos := []*repo.Entry{ + {Name: "charts", URL: srv.URL()}, } - updateCharts(repos, false, buf) + updateCharts(repos, false, buf, helmpath.Home(thome)) got := buf.String() if strings.Contains(got, "Unable to get an update") { diff --git a/cmd/helm/search.go b/cmd/helm/search.go index 625b980bd..f8b02cff8 100644 --- a/cmd/helm/search.go +++ b/cmd/helm/search.go @@ -101,11 +101,12 @@ func (s *searchCmd) buildIndex() (*search.Index, error) { } i := search.NewIndex() - for n := range rf.Repositories { + for _, re := range rf.Repositories { + n := re.Name f := s.helmhome.CacheIndex(n) ind, err := repo.LoadIndexFile(f) if err != nil { - fmt.Fprintf(s.out, "WARNING: Repo %q is corrupt. Try 'helm update': %s", f, err) + fmt.Fprintf(s.out, "WARNING: Repo %q is corrupt or missing. Try 'helm update':\n\t%s\n", f, err) continue } diff --git a/cmd/helm/search/search.go b/cmd/helm/search/search.go index 0dfb72add..def6da698 100644 --- a/cmd/helm/search/search.go +++ b/cmd/helm/search/search.go @@ -44,27 +44,34 @@ type Result struct { // Index is a searchable index of chart information. type Index struct { lines map[string]string - charts map[string]*repo.ChartRef + charts map[string]*repo.ChartVersion } const sep = "\v" // NewIndex creats a new Index. func NewIndex() *Index { - return &Index{lines: map[string]string{}, charts: map[string]*repo.ChartRef{}} + return &Index{lines: map[string]string{}, charts: map[string]*repo.ChartVersion{}} } // AddRepo adds a repository index to the search index. func (i *Index) AddRepo(rname string, ind *repo.IndexFile) { for name, ref := range ind.Entries { + if len(ref) == 0 { + // Skip chart names that havae zero releases. + continue + } + // By convention, an index file is supposed to have the newest at the + // 0 slot, so our best bet is to grab the 0 entry and build the index + // entry off of that. fname := filepath.Join(rname, name) - i.lines[fname] = indstr(rname, ref) - i.charts[fname] = ref + i.lines[fname] = indstr(rname, ref[0]) + i.charts[fname] = ref[0] } } // Entries returns the entries in an index. -func (i *Index) Entries() map[string]*repo.ChartRef { +func (i *Index) Entries() map[string]*repo.ChartVersion { return i.charts } @@ -136,7 +143,7 @@ func (i *Index) SearchRegexp(re string, threshold int) ([]*Result, error) { } // Chart returns the ChartRef for a particular name. -func (i *Index) Chart(name string) (*repo.ChartRef, error) { +func (i *Index) Chart(name string) (*repo.ChartVersion, error) { c, ok := i.charts[name] if !ok { return nil, errors.New("no such chart") @@ -174,10 +181,8 @@ func (s scoreSorter) Less(a, b int) bool { return first.Name < second.Name } -func indstr(name string, ref *repo.ChartRef) string { - i := ref.Name + sep + name + "/" + ref.Name + sep - if ref.Chartfile != nil { - i += ref.Chartfile.Description + sep + strings.Join(ref.Chartfile.Keywords, sep) - } +func indstr(name string, ref *repo.ChartVersion) string { + i := ref.Name + sep + name + "/" + ref.Name + sep + + ref.Description + sep + strings.Join(ref.Keywords, " ") return strings.ToLower(i) } diff --git a/cmd/helm/search/search_test.go b/cmd/helm/search/search_test.go index e20b8e756..9cfa1bc3e 100644 --- a/cmd/helm/search/search_test.go +++ b/cmd/helm/search/search_test.go @@ -52,32 +52,43 @@ func TestSortScore(t *testing.T) { var testCacheDir = "../testdata/" -var indexfileEntries = map[string]*repo.ChartRef{ - "niña-0.1.0": { - Name: "niña", - URL: "http://example.com/charts/nina-0.1.0.tgz", - Chartfile: &chart.Metadata{ - Name: "niña", - Version: "0.1.0", - Description: "One boat", +var indexfileEntries = map[string]repo.ChartVersions{ + "niña": { + { + URLs: []string{"http://example.com/charts/nina-0.1.0.tgz"}, + Metadata: &chart.Metadata{ + Name: "niña", + Version: "0.1.0", + Description: "One boat", + }, }, }, - "pinta-0.1.0": { - Name: "pinta", - URL: "http://example.com/charts/pinta-0.1.0.tgz", - Chartfile: &chart.Metadata{ - Name: "pinta", - Version: "0.1.0", - Description: "Two ship", + "pinta": { + { + URLs: []string{"http://example.com/charts/pinta-0.1.0.tgz"}, + Metadata: &chart.Metadata{ + Name: "pinta", + Version: "0.1.0", + Description: "Two ship", + }, }, }, - "santa-maria-1.2.3": { - Name: "santa-maria", - URL: "http://example.com/charts/santa-maria-1.2.3.tgz", - Chartfile: &chart.Metadata{ - Name: "santa-maria", - Version: "1.2.3", - Description: "Three boat", + "santa-maria": { + { + URLs: []string{"http://example.com/charts/santa-maria-1.2.3.tgz"}, + Metadata: &chart.Metadata{ + Name: "santa-maria", + Version: "1.2.3", + Description: "Three boat", + }, + }, + { + URLs: []string{"http://example.com/charts/santa-maria-1.2.2.tgz"}, + Metadata: &chart.Metadata{ + Name: "santa-maria", + Version: "1.2.2", + Description: "Three boat", + }, }, }, } @@ -85,14 +96,15 @@ var indexfileEntries = map[string]*repo.ChartRef{ func loadTestIndex(t *testing.T) *Index { i := NewIndex() i.AddRepo("testing", &repo.IndexFile{Entries: indexfileEntries}) - i.AddRepo("ztesting", &repo.IndexFile{Entries: map[string]*repo.ChartRef{ - "pinta-2.0.0": { - Name: "pinta", - URL: "http://example.com/charts/pinta-2.0.0.tgz", - Chartfile: &chart.Metadata{ - Name: "pinta", - Version: "2.0.0", - Description: "Two ship, version two", + i.AddRepo("ztesting", &repo.IndexFile{Entries: map[string]repo.ChartVersions{ + "pinta": { + { + URLs: []string{"http://example.com/charts/pinta-2.0.0.tgz"}, + Metadata: &chart.Metadata{ + Name: "pinta", + Version: "2.0.0", + Description: "Two ship, version two", + }, }, }, }}) @@ -113,44 +125,44 @@ func TestSearchByName(t *testing.T) { name: "basic search for one result", query: "santa-maria", expect: []*Result{ - {Name: "testing/santa-maria-1.2.3"}, + {Name: "testing/santa-maria"}, }, }, { name: "basic search for two results", query: "pinta", expect: []*Result{ - {Name: "testing/pinta-0.1.0"}, - {Name: "ztesting/pinta-2.0.0"}, + {Name: "testing/pinta"}, + {Name: "ztesting/pinta"}, }, }, { name: "repo-specific search for one result", query: "ztesting/pinta", expect: []*Result{ - {Name: "ztesting/pinta-2.0.0"}, + {Name: "ztesting/pinta"}, }, }, { name: "partial name search", query: "santa", expect: []*Result{ - {Name: "testing/santa-maria-1.2.3"}, + {Name: "testing/santa-maria"}, }, }, { name: "description search, one result", query: "Three", expect: []*Result{ - {Name: "testing/santa-maria-1.2.3"}, + {Name: "testing/santa-maria"}, }, }, { name: "description search, two results", query: "two", expect: []*Result{ - {Name: "testing/pinta-0.1.0"}, - {Name: "ztesting/pinta-2.0.0"}, + {Name: "testing/pinta"}, + {Name: "ztesting/pinta"}, }, }, { @@ -162,7 +174,7 @@ func TestSearchByName(t *testing.T) { name: "regexp, one result", query: "th[ref]*", expect: []*Result{ - {Name: "testing/santa-maria-1.2.3"}, + {Name: "testing/santa-maria"}, }, regexp: true, }, diff --git a/cmd/helm/search_test.go b/cmd/helm/search_test.go index ffd4493fe..c9ce62e05 100644 --- a/cmd/helm/search_test.go +++ b/cmd/helm/search_test.go @@ -34,12 +34,12 @@ func TestSearchCmd(t *testing.T) { { name: "search for 'maria', expect one match", args: []string{"maria"}, - expect: "testing/mariadb-0.3.0", + expect: "testing/mariadb", }, { name: "search for 'alpine', expect two matches", args: []string{"alpine"}, - expect: "testing/alpine-0.1.0\ntesting/alpine-0.2.0", + expect: "testing/alpine", }, { name: "search for 'syzygy', expect no matches", @@ -50,7 +50,7 @@ func TestSearchCmd(t *testing.T) { name: "search for 'alp[a-z]+', expect two matches", args: []string{"alp[a-z]+"}, flags: []string{"--regexp"}, - expect: "testing/alpine-0.1.0\ntesting/alpine-0.2.0", + expect: "testing/alpine", regexp: true, }, { diff --git a/cmd/helm/serve.go b/cmd/helm/serve.go index ebb75e223..069938fde 100644 --- a/cmd/helm/serve.go +++ b/cmd/helm/serve.go @@ -17,35 +17,40 @@ limitations under the License. package main import ( + "fmt" "io" "os" "path/filepath" "github.com/spf13/cobra" + "k8s.io/helm/cmd/helm/helmpath" "k8s.io/helm/pkg/repo" ) const serveDesc = `This command starts a local chart repository server that serves charts from a local directory.` type serveCmd struct { - repoPath string out io.Writer + home helmpath.Home + address string + repoPath string } func newServeCmd(out io.Writer) *cobra.Command { - s := &serveCmd{ - out: out, - } + srv := &serveCmd{out: out} cmd := &cobra.Command{ Use: "serve", Short: "start a local http web server", Long: serveDesc, RunE: func(cmd *cobra.Command, args []string) error { - return s.run() + srv.home = helmpath.Home(homePath()) + return srv.run() }, } - cmd.Flags().StringVar(&s.repoPath, "repo-path", localRepoDirectory(), "The local directory path from which to serve charts.") + cmd.Flags().StringVar(&srv.repoPath, "repo-path", helmpath.Home(homePath()).LocalRepository(), "The local directory path from which to serve charts.") + cmd.Flags().StringVar(&srv.address, "address", "localhost:8879", "The address to listen on.") + return cmd } @@ -58,6 +63,6 @@ func (s *serveCmd) run() error { return err } - repo.StartLocalRepo(s.repoPath) - return nil + fmt.Fprintf(s.out, "Now serving you on %s\n", s.address) + return repo.StartLocalRepo(repoPath, s.address) } diff --git a/cmd/helm/testdata/helmhome/repository/cache/testing-index.yaml b/cmd/helm/testdata/helmhome/repository/cache/testing-index.yaml index 67595882a..26ce97423 100644 --- a/cmd/helm/testdata/helmhome/repository/cache/testing-index.yaml +++ b/cmd/helm/testdata/helmhome/repository/cache/testing-index.yaml @@ -1,54 +1,46 @@ -alpine-0.1.0: - name: alpine - url: http://storage.googleapis.com/kubernetes-charts/alpine-0.1.0.tgz - created: 2016-09-06 21:58:44.211261566 +0000 UTC - checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d - chartfile: - name: alpine - home: https://k8s.io/helm - sources: - - https://github.com/kubernetes/helm - version: 0.1.0 - description: Deploy a basic Alpine Linux pod - keywords: [] - maintainers: [] - engine: "" - icon: "" -alpine-0.2.0: - name: alpine - url: http://storage.googleapis.com/kubernetes-charts/alpine-0.2.0.tgz - created: 2016-09-06 21:58:44.211261566 +0000 UTC - checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d - chartfile: - name: alpine - home: https://k8s.io/helm - sources: - - https://github.com/kubernetes/helm - version: 0.2.0 - description: Deploy a basic Alpine Linux pod - keywords: [] - maintainers: [] - engine: "" - icon: "" -mariadb-0.3.0: - name: mariadb - url: http://storage.googleapis.com/kubernetes-charts/mariadb-0.3.0.tgz - created: 2016-09-06 21:58:44.211870222 +0000 UTC - checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56 - chartfile: - name: mariadb - home: https://mariadb.org - sources: - - https://github.com/bitnami/bitnami-docker-mariadb - version: 0.3.0 - description: Chart for MariaDB - keywords: - - mariadb - - mysql - - database - - sql - maintainers: - - name: Bitnami - email: containers@bitnami.com - engine: gotpl - icon: "" +apiVersion: v1 +entries: + alpine: + - name: alpine + url: http://storage.googleapis.com/kubernetes-charts/alpine-0.1.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + home: https://k8s.io/helm + sources: + - https://github.com/kubernetes/helm + version: 0.1.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + engine: "" + icon: "" + - name: alpine + url: http://storage.googleapis.com/kubernetes-charts/alpine-0.2.0.tgz + checksum: 0e6661f193211d7a5206918d42f5c2a9470b737d + home: https://k8s.io/helm + sources: + - https://github.com/kubernetes/helm + version: 0.2.0 + description: Deploy a basic Alpine Linux pod + keywords: [] + maintainers: [] + engine: "" + icon: "" + mariadb: + - name: mariadb + url: http://storage.googleapis.com/kubernetes-charts/mariadb-0.3.0.tgz + checksum: 65229f6de44a2be9f215d11dbff311673fc8ba56 + home: https://mariadb.org + sources: + - https://github.com/bitnami/bitnami-docker-mariadb + version: 0.3.0 + description: Chart for MariaDB + keywords: + - mariadb + - mysql + - database + - sql + maintainers: + - name: Bitnami + email: containers@bitnami.com + engine: gotpl + icon: "" diff --git a/cmd/helm/testdata/helmhome/repository/repositories.yaml b/cmd/helm/testdata/helmhome/repository/repositories.yaml index cd16e634a..3835aaa5a 100644 --- a/cmd/helm/testdata/helmhome/repository/repositories.yaml +++ b/cmd/helm/testdata/helmhome/repository/repositories.yaml @@ -1 +1,6 @@ -testing: http://example.com/charts +apiVersion: v1 +generated: 2016-10-03T16:03:10.640376913-06:00 +repositories: +- cache: testing-index.yaml + name: testing + url: http://example.com/charts diff --git a/cmd/helm/testdata/repositories.yaml b/cmd/helm/testdata/repositories.yaml index 6fe931e89..0ff94a0e3 100644 --- a/cmd/helm/testdata/repositories.yaml +++ b/cmd/helm/testdata/repositories.yaml @@ -1,2 +1,6 @@ -charts: http://storage.googleapis.com/kubernetes-charts -local: http://localhost:8879/charts +apiVersion: v1 +repositories: + - name: charts + url: "http://storage.googleapis.com/kubernetes-charts" + - name: local + url: "http://localhost:8879/charts" diff --git a/cmd/helm/testdata/testserver/index.yaml b/cmd/helm/testdata/testserver/index.yaml new file mode 100644 index 000000000..9cde8e8dd --- /dev/null +++ b/cmd/helm/testdata/testserver/index.yaml @@ -0,0 +1 @@ +apiVersion: v1 diff --git a/cmd/helm/testdata/testserver/repository/repositories.yaml b/cmd/helm/testdata/testserver/repository/repositories.yaml new file mode 100644 index 000000000..271301c95 --- /dev/null +++ b/cmd/helm/testdata/testserver/repository/repositories.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +generated: 2016-10-04T13:50:02.87649685-06:00 +repositories: +- cache: "" + name: test + url: http://127.0.0.1:49216 diff --git a/docs/chart_repository.md b/docs/chart_repository.md index 56822e175..900d45d8d 100644 --- a/docs/chart_repository.md +++ b/docs/chart_repository.md @@ -19,26 +19,45 @@ The index file is a yaml file called `index.yaml`. It contains some metadata abo This is an example of an index file: ``` -alpine-0.1.0: - name: alpine - url: https://storage.googleapis.com/kubernetes-charts/alpine-0.1.0.tgz - created: 2016-05-26 11:23:44.086354411 +0000 UTC - digest: sha256:78e9a4282295184e8ce1496d23987993673f38e33e203c8bc18bc838a73e5864 - chartfile: - name: alpine - description: Deploy a basic Alpine Linux pod - version: 0.1.0 - home: https://github.com/example-charts/alpine -redis-2.0.0: - name: redis - url: https://storage.googleapis.com/kubernetes-charts/redis-2.0.0.tgz - created: 2016-05-26 11:23:44.087939192 +0000 UTC - digest: sha256:bde9c2949e64d059c18d8f93566a64dafc6d2e8e259a70322fb804831dfd0b5b - chartfile: - name: redis - description: Port of the replicatedservice template from kubernetes/charts - version: 2.0.0 - home: https://github.com/example-charts/redis +apiVersion: v1 +entries: + nginx: + - urls: + - http://storage.googleapis.com/kubernetes-charts/nginx-0.1.0.tgz + name: nginx + description: string + version: 0.1.0 + home: https://github.com/something + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + - urls: + - http://storage.googleapis.com/kubernetes-charts/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + alpine: + - urls: + - http://storage.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + name: alpine + description: string + version: 1.0.0 + home: https://github.com/something + keywords: + - linux + - alpine + - small + - sumtin + digest: "sha256:1234567890abcdef" ``` We will go through detailed GCS and Github Pages examples here, but feel free to skip to the next section if you've already created a chart repository. diff --git a/pkg/repo/doc.go b/pkg/repo/doc.go new file mode 100644 index 000000000..fb8b3f4b2 --- /dev/null +++ b/pkg/repo/doc.go @@ -0,0 +1,93 @@ +/* +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 repo implements the Helm Chart Repository. + +A chart repository is an HTTP server that provides information on charts. A local +repository cache is an on-disk representation of a chart repository. + +There are two important file formats for chart repositories. + +The first is the 'index.yaml' format, which is expressed like this: + + apiVersion: v1 + entries: + frobnitz: + - created: 2016-09-29T12:14:34.830161306-06:00 + description: This is a frobniz. + digest: 587bd19a9bd9d2bc4a6d25ab91c8c8e7042c47b4ac246e37bf8e1e74386190f4 + home: http://example.com + keywords: + - frobnitz + - sprocket + - dodad + maintainers: + - email: helm@example.com + name: The Helm Team + - email: nobody@example.com + name: Someone Else + name: frobnitz + urls: + - http://example-charts.com/testdata/repository/frobnitz-1.2.3.tgz + version: 1.2.3 + sprocket: + - created: 2016-09-29T12:14:34.830507606-06:00 + description: This is a sprocket" + digest: 8505ff813c39502cc849a38e1e4a8ac24b8e6e1dcea88f4c34ad9b7439685ae6 + home: http://example.com + keywords: + - frobnitz + - sprocket + - dodad + maintainers: + - email: helm@example.com + name: The Helm Team + - email: nobody@example.com + name: Someone Else + name: sprocket + urls: + - http://example-charts.com/testdata/repository/sprocket-1.2.0.tgz + version: 1.2.0 + generated: 2016-09-29T12:14:34.829721375-06:00 + +An index.yaml file contains the necessary descriptive information about what +charts are available in a repository, and how to get them. + +The second file format is the repositories.yaml file format. This file is for +facilitating local cached copies of one or more chart repositories. + +The format of a repository.yaml file is: + + apiVersion: v1 + generated: TIMESTAMP + repositories: + - name: stable + url: http://example.com/charts + cache: stable-index.yaml + - name: incubator + url: http://example.com/incubator + cache: incubator-index.yaml + +This file maps three bits of information about a repository: + + - The name the user uses to refer to it + - The fully qualified URL to the repository (index.yaml will be appended) + - The name of the local cachefile + +The format for both files was changed after Helm v2.0.0-Alpha.4. Helm is not +backwards compatible with those earlier versions. +*/ +package repo diff --git a/pkg/repo/index.go b/pkg/repo/index.go index 457e608b4..42f751955 100644 --- a/pkg/repo/index.go +++ b/pkg/repo/index.go @@ -17,12 +17,17 @@ limitations under the License. package repo import ( + "errors" "io/ioutil" "net/http" + "os" "path/filepath" + "sort" "strings" + "time" - "gopkg.in/yaml.v2" + "github.com/Masterminds/semver" + "github.com/ghodss/yaml" "k8s.io/helm/pkg/chartutil" "k8s.io/helm/pkg/proto/hapi/chart" @@ -31,39 +36,116 @@ import ( var indexPath = "index.yaml" +// APIVersionV1 is the v1 API version for index and repository files. +const APIVersionV1 = "v1" + +// ErrNoAPIVersion indicates that an API version was not specified. +var ErrNoAPIVersion = errors.New("no API version specified") + +// ChartVersions is a list of versioned chart references. +// Implements a sorter on Version. +type ChartVersions []*ChartVersion + +// Len returns the length. +func (c ChartVersions) Len() int { return len(c) } + +// Swap swaps the position of two items in the versions slice. +func (c ChartVersions) Swap(i, j int) { c[i], c[j] = c[j], c[i] } + +// Less returns true if the version of entry a is less than the version of entry b. +func (c ChartVersions) Less(a, b int) bool { + // Failed parse pushes to the back. + i, err := semver.NewVersion(c[a].Version) + if err != nil { + return true + } + j, err := semver.NewVersion(c[b].Version) + if err != nil { + return false + } + return i.LessThan(j) +} + // IndexFile represents the index file in a chart repository type IndexFile struct { - Entries map[string]*ChartRef + APIVersion string `json:"apiVersion"` + Generated time.Time `json:"generated"` + Entries map[string]ChartVersions `json:"entries"` + PublicKeys []string `json:"publicKeys,omitempty"` } // NewIndexFile initializes an index. func NewIndexFile() *IndexFile { - return &IndexFile{Entries: map[string]*ChartRef{}} + return &IndexFile{ + APIVersion: APIVersionV1, + Generated: time.Now(), + Entries: map[string]ChartVersions{}, + PublicKeys: []string{}, + } } // 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, - Created: nowString(), - } - i.Entries[name] = cr + cr := &ChartVersion{ + URLs: []string{baseURL + "/" + filename}, + Metadata: md, + Digest: digest, + Created: time.Now(), + } + if ee, ok := i.Entries[md.Name]; !ok { + i.Entries[md.Name] = ChartVersions{cr} + } else { + i.Entries[md.Name] = append(ee, cr) + } +} + +// Has returns true if the index has an entry for a chart with the given name and exact version. +func (i IndexFile) Has(name, version string) bool { + vs, ok := i.Entries[name] + if !ok { + return false + } + for _, ver := range vs { + // TODO: Do we need to normalize the version field with the SemVer lib? + if ver.Version == version { + return true + } + } + return false +} + +// SortEntries sorts the entries by version in descending order. +// +// In canonical form, the individual version records should be sorted so that +// the most recent release for every version is in the 0th slot in the +// Entries.ChartVersions array. That way, tooling can predict the newest +// version without needing to parse SemVers. +func (i IndexFile) SortEntries() { + for _, versions := range i.Entries { + sort.Sort(sort.Reverse(versions)) + } +} + +// WriteFile writes an index file to the given destination path. +// +// The mode on the file is set to 'mode'. +func (i IndexFile) WriteFile(dest string, mode os.FileMode) error { + b, err := yaml.Marshal(i) + if err != nil { + return err + } + return ioutil.WriteFile(dest, b, mode) } // 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" 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"` +// ChartVersion represents a chart entry in the IndexFile +type ChartVersion struct { + *chart.Metadata + URLs []string `yaml:"url" json:"urls"` + Created time.Time `yaml:"created,omitempty" json:"created,omitempty"` + Removed bool `yaml:"removed,omitempty" json:"removed,omitempty"` + Digest string `yaml:"digest,omitempty" json:"digest,omitempty"` } // IndexDirectory reads a (flat) directory and generates an index. @@ -104,42 +186,30 @@ func DownloadIndexFile(repoName, url, indexFilePath string) error { } defer resp.Body.Close() - var r IndexFile - b, err := ioutil.ReadAll(resp.Body) if err != nil { return err } - if err := yaml.Unmarshal(b, &r); err != nil { + if _, err := LoadIndex(b); err != nil { return err } return ioutil.WriteFile(indexFilePath, b, 0644) } -// UnmarshalYAML unmarshals the index file -func (i *IndexFile) UnmarshalYAML(unmarshal func(interface{}) error) error { - var refs map[string]*ChartRef - if err := unmarshal(&refs); err != nil { - return err - } - i.Entries = refs - return nil -} - -func (i *IndexFile) addEntry(name string, url string) ([]byte, error) { - if i.Entries == nil { - i.Entries = make(map[string]*ChartRef) +// LoadIndex loads an index file and does minimal validity checking. +// +// This will fail if API Version is not set (ErrNoAPIVersion) or if the unmarshal fails. +func LoadIndex(data []byte) (*IndexFile, error) { + i := &IndexFile{} + if err := yaml.Unmarshal(data, i); err != nil { + return i, err } - entry := ChartRef{Name: name, URL: url} - i.Entries[name] = &entry - out, err := yaml.Marshal(&i.Entries) - if err != nil { - return nil, err + if i.APIVersion == "" { + return i, ErrNoAPIVersion } - - return out, nil + return i, nil } // LoadIndexFile takes a file at the given path and returns an IndexFile object @@ -148,12 +218,5 @@ func LoadIndexFile(path string) (*IndexFile, error) { if err != nil { return nil, err } - - indexfile := NewIndexFile() - err = yaml.Unmarshal(b, indexfile) - if err != nil { - return nil, err - } - - return indexfile, nil + return LoadIndex(b) } diff --git a/pkg/repo/index_test.go b/pkg/repo/index_test.go index 3b248fb19..a73cd0352 100644 --- a/pkg/repo/index_test.go +++ b/pkg/repo/index_test.go @@ -17,7 +17,6 @@ limitations under the License. package repo import ( - "fmt" "io/ioutil" "net/http" "net/http/httptest" @@ -25,25 +24,74 @@ import ( "path/filepath" "testing" - "gopkg.in/yaml.v2" + "k8s.io/helm/pkg/proto/hapi/chart" ) -const testfile = "testdata/local-index.yaml" - -var ( +const ( + testfile = "testdata/local-index.yaml" testRepo = "test-repo" ) +func TestIndexFile(t *testing.T) { + i := NewIndexFile() + i.Add(&chart.Metadata{Name: "clipper", Version: "0.1.0"}, "clipper-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890") + i.Add(&chart.Metadata{Name: "cutter", Version: "0.1.1"}, "cutter-0.1.1.tgz", "http://example.com/charts", "sha256:1234567890abc") + i.Add(&chart.Metadata{Name: "cutter", Version: "0.1.0"}, "cutter-0.1.0.tgz", "http://example.com/charts", "sha256:1234567890abc") + i.Add(&chart.Metadata{Name: "cutter", Version: "0.2.0"}, "cutter-0.2.0.tgz", "http://example.com/charts", "sha256:1234567890abc") + i.SortEntries() + + if i.APIVersion != APIVersionV1 { + t.Error("Expected API version v1") + } + + if len(i.Entries) != 2 { + t.Errorf("Expected 2 charts. Got %d", len(i.Entries)) + } + + if i.Entries["clipper"][0].Name != "clipper" { + t.Errorf("Expected clipper, got %s", i.Entries["clipper"][0].Name) + } + + if len(i.Entries["cutter"]) != 3 { + t.Error("Expected two cutters.") + } + + // Test that the sort worked. 0.2 should be at the first index for Cutter. + if v := i.Entries["cutter"][0].Version; v != "0.2.0" { + t.Errorf("Unexpected first version: %s", v) + } +} + +func TestLoadIndex(t *testing.T) { + b, err := ioutil.ReadFile(testfile) + if err != nil { + t.Fatal(err) + } + i, err := LoadIndex(b) + if err != nil { + t.Fatal(err) + } + verifyLocalIndex(t, i) +} + +func TestLoadIndexFile(t *testing.T) { + i, err := LoadIndexFile(testfile) + if err != nil { + t.Fatal(err) + } + verifyLocalIndex(t, i) +} + func TestDownloadIndexFile(t *testing.T) { fileBytes, err := ioutil.ReadFile("testdata/local-index.yaml") if err != nil { - t.Errorf("%#v", err) + t.Fatal(err) } - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "binary/octet-stream") - fmt.Fprintln(w, string(fileBytes)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(fileBytes) })) + defer srv.Close() dirName, err := ioutil.TempDir("", "tmp") if err != nil { @@ -52,7 +100,7 @@ func TestDownloadIndexFile(t *testing.T) { defer os.RemoveAll(dirName) path := filepath.Join(dirName, testRepo+"-index.yaml") - if err := DownloadIndexFile(testRepo, ts.URL, path); err != nil { + if err := DownloadIndexFile(testRepo, srv.URL, path); err != nil { t.Errorf("%#v", err) } @@ -65,50 +113,111 @@ func TestDownloadIndexFile(t *testing.T) { t.Errorf("error reading index file: %#v", err) } - var i IndexFile - if err = yaml.Unmarshal(b, &i); err != nil { - t.Errorf("error unmarshaling index file: %#v", err) + i, err := LoadIndex(b) + if err != nil { + t.Errorf("Index %q failed to parse: %s", testfile, err) + return } + verifyLocalIndex(t, i) +} + +func verifyLocalIndex(t *testing.T, i *IndexFile) { numEntries := len(i.Entries) if numEntries != 2 { - t.Errorf("Expected 2 entries in index file but got %v", numEntries) + t.Errorf("Expected 2 entries in index file but got %d", numEntries) } - os.Remove(path) -} -func TestLoadIndexFile(t *testing.T) { - cf, err := LoadIndexFile(testfile) - if err != nil { - t.Errorf("Failed to load index file: %s", err) - } - if len(cf.Entries) != 2 { - t.Errorf("Expected 2 entries in the index file, but got %d", len(cf.Entries)) - } - nginx := false - alpine := false - for k, e := range cf.Entries { - if k == "nginx-0.1.0" { - if e.Name == "nginx" { - if len(e.Chartfile.Keywords) == 3 { - nginx = true - } + alpine, ok := i.Entries["alpine"] + if !ok { + t.Errorf("'alpine' section not found.") + return + } + + if l := len(alpine); l != 1 { + t.Errorf("'alpine' should have 1 chart, got %d", l) + return + } + + nginx, ok := i.Entries["nginx"] + if !ok || len(nginx) != 2 { + t.Error("Expected 2 nginx entries") + return + } + + expects := []*ChartVersion{ + { + Metadata: &chart.Metadata{ + Name: "alpine", + Description: "string", + Version: "1.0.0", + Keywords: []string{"linux", "alpine", "small", "sumtin"}, + Home: "https://github.com/something", + }, + URLs: []string{ + "http://storage.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz", + "http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz", + }, + Digest: "sha256:1234567890abcdef", + }, + { + Metadata: &chart.Metadata{ + Name: "nginx", + Description: "string", + Version: "0.1.0", + Keywords: []string{"popular", "web server", "proxy"}, + Home: "https://github.com/something", + }, + URLs: []string{ + "http://storage.googleapis.com/kubernetes-charts/nginx-0.1.0.tgz", + }, + Digest: "sha256:1234567890abcdef", + }, + { + Metadata: &chart.Metadata{ + Name: "nginx", + Description: "string", + Version: "0.2.0", + Keywords: []string{"popular", "web server", "proxy"}, + Home: "https://github.com/something/else", + }, + URLs: []string{ + "http://storage.googleapis.com/kubernetes-charts/nginx-0.2.0.tgz", + }, + Digest: "sha256:1234567890abcdef", + }, + } + tests := []*ChartVersion{alpine[0], nginx[0], nginx[1]} + + for i, tt := range tests { + expect := expects[i] + if tt.Name != expect.Name { + t.Errorf("Expected name %q, got %q", expect.Name, tt.Name) + } + if tt.Description != expect.Description { + t.Errorf("Expected description %q, got %q", expect.Description, tt.Description) + } + if tt.Version != expect.Version { + t.Errorf("Expected version %q, got %q", expect.Version, tt.Version) + } + if tt.Digest != expect.Digest { + t.Errorf("Expected digest %q, got %q", expect.Digest, tt.Digest) + } + if tt.Home != expect.Home { + t.Errorf("Expected home %q, got %q", expect.Home, tt.Home) + } + + for i, url := range tt.URLs { + if url != expect.URLs[i] { + t.Errorf("Expected URL %q, got %q", expect.URLs[i], url) } } - if k == "alpine-1.0.0" { - if e.Name == "alpine" { - if len(e.Chartfile.Keywords) == 4 { - alpine = true - } + for i, kw := range tt.Keywords { + if kw != expect.Keywords[i] { + t.Errorf("Expected keywords %q, got %q", expect.Keywords[i], kw) } } } - if !nginx { - t.Errorf("nginx entry was not decoded properly") - } - if !alpine { - t.Errorf("alpine entry was not decoded properly") - } } func TestIndexDirectory(t *testing.T) { @@ -124,21 +233,20 @@ func TestIndexDirectory(t *testing.T) { // Other things test the entry generation more thoroughly. We just test a // few fields. - cname := "frobnitz-1.2.3" - frob, ok := index.Entries[cname] + cname := "frobnitz" + frobs, ok := index.Entries[cname] if !ok { t.Fatalf("Could not read chart %s", cname) } + + frob := frobs[0] 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.URLs[0] != "http://localhost:8080/frobnitz-1.2.3.tgz" { + t.Errorf("Unexpected URLs: %v", frob.URLs) } - if frob.Chartfile.Name != "frobnitz" { - t.Errorf("Expected frobnitz, got %q", frob.Chartfile.Name) + if frob.Name != "frobnitz" { + t.Errorf("Expected frobnitz, got %q", frob.Name) } } diff --git a/pkg/repo/local.go b/pkg/repo/local.go index 3ffd72c73..b3105706a 100644 --- a/pkg/repo/local.go +++ b/pkg/repo/local.go @@ -23,21 +23,27 @@ import ( "path/filepath" "strings" + "github.com/ghodss/yaml" + "k8s.io/helm/pkg/chartutil" "k8s.io/helm/pkg/proto/hapi/chart" + "k8s.io/helm/pkg/provenance" ) var localRepoPath string // StartLocalRepo starts a web server and serves files from the given path -func StartLocalRepo(path string) { - fmt.Println("Now serving you on localhost:8879...") +func StartLocalRepo(path, address string) error { + if address == "" { + address = ":8879" + } localRepoPath = path http.HandleFunc("/", rootHandler) http.HandleFunc("/charts/", indexHandler) - http.ListenAndServe(":8879", nil) + return http.ListenAndServe(address, nil) } func rootHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "Welcome to the Kubernetes Package manager!\nBrowse charts on localhost:8879/charts!") } func indexHandler(w http.ResponseWriter, r *http.Request) { @@ -81,9 +87,14 @@ func Reindex(ch *chart.Chart, path string) error { } } if !found { - url := "localhost:8879/charts/" + name + ".tgz" + dig, err := provenance.DigestFile(path) + if err != nil { + return err + } + + y.Add(ch.Metadata, name+".tgz", "http://localhost:8879/charts", "sha256:"+dig) - out, err := y.addEntry(name, url) + out, err := yaml.Marshal(y) if err != nil { return err } diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go index 75f7708d8..baa66a7d9 100644 --- a/pkg/repo/repo.go +++ b/pkg/repo/repo.go @@ -17,22 +17,24 @@ limitations under the License. package repo // import "k8s.io/helm/pkg/repo" import ( - "crypto/sha256" - "encoding/hex" "errors" - "io" + "fmt" "io/ioutil" - "net/url" "os" "path/filepath" "strings" "time" - "gopkg.in/yaml.v2" + "github.com/ghodss/yaml" "k8s.io/helm/pkg/chartutil" + "k8s.io/helm/pkg/provenance" ) +// ErrRepoOutOfDate indicates that the repository file is out of date, but +// is fixable. +var ErrRepoOutOfDate = errors.New("repository file is out of date") + // ChartRepository represents a chart repository type ChartRepository struct { RootPath string @@ -41,41 +43,109 @@ type ChartRepository struct { IndexFile *IndexFile } +// Entry represents one repo entry in a repositories listing. +type Entry struct { + Name string `json:"name"` + Cache string `json:"cache"` + URL string `json:"url"` +} + // RepoFile represents the repositories.yaml file in $HELM_HOME type RepoFile struct { - Repositories map[string]string + APIVersion string `json:"apiVersion"` + Generated time.Time `json:"generated"` + Repositories []*Entry `json:"repositories"` +} + +// NewRepoFile generates an empty repositories file. +// +// Generated and APIVersion are automatically set. +func NewRepoFile() *RepoFile { + return &RepoFile{ + APIVersion: APIVersionV1, + Generated: time.Now(), + Repositories: []*Entry{}, + } } // LoadRepositoriesFile takes a file at the given path and returns a RepoFile object +// +// If this returns ErrRepoOutOfDate, it also returns a recovered RepoFile that +// can be saved as a replacement to the out of date file. func LoadRepositoriesFile(path string) (*RepoFile, error) { b, err := ioutil.ReadFile(path) if err != nil { return nil, err } - var r RepoFile - err = yaml.Unmarshal(b, &r) + r := &RepoFile{} + err = yaml.Unmarshal(b, r) if err != nil { return nil, err } - return &r, nil + // File is either corrupt, or is from before v2.0.0-Alpha.5 + if r.APIVersion == "" { + m := map[string]string{} + if err = yaml.Unmarshal(b, &m); err != nil { + return nil, err + } + r := NewRepoFile() + for k, v := range m { + r.Add(&Entry{ + Name: k, + URL: v, + Cache: fmt.Sprintf("%s-index.yaml", k), + }) + } + return r, ErrRepoOutOfDate + } + + return r, nil } -// UnmarshalYAML unmarshals the repo file -func (rf *RepoFile) UnmarshalYAML(unmarshal func(interface{}) error) error { - var repos map[string]string - if err := unmarshal(&repos); err != nil { - if _, ok := err.(*yaml.TypeError); !ok { - return err +// Add adds one or more repo entries to a repo file. +func (r *RepoFile) Add(re ...*Entry) { + r.Repositories = append(r.Repositories, re...) +} + +// Has returns true if the given name is already a repository name. +func (r *RepoFile) Has(name string) bool { + for _, rf := range r.Repositories { + if rf.Name == name { + return true } } - rf.Repositories = repos - return nil + return false +} + +// Remove removes the entry from the list of repositories. +func (r *RepoFile) Remove(name string) bool { + cp := []*Entry{} + found := false + for _, rf := range r.Repositories { + if rf.Name == name { + found = true + continue + } + cp = append(cp, rf) + } + r.Repositories = cp + return found +} + +// WriteFile writes a repositories file to the given path. +func (r *RepoFile) WriteFile(path string, perm os.FileMode) error { + data, err := yaml.Marshal(r) + if err != nil { + return err + } + return ioutil.WriteFile(path, data, perm) } -// LoadChartRepository takes in a path to a local chart repository -// which contains packaged charts and an index.yaml file +// LoadChartRepository loads a directory of charts as if it were a repository. +// +// It requires the presence of an index.yaml file in the directory. // // This function evaluates the contents of the directory and // returns a ChartRepository @@ -86,14 +156,17 @@ func LoadChartRepository(dir, url string) (*ChartRepository, error) { } if !dirInfo.IsDir() { - return nil, errors.New(dir + "is not a directory") + return nil, fmt.Errorf("%q is not a directory", dir) } r := &ChartRepository{RootPath: dir, URL: url} + // FIXME: Why are we recursively walking directories? + // FIXME: Why are we not reading the repositories.yaml to figure out + // what repos to use? filepath.Walk(dir, func(path string, f os.FileInfo, err error) error { if !f.IsDir() { - if strings.Contains(f.Name(), "index.yaml") { + if strings.Contains(f.Name(), "-index.yaml") { i, err := LoadIndexFile(path) if err != nil { return nil @@ -109,82 +182,35 @@ func LoadChartRepository(dir, url string) (*ChartRepository, error) { } func (r *ChartRepository) saveIndexFile() error { - index, err := yaml.Marshal(&r.IndexFile.Entries) + index, err := yaml.Marshal(r.IndexFile) if err != nil { return err } - return ioutil.WriteFile(filepath.Join(r.RootPath, indexPath), index, 0644) } -// Index generates an index for the chart repository and writes an index.yaml file +// Index generates an index for the chart repository and writes an index.yaml file. func (r *ChartRepository) Index() error { if r.IndexFile == nil { - r.IndexFile = &IndexFile{Entries: make(map[string]*ChartRef)} + r.IndexFile = NewIndexFile() } - existCharts := map[string]bool{} - for _, path := range r.ChartPaths { ch, err := chartutil.Load(path) if err != nil { return err } - chartfile := ch.Metadata - digest, err := generateDigest(path) + digest, err := provenance.DigestFile(path) if err != nil { return err } - key := chartfile.Name + "-" + chartfile.Version - if r.IndexFile.Entries == nil { - r.IndexFile.Entries = make(map[string]*ChartRef) - } - - ref, ok := r.IndexFile.Entries[key] - var created string - if ok && ref.Created != "" { - created = ref.Created - } else { - created = nowString() + if !r.IndexFile.Has(ch.Metadata.Name, ch.Metadata.Version) { + r.IndexFile.Add(ch.Metadata, path, r.URL, digest) } - - url, _ := url.Parse(r.URL) - url.Path = filepath.Join(url.Path, key+".tgz") - - entry := &ChartRef{Chartfile: chartfile, Name: chartfile.Name, URL: url.String(), Created: created, Digest: digest, Removed: false} - - r.IndexFile.Entries[key] = entry - - // chart is existing - existCharts[key] = true + // TODO: If a chart exists, but has a different Digest, should we error? } - - // update deleted charts with Removed = true - for k := range r.IndexFile.Entries { - if _, ok := existCharts[k]; !ok { - r.IndexFile.Entries[k].Removed = true - } - } - + r.IndexFile.SortEntries() return r.saveIndexFile() } - -func nowString() string { - // FIXME: This is a different date format than we use elsewhere. - return time.Now().UTC().String() -} - -func generateDigest(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - - h := sha256.New() - io.Copy(h, f) - - digest := h.Sum([]byte{}) - return "sha256:" + hex.EncodeToString(digest[:]), nil -} diff --git a/pkg/repo/repo_test.go b/pkg/repo/repo_test.go index 651f7fcae..0f4589dba 100644 --- a/pkg/repo/repo_test.go +++ b/pkg/repo/repo_test.go @@ -22,12 +22,111 @@ import ( "reflect" "testing" "time" + + "k8s.io/helm/pkg/proto/hapi/chart" ) const testRepositoriesFile = "testdata/repositories.yaml" const testRepository = "testdata/repository" const testURL = "http://example-charts.com" +func TestRepoFile(t *testing.T) { + rf := NewRepoFile() + rf.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + Cache: "stable-index.yaml", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + Cache: "incubator-index.yaml", + }, + ) + + if len(rf.Repositories) != 2 { + t.Fatal("Expected 2 repositories") + } + + if rf.Has("nosuchrepo") { + t.Error("Found nonexistent repo") + } + if !rf.Has("incubator") { + t.Error("incubator repo is missing") + } + + stable := rf.Repositories[0] + if stable.Name != "stable" { + t.Error("stable is not named stable") + } + if stable.URL != "https://example.com/stable/charts" { + t.Error("Wrong URL for stable") + } + if stable.Cache != "stable-index.yaml" { + t.Error("Wrong cache name for stable") + } +} + +func TestLoadRepositoriesFile(t *testing.T) { + expects := NewRepoFile() + expects.Add( + &Entry{ + Name: "stable", + URL: "https://example.com/stable/charts", + Cache: "stable-index.yaml", + }, + &Entry{ + Name: "incubator", + URL: "https://example.com/incubator", + Cache: "incubator-index.yaml", + }, + ) + + repofile, err := LoadRepositoriesFile(testRepositoriesFile) + if err != nil { + t.Errorf("%q could not be loaded: %s", testRepositoriesFile, err) + } + + if len(expects.Repositories) != len(repofile.Repositories) { + t.Fatalf("Unexpected repo data: %#v", repofile.Repositories) + } + + for i, expect := range expects.Repositories { + got := repofile.Repositories[i] + if expect.Name != got.Name { + t.Errorf("Expected name %q, got %q", expect.Name, got.Name) + } + if expect.URL != got.URL { + t.Errorf("Expected url %q, got %q", expect.URL, got.URL) + } + if expect.Cache != got.Cache { + t.Errorf("Expected cache %q, got %q", expect.Cache, got.Cache) + } + } +} + +func TestLoadPreV1RepositoriesFile(t *testing.T) { + r, err := LoadRepositoriesFile("testdata/old-repositories.yaml") + if err != nil && err != ErrRepoOutOfDate { + t.Fatal(err) + } + if len(r.Repositories) != 3 { + t.Fatalf("Expected 3 repos: %#v", r) + } + + // Because they are parsed as a map, we lose ordering. + found := false + for _, rr := range r.Repositories { + if rr.Name == "best-charts-ever" { + found = true + } + } + if !found { + t.Errorf("expected the best charts ever. Got %#v", r.Repositories) + } +} + func TestLoadChartRepository(t *testing.T) { cr, err := LoadChartRepository(testRepository, testURL) if err != nil { @@ -66,76 +165,94 @@ func TestIndex(t *testing.T) { if err != nil { t.Errorf("Error loading index file %v", err) } + verifyIndex(t, actual) - entries := actual.Entries - numEntries := len(entries) - if numEntries != 2 { - t.Errorf("Expected 2 charts to be listed in index file but got %v", numEntries) - } - - timestamps := make(map[string]string) - var empty time.Time - for chartName, details := range entries { - if details == nil { - t.Errorf("Chart Entry is not filled out for %s", chartName) - } - - if details.Created == empty.String() { - t.Errorf("Created timestamp under %s chart entry is nil", chartName) - } - timestamps[chartName] = details.Created - - if details.Digest == "" { - t.Errorf("Digest was not set for %s", chartName) - } - } - - if err = cr.Index(); err != nil { - t.Errorf("Error performing index the second time: %v\n", err) + // Re-index and test again. + err = cr.Index() + if err != nil { + t.Errorf("Error performing re-index: %s\n", err) } second, err := LoadIndexFile(tempIndexPath) if err != nil { - t.Errorf("Error loading index file second time: %#v\n", err) + t.Errorf("Error re-loading index file %v", err) } + verifyIndex(t, second) +} - for chart, created := range timestamps { - v, ok := second.Entries[chart] - if !ok { - t.Errorf("Expected %s chart entry in index file but did not find it", chart) - } - if v.Created != created { - t.Errorf("Expected Created timestamp to be %s, but got %s for chart %s", created, v.Created, chart) - } - // Created manually since we control the input of the test - expectedURL := testURL + "/" + chart + ".tgz" - if v.URL != expectedURL { - t.Errorf("Expected url in entry to be %s but got %s for chart: %s", expectedURL, v.URL, chart) - } +func verifyIndex(t *testing.T, actual *IndexFile) { + + var empty time.Time + if actual.Generated == empty { + t.Errorf("Generated should be greater than 0: %s", actual.Generated) } -} -func TestLoadRepositoriesFile(t *testing.T) { - rf, err := LoadRepositoriesFile(testRepositoriesFile) - if err != nil { - t.Errorf(testRepositoriesFile + " could not be loaded: " + err.Error()) + if actual.APIVersion != APIVersionV1 { + t.Error("Expected v1 API") } - expected := map[string]string{"best-charts-ever": "http://best-charts-ever.com", - "okay-charts": "http://okay-charts.org", "example123": "http://examplecharts.net/charts/123"} - numOfRepositories := len(rf.Repositories) - expectedNumOfRepositories := 3 - if numOfRepositories != expectedNumOfRepositories { - t.Errorf("Expected %v repositories but only got %v", expectedNumOfRepositories, numOfRepositories) + entries := actual.Entries + if numEntries := len(entries); numEntries != 2 { + t.Errorf("Expected 2 charts to be listed in index file but got %v", numEntries) } - for expectedRepo, expectedURL := range expected { - actual, ok := rf.Repositories[expectedRepo] + expects := map[string]ChartVersions{ + "frobnitz": { + { + Metadata: &chart.Metadata{ + Name: "frobnitz", + Version: "1.2.3", + }, + }, + }, + "sprocket": { + { + Metadata: &chart.Metadata{ + Name: "sprocket", + Version: "1.2.0", + }, + }, + }, + } + + for name, versions := range expects { + got, ok := entries[name] if !ok { - t.Errorf("Expected repository: %v but was not found", expectedRepo) + t.Errorf("Could not find %q entry", name) + continue } - - if expectedURL != actual { - t.Errorf("Expected url %s for the %s repository but got %s ", expectedURL, expectedRepo, actual) + if len(versions) != len(got) { + t.Errorf("Expected %d versions, got %d", len(versions), len(got)) + continue + } + for i, e := range versions { + g := got[i] + if e.Name != g.Name { + t.Errorf("Expected %q, got %q", e.Name, g.Name) + } + if e.Version != g.Version { + t.Errorf("Expected %q, got %q", e.Version, g.Version) + } + if len(g.Keywords) != 3 { + t.Error("Expected 3 keyrwords.") + } + if len(g.Maintainers) != 2 { + t.Error("Expected 2 maintainers.") + } + if g.Created == empty { + t.Error("Expected created to be non-empty") + } + if g.Description == "" { + t.Error("Expected description to be non-empty") + } + if g.Home == "" { + t.Error("Expected home to be non-empty") + } + if g.Digest == "" { + t.Error("Expected digest to be non-empty") + } + if len(g.URLs) != 1 { + t.Error("Expected exactly 1 URL") + } } } } diff --git a/pkg/repo/repotest/server.go b/pkg/repo/repotest/server.go index eb737290c..8023dbc5c 100644 --- a/pkg/repo/repotest/server.go +++ b/pkg/repo/repotest/server.go @@ -82,20 +82,27 @@ func (s *Server) CopyCharts(origin string) ([]string, error) { copied[i] = newname } + err = s.CreateIndex() + return copied, err +} + +// CreateIndex will read docroot and generate an index.yaml file. +func (s *Server) CreateIndex() error { // generate the index index, err := repo.IndexDirectory(s.docroot, s.URL()) if err != nil { - return copied, err + return err } - d, err := yaml.Marshal(index.Entries) + d, err := yaml.Marshal(index) if err != nil { - return copied, err + return err } + println(string(d)) + ifile := filepath.Join(s.docroot, "index.yaml") - err = ioutil.WriteFile(ifile, d, 0755) - return copied, err + return ioutil.WriteFile(ifile, d, 0755) } func (s *Server) start() { @@ -119,12 +126,9 @@ func (s *Server) URL() string { // 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 - } + rf := repo.NewRepoFile() + rf.Add(&repo.Entry{Name: name, URL: url}) os.MkdirAll(filepath.Join(helmhome, "repository", name), 0755) dest := filepath.Join(helmhome, "repository/repositories.yaml") - return ioutil.WriteFile(dest, data, 0666) + return rf.WriteFile(dest, 0644) } diff --git a/pkg/repo/repotest/server_test.go b/pkg/repo/repotest/server_test.go index 8437ed512..b2a9a5b00 100644 --- a/pkg/repo/repotest/server_test.go +++ b/pkg/repo/repotest/server_test.go @@ -22,7 +22,7 @@ import ( "path/filepath" "testing" - "gopkg.in/yaml.v2" + "github.com/ghodss/yaml" "k8s.io/helm/pkg/repo" ) @@ -77,23 +77,20 @@ func TestServer(t *testing.T) { return } - var m map[string]*repo.ChartRef - if err := yaml.Unmarshal(data, &m); err != nil { + m := repo.NewIndexFile() + if err := yaml.Unmarshal(data, m); err != nil { t.Error(err) return } - if l := len(m); l != 1 { + if l := len(m.Entries); 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) + expect := "examplechart" + if !m.Has(expect, "0.1.0") { + t.Errorf("missing %q", expect) } res, err = http.Get(srv.URL() + "/index.yaml-nosuchthing") diff --git a/pkg/repo/testdata/local-index.yaml b/pkg/repo/testdata/local-index.yaml index 3db03faa4..ae29dfd8f 100644 --- a/pkg/repo/testdata/local-index.yaml +++ b/pkg/repo/testdata/local-index.yaml @@ -1,19 +1,32 @@ -nginx-0.1.0: - url: http://storage.googleapis.com/kubernetes-charts/nginx-0.1.0.tgz - name: nginx - chartfile: +apiVersion: v1 +entries: + nginx: + - urls: + - http://storage.googleapis.com/kubernetes-charts/nginx-0.1.0.tgz name: nginx description: string version: 0.1.0 home: https://github.com/something + digest: "sha256:1234567890abcdef" keywords: - popular - web server - proxy -alpine-1.0.0: - url: http://storage.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz - name: alpine - chartfile: + - urls: + - http://storage.googleapis.com/kubernetes-charts/nginx-0.2.0.tgz + name: nginx + description: string + version: 0.2.0 + home: https://github.com/something/else + digest: "sha256:1234567890abcdef" + keywords: + - popular + - web server + - proxy + alpine: + - urls: + - http://storage.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz + - http://storage2.googleapis.com/kubernetes-charts/alpine-1.0.0.tgz name: alpine description: string version: 1.0.0 @@ -23,4 +36,5 @@ alpine-1.0.0: - alpine - small - sumtin + digest: "sha256:1234567890abcdef" diff --git a/pkg/repo/testdata/old-repositories.yaml b/pkg/repo/testdata/old-repositories.yaml new file mode 100644 index 000000000..3fb55b060 --- /dev/null +++ b/pkg/repo/testdata/old-repositories.yaml @@ -0,0 +1,3 @@ +best-charts-ever: http://best-charts-ever.com +okay-charts: http://okay-charts.org +example123: http://examplecharts.net/charts/123 diff --git a/pkg/repo/testdata/repositories.yaml b/pkg/repo/testdata/repositories.yaml index 3fb55b060..a28c48eab 100644 --- a/pkg/repo/testdata/repositories.yaml +++ b/pkg/repo/testdata/repositories.yaml @@ -1,3 +1,8 @@ -best-charts-ever: http://best-charts-ever.com -okay-charts: http://okay-charts.org -example123: http://examplecharts.net/charts/123 +apiVersion: v1 +repositories: + - name: stable + url: https://example.com/stable/charts + cache: stable-index.yaml + - name: incubator + url: https://example.com/incubator + cache: incubator-index.yaml