mirror of https://github.com/helm/helm
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.
487 lines
15 KiB
487 lines
15 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 downloader
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"helm.sh/helm/v4/internal/fileutil"
|
|
"helm.sh/helm/v4/pkg/getter"
|
|
"helm.sh/helm/v4/pkg/helmpath"
|
|
"helm.sh/helm/v4/pkg/provenance"
|
|
"helm.sh/helm/v4/pkg/registry"
|
|
"helm.sh/helm/v4/pkg/repo/v1"
|
|
)
|
|
|
|
// Type represents the type of artifact being downloaded.
|
|
type Type string
|
|
|
|
const (
|
|
TypeChart Type = "chart"
|
|
TypePlugin Type = "plugin"
|
|
// Future artifact types beyond charts and plugins can be added here
|
|
)
|
|
|
|
// VerificationStrategy describes a strategy for determining whether to verify an downloader.
|
|
type VerificationStrategy int
|
|
|
|
const (
|
|
// VerifyNever will skip all verification of an downloader.
|
|
VerifyNever VerificationStrategy = iota
|
|
// VerifyIfPossible will attempt a verification, it will not error if verification
|
|
// data is missing. But it will not stop processing if verification fails.
|
|
VerifyIfPossible
|
|
// VerifyAlways will always attempt a verification, and will fail if the
|
|
// verification fails.
|
|
VerifyAlways
|
|
// VerifyLater will fetch verification data, but not do any verification.
|
|
// This is to accommodate the case where another step of the process will
|
|
// perform verification.
|
|
VerifyLater
|
|
)
|
|
|
|
// ErrNoOwnerRepo indicates that a given artifact URL can't be found in any repos.
|
|
var ErrNoOwnerRepo = errors.New("could not find a repo containing the given URL")
|
|
|
|
// Downloader handles downloading artifacts with verification and caching.
|
|
type Downloader struct {
|
|
// Out is the location to write warning and info messages.
|
|
Out io.Writer
|
|
// Verify indicates what verification strategy to use.
|
|
Verify VerificationStrategy
|
|
// Keyring is the keyring file used for verification.
|
|
Keyring string
|
|
// Getters provide protocol handling for different URL schemes.
|
|
Getters getter.Providers
|
|
// Options provide parameters to be passed along to Getters.
|
|
Options []getter.Option
|
|
// ContentCache is the location where Cache stores its files by default.
|
|
ContentCache string
|
|
// Cache specifies the cache implementation to use.
|
|
Cache Cache
|
|
|
|
// Configuration for repository-based transports
|
|
repositoryConfig string
|
|
repositoryCache string
|
|
// Configuration for OCI-based transports
|
|
registryClient *registry.Client
|
|
}
|
|
|
|
// Download retrieves an downloader. Depending on the settings, it may also download a provenance file.
|
|
//
|
|
// If Verify is set to VerifyNever, the verification will be nil.
|
|
// If Verify is set to VerifyIfPossible, this will return a verification (or nil on failure), and print a warning on failure.
|
|
// If Verify is set to VerifyAlways, this will return a verification or an error if the verification fails.
|
|
// If Verify is set to VerifyLater, this will download the prov file (if it exists), but not verify it.
|
|
//
|
|
// Returns a string path to the location where the file was downloaded and a verification
|
|
// (if provenance was verified), or an error if something bad happened.
|
|
func (d *Downloader) Download(ref, version, dest string, artifactType Type) (string, *provenance.Verification, error) {
|
|
if d.Cache == nil {
|
|
if d.ContentCache == "" {
|
|
return "", nil, errors.New("content cache must be set")
|
|
}
|
|
d.Cache = &DiskCache{Root: d.ContentCache}
|
|
slog.Debug("set up default downloader cache")
|
|
}
|
|
|
|
hash, u, err := d.ResolveArtifactVersion(ref, version, artifactType)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
if err := d.validateArtifactTypeForScheme(u.Scheme, artifactType); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
t, err := d.Getters.ByScheme(u.Scheme)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
// Check the cache for the content. Otherwise download it.
|
|
var data *bytes.Buffer
|
|
var found bool
|
|
var digest []byte
|
|
var digest32 [32]byte
|
|
if hash != "" {
|
|
// if there is a hash, populate the other formats
|
|
digest, err = hex.DecodeString(hash)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
copy(digest32[:], digest)
|
|
if pth, err := d.Cache.Get(digest32, CacheChart); err == nil {
|
|
fdata, err := os.ReadFile(pth)
|
|
if err == nil {
|
|
found = true
|
|
data = bytes.NewBuffer(fdata)
|
|
slog.Debug("found artifact in cache", "id", hash, "type", artifactType)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
d.Options = append(d.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream"))
|
|
opts := append(d.Options, getter.WithURL(u.String()))
|
|
data, err = t.Get(u.String(), opts...)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
}
|
|
|
|
name := d.generateArtifactName(u, artifactType)
|
|
destfile := filepath.Join(dest, name)
|
|
if err := fileutil.AtomicWriteFile(destfile, data, 0644); err != nil {
|
|
return destfile, nil, err
|
|
}
|
|
|
|
// If provenance is requested, verify it.
|
|
ver := &provenance.Verification{}
|
|
if d.Verify > VerifyNever {
|
|
found = false
|
|
var body *bytes.Buffer
|
|
if hash != "" {
|
|
if pth, err := d.Cache.Get(digest32, CacheProv); err == nil {
|
|
fdata, err := os.ReadFile(pth)
|
|
if err == nil {
|
|
found = true
|
|
body = bytes.NewBuffer(fdata)
|
|
slog.Debug("found provenance in cache", "id", hash, "type", artifactType)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
body, err = t.Get(u.String() + ".prov") // No options for provenance request (matches original implementation)
|
|
if err != nil {
|
|
if d.Verify == VerifyAlways {
|
|
return destfile, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov")
|
|
}
|
|
fmt.Fprintf(d.Out, "WARNING: Verification not found for %s: %s\n", ref, err)
|
|
return destfile, ver, nil
|
|
}
|
|
}
|
|
provfile := destfile + ".prov"
|
|
if err := fileutil.AtomicWriteFile(provfile, body, 0644); err != nil {
|
|
return destfile, nil, err
|
|
}
|
|
|
|
if d.Verify != VerifyLater {
|
|
ver, err = d.VerifyArtifact(destfile, destfile+".prov")
|
|
if err != nil {
|
|
return destfile, ver, err
|
|
}
|
|
}
|
|
}
|
|
return destfile, ver, nil
|
|
}
|
|
|
|
// SetRepositoryConfig sets the repository configuration for repository-based transports.
|
|
func (d *Downloader) SetRepositoryConfig(config, cache string) {
|
|
d.repositoryConfig = config
|
|
d.repositoryCache = cache
|
|
}
|
|
|
|
// SetRegistryClient sets the registry client for OCI-based transports.
|
|
func (d *Downloader) SetRegistryClient(client *registry.Client) {
|
|
d.registryClient = client
|
|
}
|
|
|
|
// configureGetter configures a getter based on its supported interfaces.
|
|
func (d *Downloader) validateArtifactTypeForScheme(scheme string, artifactType Type) error {
|
|
g, err := d.Getters.ByScheme(scheme)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If getter implements getter.Restricted, check restrictions
|
|
if restricted, ok := g.(getter.Restricted); ok {
|
|
supportedTypes := restricted.RestrictToArtifactTypes()
|
|
if supportedTypes == nil {
|
|
return nil
|
|
}
|
|
for _, t := range supportedTypes {
|
|
if t == string(artifactType) {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("scheme %s does not support artifact type %s", scheme, artifactType)
|
|
}
|
|
|
|
// Default: support all types
|
|
return nil
|
|
}
|
|
|
|
// generateArtifactName generates the filename for the downloaded downloader.
|
|
func (d *Downloader) generateArtifactName(u *url.URL, artifactType Type) string {
|
|
name := filepath.Base(u.Path)
|
|
|
|
// Handle OCI references
|
|
if u.Scheme == registry.OCIScheme {
|
|
idx := strings.LastIndexByte(name, ':')
|
|
if idx >= 0 {
|
|
name = fmt.Sprintf("%s-%s", name[:idx], name[idx+1:])
|
|
}
|
|
}
|
|
|
|
// Add appropriate extension based on artifact type
|
|
switch artifactType {
|
|
case TypeChart:
|
|
if !strings.HasSuffix(name, ".tgz") {
|
|
name += ".tgz"
|
|
}
|
|
case TypePlugin:
|
|
if !strings.HasSuffix(name, ".tgz") {
|
|
name += ".tgz"
|
|
}
|
|
default:
|
|
// Default to tgz for unknown types
|
|
if !strings.HasSuffix(name, ".tgz") {
|
|
name += ".tgz"
|
|
}
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// ResolveArtifactVersion resolves an artifact reference to a URL.
|
|
//
|
|
// It returns:
|
|
// - A hash of the content if available
|
|
// - The URL for downloading the artifact
|
|
// - An error if resolution fails
|
|
//
|
|
// A reference may be an HTTP URL, an OCI reference URL, a 'reponame/artifactname'
|
|
// reference, or a local path.
|
|
//
|
|
// A version is a SemVer string (1.2.3-beta.1+f334a6789).
|
|
func (d *Downloader) ResolveArtifactVersion(ref, version string, artifactType Type) (string, *url.URL, error) {
|
|
u, err := url.Parse(ref)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("invalid artifact URL format: %s", ref)
|
|
}
|
|
|
|
// Handle OCI references - supported for all artifact types
|
|
if registry.IsOCI(u.String()) {
|
|
if d.registryClient == nil {
|
|
// In testing or when registry client is not configured,
|
|
// treat OCI URLs as direct URLs to allow testing
|
|
return "", u, nil
|
|
}
|
|
|
|
digest, OCIref, err := d.registryClient.ValidateReference(ref, version, u)
|
|
return digest, OCIref, err
|
|
}
|
|
|
|
// Handle direct URLs (http/https/file) - supported for all artifact types
|
|
if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 {
|
|
// For direct URLs, version is typically ignored since URLs are specific
|
|
// But we could validate that the URL contains the expected version string
|
|
return "", u, nil
|
|
}
|
|
|
|
// Handle repository-based references (reponame/artifactname)
|
|
switch artifactType {
|
|
case TypeChart:
|
|
return d.resolveChartFromRepository(ref, version, u)
|
|
|
|
case TypePlugin:
|
|
// Plugins use modern OCI-based distribution, not repository-based like charts
|
|
// Repository-based plugin distribution is not planned due to scalability issues
|
|
// with the chart repository index model
|
|
return "", nil, fmt.Errorf("repository-based plugin distribution is not supported - use OCI references instead (oci://registry/plugin:version)")
|
|
|
|
default:
|
|
return "", nil, fmt.Errorf("unknown artifact type: %s", artifactType)
|
|
}
|
|
}
|
|
|
|
// VerifyArtifact verifies an artifact using its provenance file.
|
|
func (d *Downloader) VerifyArtifact(artifactPath, provPath string) (*provenance.Verification, error) {
|
|
// Check if artifact exists and is valid format
|
|
switch fi, err := os.Stat(artifactPath); {
|
|
case err != nil:
|
|
return nil, err
|
|
case fi.IsDir():
|
|
return nil, errors.New("unpacked artifacts cannot be verified")
|
|
case !strings.HasSuffix(artifactPath, ".tgz"):
|
|
return nil, errors.New("artifact must be a tgz file")
|
|
}
|
|
|
|
if _, err := os.Stat(provPath); err != nil {
|
|
return nil, fmt.Errorf("could not load provenance file %s: %w", provPath, err)
|
|
}
|
|
|
|
sig, err := provenance.NewFromKeyring(d.Keyring, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load keyring: %w", err)
|
|
}
|
|
|
|
// Read artifact and provenance files
|
|
artifactData, err := os.ReadFile(artifactPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read artifact: %w", err)
|
|
}
|
|
provData, err := os.ReadFile(provPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read provenance file: %w", err)
|
|
}
|
|
|
|
return sig.Verify(artifactData, provData, filepath.Base(artifactPath))
|
|
}
|
|
|
|
// loadRepoConfig loads the repository configuration file.
|
|
func loadRepoConfig(file string) (*repo.File, error) {
|
|
r, err := repo.LoadFile(file)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// pickChartRepositoryConfigByName returns the repository configuration for the given name.
|
|
func pickChartRepositoryConfigByName(name string, cfgs []*repo.Entry) (*repo.Entry, error) {
|
|
for _, rc := range cfgs {
|
|
if rc.Name == name {
|
|
if rc.URL == "" {
|
|
return nil, fmt.Errorf("no URL found for repository %s", name)
|
|
}
|
|
return rc, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("repository name (%s) not found. You must add the repository before using it", name)
|
|
}
|
|
|
|
// scanReposForURL scans all repositories for a URL that matches the given reference.
|
|
func (d *Downloader) scanReposForURL(ref string, rf *repo.File) (*repo.Entry, error) {
|
|
// Scan all of the repositories looking for a URL match
|
|
for _, rc := range rf.Repositories {
|
|
if strings.Index(ref, rc.URL) == 0 {
|
|
return rc, nil
|
|
}
|
|
}
|
|
|
|
// This means that the URL is not associated with a known repository
|
|
return nil, ErrNoOwnerRepo
|
|
}
|
|
|
|
// resolveChartFromRepository resolves a chart reference from a repository.
|
|
func (d *Downloader) resolveChartFromRepository(ref, version string, u *url.URL) (string, *url.URL, error) {
|
|
rf, err := loadRepoConfig(d.repositoryConfig)
|
|
if err != nil {
|
|
return "", u, err
|
|
}
|
|
|
|
// Handle direct URLs that might be in a known repository
|
|
if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 {
|
|
// Try to find the parent repo that contains this chart URL
|
|
rc, err := d.scanReposForURL(ref, rf)
|
|
if err != nil {
|
|
// If there is no special config, return the URL for direct download
|
|
if err == ErrNoOwnerRepo {
|
|
// This will be handled by the transport layer
|
|
return "", u, nil
|
|
}
|
|
return "", u, err
|
|
}
|
|
|
|
// Configure transport options based on repository config
|
|
d.configureRepositoryOptions(rc)
|
|
return "", u, nil
|
|
}
|
|
|
|
// Handle repository-based references in the form "reponame/chartname"
|
|
p := strings.SplitN(u.Path, "/", 2)
|
|
if len(p) < 2 {
|
|
return "", u, fmt.Errorf("non-absolute URLs should be in form of repo_name/path_to_chart, got: %s", u)
|
|
}
|
|
|
|
repoName := p[0]
|
|
chartName := p[1]
|
|
rc, err := pickChartRepositoryConfigByName(repoName, rf.Repositories)
|
|
if err != nil {
|
|
return "", u, err
|
|
}
|
|
|
|
// Configure transport options for this repository
|
|
d.configureRepositoryOptions(rc)
|
|
|
|
// Load the repository index to find the chart
|
|
idxFile := filepath.Join(d.repositoryCache, helmpath.CacheIndexFile(rc.Name))
|
|
i, err := repo.LoadIndexFile(idxFile)
|
|
if err != nil {
|
|
return "", u, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err)
|
|
}
|
|
|
|
cv, err := i.Get(chartName, version)
|
|
if err != nil {
|
|
return "", u, fmt.Errorf("chart %q matching %s not found in %s index. (try 'helm repo update'): %w", chartName, version, rc.Name, err)
|
|
}
|
|
|
|
if len(cv.URLs) == 0 {
|
|
return "", u, fmt.Errorf("chart %q has no downloadable URLs", ref)
|
|
}
|
|
|
|
// TODO: Seems that picking first URL is not fully correct
|
|
resolvedURL, err := repo.ResolveReferenceURL(rc.URL, cv.URLs[0])
|
|
if err != nil {
|
|
return cv.Digest, u, fmt.Errorf("invalid chart URL format: %s", ref)
|
|
}
|
|
|
|
loc, err := url.Parse(resolvedURL)
|
|
return cv.Digest, loc, err
|
|
}
|
|
|
|
// configureRepositoryOptions configures transport options for a repository.
|
|
func (d *Downloader) configureRepositoryOptions(rc *repo.Entry) {
|
|
if rc == nil {
|
|
return
|
|
}
|
|
|
|
// Add TLS configuration if available
|
|
if rc.CertFile != "" || rc.KeyFile != "" || rc.CAFile != "" {
|
|
d.Options = append(d.Options, getter.WithTLSClientConfig(rc.CertFile, rc.KeyFile, rc.CAFile))
|
|
if rc.InsecureSkipTLSverify {
|
|
d.Options = append(d.Options, getter.WithInsecureSkipVerifyTLS(rc.InsecureSkipTLSverify))
|
|
}
|
|
}
|
|
|
|
// Add basic auth if available
|
|
if rc.Username != "" && rc.Password != "" {
|
|
d.Options = append(d.Options, getter.WithBasicAuth(rc.Username, rc.Password))
|
|
}
|
|
|
|
// Add pass credentials all flag if set
|
|
if rc.PassCredentialsAll {
|
|
d.Options = append(d.Options, getter.WithPassCredentialsAll(true))
|
|
}
|
|
|
|
// Set the repository URL for the getter
|
|
if rc.URL != "" {
|
|
d.Options = append(d.Options, getter.WithURL(rc.URL))
|
|
}
|
|
}
|