You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helm/pkg/getter/gitgetter.go

278 lines
8.0 KiB

/*
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 getter
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GitGetter handles fetching charts from Git repositories
type GitGetter struct {
opts getterOptions
}
// Get performs a Get from a Git repository and returns the body.
func (g *GitGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
for _, opt := range options {
opt(&g.opts)
}
return g.get(href)
}
// get clones a Git repository, packages the chart, and returns it as a buffer
func (g *GitGetter) get(href string) (*bytes.Buffer, error) {
// Parse the Git URL
// Format: git://github.com/user/repo@ref?path=charts/mychart
// Or: git+https://github.com/user/repo@ref?path=charts/mychart
repoURL, ref, chartPath, err := parseGitURL(href)
if err != nil {
return nil, fmt.Errorf("failed to parse git URL: %w", err)
}
// Use version from options if provided (takes precedence over URL ref)
// This allows the dependency version field to specify the Git ref
if g.opts.version != "" && g.opts.version != "*" {
ref = g.opts.version
}
// Create a temporary directory for cloning
tmpDir, err := os.MkdirTemp("", "helm-git-")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
slog.Debug("cloning git repository", "url", repoURL, "ref", ref, "path", chartPath)
// Clone the repository
if err := g.cloneRepo(repoURL, ref, tmpDir); err != nil {
return nil, fmt.Errorf("failed to clone repository: %w", err)
}
// Determine the chart directory
chartDir := tmpDir
if chartPath != "" {
chartDir = filepath.Join(tmpDir, chartPath)
}
// Check if the chart directory exists
if _, err := os.Stat(chartDir); os.IsNotExist(err) {
return nil, fmt.Errorf("chart path %q does not exist in repository", chartPath)
}
// Package the chart into a tarball
tarData, err := g.packageChart(chartDir)
if err != nil {
return nil, fmt.Errorf("failed to package chart: %w", err)
}
return tarData, nil
}
// cloneRepo clones a Git repository to the specified directory
func (g *GitGetter) cloneRepo(repoURL, ref, destDir string) error {
// Use shallow clone for better performance
args := []string{"clone", "--depth", "1"}
// If a specific ref is provided, clone that branch/tag
if ref != "" && ref != "HEAD" && ref != "master" && ref != "main" {
args = append(args, "--branch", ref)
}
args = append(args, repoURL, destDir)
cmd := exec.Command("git", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// If shallow clone with branch failed, try full clone and checkout
if ref != "" && ref != "HEAD" && ref != "master" && ref != "main" {
slog.Debug("shallow clone failed, trying full clone", "error", stderr.String())
return g.fullCloneAndCheckout(repoURL, ref, destDir)
}
return fmt.Errorf("git clone failed: %s", stderr.String())
}
// If ref is specified but wasn't used in clone (HEAD, master, main), checkout now
if ref != "" && (ref == "HEAD" || ref == "master" || ref == "main") {
cmd := exec.Command("git", "-C", destDir, "checkout", ref)
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git checkout failed: %s", stderr.String())
}
}
return nil
}
// fullCloneAndCheckout performs a full clone and checks out a specific ref (commit SHA, tag, or branch)
func (g *GitGetter) fullCloneAndCheckout(repoURL, ref, destDir string) error {
var stderr bytes.Buffer
// Remove the directory if it exists from failed shallow clone
os.RemoveAll(destDir)
// Full clone
cmd := exec.Command("git", "clone", repoURL, destDir)
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git clone failed: %s", stderr.String())
}
// Checkout the specific ref
cmd = exec.Command("git", "-C", destDir, "checkout", ref)
stderr.Reset()
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git checkout failed for ref %q: %s", ref, stderr.String())
}
return nil
}
// packageChart packages a chart directory into a tarball
func (g *GitGetter) packageChart(chartDir string) (*bytes.Buffer, error) {
// Use helm package command to create the tarball
tmpDir, err := os.MkdirTemp("", "helm-git-package-")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory for packaging: %w", err)
}
defer os.RemoveAll(tmpDir)
// Use --dependency-update to automatically fetch any dependencies the chart needs
// This handles charts that have their own dependencies
cmd := exec.Command("helm", "package", chartDir, "-d", tmpDir, "--dependency-update")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("helm package failed: %s", stderr.String())
}
// Find the created tarball
entries, err := os.ReadDir(tmpDir)
if err != nil {
return nil, fmt.Errorf("failed to read package directory: %w", err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("no package file created")
}
// Read the tarball
tarPath := filepath.Join(tmpDir, entries[0].Name())
tarData, err := os.ReadFile(tarPath)
if err != nil {
return nil, fmt.Errorf("failed to read package file: %w", err)
}
return bytes.NewBuffer(tarData), nil
}
// parseGitURL parses a Git URL and extracts the repository URL, ref, and chart path
// Supported formats:
// - git://github.com/user/repo@ref?path=charts/mychart
// - git+https://github.com/user/repo@ref?path=charts/mychart
// - git+ssh://git@github.com/user/repo@ref?path=charts/mychart
func parseGitURL(href string) (repoURL, ref, chartPath string, err error) {
u, err := url.Parse(href)
if err != nil {
return "", "", "", err
}
// Extract the ref from the URL fragment or path
ref = "HEAD" // default ref
repoPath := u.Path
// Check if ref is specified with @ symbol
if strings.Contains(u.Path, "@") {
parts := strings.SplitN(u.Path, "@", 2)
repoPath = parts[0]
ref = parts[1]
}
// Check if ref is specified as a query parameter
if u.Query().Get("ref") != "" {
ref = u.Query().Get("ref")
}
// Extract chart path from query parameter
chartPath = u.Query().Get("path")
// Reconstruct the repository URL
scheme := u.Scheme
if strings.HasPrefix(scheme, "git+") {
scheme = strings.TrimPrefix(scheme, "git+")
} else if scheme == "git" {
// Convert git:// to https:// for better compatibility
scheme = "https"
}
// Handle git+ssh special case
if scheme == "ssh" {
// git+ssh://git@github.com/user/repo becomes git@github.com:user/repo
if u.User != nil {
username := u.User.Username()
repoURL = fmt.Sprintf("%s@%s:%s", username, u.Host, strings.TrimPrefix(repoPath, "/"))
} else {
repoURL = fmt.Sprintf("git@%s:%s", u.Host, strings.TrimPrefix(repoPath, "/"))
}
} else {
repoURL = fmt.Sprintf("%s://%s%s", scheme, u.Host, repoPath)
}
return repoURL, ref, chartPath, nil
}
// computeHash computes a SHA256 hash for the given data
func computeHash(data []byte) string {
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:])
}
// NewGitGetter constructs a valid Git backend handler
func NewGitGetter(options ...Option) (Getter, error) {
var client GitGetter
for _, opt := range options {
opt(&client.opts)
}
// Check if git is available
if _, err := exec.LookPath("git"); err != nil {
return nil, fmt.Errorf("git command not found in PATH: %w", err)
}
// Check if helm is available (for packaging)
if _, err := exec.LookPath("helm"); err != nil {
return nil, fmt.Errorf("helm command not found in PATH: %w", err)
}
return &client, nil
}