diff --git a/cmd/helm/repo_add.go b/cmd/helm/repo_add.go index e13df7ad8..fcbb64274 100644 --- a/cmd/helm/repo_add.go +++ b/cmd/helm/repo_add.go @@ -17,56 +17,16 @@ limitations under the License. package main import ( - "context" - "fmt" "io" - "io/ioutil" - "os" - "path/filepath" - "strings" - "time" - "github.com/gofrs/flock" - "github.com/pkg/errors" "github.com/spf13/cobra" - "golang.org/x/term" - "sigs.k8s.io/yaml" "helm.sh/helm/v3/cmd/helm/require" - "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/action" ) -// Repositories that have been permanently deleted and no longer work -var deprecatedRepos = map[string]string{ - "//kubernetes-charts.storage.googleapis.com": "https://charts.helm.sh/stable", - "//kubernetes-charts-incubator.storage.googleapis.com": "https://charts.helm.sh/incubator", -} - -type repoAddOptions struct { - name string - url string - username string - password string - passwordFromStdinOpt bool - passCredentialsAll bool - forceUpdate bool - allowDeprecatedRepos bool - - certFile string - keyFile string - caFile string - insecureSkipTLSverify bool - - repoFile string - repoCache string - - // Deprecated, but cannot be removed until Helm 4 - deprecatedNoUpdate bool -} - func newRepoAddCmd(out io.Writer) *cobra.Command { - o := &repoAddOptions{} + o := &action.RepoAddOptions{} cmd := &cobra.Command{ Use: "add [NAME] [URL]", @@ -74,148 +34,27 @@ func newRepoAddCmd(out io.Writer) *cobra.Command { Args: require.ExactArgs(2), ValidArgsFunction: noCompletions, RunE: func(cmd *cobra.Command, args []string) error { - o.name = args[0] - o.url = args[1] - o.repoFile = settings.RepositoryConfig - o.repoCache = settings.RepositoryCache + o.Name = args[0] + o.URL = args[1] + o.RepoFile = settings.RepositoryConfig + o.RepoCache = settings.RepositoryCache - return o.run(out) + return o.Run(settings, out) }, } f := cmd.Flags() - f.StringVar(&o.username, "username", "", "chart repository username") - f.StringVar(&o.password, "password", "", "chart repository password") - f.BoolVarP(&o.passwordFromStdinOpt, "password-stdin", "", false, "read chart repository password from stdin") - f.BoolVar(&o.forceUpdate, "force-update", false, "replace (overwrite) the repo if it already exists") - f.BoolVar(&o.deprecatedNoUpdate, "no-update", false, "Ignored. Formerly, it would disabled forced updates. It is deprecated by force-update.") - f.StringVar(&o.certFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") - f.StringVar(&o.keyFile, "key-file", "", "identify HTTPS client using this SSL key file") - f.StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") - f.BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the repository") - f.BoolVar(&o.allowDeprecatedRepos, "allow-deprecated-repos", false, "by default, this command will not allow adding official repos that have been permanently deleted. This disables that behavior") - f.BoolVar(&o.passCredentialsAll, "pass-credentials", false, "pass credentials to all domains") + f.StringVar(&o.Username, "username", "", "chart repository username") + f.StringVar(&o.Password, "password", "", "chart repository password") + f.BoolVarP(&o.PasswordFromStdinOpt, "password-stdin", "", false, "read chart repository password from stdin") + f.BoolVar(&o.ForceUpdate, "force-update", false, "replace (overwrite) the repo if it already exists") + f.BoolVar(&o.DeprecatedNoUpdate, "no-update", false, "Ignored. Formerly, it would disabled forced updates. It is deprecated by force-update.") + f.StringVar(&o.CertFile, "cert-file", "", "identify HTTPS client using this SSL certificate file") + f.StringVar(&o.KeyFile, "key-file", "", "identify HTTPS client using this SSL key file") + f.StringVar(&o.CaFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle") + f.BoolVar(&o.InsecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the repository") + f.BoolVar(&o.AllowDeprecatedRepos, "allow-deprecated-repos", false, "by default, this command will not allow adding official repos that have been permanently deleted. This disables that behavior") + f.BoolVar(&o.PassCredentialsAll, "pass-credentials", false, "pass credentials to all domains") return cmd } - -func (o *repoAddOptions) run(out io.Writer) error { - // Block deprecated repos - if !o.allowDeprecatedRepos { - for oldURL, newURL := range deprecatedRepos { - if strings.Contains(o.url, oldURL) { - return fmt.Errorf("repo %q is no longer available; try %q instead", o.url, newURL) - } - } - } - - // Ensure the file directory exists as it is required for file locking - err := os.MkdirAll(filepath.Dir(o.repoFile), os.ModePerm) - if err != nil && !os.IsExist(err) { - return err - } - - // Acquire a file lock for process synchronization - repoFileExt := filepath.Ext(o.repoFile) - var lockPath string - if len(repoFileExt) > 0 && len(repoFileExt) < len(o.repoFile) { - lockPath = strings.TrimSuffix(o.repoFile, repoFileExt) + ".lock" - } else { - lockPath = o.repoFile + ".lock" - } - fileLock := flock.New(lockPath) - lockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - locked, err := fileLock.TryLockContext(lockCtx, time.Second) - if err == nil && locked { - defer fileLock.Unlock() - } - if err != nil { - return err - } - - b, err := ioutil.ReadFile(o.repoFile) - if err != nil && !os.IsNotExist(err) { - return err - } - - var f repo.File - if err := yaml.Unmarshal(b, &f); err != nil { - return err - } - - if o.username != "" && o.password == "" { - if o.passwordFromStdinOpt { - passwordFromStdin, err := io.ReadAll(os.Stdin) - if err != nil { - return err - } - password := strings.TrimSuffix(string(passwordFromStdin), "\n") - password = strings.TrimSuffix(password, "\r") - o.password = password - } else { - fd := int(os.Stdin.Fd()) - fmt.Fprint(out, "Password: ") - password, err := term.ReadPassword(fd) - fmt.Fprintln(out) - if err != nil { - return err - } - o.password = string(password) - } - } - - c := repo.Entry{ - Name: o.name, - URL: o.url, - Username: o.username, - Password: o.password, - PassCredentialsAll: o.passCredentialsAll, - CertFile: o.certFile, - KeyFile: o.keyFile, - CAFile: o.caFile, - InsecureSkipTLSverify: o.insecureSkipTLSverify, - } - - // Check if the repo name is legal - if strings.Contains(o.name, "/") { - return errors.Errorf("repository name (%s) contains '/', please specify a different name without '/'", o.name) - } - - // If the repo exists do one of two things: - // 1. If the configuration for the name is the same continue without error - // 2. When the config is different require --force-update - if !o.forceUpdate && f.Has(o.name) { - existing := f.Get(o.name) - if c != *existing { - - // The input coming in for the name is different from what is already - // configured. Return an error. - return errors.Errorf("repository name (%s) already exists, please specify a different name", o.name) - } - - // The add is idempotent so do nothing - fmt.Fprintf(out, "%q already exists with the same configuration, skipping\n", o.name) - return nil - } - - r, err := repo.NewChartRepository(&c, getter.All(settings)) - if err != nil { - return err - } - - if o.repoCache != "" { - r.CachePath = o.repoCache - } - if _, err := r.DownloadIndexFile(); err != nil { - return errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", o.url) - } - - f.Update(&c) - - if err := f.WriteFile(o.repoFile, 0644); err != nil { - return err - } - fmt.Fprintf(out, "%q has been added to your repositories\n", o.name) - return nil -} diff --git a/cmd/helm/repo_add_test.go b/cmd/helm/repo_add_test.go index f9b0cab00..0608f4668 100644 --- a/cmd/helm/repo_add_test.go +++ b/cmd/helm/repo_add_test.go @@ -18,19 +18,12 @@ package main import ( "fmt" - "io/ioutil" "os" "path/filepath" "strings" - "sync" "testing" - "sigs.k8s.io/yaml" - "helm.sh/helm/v3/internal/test/ensure" - "helm.sh/helm/v3/pkg/helmpath" - "helm.sh/helm/v3/pkg/helmpath/xdg" - "helm.sh/helm/v3/pkg/repo" "helm.sh/helm/v3/pkg/repo/repotest" ) @@ -81,162 +74,6 @@ func TestRepoAddCmd(t *testing.T) { runTestCmd(t, tests) } -func TestRepoAdd(t *testing.T) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } - defer ts.Stop() - - rootDir := ensure.TempDir(t) - repoFile := filepath.Join(rootDir, "repositories.yaml") - - const testRepoName = "test-name" - - o := &repoAddOptions{ - name: testRepoName, - url: ts.URL(), - forceUpdate: false, - deprecatedNoUpdate: true, - repoFile: repoFile, - } - os.Setenv(xdg.CacheHomeEnvVar, rootDir) - - if err := o.run(ioutil.Discard); err != nil { - t.Error(err) - } - - f, err := repo.LoadFile(repoFile) - if err != nil { - t.Fatal(err) - } - - if !f.Has(testRepoName) { - t.Errorf("%s was not successfully inserted into %s", testRepoName, repoFile) - } - - idx := filepath.Join(helmpath.CachePath("repository"), helmpath.CacheIndexFile(testRepoName)) - if _, err := os.Stat(idx); os.IsNotExist(err) { - t.Errorf("Error cache index file was not created for repository %s", testRepoName) - } - idx = filepath.Join(helmpath.CachePath("repository"), helmpath.CacheChartsFile(testRepoName)) - if _, err := os.Stat(idx); os.IsNotExist(err) { - t.Errorf("Error cache charts file was not created for repository %s", testRepoName) - } - - o.forceUpdate = true - - if err := o.run(ioutil.Discard); err != nil { - t.Errorf("Repository was not updated: %s", err) - } - - if err := o.run(ioutil.Discard); err != nil { - t.Errorf("Duplicate repository name was added") - } -} - -func TestRepoAddCheckLegalName(t *testing.T) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } - defer ts.Stop() - defer resetEnv()() - - const testRepoName = "test-hub/test-name" - - rootDir := ensure.TempDir(t) - repoFile := filepath.Join(ensure.TempDir(t), "repositories.yaml") - - o := &repoAddOptions{ - name: testRepoName, - url: ts.URL(), - forceUpdate: false, - deprecatedNoUpdate: true, - repoFile: repoFile, - } - os.Setenv(xdg.CacheHomeEnvVar, rootDir) - - wantErrorMsg := fmt.Sprintf("repository name (%s) contains '/', please specify a different name without '/'", testRepoName) - - if err := o.run(ioutil.Discard); err != nil { - if wantErrorMsg != err.Error() { - t.Fatalf("Actual error %s, not equal to expected error %s", err, wantErrorMsg) - } - } else { - t.Fatalf("expect reported an error.") - } -} - -func TestRepoAddConcurrentGoRoutines(t *testing.T) { - const testName = "test-name" - repoFile := filepath.Join(ensure.TempDir(t), "repositories.yaml") - repoAddConcurrent(t, testName, repoFile) -} - -func TestRepoAddConcurrentDirNotExist(t *testing.T) { - const testName = "test-name-2" - repoFile := filepath.Join(ensure.TempDir(t), "foo", "repositories.yaml") - repoAddConcurrent(t, testName, repoFile) -} - -func TestRepoAddConcurrentNoFileExtension(t *testing.T) { - const testName = "test-name-3" - repoFile := filepath.Join(ensure.TempDir(t), "repositories") - repoAddConcurrent(t, testName, repoFile) -} - -func TestRepoAddConcurrentHiddenFile(t *testing.T) { - const testName = "test-name-4" - repoFile := filepath.Join(ensure.TempDir(t), ".repositories") - repoAddConcurrent(t, testName, repoFile) -} - -func repoAddConcurrent(t *testing.T, testName, repoFile string) { - ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") - if err != nil { - t.Fatal(err) - } - defer ts.Stop() - - var wg sync.WaitGroup - wg.Add(3) - for i := 0; i < 3; i++ { - go func(name string) { - defer wg.Done() - o := &repoAddOptions{ - name: name, - url: ts.URL(), - deprecatedNoUpdate: true, - forceUpdate: false, - repoFile: repoFile, - } - if err := o.run(ioutil.Discard); err != nil { - t.Error(err) - } - }(fmt.Sprintf("%s-%d", testName, i)) - } - wg.Wait() - - b, err := ioutil.ReadFile(repoFile) - if err != nil { - t.Error(err) - } - - var f repo.File - if err := yaml.Unmarshal(b, &f); err != nil { - t.Error(err) - } - - var name string - for i := 0; i < 3; i++ { - name = fmt.Sprintf("%s-%d", testName, i) - if !f.Has(name) { - t.Errorf("%s was not successfully inserted into %s: %s", name, repoFile, f.Repositories[0]) - } - } -} - func TestRepoAddFileCompletion(t *testing.T) { checkFileCompletion(t, "repo add", false) checkFileCompletion(t, "repo add reponame", false) diff --git a/cmd/helm/repo_remove_test.go b/cmd/helm/repo_remove_test.go index 768295655..7bd6c058f 100644 --- a/cmd/helm/repo_remove_test.go +++ b/cmd/helm/repo_remove_test.go @@ -25,6 +25,7 @@ import ( "testing" "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/repo" "helm.sh/helm/v3/pkg/repo/repotest" @@ -53,13 +54,13 @@ func TestRepoRemove(t *testing.T) { if err := rmOpts.run(os.Stderr); err == nil { t.Errorf("Expected error removing %s, but did not get one.", testRepoName) } - o := &repoAddOptions{ - name: testRepoName, - url: ts.URL(), - repoFile: repoFile, + o := &action.RepoAddOptions{ + Name: testRepoName, + URL: ts.URL(), + RepoFile: repoFile, } - if err := o.run(os.Stderr); err != nil { + if err := o.Run(settings, os.Stderr); err != nil { t.Error(err) } @@ -92,13 +93,13 @@ func TestRepoRemove(t *testing.T) { // Add test repos for _, repoName := range testRepoNames { - o := &repoAddOptions{ - name: repoName, - url: ts.URL(), - repoFile: repoFile, + o := &action.RepoAddOptions{ + Name: repoName, + URL: ts.URL(), + RepoFile: repoFile, } - if err := o.run(os.Stderr); err != nil { + if err := o.Run(settings, os.Stderr); err != nil { t.Error(err) } @@ -177,13 +178,13 @@ func TestRepoRemoveCompletion(t *testing.T) { // Add test repos for _, repoName := range testRepoNames { - o := &repoAddOptions{ - name: repoName, - url: ts.URL(), - repoFile: repoFile, + o := &action.RepoAddOptions{ + Name: repoName, + URL: ts.URL(), + RepoFile: repoFile, } - if err := o.run(os.Stderr); err != nil { + if err := o.Run(settings, os.Stderr); err != nil { t.Error(err) } } diff --git a/pkg/action/repo_add.go b/pkg/action/repo_add.go new file mode 100644 index 000000000..ce9e975da --- /dev/null +++ b/pkg/action/repo_add.go @@ -0,0 +1,186 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/pkg/errors" + "golang.org/x/term" + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/repo" +) + +// Repositories that have been permanently deleted and no longer work +var deprecatedRepos = map[string]string{ + "//kubernetes-charts.storage.googleapis.com": "https://charts.helm.sh/stable", + "//kubernetes-charts-incubator.storage.googleapis.com": "https://charts.helm.sh/incubator", +} + +type RepoAddOptions struct { + Name string + URL string + Username string + Password string + PasswordFromStdinOpt bool + PassCredentialsAll bool + ForceUpdate bool + AllowDeprecatedRepos bool + + CertFile string + KeyFile string + CaFile string + InsecureSkipTLSverify bool + + RepoFile string + RepoCache string + + // Deprecated, but cannot be removed until Helm 4 + DeprecatedNoUpdate bool +} + +func (o *RepoAddOptions) Run(settings *cli.EnvSettings, out io.Writer) error { + // Block deprecated repos + if !o.AllowDeprecatedRepos { + for oldURL, newURL := range deprecatedRepos { + if strings.Contains(o.URL, oldURL) { + return fmt.Errorf("repo %q is no longer available; try %q instead", o.URL, newURL) + } + } + } + + // Ensure the file directory exists as it is required for file locking + err := os.MkdirAll(filepath.Dir(o.RepoFile), os.ModePerm) + if err != nil && !os.IsExist(err) { + return err + } + + // Acquire a file lock for process synchronization + repoFileExt := filepath.Ext(o.RepoFile) + var lockPath string + if len(repoFileExt) > 0 && len(repoFileExt) < len(o.RepoFile) { + lockPath = strings.TrimSuffix(o.RepoFile, repoFileExt) + ".lock" + } else { + lockPath = o.RepoFile + ".lock" + } + fileLock := flock.New(lockPath) + lockCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + locked, err := fileLock.TryLockContext(lockCtx, time.Second) + if err == nil && locked { + defer fileLock.Unlock() + } + if err != nil { + return err + } + + b, err := ioutil.ReadFile(o.RepoFile) + if err != nil && !os.IsNotExist(err) { + return err + } + + var f repo.File + if err := yaml.Unmarshal(b, &f); err != nil { + return err + } + + if o.Username != "" && o.Password == "" { + if o.PasswordFromStdinOpt { + passwordFromStdin, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + password := strings.TrimSuffix(string(passwordFromStdin), "\n") + password = strings.TrimSuffix(password, "\r") + o.Password = password + } else { + fd := int(os.Stdin.Fd()) + fmt.Fprint(out, "Password: ") + password, err := term.ReadPassword(fd) + fmt.Fprintln(out) + if err != nil { + return err + } + o.Password = string(password) + } + } + + c := repo.Entry{ + Name: o.Name, + URL: o.URL, + Username: o.Username, + Password: o.Password, + PassCredentialsAll: o.PassCredentialsAll, + CertFile: o.CertFile, + KeyFile: o.KeyFile, + CAFile: o.CaFile, + InsecureSkipTLSverify: o.InsecureSkipTLSverify, + } + + // Check if the repo name is legal + if strings.Contains(o.Name, "/") { + return errors.Errorf("repository name (%s) contains '/', please specify a different name without '/'", o.Name) + } + + // If the repo exists do one of two things: + // 1. If the configuration for the name is the same continue without error + // 2. When the config is different require --force-update + if !o.ForceUpdate && f.Has(o.Name) { + existing := f.Get(o.Name) + if c != *existing { + + // The input coming in for the name is different from what is already + // configured. Return an error. + return errors.Errorf("repository name (%s) already exists, please specify a different name", o.Name) + } + + // The add is idempotent so do nothing + fmt.Fprintf(out, "%q already exists with the same configuration, skipping\n", o.Name) + return nil + } + + r, err := repo.NewChartRepository(&c, getter.All(settings)) + if err != nil { + return err + } + + if o.RepoCache != "" { + r.CachePath = o.RepoCache + } + if _, err := r.DownloadIndexFile(); err != nil { + return errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", o.URL) + } + + f.Update(&c) + + if err := f.WriteFile(o.RepoFile, 0644); err != nil { + return err + } + fmt.Fprintf(out, "%q has been added to your repositories\n", o.Name) + return nil +} diff --git a/pkg/action/repo_add_test.go b/pkg/action/repo_add_test.go new file mode 100644 index 000000000..8cfc2bd49 --- /dev/null +++ b/pkg/action/repo_add_test.go @@ -0,0 +1,192 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package action + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sync" + "testing" + + "sigs.k8s.io/yaml" + + "helm.sh/helm/v3/internal/test/ensure" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/helmpath/xdg" + "helm.sh/helm/v3/pkg/repo" + "helm.sh/helm/v3/pkg/repo/repotest" +) + +var settings = cli.New() + +func TestRepoAdd(t *testing.T) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + rootDir := ensure.TempDir(t) + repoFile := filepath.Join(rootDir, "repositories.yaml") + + const testRepoName = "test-name" + + o := &RepoAddOptions{ + Name: testRepoName, + URL: ts.URL(), + ForceUpdate: false, + DeprecatedNoUpdate: true, + RepoFile: repoFile, + } + os.Setenv(xdg.CacheHomeEnvVar, rootDir) + + if err := o.Run(settings, ioutil.Discard); err != nil { + t.Error(err) + } + + f, err := repo.LoadFile(repoFile) + if err != nil { + t.Fatal(err) + } + + if !f.Has(testRepoName) { + t.Errorf("%s was not successfully inserted into %s", testRepoName, repoFile) + } + + idx := filepath.Join(helmpath.CachePath("repository"), helmpath.CacheIndexFile(testRepoName)) + if _, err := os.Stat(idx); os.IsNotExist(err) { + t.Errorf("Error cache index file was not created for repository %s", testRepoName) + } + idx = filepath.Join(helmpath.CachePath("repository"), helmpath.CacheChartsFile(testRepoName)) + if _, err := os.Stat(idx); os.IsNotExist(err) { + t.Errorf("Error cache charts file was not created for repository %s", testRepoName) + } + + o.ForceUpdate = true + + if err := o.Run(settings, ioutil.Discard); err != nil { + t.Errorf("Repository was not updated: %s", err) + } + + if err := o.Run(settings, ioutil.Discard); err != nil { + t.Errorf("Duplicate repository name was added") + } +} + +func TestRepoAddCheckLegalName(t *testing.T) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + const testRepoName = "test-hub/test-name" + + rootDir := ensure.TempDir(t) + repoFile := filepath.Join(ensure.TempDir(t), "repositories.yaml") + + o := &RepoAddOptions{ + Name: testRepoName, + URL: ts.URL(), + ForceUpdate: false, + DeprecatedNoUpdate: true, + RepoFile: repoFile, + } + os.Setenv(xdg.CacheHomeEnvVar, rootDir) + + wantErrorMsg := fmt.Sprintf("repository name (%s) contains '/', please specify a different name without '/'", testRepoName) + + if err := o.Run(settings, ioutil.Discard); err != nil { + if wantErrorMsg != err.Error() { + t.Fatalf("Actual error %s, not equal to expected error %s", err, wantErrorMsg) + } + } else { + t.Fatalf("expect reported an error.") + } +} + +func TestRepoAddConcurrentGoRoutines(t *testing.T) { + const testName = "test-name" + repoFile := filepath.Join(ensure.TempDir(t), "repositories.yaml") + repoAddConcurrent(t, testName, repoFile) +} + +func TestRepoAddConcurrentDirNotExist(t *testing.T) { + const testName = "test-name-2" + repoFile := filepath.Join(ensure.TempDir(t), "foo", "repositories.yaml") + repoAddConcurrent(t, testName, repoFile) +} + +func TestRepoAddConcurrentNoFileExtension(t *testing.T) { + const testName = "test-name-3" + repoFile := filepath.Join(ensure.TempDir(t), "repositories") + repoAddConcurrent(t, testName, repoFile) +} + +func TestRepoAddConcurrentHiddenFile(t *testing.T) { + const testName = "test-name-4" + repoFile := filepath.Join(ensure.TempDir(t), ".repositories") + repoAddConcurrent(t, testName, repoFile) +} + +func repoAddConcurrent(t *testing.T, testName, repoFile string) { + ts, err := repotest.NewTempServerWithCleanup(t, "testdata/testserver/*.*") + if err != nil { + t.Fatal(err) + } + defer ts.Stop() + + var wg sync.WaitGroup + wg.Add(3) + for i := 0; i < 3; i++ { + go func(name string) { + defer wg.Done() + o := &RepoAddOptions{ + Name: name, + URL: ts.URL(), + DeprecatedNoUpdate: true, + ForceUpdate: false, + RepoFile: repoFile, + } + if err := o.Run(settings, ioutil.Discard); err != nil { + t.Error(err) + } + }(fmt.Sprintf("%s-%d", testName, i)) + } + wg.Wait() + + b, err := ioutil.ReadFile(repoFile) + if err != nil { + t.Error(err) + } + + var f repo.File + if err := yaml.Unmarshal(b, &f); err != nil { + t.Error(err) + } + + var name string + for i := 0; i < 3; i++ { + name = fmt.Sprintf("%s-%d", testName, i) + if !f.Has(name) { + t.Errorf("%s was not successfully inserted into %s: %s", name, repoFile, f.Repositories[0]) + } + } +}