From ca7120c75f7836c134b1c01c161e49f430386be8 Mon Sep 17 00:00:00 2001 From: yxxhero Date: Fri, 9 Jul 2021 07:50:20 +0800 Subject: [PATCH] fix conflic in resolver package Signed-off-by: yxxhero --- cmd/helm/dependency.go | 30 ++++++++++ internal/resolver/resolver.go | 32 ++++++++++ internal/resolver/resolver_test.go | 19 ++++++ pkg/downloader/git_downloader.go | 79 +++++++++++++++++++++++++ pkg/downloader/manager.go | 24 ++++++++ pkg/downloader/manager_test.go | 7 +++ pkg/gitutil/gitutil.go | 76 ++++++++++++++++++++++++ pkg/gitutil/gitutil_test.go | 93 ++++++++++++++++++++++++++++++ 8 files changed, 360 insertions(+) create mode 100644 pkg/downloader/git_downloader.go create mode 100644 pkg/gitutil/gitutil.go create mode 100644 pkg/gitutil/gitutil_test.go diff --git a/cmd/helm/dependency.go b/cmd/helm/dependency.go index 03874742c..75205acae 100644 --- a/cmd/helm/dependency.go +++ b/cmd/helm/dependency.go @@ -71,6 +71,36 @@ the dependency charts stored locally. The path should start with a prefix of If the dependency chart is retrieved locally, it is not required to have the repository added to helm by "helm add repo". Version matching is also supported for this case. + +A repository can be defined as a git URL. The path must start with a prefix of +"git:" followed by a valid git repository URL. + +# requirements.yaml +dependencies: +- name: nginx + version: "master" + repository: "git:https://github.com/helm/helm-chart.git" + +The 'repository' can be the https or ssh URL that you would use to clone a git +repo or add as a git remote, prefixed with 'git:'. +For example 'git:git@github.com:helm/helm-chart.git' or +'git:https://github.com/helm/helm-chart.git' + +When using a 'git:' repository, the 'version' must be a valid tag or branch +name for the git repo. For example 'master'. + +Limitations when working with git repositories: +* Helm will use the 'git' executable on your system to retrieve information +about the repo. The 'git' command must be properly configured and available +on the PATH. +* When specifying a private repo, if git tries to query the user for +username/passowrd for an HTTPS url, or for a certificate password for an SSH +url, it may cause Helm to hang. Input is not forwarded to the child git +process, so it will not be able to receive user input. For private repos +it is recommended to use an SSH git url, and have your git client configured +with an SSH cert that does not require a password. +* The helm chart and 'Chart.yaml' must be in the root of the git repo. +The chart cannot be loaded from a subdirectory. ` const dependencyListDesc = ` diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 5e8921f96..86cf7227d 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -29,12 +29,18 @@ import ( "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/gates" + "helm.sh/helm/v3/pkg/gitutil" "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/provenance" "helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/repo" ) +const FeatureGateOCI = gates.Gate("HELM_EXPERIMENTAL_OCI") + +var gitGetRefs = gitutil.GetRefs + // Resolver resolves dependencies from semantic version ranges to a particular version. type Resolver struct { chartpath string @@ -107,6 +113,32 @@ func (r *Resolver) Resolve(reqs []*chart.Dependency, repoNames map[string]string continue } + if strings.HasPrefix(d.Repository, "git://") { + refs, err := gitGetRefs(strings.TrimPrefix(d.Repository, "git://")) + + if err != nil { + return nil, err + } + + _, found := refs[d.Version] + + if !found { + return nil, fmt.Errorf(`dependency %q is missing git branch or tag: %s. + When using a "git:" type repository, the "version" should be a valid branch or tag name`, d.Name, d.Version) + } + + locked[i] = &chart.Dependency{ + Name: d.Name, + Repository: d.Repository, + Version: d.Version, + } + continue + } + + if err != nil { + return nil, errors.Wrapf(err, "dependency %q has an invalid version/constraint format", d.Name) + } + repoName := repoNames[d.Name] // if the repository was not defined, but the dependency defines a repository url, bypass the cache if repoName == "" && d.Repository != "" { diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index a79852175..37dabb769 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -23,7 +23,15 @@ import ( "helm.sh/helm/v3/pkg/registry" ) +func fakeGetRefs(gitRepoURL string) (map[string]string, error) { + refs := map[string]string{ + "master": "9668ad4d90c5e95bd520e58e7387607be6b63bb6", + } + return refs, nil +} + func TestResolve(t *testing.T) { + gitGetRefs = fakeGetRefs tests := []struct { name string req []*chart.Dependency @@ -137,6 +145,17 @@ func TestResolve(t *testing.T) { }, err: true, }, + { + name: "repo from git url", + req: []*chart.Dependency{ + {Name: "gitdependency", Repository: "git:git@github.com:helm/gitdependency.git", Version: "master"}, + }, + expect: &chart.Lock{ + Dependencies: []*chart.Dependency{ + {Name: "gitdependency", Repository: "git:git@github.com:helm/gitdependency.git", Version: "master"}, + }, + }, + }, } repoNames := map[string]string{"alpine": "kubernetes-charts", "redis": "kubernetes-charts"} diff --git a/pkg/downloader/git_downloader.go b/pkg/downloader/git_downloader.go new file mode 100644 index 000000000..a6b8694bb --- /dev/null +++ b/pkg/downloader/git_downloader.go @@ -0,0 +1,79 @@ +/* +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 downloader + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/gitutil" +) + +// Assigned here so it can be overridden for testing. +var gitCloneTo = gitutil.CloneTo + +// GitDownloader handles downloading a chart from a git url. +type GitDownloader struct{} + +// ensureGitDirIgnored will append ".git/" to the .helmignore file in a directory. +// Create the .helmignore file if it does not exist. +func (g *GitDownloader) ensureGitDirIgnored(repoPath string) error { + helmignorePath := filepath.Join(repoPath, ".helmignore") + f, err := os.OpenFile(helmignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + if _, err := f.WriteString("\n.git/\n"); err != nil { + return err + } + return nil +} + +// DownloadTo will create a temp directory, then fetch a git repo into it. +// The git repo will be archived into a chart and copied to the destPath. +func (g *GitDownloader) DownloadTo(gitURL string, ref string, destPath string) error { + // the git archive command returns a tgz archive. we need to extract it to get the actual chart files. + tmpDir, err := ioutil.TempDir("", "helm") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + if err = gitCloneTo(gitURL, ref, tmpDir); err != nil { + return fmt.Errorf("Unable to retrieve git repo. %s", err) + } + + // A .helmignore that includes an ignore for .git/ should be included in the git repo itself, + // but a lot of people will probably not think about that. + // To prevent the git history from bleeding into the charts archive, append/create .helmignore. + g.ensureGitDirIgnored(tmpDir) + + // Turn the extracted git archive into a chart and move it into the charts directory. + // This is using chartutil.Save() so that .helmignore logic is applied. + loadedChart, loadErr := loader.LoadDir(tmpDir) + if loadErr != nil { + return fmt.Errorf("Unable to process the git repo %s as a chart. %s", gitURL, err) + } + if _, saveErr := chartutil.Save(loadedChart, destPath); saveErr != nil { + return fmt.Errorf("Unable to save the git repo %s as a chart. %s", gitURL, err) + } + return nil +} diff --git a/pkg/downloader/manager.go b/pkg/downloader/manager.go index 18b28dde1..f2d4494aa 100644 --- a/pkg/downloader/manager.go +++ b/pkg/downloader/manager.go @@ -310,6 +310,20 @@ func (m *Manager) downloadAll(deps []*chart.Dependency) error { dep.Version = ver continue } + if strings.HasPrefix(dep.Repository, "git:") { + destPath := filepath.Join(m.ChartPath, "charts") + gitURL := strings.TrimPrefix(dep.Repository, "git:") + if m.Debug { + fmt.Fprintf(m.Out, "Downloading %s from git repo %s\n", dep.Name, gitURL) + } + dl := GitDownloader{} + if err := dl.DownloadTo(gitURL, dep.Version, destPath); err != nil { + saveError = fmt.Errorf("could not download %s: %s", gitURL, err) + break + } + continue + } + fmt.Fprintf(m.Out, "Downloading %s from repo %s\n", dep.Name, dep.Repository) // Any failure to resolve/download a chart should fail: // https://github.com/helm/helm/issues/1439 @@ -594,6 +608,16 @@ func (m *Manager) resolveRepoNames(deps []*chart.Dependency) (map[string]string, reposMap[dd.Name] = dd.Repository continue } + // if dep chart is from a git url, assume it is valid for now. + // if the repo does not exist then it will later error when we try to fetch branches and tags. + // we could check for the repo existence here, but trying to avoid anotehr git request. + if strings.HasPrefix(dd.Repository, "git:") { + if m.Debug { + fmt.Fprintf(m.Out, "Repository from git url: %s\n", strings.TrimPrefix(dd.Repository, "git:")) + } + reposMap[dd.Name] = dd.Repository + continue + } found := false diff --git a/pkg/downloader/manager_test.go b/pkg/downloader/manager_test.go index f7ab1a568..b31533517 100644 --- a/pkg/downloader/manager_test.go +++ b/pkg/downloader/manager_test.go @@ -193,6 +193,13 @@ func TestGetRepoNames(t *testing.T) { }, expect: map[string]string{}, }, + { + name: "repo from git url", + req: []*chart.Dependency{ + {Name: "local-dep", Repository: "git:https://github.com/git/git"}, + }, + expect: map[string]string{"local-dep": "git:https://github.com/git/git"}, + }, } for _, tt := range tests { diff --git a/pkg/gitutil/gitutil.go b/pkg/gitutil/gitutil.go new file mode 100644 index 000000000..85b2a2ea1 --- /dev/null +++ b/pkg/gitutil/gitutil.go @@ -0,0 +1,76 @@ +/* +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 gitutil + +import ( + "bufio" + "fmt" + "os/exec" + "regexp" + "strings" +) + +var execCommand = exec.Command + +// This regex is designed to match output from git of the style: +// ebeb6eafceb61dd08441ffe086c77eb472842494 refs/tags/v0.21.0 +// and extract the hash and ref name as capture groups +var gitRefLineRegexp = regexp.MustCompile(`^([a-fA-F0-9]+)\s+(refs\/(?:tags|heads|pull|remotes)\/)(.*)$`) + +// Run a git command as a child process. +// If git is not on the path, an error will be returned. +// Returns the command output or an error. +func gitExec(args ...string) ([]byte, error) { + cmd := execCommand("git", args...) + output, err := cmd.CombinedOutput() + if err != nil { + err = fmt.Errorf("Error executing git command:\ngit %s\n\n%s\n%s", strings.Join(args, " "), string(output), err) + } + return output, err +} + +// GetRefs loads the tags, refs, branches (commit-ish) from a git repo. +// Returns a map of tags and branch names to commit shas +func GetRefs(gitRepoURL string) (map[string]string, error) { + output, err := gitExec("ls-remote", "--tags", "--heads", gitRepoURL) + if err != nil { + return nil, err + } + + tagsToCommitShas := map[string]string{} + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + line := scanner.Bytes() + match := gitRefLineRegexp.FindSubmatch(line) + if len(match) == 4 { + // As documented in gitrevisions: https://www.kernel.org/pub/software/scm/git/docs/gitrevisions.html#_specifying_revisions + // "A suffix ^ followed by an empty brace pair means the object could be a tag, and dereference the tag recursively until a non-tag object is found." + // In other words, the hash without ^{} is the hash of the tag, and the hash with ^{} is the hash of the commit at which the tag was made. + // For our purposes, either will work. + var name = strings.TrimSuffix(string(match[3]), "^{}") + tagsToCommitShas[name] = string(match[1]) + } + } + + return tagsToCommitShas, nil +} + +// CloneTo fetches a git repo at a specific ref from a git url +func CloneTo(gitRepoURL string, ref string, destinationPath string) error { + _, err := gitExec("clone", "--depth", "1", gitRepoURL, "--branch", ref, "--single-branch", destinationPath) + return err +} diff --git a/pkg/gitutil/gitutil_test.go b/pkg/gitutil/gitutil_test.go new file mode 100644 index 000000000..cd61719c3 --- /dev/null +++ b/pkg/gitutil/gitutil_test.go @@ -0,0 +1,93 @@ +/* +Copyright The Helm Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitutil + +import ( + "fmt" + "os" + "os/exec" + "reflect" + "testing" +) + +func fakeExecCommand(command string, args ...string) *exec.Cmd { + cs := []string{"-test.run=TestHelperProcess", "--", command} + cs = append(cs, args...) + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} + return cmd +} + +// TestHelperProcess is not a test. It is used to create a mock process to spawn as a child process for testing exec.Command(). +// Borrowed from the way Go tests exec.Command() internally: https://github.com/golang/go/blob/master/src/os/exec/exec_test.go#L727 +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + args := os.Args + cmd := args[len(args)-1] // the last arg is the github url. using this to determine what to return. + result := "Unknown Command" + exitCode := 1 + + switch cmd { + case "success": + exitCode = 0 + result = `From git@github.com:helm/helm.git +9b42702a4bced339ff424a78ad68dd6be6e1a80a refs/heads/dev +9668ad4d90c5e95bd520e58e7387607be6b63bb6 refs/heads/master +44fb06eb69fecd4b6a5b2443a4768ba12bd70c09 refs/tags/v2.10.0 +9ad53aac42165a5fadc6c87be0dea6b115f93090 refs/tags/v2.10.0^{} +4fdd07f21418abb43925998cf690857adc16451b refs/tags/v2.10.0-rc.1 +aa98e7e3dd2356bce72e8e367e8c87e8085c692b refs/tags/v2.10.0-rc.1^{}` + + case "error": + exitCode = 1 + result = `ssh: Could not resolve hostname git: nodename nor servname provided, or not known +fatal: Could not read from remote repository. + +Please make sure you have the correct access rights +and the repository exists.` + } + + fmt.Fprintf(os.Stdout, result) + os.Exit(exitCode) +} + +func TestGetRefsWhenGitReturnsRefs(t *testing.T) { + expected := map[string]string{ + "dev": "9b42702a4bced339ff424a78ad68dd6be6e1a80a", + "master": "9668ad4d90c5e95bd520e58e7387607be6b63bb6", + "v2.10.0": "9ad53aac42165a5fadc6c87be0dea6b115f93090", + "v2.10.0-rc.1": "aa98e7e3dd2356bce72e8e367e8c87e8085c692b", + } + execCommand = fakeExecCommand + defer func() { execCommand = exec.Command }() + result, err := GetRefs("success") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result, expected) { + t.Errorf("Expected %q, got %q", expected, result) + } +} +func TestGetRefsWhenGitCommandReturnsError(t *testing.T) { + execCommand = fakeExecCommand + defer func() { execCommand = exec.Command }() + _, err := GetRefs("error") + if err == nil { + t.Errorf("Error should have been returned, but was not") + } +}