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.
264 lines
8.1 KiB
264 lines
8.1 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.
|
|
*/
|
|
|
|
// Plugin-specific artifact downloading capabilities.
|
|
package downloader
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"helm.sh/helm/v4/pkg/getter"
|
|
"helm.sh/helm/v4/pkg/provenance"
|
|
"helm.sh/helm/v4/pkg/registry"
|
|
)
|
|
|
|
// HTTPClient interface for HTTP operations, enables mocking
|
|
type HTTPClient interface {
|
|
Head(url string) (*http.Response, error)
|
|
}
|
|
|
|
// PluginDownloader handles downloading plugins with plugin-specific features.
|
|
type PluginDownloader 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
|
|
// RegistryClient for OCI plugin downloads
|
|
RegistryClient *registry.Client
|
|
|
|
// ContentCache is the location where Cache stores its files by default
|
|
ContentCache string
|
|
|
|
// Cache specifies the cache implementation to use.
|
|
Cache Cache
|
|
|
|
// Internal unified downloader
|
|
downloader *Downloader
|
|
}
|
|
|
|
// DownloadTo downloads a plugin to the specified destination.
|
|
func (p *PluginDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) {
|
|
// Initialize the internal downloader if needed
|
|
if p.downloader == nil {
|
|
// Use built-in getters as base
|
|
getters := getter.Getters()
|
|
|
|
// Add VCS getter specifically for plugins
|
|
if _, err := getter.NewVCSGetter(getter.WithArtifactType("plugin")); err == nil {
|
|
getters = append(getters, getter.Provider{
|
|
Schemes: []string{"git", "git+http", "git+https", "git+ssh"},
|
|
New: func(options ...getter.Option) (getter.Getter, error) {
|
|
return getter.NewVCSGetter(append(options, getter.WithArtifactType("plugin"))...)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Add smart HTTP getter for plugins that intelligently routes between VCS and HTTP getters.
|
|
// Tries VCS first for repository URLs, then falls back to HTTP for direct file URLs.
|
|
// Supports both HTTP and HTTPS (e.g., local instances, development servers, etc.)
|
|
if httpGetter, err := getter.NewHTTPGetter(); err == nil {
|
|
if vcsGetter, err := getter.NewVCSGetter(getter.WithArtifactType("plugin")); err == nil {
|
|
smartGetter := &SmartHTTPDownloader{
|
|
vcsGetter: vcsGetter,
|
|
httpGetter: httpGetter,
|
|
httpClient: nil, // Use default HTTP client
|
|
}
|
|
getters = append(getters, getter.Provider{
|
|
Schemes: []string{"http", "https"},
|
|
New: func(_ ...getter.Option) (getter.Getter, error) {
|
|
return smartGetter, nil
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
p.downloader = &Downloader{
|
|
Verify: p.Verify,
|
|
Keyring: p.Keyring,
|
|
Getters: getters,
|
|
Cache: p.Cache,
|
|
ContentCache: p.ContentCache,
|
|
}
|
|
if p.RegistryClient != nil {
|
|
p.downloader.SetRegistryClient(p.RegistryClient)
|
|
}
|
|
}
|
|
|
|
return p.downloader.Download(ref, version, dest, TypePlugin)
|
|
}
|
|
|
|
// DownloadToCache downloads a plugin to the cache.
|
|
func (p *PluginDownloader) DownloadToCache(ref, version string) (string, *provenance.Verification, error) {
|
|
return p.DownloadTo(ref, version, p.ContentCache)
|
|
}
|
|
|
|
// ResolvePluginVersion resolves a plugin reference to a URL.
|
|
// This delegates to the unified artifact downloader.
|
|
func (p *PluginDownloader) ResolvePluginVersion(ref, version string) (string, *url.URL, error) {
|
|
// Initialize the internal downloader if needed
|
|
if p.downloader == nil {
|
|
p.downloader = &Downloader{}
|
|
if p.RegistryClient != nil {
|
|
p.downloader.SetRegistryClient(p.RegistryClient)
|
|
}
|
|
}
|
|
|
|
return p.downloader.ResolveArtifactVersion(ref, version, TypePlugin)
|
|
}
|
|
|
|
// SmartHTTPDownloader tries VCS getter for repository URLs, falls back to HTTP for direct file URLs.
|
|
type SmartHTTPDownloader struct {
|
|
vcsGetter getter.Getter
|
|
httpGetter getter.Getter
|
|
httpClient HTTPClient // For dependency injection in tests
|
|
}
|
|
|
|
// DefaultHTTPClient implements HTTPClient using the standard http.Client
|
|
type DefaultHTTPClient struct{}
|
|
|
|
func (c *DefaultHTTPClient) Head(url string) (*http.Response, error) {
|
|
return http.Head(url)
|
|
}
|
|
|
|
func (s *SmartHTTPDownloader) Get(url string, options ...getter.Option) (*bytes.Buffer, error) {
|
|
// First, check if the URL serves a tarball via HTTP HEAD request
|
|
if s.servesArchiveContent(url) {
|
|
// URL serves archive content, use HTTP getter
|
|
return s.httpGetter.Get(url, options...)
|
|
}
|
|
|
|
// URL doesn't serve archive content, try VCS getter for repository-style installation
|
|
if vcsResult, vcsErr := s.vcsGetter.Get(url, options...); vcsErr == nil {
|
|
return vcsResult, nil
|
|
}
|
|
|
|
// VCS failed, fall back to HTTP in case HEAD request was wrong or server doesn't support HEAD
|
|
result, httpErr := s.httpGetter.Get(url, options...)
|
|
if httpErr == nil && s.isValidPluginContent(result) {
|
|
return result, nil
|
|
}
|
|
|
|
// Both approaches failed, return the most informative error
|
|
if httpErr != nil {
|
|
return nil, fmt.Errorf("failed to download plugin: HTTP failed (%v), VCS failed", httpErr)
|
|
}
|
|
|
|
return nil, fmt.Errorf("URL does not contain a valid plugin archive")
|
|
}
|
|
|
|
// servesArchiveContent checks if the URL serves archive content via HTTP HEAD request
|
|
func (s *SmartHTTPDownloader) servesArchiveContent(url string) bool {
|
|
// .prov files are always served via HTTP, never VCS
|
|
// This handles the case where the downloader makes separate calls for
|
|
// both "plugin.tgz" and "plugin.tgz.prov" - the .prov file is not an archive
|
|
if strings.HasSuffix(url, ".prov") {
|
|
return true
|
|
}
|
|
|
|
// Use injected HTTP client or default client
|
|
client := s.httpClient
|
|
if client == nil {
|
|
client = &DefaultHTTPClient{}
|
|
}
|
|
|
|
// Perform HEAD request to check Content-Type without downloading the content
|
|
resp, err := client.Head(url)
|
|
if err != nil {
|
|
// HEAD request failed, we'll have to try other approaches
|
|
return false
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Check Content-Type header for archive formats
|
|
contentType := resp.Header.Get("Content-Type")
|
|
archiveTypes := []string{
|
|
"application/gzip",
|
|
"application/x-gzip",
|
|
"application/x-tar",
|
|
"application/x-compressed-tar",
|
|
"application/octet-stream", // Generic binary, might be a tarball
|
|
}
|
|
|
|
for _, archiveType := range archiveTypes {
|
|
if strings.Contains(contentType, archiveType) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check Content-Disposition for attachment with archive extensions
|
|
disposition := resp.Header.Get("Content-Disposition")
|
|
if strings.Contains(disposition, "attachment") {
|
|
archiveExtensions := []string{".tgz", ".tar.gz", ".tar", ".zip"}
|
|
for _, ext := range archiveExtensions {
|
|
if strings.Contains(disposition, ext) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isValidPluginContent checks if the content looks like a valid plugin tarball
|
|
func (s *SmartHTTPDownloader) isValidPluginContent(content *bytes.Buffer) bool {
|
|
if content == nil || content.Len() < 100 {
|
|
return false
|
|
}
|
|
|
|
// Check for tarball magic bytes
|
|
data := content.Bytes()
|
|
|
|
// Gzip magic bytes (0x1f, 0x8b) - most .tgz files
|
|
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
|
|
return true
|
|
}
|
|
|
|
// Uncompressed tar magic "ustar" at offset 257
|
|
if len(data) >= 262 && string(data[257:262]) == "ustar" {
|
|
return true
|
|
}
|
|
|
|
// If it starts with HTML, it's definitely not a plugin
|
|
htmlIndicators := []string{
|
|
"<!DOCTYPE", "<!doctype", "<html", "<HTML", "<head", "<HEAD",
|
|
}
|
|
contentStr := string(data[:min(500, len(data))])
|
|
for _, indicator := range htmlIndicators {
|
|
if strings.Contains(contentStr, indicator) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// If we can't determine, assume it's valid (conservative approach)
|
|
return true
|
|
}
|
|
|
|
// RestrictToArtifactTypes implements getter.Restricted.
|
|
func (s *SmartHTTPDownloader) RestrictToArtifactTypes() []string {
|
|
return []string{"plugin"}
|
|
}
|
|
|
|
// NewPluginDownloader creates a new PluginDownloader.
|
|
func NewPluginDownloader() *PluginDownloader {
|
|
return &PluginDownloader{}
|
|
}
|