pull/31387/merge
Scott Rigby 12 months ago committed by GitHub
commit a2cc0cce14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -27,7 +27,7 @@ require (
github.com/mattn/go-shellwords v1.0.12 github.com/mattn/go-shellwords v1.0.12
github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/copystructure v1.2.0
github.com/moby/term v0.5.2 github.com/moby/term v0.5.2
github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/rubenv/sql-migrate v1.8.0 github.com/rubenv/sql-migrate v1.8.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2

@ -0,0 +1,218 @@
/*
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 installer
import (
"bytes"
"fmt"
"log/slog"
"os"
"path/filepath"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/plugin/cache"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/downloader"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/helmpath"
)
// ArtifactInstaller installs plugins using the unified artifact downloader.
type ArtifactInstaller struct {
CacheDir string
PluginName string
base
downloader *downloader.Downloader
settings *cli.EnvSettings
version string // Version constraint for VCS sources
// Cached data to avoid duplicate downloads
pluginData []byte
provData []byte
}
// NewArtifactInstaller creates a new ArtifactInstaller.
func NewArtifactInstaller(source string) (*ArtifactInstaller, error) {
key, err := cache.Key(source)
if err != nil {
return nil, err
}
settings := cli.New()
downloader := &downloader.Downloader{}
i := &ArtifactInstaller{
CacheDir: helmpath.CachePath("plugins", key),
PluginName: stripPluginName(filepath.Base(source)),
base: newBase(source),
downloader: downloader,
settings: settings,
}
return i, nil
}
// Install downloads and installs the plugin.
func (i *ArtifactInstaller) Install() error {
// Download plugin using unified downloader
if i.pluginData == nil {
tempDir := i.CacheDir + "-download"
if err := os.MkdirAll(tempDir, 0755); err != nil {
return fmt.Errorf("failed to create temp download directory: %w", err)
}
defer os.RemoveAll(tempDir)
pluginPath, _, err := i.downloader.Download(i.Source, i.version, tempDir, downloader.TypePlugin)
if err != nil {
return fmt.Errorf("failed to download plugin: %w", err)
}
// Read the downloaded data
data, err := os.ReadFile(pluginPath)
if err != nil {
return fmt.Errorf("failed to read downloaded plugin: %w", err)
}
i.pluginData = data
// Try to get provenance data
provPath := pluginPath + ".prov"
if provData, err := os.ReadFile(provPath); err == nil {
i.provData = provData
}
}
// Extract metadata to get the actual plugin name and version
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
// Save the original tarball to plugins directory
tarballPath := helmpath.DataPath("plugins", filename)
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil {
return fmt.Errorf("failed to save tarball: %w", err)
}
// Save prov file if we have the data
if i.provData != nil {
provPath := tarballPath + ".prov"
if err := os.WriteFile(provPath, i.provData, 0644); err != nil {
slog.Warn("failed to write provenance file", "path", provPath, "error", err)
}
}
// Extract plugin to cache directory
extractor, err := NewExtractor(filename)
if err != nil {
return err
}
if err := extractor.Extract(bytes.NewBuffer(i.pluginData), i.CacheDir); err != nil {
return fmt.Errorf("extracting files from archive: %w", err)
}
// Detect where the plugin.yaml actually is
pluginRoot, err := detectPluginRoot(i.CacheDir)
if err != nil {
return err
}
// Validate plugin structure if needed
if err := validatePluginName(pluginRoot, i.PluginName); err != nil {
return err
}
src, err := filepath.Abs(pluginRoot)
if err != nil {
return err
}
return fs.CopyDir(src, i.Path())
}
// Update updates a plugin by reinstalling it.
func (i *ArtifactInstaller) Update() error {
if err := os.RemoveAll(i.Path()); err != nil {
return err
}
return i.Install()
}
// Path returns where the plugin will be installed.
func (i *ArtifactInstaller) Path() string {
if i.Source == "" {
return ""
}
return filepath.Join(i.settings.PluginsDirectory, i.PluginName)
}
// SupportsVerification returns true if the installer can verify plugins.
func (i *ArtifactInstaller) SupportsVerification() bool {
return true // The unified downloader supports verification
}
// SetOptions sets additional options for the downloader.
func (i *ArtifactInstaller) SetOptions(options []getter.Option) {
i.downloader.Options = append(i.downloader.Options, options...)
}
// SetVersion sets the version constraint for the plugin download.
func (i *ArtifactInstaller) SetVersion(version string) {
i.version = version
}
// GetVerificationData returns cached plugin and provenance data for verification.
func (i *ArtifactInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
// Ensure data is cached
if i.pluginData == nil {
// Download plugin using unified downloader
tempDir := i.CacheDir + "-verification"
if err := os.MkdirAll(tempDir, 0755); err != nil {
return nil, nil, "", fmt.Errorf("failed to create temp verification directory: %w", err)
}
defer os.RemoveAll(tempDir)
pluginPath, _, err := i.downloader.Download(i.Source, i.version, tempDir, downloader.TypePlugin)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to download plugin for verification: %w", err)
}
// Read the downloaded data
data, err := os.ReadFile(pluginPath)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to read downloaded plugin: %w", err)
}
i.pluginData = data
// Try to get provenance data
provPath := pluginPath + ".prov"
if provData, err := os.ReadFile(provPath); err == nil {
i.provData = provData
}
}
// Extract metadata to get the filename
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return nil, nil, "", fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename = fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
return i.pluginData, i.provData, filename, nil
}

@ -46,21 +46,6 @@ var Extractors = map[string]Extractor{
".tgz": &TarGzExtractor{}, ".tgz": &TarGzExtractor{},
} }
// Convert a media type to an extractor extension.
//
// This should be refactored in Helm 4, combined with the extension-based mechanism.
func mediaTypeToExtension(mt string) (string, bool) {
switch strings.ToLower(mt) {
case "application/gzip", "application/x-gzip", "application/x-tgz", "application/x-gtar":
return ".tgz", true
case "application/octet-stream":
// Generic binary type - we'll need to check the URL suffix
return "", false
default:
return "", false
}
}
// NewExtractor creates a new extractor matching the source file name // NewExtractor creates a new extractor matching the source file name
func NewExtractor(source string) (Extractor, error) { func NewExtractor(source string) (Extractor, error) {
for suffix, extractor := range Extractors { for suffix, extractor := range Extractors {

@ -1,191 +0,0 @@
/*
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"bytes"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/plugin/cache"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/helmpath"
)
// HTTPInstaller installs plugins from an archive served by a web server.
type HTTPInstaller struct {
CacheDir string
PluginName string
base
extractor Extractor
getter getter.Getter
// Cached data to avoid duplicate downloads
pluginData []byte
provData []byte
}
// NewHTTPInstaller creates a new HttpInstaller.
func NewHTTPInstaller(source string) (*HTTPInstaller, error) {
key, err := cache.Key(source)
if err != nil {
return nil, err
}
extractor, err := NewExtractor(source)
if err != nil {
return nil, err
}
get, err := getter.All(new(cli.EnvSettings)).ByScheme("http")
if err != nil {
return nil, err
}
i := &HTTPInstaller{
CacheDir: helmpath.CachePath("plugins", key),
PluginName: stripPluginName(filepath.Base(source)),
base: newBase(source),
extractor: extractor,
getter: get,
}
return i, nil
}
// Install downloads and extracts the tarball into the cache directory
// and installs into the plugin directory.
//
// Implements Installer.
func (i *HTTPInstaller) Install() error {
// Ensure plugin data is cached
if i.pluginData == nil {
pluginData, err := i.getter.Get(i.Source)
if err != nil {
return err
}
i.pluginData = pluginData.Bytes()
}
// Save the original tarball to plugins directory for verification
// Extract metadata to get the actual plugin name and version
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
tarballPath := helmpath.DataPath("plugins", filename)
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil {
return fmt.Errorf("failed to save tarball: %w", err)
}
// Ensure prov data is cached if available
if i.provData == nil {
// Try to download .prov file if it exists
provURL := i.Source + ".prov"
if provData, err := i.getter.Get(provURL); err == nil {
i.provData = provData.Bytes()
}
}
// Save prov file if we have the data
if i.provData != nil {
provPath := tarballPath + ".prov"
if err := os.WriteFile(provPath, i.provData, 0644); err != nil {
slog.Debug("failed to save provenance file", "error", err)
}
}
if err := i.extractor.Extract(bytes.NewBuffer(i.pluginData), i.CacheDir); err != nil {
return fmt.Errorf("extracting files from archive: %w", err)
}
// Detect where the plugin.yaml actually is
pluginRoot, err := detectPluginRoot(i.CacheDir)
if err != nil {
return err
}
// Validate plugin structure if needed
if err := validatePluginName(pluginRoot, i.PluginName); err != nil {
return err
}
src, err := filepath.Abs(pluginRoot)
if err != nil {
return err
}
slog.Debug("copying", "source", src, "path", i.Path())
return fs.CopyDir(src, i.Path())
}
// Update updates a local repository
// Not implemented for now since tarball most likely will be packaged by version
func (i *HTTPInstaller) Update() error {
return fmt.Errorf("method Update() not implemented for HttpInstaller")
}
// Path is overridden because we want to join on the plugin name not the file name
func (i HTTPInstaller) Path() string {
if i.Source == "" {
return ""
}
return helmpath.DataPath("plugins", i.PluginName)
}
// SupportsVerification returns true if the HTTP installer can verify plugins
func (i *HTTPInstaller) SupportsVerification() bool {
// Only support verification for tarball URLs
return strings.HasSuffix(i.Source, ".tgz") || strings.HasSuffix(i.Source, ".tar.gz")
}
// GetVerificationData returns cached plugin and provenance data for verification
func (i *HTTPInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
if !i.SupportsVerification() {
return nil, nil, "", fmt.Errorf("verification not supported for this source")
}
// Download plugin data once and cache it
if i.pluginData == nil {
data, err := i.getter.Get(i.Source)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to download plugin: %w", err)
}
i.pluginData = data.Bytes()
}
// Download prov data once and cache it if available
if i.provData == nil {
provData, err := i.getter.Get(i.Source + ".prov")
if err != nil {
// If provenance file doesn't exist, set provData to nil
// The verification logic will handle this gracefully
i.provData = nil
} else {
i.provData = provData.Bytes()
}
}
return i.pluginData, i.provData, filepath.Base(i.Source), nil
}

@ -1,601 +0,0 @@
/*
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/base64"
"errors"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/helmpath"
)
var _ Installer = new(HTTPInstaller)
// Fake http client
type TestHTTPGetter struct {
MockResponse *bytes.Buffer
MockError error
}
func (t *TestHTTPGetter) Get(_ string, _ ...getter.Option) (*bytes.Buffer, error) {
return t.MockResponse, t.MockError
}
// Fake plugin tarball data
var fakePluginB64 = "H4sIAAAAAAAAA+3SQUvDMBgG4Jz7K0LwapdvSxrwJig6mCKC5xHabBaXdDSt4L+3cQ56mV42ZPg+lw+SF5LwZmXf3OV206/rMGEnIgdG6zTJaDmee4y01FOlZpqGHJGZSsb1qS401sfOtpyz0FTup9xv+2dqNep/N/IP6zdHPSMVXCh1sH8yhtGMDBUFFTL1r4iIcXnUWxzwz/sP1rsrLkbfQGTvro11E4ZlmcucRNZHu04py1OO73OVi2Vbb7td9vp7nXevtvsKRpGVjfc2VMP2xf3t4mH5tHi5mz8ub+bPk9JXIvvr5wMAAAAAAAAAAAAAAAAAAAAAnLVPqwHcXQAoAAA="
func TestStripName(t *testing.T) {
if stripPluginName("fake-plugin-0.0.1.tar.gz") != "fake-plugin" {
t.Errorf("name does not match expected value")
}
if stripPluginName("fake-plugin-0.0.1.tgz") != "fake-plugin" {
t.Errorf("name does not match expected value")
}
if stripPluginName("fake-plugin.tgz") != "fake-plugin" {
t.Errorf("name does not match expected value")
}
if stripPluginName("fake-plugin.tar.gz") != "fake-plugin" {
t.Errorf("name does not match expected value")
}
}
func mockArchiveServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, ".tar.gz") {
w.Header().Add("Content-Type", "text/html")
fmt.Fprintln(w, "broken")
return
}
w.Header().Add("Content-Type", "application/gzip")
fmt.Fprintln(w, "test")
}))
}
func TestHTTPInstaller(t *testing.T) {
ensure.HelmHome(t)
srv := mockArchiveServer()
defer srv.Close()
source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz"
if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
}
i, err := NewForSource(source, "0.0.1")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// ensure a HTTPInstaller was returned
httpInstaller, ok := i.(*HTTPInstaller)
if !ok {
t.Fatal("expected a HTTPInstaller")
}
// inject fake http client responding with minimal plugin tarball
mockTgz, err := base64.StdEncoding.DecodeString(fakePluginB64)
if err != nil {
t.Fatalf("Could not decode fake tgz plugin: %s", err)
}
httpInstaller.getter = &TestHTTPGetter{
MockResponse: bytes.NewBuffer(mockTgz),
}
// install the plugin
if err := Install(i); err != nil {
t.Fatal(err)
}
if i.Path() != helmpath.DataPath("plugins", "fake-plugin") {
t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/fake-plugin', got %q", i.Path())
}
// Install again to test plugin exists error
if err := Install(i); err == nil {
t.Fatal("expected error for plugin exists, got none")
} else if err.Error() != "plugin already exists" {
t.Fatalf("expected error for plugin exists, got (%v)", err)
}
}
func TestHTTPInstallerNonExistentVersion(t *testing.T) {
ensure.HelmHome(t)
srv := mockArchiveServer()
defer srv.Close()
source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz"
if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
}
i, err := NewForSource(source, "0.0.2")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// ensure a HTTPInstaller was returned
httpInstaller, ok := i.(*HTTPInstaller)
if !ok {
t.Fatal("expected a HTTPInstaller")
}
// inject fake http client responding with error
httpInstaller.getter = &TestHTTPGetter{
MockError: fmt.Errorf("failed to download plugin for some reason"),
}
// attempt to install the plugin
if err := Install(i); err == nil {
t.Fatal("expected error from http client")
}
}
func TestHTTPInstallerUpdate(t *testing.T) {
srv := mockArchiveServer()
defer srv.Close()
source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz"
ensure.HelmHome(t)
if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
}
i, err := NewForSource(source, "0.0.1")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// ensure a HTTPInstaller was returned
httpInstaller, ok := i.(*HTTPInstaller)
if !ok {
t.Fatal("expected a HTTPInstaller")
}
// inject fake http client responding with minimal plugin tarball
mockTgz, err := base64.StdEncoding.DecodeString(fakePluginB64)
if err != nil {
t.Fatalf("Could not decode fake tgz plugin: %s", err)
}
httpInstaller.getter = &TestHTTPGetter{
MockResponse: bytes.NewBuffer(mockTgz),
}
// install the plugin before updating
if err := Install(i); err != nil {
t.Fatal(err)
}
if i.Path() != helmpath.DataPath("plugins", "fake-plugin") {
t.Fatalf("expected path '$XDG_CONFIG_HOME/helm/plugins/fake-plugin', got %q", i.Path())
}
// Update plugin, should fail because it is not implemented
if err := Update(i); err == nil {
t.Fatal("update method not implemented for http installer")
}
}
func TestExtract(t *testing.T) {
source := "https://repo.localdomain/plugins/fake-plugin-0.0.1.tar.gz"
tempDir := t.TempDir()
// Get current umask to predict expected permissions
currentUmask := syscall.Umask(0)
syscall.Umask(currentUmask)
// Write a tarball to a buffer for us to extract
var tarbuf bytes.Buffer
tw := tar.NewWriter(&tarbuf)
var files = []struct {
Name, Body string
Mode int64
}{
{"plugin.yaml", "plugin metadata", 0600},
{"README.md", "some text", 0777},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Typeflag: tar.TypeReg,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
// Add pax global headers. This should be ignored.
// Note the PAX header that isn't global cannot be written using WriteHeader.
// Details are in the internal Go function for the tar packaged named
// allowedFormats. For a TypeXHeader it will return a message stating
// "cannot manually encode TypeXHeader, TypeGNULongName, or TypeGNULongLink headers"
if err := tw.WriteHeader(&tar.Header{
Name: "pax_global_header",
Typeflag: tar.TypeXGlobalHeader,
}); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
// END tarball creation
extractor, err := NewExtractor(source)
if err != nil {
t.Fatal(err)
}
if err = extractor.Extract(&buf, tempDir); err != nil {
t.Fatalf("Did not expect error but got error: %v", err)
}
// Calculate expected permissions after umask is applied
expectedPluginYAMLPerm := os.FileMode(0600 &^ currentUmask)
expectedReadmePerm := os.FileMode(0777 &^ currentUmask)
pluginYAMLFullPath := filepath.Join(tempDir, "plugin.yaml")
if info, err := os.Stat(pluginYAMLFullPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Expected %s to exist but doesn't", pluginYAMLFullPath)
}
t.Fatal(err)
} else if info.Mode().Perm() != expectedPluginYAMLPerm {
t.Fatalf("Expected %s to have %o mode but has %o (umask: %o)",
pluginYAMLFullPath, expectedPluginYAMLPerm, info.Mode().Perm(), currentUmask)
}
readmeFullPath := filepath.Join(tempDir, "README.md")
if info, err := os.Stat(readmeFullPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
t.Fatalf("Expected %s to exist but doesn't", readmeFullPath)
}
t.Fatal(err)
} else if info.Mode().Perm() != expectedReadmePerm {
t.Fatalf("Expected %s to have %o mode but has %o (umask: %o)",
readmeFullPath, expectedReadmePerm, info.Mode().Perm(), currentUmask)
}
}
func TestCleanJoin(t *testing.T) {
for i, fixture := range []struct {
path string
expect string
expectError bool
}{
{"foo/bar.txt", "/tmp/foo/bar.txt", false},
{"/foo/bar.txt", "", true},
{"./foo/bar.txt", "/tmp/foo/bar.txt", false},
{"./././././foo/bar.txt", "/tmp/foo/bar.txt", false},
{"../../../../foo/bar.txt", "", true},
{"foo/../../../../bar.txt", "", true},
{"c:/foo/bar.txt", "/tmp/c:/foo/bar.txt", true},
{"foo\\bar.txt", "/tmp/foo/bar.txt", false},
{"c:\\foo\\bar.txt", "", true},
} {
out, err := cleanJoin("/tmp", fixture.path)
if err != nil {
if !fixture.expectError {
t.Errorf("Test %d: Path was not cleaned: %s", i, err)
}
continue
}
if fixture.expect != out {
t.Errorf("Test %d: Expected %q but got %q", i, fixture.expect, out)
}
}
}
func TestMediaTypeToExtension(t *testing.T) {
for mt, shouldPass := range map[string]bool{
"": false,
"application/gzip": true,
"application/x-gzip": true,
"application/x-tgz": true,
"application/x-gtar": true,
"application/json": false,
} {
ext, ok := mediaTypeToExtension(mt)
if ok != shouldPass {
t.Errorf("Media type %q failed test", mt)
}
if shouldPass && ext == "" {
t.Errorf("Expected an extension but got empty string")
}
if !shouldPass && len(ext) != 0 {
t.Error("Expected extension to be empty for unrecognized type")
}
}
}
func TestExtractWithNestedDirectories(t *testing.T) {
source := "https://repo.localdomain/plugins/nested-plugin-0.0.1.tar.gz"
tempDir := t.TempDir()
// Write a tarball with nested directory structure
var tarbuf bytes.Buffer
tw := tar.NewWriter(&tarbuf)
var files = []struct {
Name string
Body string
Mode int64
TypeFlag byte
}{
{"plugin.yaml", "plugin metadata", 0600, tar.TypeReg},
{"bin/", "", 0755, tar.TypeDir},
{"bin/plugin", "#!/bin/bash\necho plugin", 0755, tar.TypeReg},
{"docs/", "", 0755, tar.TypeDir},
{"docs/README.md", "readme content", 0644, tar.TypeReg},
{"docs/examples/", "", 0755, tar.TypeDir},
{"docs/examples/example1.yaml", "example content", 0644, tar.TypeReg},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Typeflag: file.TypeFlag,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if file.TypeFlag == tar.TypeReg {
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
extractor, err := NewExtractor(source)
if err != nil {
t.Fatal(err)
}
// First extraction
if err = extractor.Extract(&buf, tempDir); err != nil {
t.Fatalf("First extraction failed: %v", err)
}
// Verify nested structure was created
nestedFile := filepath.Join(tempDir, "docs", "examples", "example1.yaml")
if _, err := os.Stat(nestedFile); err != nil {
t.Fatalf("Expected nested file %s to exist but got error: %v", nestedFile, err)
}
// Reset buffer for second extraction
buf.Reset()
gz = gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
// Second extraction to same directory (should not fail)
if err = extractor.Extract(&buf, tempDir); err != nil {
t.Fatalf("Second extraction to existing directory failed: %v", err)
}
}
func TestExtractWithExistingDirectory(t *testing.T) {
source := "https://repo.localdomain/plugins/test-plugin-0.0.1.tar.gz"
tempDir := t.TempDir()
// Pre-create the cache directory structure
cacheDir := filepath.Join(tempDir, "cache")
if err := os.MkdirAll(filepath.Join(cacheDir, "existing", "dir"), 0755); err != nil {
t.Fatal(err)
}
// Create a file in the existing directory
existingFile := filepath.Join(cacheDir, "existing", "file.txt")
if err := os.WriteFile(existingFile, []byte("existing content"), 0644); err != nil {
t.Fatal(err)
}
// Write a tarball
var tarbuf bytes.Buffer
tw := tar.NewWriter(&tarbuf)
files := []struct {
Name string
Body string
Mode int64
TypeFlag byte
}{
{"plugin.yaml", "plugin metadata", 0600, tar.TypeReg},
{"existing/", "", 0755, tar.TypeDir},
{"existing/dir/", "", 0755, tar.TypeDir},
{"existing/dir/newfile.txt", "new content", 0644, tar.TypeReg},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Typeflag: file.TypeFlag,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if file.TypeFlag == tar.TypeReg {
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
extractor, err := NewExtractor(source)
if err != nil {
t.Fatal(err)
}
// Extract to directory with existing content
if err = extractor.Extract(&buf, cacheDir); err != nil {
t.Fatalf("Extraction to directory with existing content failed: %v", err)
}
// Verify new file was created
newFile := filepath.Join(cacheDir, "existing", "dir", "newfile.txt")
if _, err := os.Stat(newFile); err != nil {
t.Fatalf("Expected new file %s to exist but got error: %v", newFile, err)
}
// Verify existing file is still there
if _, err := os.Stat(existingFile); err != nil {
t.Fatalf("Expected existing file %s to still exist but got error: %v", existingFile, err)
}
}
func TestExtractPluginInSubdirectory(t *testing.T) {
ensure.HelmHome(t)
source := "https://repo.localdomain/plugins/subdir-plugin-1.0.0.tar.gz"
tempDir := t.TempDir()
// Create a tarball where plugin files are in a subdirectory
var tarbuf bytes.Buffer
tw := tar.NewWriter(&tarbuf)
files := []struct {
Name string
Body string
Mode int64
TypeFlag byte
}{
{"my-plugin/", "", 0755, tar.TypeDir},
{"my-plugin/plugin.yaml", "name: my-plugin\nversion: 1.0.0\nusage: test\ndescription: test plugin\ncommand: $HELM_PLUGIN_DIR/bin/my-plugin", 0644, tar.TypeReg},
{"my-plugin/bin/", "", 0755, tar.TypeDir},
{"my-plugin/bin/my-plugin", "#!/bin/bash\necho test", 0755, tar.TypeReg},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Typeflag: file.TypeFlag,
Mode: file.Mode,
Size: int64(len(file.Body)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if file.TypeFlag == tar.TypeReg {
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatal(err)
}
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write(tarbuf.Bytes()); err != nil {
t.Fatal(err)
}
gz.Close()
// Test the installer
installer := &HTTPInstaller{
CacheDir: tempDir,
PluginName: "subdir-plugin",
base: newBase(source),
extractor: &TarGzExtractor{},
}
// Create a mock getter
installer.getter = &TestHTTPGetter{
MockResponse: &buf,
}
// Ensure the destination directory doesn't exist
// (In a real scenario, this is handled by installer.Install() wrapper)
destPath := installer.Path()
if err := os.RemoveAll(destPath); err != nil {
t.Fatalf("Failed to clean destination path: %v", err)
}
// Install should handle the subdirectory correctly
if err := installer.Install(); err != nil {
t.Fatalf("Failed to install plugin with subdirectory: %v", err)
}
// The plugin should be installed from the subdirectory
// Check that detectPluginRoot found the correct location
pluginRoot, err := detectPluginRoot(tempDir)
if err != nil {
t.Fatalf("Failed to detect plugin root: %v", err)
}
expectedRoot := filepath.Join(tempDir, "my-plugin")
if pluginRoot != expectedRoot {
t.Errorf("Expected plugin root to be %s but got %s", expectedRoot, pluginRoot)
}
}

@ -19,13 +19,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"helm.sh/helm/v4/internal/plugin" "helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/pkg/registry"
) )
// ErrMissingMetadata indicates that plugin.yaml is missing. // ErrMissingMetadata indicates that plugin.yaml is missing.
@ -138,29 +135,16 @@ func Update(i Installer) error {
return i.Update() return i.Update()
} }
// NewForSource determines the correct Installer for the given source. // FindSource determines the correct Installer for the given source.
func NewForSource(source, version string) (Installer, error) { func FindSource(source string) (Installer, error) {
// Check if source is an OCI registry reference
if strings.HasPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme)) {
return NewOCIInstaller(source)
}
// Check if source is a local directory // Check if source is a local directory
if isLocalReference(source) { if isLocalReference(source) {
return NewLocalInstaller(source) return NewLocalInstaller(source)
} else if isRemoteHTTPArchive(source) {
return NewHTTPInstaller(source)
} }
return NewVCSInstaller(source, version)
}
// FindSource determines the correct Installer for the given source. // For all remote sources (HTTP, HTTPS, OCI, etc.), use the unified artifact installer
func FindSource(location string) (Installer, error) { // This enables support for any transport that charts support, including getter/v1 plugins
installer, err := existingVCSRepo(location) return NewArtifactInstaller(source)
if err != nil && err.Error() == "Cannot detect VCS" {
slog.Warn("cannot get information about plugin source", "location", location, slog.Any("error", err))
return installer, errors.New("cannot get information about plugin source")
}
return installer, err
} }
// isLocalReference checks if the source exists on the filesystem. // isLocalReference checks if the source exists on the filesystem.
@ -169,46 +153,6 @@ func isLocalReference(source string) bool {
return err == nil return err == nil
} }
// isRemoteHTTPArchive checks if the source is a http/https url and is an archive
//
// It works by checking whether the source looks like a URL and, if it does, running a
// HEAD operation to see if the remote resource is a file that we understand.
func isRemoteHTTPArchive(source string) bool {
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
// First, check if the URL ends with a known archive suffix
// This is more reliable than content-type detection
for suffix := range Extractors {
if strings.HasSuffix(source, suffix) {
return true
}
}
// If no suffix match, try HEAD request to check content type
res, err := http.Head(source)
if err != nil {
// If we get an error at the network layer, we can't install it. So
// we return false.
return false
}
// Next, we look for the content type or content disposition headers to see
// if they have matching extractors.
contentType := res.Header.Get("content-type")
foundSuffix, ok := mediaTypeToExtension(contentType)
if !ok {
// Media type not recognized
return false
}
for suffix := range Extractors {
if strings.HasSuffix(foundSuffix, suffix) {
return true
}
}
}
return false
}
// isPlugin checks if the directory contains a plugin.yaml file. // isPlugin checks if the directory contains a plugin.yaml file.
func isPlugin(dirname string) bool { func isPlugin(dirname string) bool {
_, err := os.Stat(filepath.Join(dirname, plugin.PluginFileName)) _, err := os.Stat(filepath.Join(dirname, plugin.PluginFileName))

@ -15,33 +15,140 @@ limitations under the License.
package installer package installer
import "testing" import (
"testing"
func TestIsRemoteHTTPArchive(t *testing.T) { "helm.sh/helm/v4/pkg/getter"
srv := mockArchiveServer() )
defer srv.Close()
source := srv.URL + "/plugins/fake-plugin-0.0.1.tar.gz"
if isRemoteHTTPArchive("/not/a/URL") { // TestArtifactInstaller_VersionSupport tests that ArtifactInstaller properly handles version constraints
t.Errorf("Expected non-URL to return false") func TestArtifactInstaller_VersionSupport(t *testing.T) {
tests := []struct {
name string
source string
version string
}{
{
name: "VCS source with version tag",
source: "https://github.com/user/plugin",
version: "v1.2.3",
},
{
name: "VCS source with branch",
source: "https://github.com/user/plugin",
version: "main",
},
{
name: "VCS source with commit hash",
source: "https://github.com/user/plugin",
version: "abc123def456",
},
{
name: "HTTP source with version",
source: "https://example.com/plugin.tgz",
version: "1.0.0",
},
{
name: "OCI source with version",
source: "oci://registry/plugin",
version: "1.0.0",
},
} }
// URLs with valid archive extensions are considered valid archives for _, tt := range tests {
// even if the server is unreachable (optimization to avoid unnecessary HTTP requests) t.Run(tt.name, func(t *testing.T) {
if !isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.tgz") { installer, err := NewArtifactInstaller(tt.source)
t.Errorf("URL with .tgz extension should be considered a valid archive") if err != nil {
t.Fatalf("NewArtifactInstaller() error = %v", err)
}
// Test SetVersion method
installer.SetVersion(tt.version)
// Verify version was set (we can't easily test the internal version field
// without making it public, but we can test that SetVersion doesn't panic)
// Test that we can call SetVersion multiple times
installer.SetVersion("different-version")
installer.SetVersion(tt.version) // Set back to test version
})
} }
}
// TestArtifactInstaller_SetOptions tests that ArtifactInstaller properly handles getter options
func TestArtifactInstaller_SetOptions(t *testing.T) {
source := "oci://registry/plugin"
installer, err := NewArtifactInstaller(source)
if err != nil {
t.Fatalf("NewArtifactInstaller() error = %v", err)
}
// Test SetOptions method
options := []getter.Option{
getter.WithBasicAuth("user", "pass"),
getter.WithPlainHTTP(true),
getter.WithInsecureSkipVerifyTLS(true),
}
// Should not panic
installer.SetOptions(options)
// Test with invalid extension and unreachable server // Test that we can call SetOptions multiple times
if isRemoteHTTPArchive("https://127.0.0.1:123/fake/plugin-1.2.3.notanarchive") { moreOptions := []getter.Option{
t.Errorf("Bad URL without valid extension should not succeed") getter.WithTLSClientConfig("cert", "key", "ca"),
} }
installer.SetOptions(moreOptions)
}
// TestFindSource_ReturnsArtifactInstaller tests that FindSource returns ArtifactInstaller for various sources
func TestFindSource_ReturnsArtifactInstaller(t *testing.T) {
tests := []struct {
name string
source string
}{
{
name: "HTTP URL",
source: "https://example.com/plugin.tgz",
},
{
name: "HTTPS URL",
source: "https://example.com/plugin.tgz",
},
{
name: "VCS URL",
source: "https://github.com/user/plugin",
},
{
name: "OCI reference",
source: "oci://registry/plugin:1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
installer, err := FindSource(tt.source)
if err != nil {
t.Fatalf("FindSource() error = %v", err)
}
if _, ok := installer.(*ArtifactInstaller); !ok {
t.Errorf("FindSource() returned %T, expected *ArtifactInstaller", installer)
}
})
}
}
// TestFindSource_LocalPath tests that FindSource returns LocalInstaller for local paths
func TestFindSource_LocalPath(t *testing.T) {
// Create a temporary directory to simulate a local plugin
tempDir := t.TempDir()
if !isRemoteHTTPArchive(source) { installer, err := FindSource(tempDir)
t.Errorf("Expected %q to be a valid archive URL", source) if err != nil {
t.Fatalf("FindSource() error = %v", err)
} }
if isRemoteHTTPArchive(source + "-not-an-extension") { if _, ok := installer.(*LocalInstaller); !ok {
t.Error("Expected media type match to fail") t.Errorf("FindSource() returned %T, expected *LocalInstaller", installer)
} }
} }

@ -38,7 +38,7 @@ func TestLocalInstaller(t *testing.T) {
} }
source := "../testdata/plugdir/good/echo-v1" source := "../testdata/plugdir/good/echo-v1"
i, err := NewForSource(source, "") i, err := FindSource(source)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -55,7 +55,7 @@ func TestLocalInstaller(t *testing.T) {
func TestLocalInstallerNotAFolder(t *testing.T) { func TestLocalInstallerNotAFolder(t *testing.T) {
source := "../testdata/plugdir/good/echo-v1/plugin.yaml" source := "../testdata/plugdir/good/echo-v1/plugin.yaml"
i, err := NewForSource(source, "") i, err := FindSource(source)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }
@ -117,7 +117,7 @@ func TestLocalInstallerTarball(t *testing.T) {
} }
// Test installation // Test installation
i, err := NewForSource(tarballPath, "") i, err := FindSource(tarballPath)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %s", err) t.Fatalf("unexpected error: %s", err)
} }

@ -1,301 +0,0 @@
/*
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 installer
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/plugin/cache"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/helmpath"
"helm.sh/helm/v4/pkg/registry"
)
// Ensure OCIInstaller implements Verifier
var _ Verifier = (*OCIInstaller)(nil)
// OCIInstaller installs plugins from OCI registries
type OCIInstaller struct {
CacheDir string
PluginName string
base
settings *cli.EnvSettings
getter getter.Getter
// Cached data to avoid duplicate downloads
pluginData []byte
provData []byte
}
// NewOCIInstaller creates a new OCIInstaller with optional getter options
func NewOCIInstaller(source string, options ...getter.Option) (*OCIInstaller, error) {
// Extract plugin name from OCI reference using robust registry parsing
pluginName, err := registry.GetPluginName(source)
if err != nil {
return nil, err
}
key, err := cache.Key(source)
if err != nil {
return nil, err
}
settings := cli.New()
// Always add plugin artifact type and any provided options
pluginOptions := append([]getter.Option{getter.WithArtifactType("plugin")}, options...)
getterProvider, err := getter.NewOCIGetter(pluginOptions...)
if err != nil {
return nil, err
}
i := &OCIInstaller{
CacheDir: helmpath.CachePath("plugins", key),
PluginName: pluginName,
base: newBase(source),
settings: settings,
getter: getterProvider,
}
return i, nil
}
// Install downloads and installs a plugin from OCI registry
// Implements Installer.
func (i *OCIInstaller) Install() error {
slog.Debug("pulling OCI plugin", "source", i.Source)
// Ensure plugin data is cached
if i.pluginData == nil {
pluginData, err := i.getter.Get(i.Source)
if err != nil {
return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
}
i.pluginData = pluginData.Bytes()
}
// Extract metadata to get the actual plugin name and version
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename := fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
tarballPath := helmpath.DataPath("plugins", filename)
if err := os.MkdirAll(filepath.Dir(tarballPath), 0755); err != nil {
return fmt.Errorf("failed to create plugins directory: %w", err)
}
if err := os.WriteFile(tarballPath, i.pluginData, 0644); err != nil {
return fmt.Errorf("failed to save tarball: %w", err)
}
// Ensure prov data is cached if available
if i.provData == nil {
// Try to download .prov file if it exists
provSource := i.Source + ".prov"
if provData, err := i.getter.Get(provSource); err == nil {
i.provData = provData.Bytes()
}
}
// Save prov file if we have the data
if i.provData != nil {
provPath := tarballPath + ".prov"
if err := os.WriteFile(provPath, i.provData, 0644); err != nil {
slog.Debug("failed to save provenance file", "error", err)
}
}
// Check if this is a gzip compressed file
if len(i.pluginData) < 2 || i.pluginData[0] != 0x1f || i.pluginData[1] != 0x8b {
return fmt.Errorf("plugin data is not a gzip compressed archive")
}
// Create cache directory
if err := os.MkdirAll(i.CacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
// Extract as gzipped tar
if err := extractTarGz(bytes.NewReader(i.pluginData), i.CacheDir); err != nil {
return fmt.Errorf("failed to extract plugin: %w", err)
}
// Verify plugin.yaml exists - check root and subdirectories
pluginDir := i.CacheDir
if !isPlugin(pluginDir) {
// Check if plugin.yaml is in a subdirectory
entries, err := os.ReadDir(i.CacheDir)
if err != nil {
return err
}
foundPluginDir := ""
for _, entry := range entries {
if entry.IsDir() {
subDir := filepath.Join(i.CacheDir, entry.Name())
if isPlugin(subDir) {
foundPluginDir = subDir
break
}
}
}
if foundPluginDir == "" {
return ErrMissingMetadata
}
// Use the subdirectory as the plugin directory
pluginDir = foundPluginDir
}
// Copy from cache to final destination
src, err := filepath.Abs(pluginDir)
if err != nil {
return err
}
slog.Debug("copying", "source", src, "path", i.Path())
return fs.CopyDir(src, i.Path())
}
// Update updates a plugin by reinstalling it
func (i *OCIInstaller) Update() error {
// For OCI, update means removing the old version and installing the new one
if err := os.RemoveAll(i.Path()); err != nil {
return err
}
return i.Install()
}
// Path is where the plugin will be installed
func (i OCIInstaller) Path() string {
if i.Source == "" {
return ""
}
return filepath.Join(i.settings.PluginsDirectory, i.PluginName)
}
// extractTarGz extracts a gzipped tar archive to a directory
func extractTarGz(r io.Reader, targetDir string) error {
gzr, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gzr.Close()
return extractTar(gzr, targetDir)
}
// extractTar extracts a tar archive to a directory
func extractTar(r io.Reader, targetDir string) error {
tarReader := tar.NewReader(r)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
path, err := cleanJoin(targetDir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
case tar.TypeReg:
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return err
}
defer outFile.Close()
if _, err := io.Copy(outFile, tarReader); err != nil {
return err
}
case tar.TypeXGlobalHeader, tar.TypeXHeader:
// Skip these
continue
default:
return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
}
}
return nil
}
// SupportsVerification returns true since OCI plugins can be verified
func (i *OCIInstaller) SupportsVerification() bool {
return true
}
// GetVerificationData downloads and caches plugin and provenance data from OCI registry for verification
func (i *OCIInstaller) GetVerificationData() (archiveData, provData []byte, filename string, err error) {
slog.Debug("getting verification data for OCI plugin", "source", i.Source)
// Download plugin data once and cache it
if i.pluginData == nil {
pluginDataBuffer, err := i.getter.Get(i.Source)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
}
i.pluginData = pluginDataBuffer.Bytes()
}
// Download prov data once and cache it if available
if i.provData == nil {
provSource := i.Source + ".prov"
// Calling getter.Get again is reasonable because: 1. The OCI registry client already optimizes the underlying network calls
// 2. Both calls use the same underlying manifest and memory store 3. The second .prov call is very fast since the data is already pulled
provDataBuffer, err := i.getter.Get(provSource)
if err != nil {
// If provenance file doesn't exist, set provData to nil
// The verification logic will handle this gracefully
i.provData = nil
} else {
i.provData = provDataBuffer.Bytes()
}
}
// Extract metadata to get the filename
metadata, err := plugin.ExtractTgzPluginMetadata(bytes.NewReader(i.pluginData))
if err != nil {
return nil, nil, "", fmt.Errorf("failed to extract plugin metadata from tarball: %w", err)
}
filename = fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version)
slog.Debug("got verification data for OCI plugin", "filename", filename)
return i.pluginData, i.provData, filename, nil
}

@ -1,806 +0,0 @@
/*
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/helmpath"
)
var _ Installer = new(OCIInstaller)
// createTestPluginTarGz creates a test plugin tar.gz with plugin.yaml
func createTestPluginTarGz(t *testing.T, pluginName string) []byte {
t.Helper()
var buf bytes.Buffer
gzWriter := gzip.NewWriter(&buf)
tarWriter := tar.NewWriter(gzWriter)
// Add plugin.yaml
pluginYAML := fmt.Sprintf(`name: %s
version: "1.0.0"
description: "Test plugin for OCI installer"
command: "$HELM_PLUGIN_DIR/bin/%s"
`, pluginName, pluginName)
header := &tar.Header{
Name: "plugin.yaml",
Mode: 0644,
Size: int64(len(pluginYAML)),
Typeflag: tar.TypeReg,
}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write([]byte(pluginYAML)); err != nil {
t.Fatal(err)
}
// Add bin directory
dirHeader := &tar.Header{
Name: "bin/",
Mode: 0755,
Typeflag: tar.TypeDir,
}
if err := tarWriter.WriteHeader(dirHeader); err != nil {
t.Fatal(err)
}
// Add executable
execContent := fmt.Sprintf("#!/bin/sh\necho '%s test plugin'", pluginName)
execHeader := &tar.Header{
Name: fmt.Sprintf("bin/%s", pluginName),
Mode: 0755,
Size: int64(len(execContent)),
Typeflag: tar.TypeReg,
}
if err := tarWriter.WriteHeader(execHeader); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write([]byte(execContent)); err != nil {
t.Fatal(err)
}
tarWriter.Close()
gzWriter.Close()
return buf.Bytes()
}
// mockOCIRegistryWithArtifactType creates a mock OCI registry server using the new artifact type approach
func mockOCIRegistryWithArtifactType(t *testing.T, pluginName string) (*httptest.Server, string) {
t.Helper()
pluginData := createTestPluginTarGz(t, pluginName)
layerDigest := fmt.Sprintf("sha256:%x", sha256Sum(pluginData))
// Create empty config data (as per OCI v1.1+ spec)
configData := []byte("{}")
configDigest := fmt.Sprintf("sha256:%x", sha256Sum(configData))
// Create manifest with artifact type
manifest := ocispec.Manifest{
MediaType: ocispec.MediaTypeImageManifest,
ArtifactType: "application/vnd.helm.plugin.v1+json", // Using artifact type
Config: ocispec.Descriptor{
MediaType: "application/vnd.oci.empty.v1+json", // Empty config
Digest: digest.Digest(configDigest),
Size: int64(len(configData)),
},
Layers: []ocispec.Descriptor{
{
MediaType: "application/vnd.oci.image.layer.v1.tar",
Digest: digest.Digest(layerDigest),
Size: int64(len(pluginData)),
Annotations: map[string]string{
ocispec.AnnotationTitle: pluginName + "-1.0.0.tgz", // Layer named with version
},
},
},
}
manifestData, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
manifestDigest := fmt.Sprintf("sha256:%x", sha256Sum(manifestData))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/") && !strings.Contains(r.URL.Path, "/manifests/") && !strings.Contains(r.URL.Path, "/blobs/"):
// API version check
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/manifests/") && strings.Contains(r.URL.Path, pluginName):
// Return manifest
w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest)
w.Header().Set("Docker-Content-Digest", manifestDigest)
w.WriteHeader(http.StatusOK)
w.Write(manifestData)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+layerDigest):
// Return layer data
w.Header().Set("Content-Type", "application/vnd.oci.image.layer.v1.tar")
w.WriteHeader(http.StatusOK)
w.Write(pluginData)
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+configDigest):
// Return config data
w.Header().Set("Content-Type", "application/vnd.oci.empty.v1+json")
w.WriteHeader(http.StatusOK)
w.Write(configData)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
// Parse server URL to get host:port format for OCI reference
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
registryHost := serverURL.Host
return server, registryHost
}
// sha256Sum calculates SHA256 sum of data
func sha256Sum(data []byte) []byte {
h := sha256.New()
h.Write(data)
return h.Sum(nil)
}
func TestNewOCIInstaller(t *testing.T) {
tests := []struct {
name string
source string
expectName string
expectError bool
}{
{
name: "valid OCI reference with tag",
source: "oci://ghcr.io/user/plugin-name:v1.0.0",
expectName: "plugin-name",
expectError: false,
},
{
name: "valid OCI reference with digest",
source: "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef",
expectName: "plugin-name",
expectError: false,
},
{
name: "valid OCI reference without tag",
source: "oci://ghcr.io/user/plugin-name",
expectName: "plugin-name",
expectError: false,
},
{
name: "valid OCI reference with multiple path segments",
source: "oci://registry.example.com/org/team/plugin-name:latest",
expectName: "plugin-name",
expectError: false,
},
{
name: "invalid OCI reference - no path",
source: "oci://registry.example.com",
expectName: "",
expectError: true,
},
{
name: "valid OCI reference - single path segment",
source: "oci://registry.example.com/plugin",
expectName: "plugin",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
installer, err := NewOCIInstaller(tt.source)
if tt.expectError {
if err == nil {
t.Errorf("expected error but got none")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
// Check all fields thoroughly
if installer.PluginName != tt.expectName {
t.Errorf("expected plugin name %s, got %s", tt.expectName, installer.PluginName)
}
if installer.Source != tt.source {
t.Errorf("expected source %s, got %s", tt.source, installer.Source)
}
if installer.CacheDir == "" {
t.Error("expected non-empty cache directory")
}
if !strings.Contains(installer.CacheDir, "plugins") {
t.Errorf("expected cache directory to contain 'plugins', got %s", installer.CacheDir)
}
if installer.settings == nil {
t.Error("expected settings to be initialized")
}
// Check that Path() method works
expectedPath := helmpath.DataPath("plugins", tt.expectName)
if installer.Path() != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, installer.Path())
}
})
}
}
func TestOCIInstaller_Path(t *testing.T) {
tests := []struct {
name string
source string
pluginName string
expectPath string
}{
{
name: "valid plugin name",
source: "oci://ghcr.io/user/plugin-name:v1.0.0",
pluginName: "plugin-name",
expectPath: helmpath.DataPath("plugins", "plugin-name"),
},
{
name: "empty source",
source: "",
pluginName: "",
expectPath: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
installer := &OCIInstaller{
PluginName: tt.pluginName,
base: newBase(tt.source),
settings: cli.New(),
}
path := installer.Path()
if path != tt.expectPath {
t.Errorf("expected path %s, got %s", tt.expectPath, path)
}
})
}
}
func TestOCIInstaller_Install(t *testing.T) {
// Set up isolated test environment
ensure.HelmHome(t)
pluginName := "test-plugin-basic"
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
defer server.Close()
// Test OCI reference
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
// Test with plain HTTP (since test server uses HTTP)
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// The OCI installer uses helmpath.DataPath, which is isolated by ensure.HelmHome(t)
actualPath := installer.Path()
t.Logf("Installer will use path: %s", actualPath)
// Install the plugin
if err := Install(installer); err != nil {
t.Fatalf("Expected installation to succeed, got error: %v", err)
}
// Verify plugin was installed to the correct location
if !isPlugin(actualPath) {
t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
}
// Debug: list what was actually created
if entries, err := os.ReadDir(actualPath); err != nil {
t.Fatalf("Could not read plugin directory %s: %v", actualPath, err)
} else {
t.Logf("Plugin directory %s contains:", actualPath)
for _, entry := range entries {
t.Logf(" - %s", entry.Name())
}
}
// Verify the plugin.yaml file exists and is valid
pluginFile := filepath.Join(actualPath, "plugin.yaml")
if _, err := os.Stat(pluginFile); err != nil {
t.Errorf("Expected plugin.yaml to exist, got error: %v", err)
}
}
func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) {
testCases := []struct {
name string
pluginName string
options []getter.Option
wantErr bool
}{
{
name: "plain HTTP",
pluginName: "example-cli-plain-http",
options: []getter.Option{getter.WithPlainHTTP(true)},
wantErr: false,
},
{
name: "insecure skip TLS verify",
pluginName: "example-cli-insecure",
options: []getter.Option{getter.WithPlainHTTP(true), getter.WithInsecureSkipVerifyTLS(true)},
wantErr: false,
},
{
name: "with timeout",
pluginName: "example-cli-timeout",
options: []getter.Option{getter.WithPlainHTTP(true), getter.WithTimeout(30 * time.Second)},
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Set up isolated test environment for each subtest
ensure.HelmHome(t)
server, registryHost := mockOCIRegistryWithArtifactType(t, tc.pluginName)
defer server.Close()
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, tc.pluginName)
installer, err := NewOCIInstaller(source, tc.options...)
if err != nil {
if !tc.wantErr {
t.Fatalf("Expected no error creating installer, got %v", err)
}
return
}
// The installer now uses our isolated test directory
actualPath := installer.Path()
// Install the plugin
err = Install(installer)
if tc.wantErr {
if err == nil {
t.Errorf("Expected installation to fail, but it succeeded")
}
} else {
if err != nil {
t.Errorf("Expected installation to succeed, got error: %v", err)
} else {
// Verify plugin was installed to the actual path
if !isPlugin(actualPath) {
t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
}
}
}
})
}
}
func TestOCIInstaller_Install_AlreadyExists(t *testing.T) {
// Set up isolated test environment
ensure.HelmHome(t)
pluginName := "test-plugin-exists"
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
defer server.Close()
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// First install should succeed
if err := Install(installer); err != nil {
t.Fatalf("Expected first installation to succeed, got error: %v", err)
}
// Verify plugin was installed
if !isPlugin(installer.Path()) {
t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
}
// Second install should fail with "plugin already exists"
err = Install(installer)
if err == nil {
t.Error("Expected error when installing plugin that already exists")
} else if !strings.Contains(err.Error(), "plugin already exists") {
t.Errorf("Expected 'plugin already exists' error, got: %v", err)
}
}
func TestOCIInstaller_Update(t *testing.T) {
// Set up isolated test environment
ensure.HelmHome(t)
pluginName := "test-plugin-update"
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
defer server.Close()
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
// Test update when plugin does not exist - should fail
err = Update(installer)
if err == nil {
t.Error("Expected error when updating plugin that does not exist")
} else if !strings.Contains(err.Error(), "plugin does not exist") {
t.Errorf("Expected 'plugin does not exist' error, got: %v", err)
}
// Install plugin first
if err := Install(installer); err != nil {
t.Fatalf("Expected installation to succeed, got error: %v", err)
}
// Verify plugin was installed
if !isPlugin(installer.Path()) {
t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
}
// Test update when plugin exists - should succeed
// For OCI, Update() removes old version and reinstalls
if err := Update(installer); err != nil {
t.Errorf("Expected update to succeed, got error: %v", err)
}
// Verify plugin is still installed after update
if !isPlugin(installer.Path()) {
t.Errorf("Expected plugin directory %s to contain plugin.yaml after update", installer.Path())
}
}
func TestOCIInstaller_Install_ComponentExtraction(t *testing.T) {
// Test that we can extract a plugin archive properly
// This tests the extraction logic that Install() uses
tempDir := t.TempDir()
pluginName := "test-plugin-extract"
pluginData := createTestPluginTarGz(t, pluginName)
// Test extraction
err := extractTarGz(bytes.NewReader(pluginData), tempDir)
if err != nil {
t.Fatalf("Failed to extract plugin: %v", err)
}
// Verify plugin.yaml exists
pluginYAMLPath := filepath.Join(tempDir, "plugin.yaml")
if _, err := os.Stat(pluginYAMLPath); os.IsNotExist(err) {
t.Errorf("plugin.yaml not found after extraction")
}
// Verify bin directory exists
binPath := filepath.Join(tempDir, "bin")
if _, err := os.Stat(binPath); os.IsNotExist(err) {
t.Errorf("bin directory not found after extraction")
}
// Verify executable exists and has correct permissions
execPath := filepath.Join(tempDir, "bin", pluginName)
if info, err := os.Stat(execPath); err != nil {
t.Errorf("executable not found: %v", err)
} else if info.Mode()&0111 == 0 {
t.Errorf("file is not executable")
}
// Verify this would be recognized as a plugin
if !isPlugin(tempDir) {
t.Errorf("extracted directory is not a valid plugin")
}
}
func TestExtractTarGz(t *testing.T) {
tempDir := t.TempDir()
// Create a test tar.gz file
var buf bytes.Buffer
gzWriter := gzip.NewWriter(&buf)
tarWriter := tar.NewWriter(gzWriter)
// Add a test file to the archive
testContent := "test content"
header := &tar.Header{
Name: "test-file.txt",
Mode: 0644,
Size: int64(len(testContent)),
Typeflag: tar.TypeReg,
}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
t.Fatal(err)
}
// Add a test directory
dirHeader := &tar.Header{
Name: "test-dir/",
Mode: 0755,
Typeflag: tar.TypeDir,
}
if err := tarWriter.WriteHeader(dirHeader); err != nil {
t.Fatal(err)
}
tarWriter.Close()
gzWriter.Close()
// Test extraction
err := extractTarGz(bytes.NewReader(buf.Bytes()), tempDir)
if err != nil {
t.Errorf("extractTarGz failed: %v", err)
}
// Verify extracted file
extractedFile := filepath.Join(tempDir, "test-file.txt")
content, err := os.ReadFile(extractedFile)
if err != nil {
t.Errorf("failed to read extracted file: %v", err)
}
if string(content) != testContent {
t.Errorf("expected content %s, got %s", testContent, string(content))
}
// Verify extracted directory
extractedDir := filepath.Join(tempDir, "test-dir")
if _, err := os.Stat(extractedDir); os.IsNotExist(err) {
t.Errorf("extracted directory does not exist: %s", extractedDir)
}
}
func TestExtractTarGz_InvalidGzip(t *testing.T) {
tempDir := t.TempDir()
// Test with invalid gzip data
invalidGzipData := []byte("not gzip data")
err := extractTarGz(bytes.NewReader(invalidGzipData), tempDir)
if err == nil {
t.Error("expected error for invalid gzip data")
}
}
func TestExtractTar_UnknownFileType(t *testing.T) {
tempDir := t.TempDir()
// Create a test tar file
var buf bytes.Buffer
tarWriter := tar.NewWriter(&buf)
// Add a test file
testContent := "test content"
header := &tar.Header{
Name: "test-file.txt",
Mode: 0644,
Size: int64(len(testContent)),
Typeflag: tar.TypeReg,
}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
t.Fatal(err)
}
// Test unknown file type
unknownHeader := &tar.Header{
Name: "unknown-type",
Mode: 0644,
Typeflag: tar.TypeSymlink, // Use a type that's not handled
}
if err := tarWriter.WriteHeader(unknownHeader); err != nil {
t.Fatal(err)
}
tarWriter.Close()
// Test extraction - should fail due to unknown type
err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
if err == nil {
t.Error("expected error for unknown tar file type")
}
if !strings.Contains(err.Error(), "unknown type") {
t.Errorf("expected 'unknown type' error, got: %v", err)
}
}
func TestExtractTar_SuccessfulExtraction(t *testing.T) {
tempDir := t.TempDir()
// Since we can't easily create extended headers with Go's tar package,
// we'll test the logic that skips them by creating a simple tar with regular files
// and then testing that the extraction works correctly.
// Create a test tar file
var buf bytes.Buffer
tarWriter := tar.NewWriter(&buf)
// Add a regular file
testContent := "test content"
header := &tar.Header{
Name: "test-file.txt",
Mode: 0644,
Size: int64(len(testContent)),
Typeflag: tar.TypeReg,
}
if err := tarWriter.WriteHeader(header); err != nil {
t.Fatal(err)
}
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
t.Fatal(err)
}
tarWriter.Close()
// Test extraction
err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
if err != nil {
t.Errorf("extractTar failed: %v", err)
}
// Verify the regular file was extracted
extractedFile := filepath.Join(tempDir, "test-file.txt")
content, err := os.ReadFile(extractedFile)
if err != nil {
t.Errorf("failed to read extracted file: %v", err)
}
if string(content) != testContent {
t.Errorf("expected content %s, got %s", testContent, string(content))
}
}
func TestOCIInstaller_Install_PlainHTTPOption(t *testing.T) {
// Test that PlainHTTP option is properly passed to getter
source := "oci://example.com/test-plugin:v1.0.0"
// Test with PlainHTTP=false (default)
installer1, err := NewOCIInstaller(source)
if err != nil {
t.Fatalf("failed to create installer: %v", err)
}
if installer1.getter == nil {
t.Error("getter should be initialized")
}
// Test with PlainHTTP=true
installer2, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
if err != nil {
t.Fatalf("failed to create installer with PlainHTTP=true: %v", err)
}
if installer2.getter == nil {
t.Error("getter should be initialized with PlainHTTP=true")
}
// Both installers should have the same basic properties
if installer1.PluginName != installer2.PluginName {
t.Error("plugin names should match")
}
if installer1.Source != installer2.Source {
t.Error("sources should match")
}
// Test with multiple options
installer3, err := NewOCIInstaller(source,
getter.WithPlainHTTP(true),
getter.WithBasicAuth("user", "pass"),
)
if err != nil {
t.Fatalf("failed to create installer with multiple options: %v", err)
}
if installer3.getter == nil {
t.Error("getter should be initialized with multiple options")
}
}
func TestOCIInstaller_Install_ValidationErrors(t *testing.T) {
tests := []struct {
name string
layerData []byte
expectError bool
errorMsg string
}{
{
name: "non-gzip layer",
layerData: []byte("not gzip data"),
expectError: true,
errorMsg: "is not a gzip compressed archive",
},
{
name: "empty layer",
layerData: []byte{},
expectError: true,
errorMsg: "is not a gzip compressed archive",
},
{
name: "single byte layer",
layerData: []byte{0x1f},
expectError: true,
errorMsg: "is not a gzip compressed archive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test the gzip validation logic that's used in the Install method
if len(tt.layerData) < 2 || tt.layerData[0] != 0x1f || tt.layerData[1] != 0x8b {
// This matches the validation in the Install method
if !tt.expectError {
t.Error("expected valid gzip data")
}
if !strings.Contains(tt.errorMsg, "is not a gzip compressed archive") {
t.Errorf("expected error message to contain 'is not a gzip compressed archive'")
}
}
})
}
}

@ -1,179 +0,0 @@
/*
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"errors"
"fmt"
stdfs "io/fs"
"log/slog"
"os"
"sort"
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/vcs"
"helm.sh/helm/v4/internal/plugin/cache"
"helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/helmpath"
)
// VCSInstaller installs plugins from remote a repository.
type VCSInstaller struct {
Repo vcs.Repo
Version string
base
}
func existingVCSRepo(location string) (Installer, error) {
repo, err := vcs.NewRepo("", location)
if err != nil {
return nil, err
}
i := &VCSInstaller{
Repo: repo,
base: newBase(repo.Remote()),
}
return i, nil
}
// NewVCSInstaller creates a new VCSInstaller.
func NewVCSInstaller(source, version string) (*VCSInstaller, error) {
key, err := cache.Key(source)
if err != nil {
return nil, err
}
cachedpath := helmpath.CachePath("plugins", key)
repo, err := vcs.NewRepo(source, cachedpath)
if err != nil {
return nil, err
}
i := &VCSInstaller{
Repo: repo,
Version: version,
base: newBase(source),
}
return i, nil
}
// Install clones a remote repository and installs into the plugin directory.
//
// Implements Installer.
func (i *VCSInstaller) Install() error {
if err := i.sync(i.Repo); err != nil {
return err
}
ref, err := i.solveVersion(i.Repo)
if err != nil {
return err
}
if ref != "" {
if err := i.setVersion(i.Repo, ref); err != nil {
return err
}
}
if !isPlugin(i.Repo.LocalPath()) {
return ErrMissingMetadata
}
slog.Debug("copying files", "source", i.Repo.LocalPath(), "destination", i.Path())
return fs.CopyDir(i.Repo.LocalPath(), i.Path())
}
// Update updates a remote repository
func (i *VCSInstaller) Update() error {
slog.Debug("updating", "source", i.Repo.Remote())
if i.Repo.IsDirty() {
return errors.New("plugin repo was modified")
}
if err := i.Repo.Update(); err != nil {
return err
}
if !isPlugin(i.Repo.LocalPath()) {
return ErrMissingMetadata
}
return nil
}
func (i *VCSInstaller) solveVersion(repo vcs.Repo) (string, error) {
if i.Version == "" {
return "", nil
}
if repo.IsReference(i.Version) {
return i.Version, nil
}
// Create the constraint first to make sure it's valid before
// working on the repo.
constraint, err := semver.NewConstraint(i.Version)
if err != nil {
return "", err
}
// Get the tags
refs, err := repo.Tags()
if err != nil {
return "", err
}
slog.Debug("found refs", "refs", refs)
// Convert and filter the list to semver.Version instances
semvers := getSemVers(refs)
// Sort semver list
sort.Sort(sort.Reverse(semver.Collection(semvers)))
for _, v := range semvers {
if constraint.Check(v) {
// If the constraint passes get the original reference
ver := v.Original()
slog.Debug("setting to version", "version", ver)
return ver, nil
}
}
return "", fmt.Errorf("requested version %q does not exist for plugin %q", i.Version, i.Repo.Remote())
}
// setVersion attempts to checkout the version
func (i *VCSInstaller) setVersion(repo vcs.Repo, ref string) error {
slog.Debug("setting version", "version", i.Version)
return repo.UpdateVersion(ref)
}
// sync will clone or update a remote repo.
func (i *VCSInstaller) sync(repo vcs.Repo) error {
if _, err := os.Stat(repo.LocalPath()); errors.Is(err, stdfs.ErrNotExist) {
slog.Debug("cloning", "source", repo.Remote(), "destination", repo.LocalPath())
return repo.Get()
}
slog.Debug("updating", "source", repo.Remote(), "destination", repo.LocalPath())
return repo.Update()
}
// Filter a list of versions to only included semantic versions. The response
// is a mapping of the original version to the semantic version.
func getSemVers(refs []string) []*semver.Version {
var sv []*semver.Version
for _, r := range refs {
if v, err := semver.NewVersion(r); err == nil {
sv = append(sv, v)
}
}
return sv
}

@ -1,189 +0,0 @@
/*
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 installer // import "helm.sh/helm/v4/internal/plugin/installer"
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Masterminds/vcs"
"helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/helmpath"
)
var _ Installer = new(VCSInstaller)
type testRepo struct {
local, remote, current string
tags, branches []string
err error
vcs.Repo
}
func (r *testRepo) LocalPath() string { return r.local }
func (r *testRepo) Remote() string { return r.remote }
func (r *testRepo) Update() error { return r.err }
func (r *testRepo) Get() error { return r.err }
func (r *testRepo) IsReference(string) bool { return false }
func (r *testRepo) Tags() ([]string, error) { return r.tags, r.err }
func (r *testRepo) Branches() ([]string, error) { return r.branches, r.err }
func (r *testRepo) UpdateVersion(version string) error {
r.current = version
return r.err
}
func TestVCSInstaller(t *testing.T) {
ensure.HelmHome(t)
if err := os.MkdirAll(helmpath.DataPath("plugins"), 0755); err != nil {
t.Fatalf("Could not create %s: %s", helmpath.DataPath("plugins"), err)
}
source := "https://github.com/adamreese/helm-env"
testRepoPath, _ := filepath.Abs("../testdata/plugdir/good/echo-v1")
repo := &testRepo{
local: testRepoPath,
tags: []string{"0.1.0", "0.1.1"},
}
i, err := NewForSource(source, "~0.1.0")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// ensure a VCSInstaller was returned
vcsInstaller, ok := i.(*VCSInstaller)
if !ok {
t.Fatal("expected a VCSInstaller")
}
// set the testRepo in the VCSInstaller
vcsInstaller.Repo = repo
if err := Install(i); err != nil {
t.Fatal(err)
}
if repo.current != "0.1.1" {
t.Fatalf("expected version '0.1.1', got %q", repo.current)
}
expectedPath := helmpath.DataPath("plugins", "helm-env")
if i.Path() != expectedPath {
t.Fatalf("expected path %q, got %q", expectedPath, i.Path())
}
// Install again to test plugin exists error
if err := Install(i); err == nil {
t.Fatalf("expected error for plugin exists, got none")
} else if err.Error() != "plugin already exists" {
t.Fatalf("expected error for plugin exists, got (%v)", err)
}
// Testing FindSource method, expect error because plugin code is not a cloned repository
if _, err := FindSource(i.Path()); err == nil {
t.Fatalf("expected error for inability to find plugin source, got none")
} else if err.Error() != "cannot get information about plugin source" {
t.Fatalf("expected error for inability to find plugin source, got (%v)", err)
}
}
func TestVCSInstallerNonExistentVersion(t *testing.T) {
ensure.HelmHome(t)
source := "https://github.com/adamreese/helm-env"
version := "0.2.0"
i, err := NewForSource(source, version)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// ensure a VCSInstaller was returned
if _, ok := i.(*VCSInstaller); !ok {
t.Fatal("expected a VCSInstaller")
}
if err := Install(i); err == nil {
t.Fatalf("expected error for version does not exists, got none")
} else if strings.Contains(err.Error(), "Could not resolve host: github.com") {
t.Skip("Unable to run test without Internet access")
} else if err.Error() != fmt.Sprintf("requested version %q does not exist for plugin %q", version, source) {
t.Fatalf("expected error for version does not exists, got (%v)", err)
}
}
func TestVCSInstallerUpdate(t *testing.T) {
ensure.HelmHome(t)
source := "https://github.com/adamreese/helm-env"
i, err := NewForSource(source, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
// ensure a VCSInstaller was returned
if _, ok := i.(*VCSInstaller); !ok {
t.Fatal("expected a VCSInstaller")
}
if err := Update(i); err == nil {
t.Fatal("expected error for plugin does not exist, got none")
} else if err.Error() != "plugin does not exist" {
t.Fatalf("expected error for plugin does not exist, got (%v)", err)
}
// Install plugin before update
if err := Install(i); err != nil {
if strings.Contains(err.Error(), "Could not resolve host: github.com") {
t.Skip("Unable to run test without Internet access")
} else {
t.Fatal(err)
}
}
// Test FindSource method for positive result
pluginInfo, err := FindSource(i.Path())
if err != nil {
t.Fatal(err)
}
vcsInstaller := pluginInfo.(*VCSInstaller)
repoRemote := vcsInstaller.Repo.Remote()
if repoRemote != source {
t.Fatalf("invalid source found, expected %q got %q", source, repoRemote)
}
// Update plugin
if err := Update(i); err != nil {
t.Fatal(err)
}
// Test update failure
if err := os.Remove(filepath.Join(vcsInstaller.Repo.LocalPath(), "plugin.yaml")); err != nil {
t.Fatal(err)
}
// Testing update for error
if err := Update(vcsInstaller); err == nil {
t.Fatalf("expected error for plugin modified, got none")
} else if err.Error() != "plugin repo was modified" {
t.Fatalf("expected error for plugin modified, got (%v)", err)
}
}

@ -101,21 +101,32 @@ func (o *pluginInstallOptions) complete(args []string) error {
} }
func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, error) { func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, error) {
// Check if source is an OCI registry reference // Use the unified ArtifactInstaller for all sources (OCI, HTTP, local, etc.)
if strings.HasPrefix(o.source, fmt.Sprintf("%s://", registry.OCIScheme)) { artifactInstaller, err := installer.FindSource(o.source)
// Build getter options for OCI if err != nil {
options := []getter.Option{ return nil, err
getter.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile), }
getter.WithInsecureSkipVerifyTLS(o.insecureSkipTLSverify),
getter.WithPlainHTTP(o.plainHTTP), // Configure version and options for ArtifactInstaller
getter.WithBasicAuth(o.username, o.password), if ai, ok := artifactInstaller.(*installer.ArtifactInstaller); ok {
// Set version if specified
if o.version != "" {
ai.SetVersion(o.version)
} }
return installer.NewOCIInstaller(o.source, options...) // Configure OCI-specific options if this is an OCI source
if strings.HasPrefix(o.source, fmt.Sprintf("%s://", registry.OCIScheme)) {
options := []getter.Option{
getter.WithTLSClientConfig(o.certFile, o.keyFile, o.caFile),
getter.WithInsecureSkipVerifyTLS(o.insecureSkipTLSverify),
getter.WithPlainHTTP(o.plainHTTP),
getter.WithBasicAuth(o.username, o.password),
}
ai.SetOptions(options)
}
} }
// For non-OCI sources, use the original logic return artifactInstaller, nil
return installer.NewForSource(o.source, o.version)
} }
func (o *pluginInstallOptions) run(out io.Writer) error { func (o *pluginInstallOptions) run(out io.Writer) error {

@ -0,0 +1,266 @@
/*
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 cmd
import (
"fmt"
"strings"
"testing"
"helm.sh/helm/v4/internal/plugin/installer"
"helm.sh/helm/v4/pkg/registry"
)
func TestPluginInstallOptions_NewInstallerForSource(t *testing.T) {
tests := []struct {
name string
source string
version string
certFile string
keyFile string
caFile string
insecureSkipTLS bool
plainHTTP bool
username string
password string
expectedType string
expectOCIOptions bool
}{
{
name: "VCS source with version",
source: "https://github.com/user/plugin",
version: "v1.2.3",
expectedType: "*installer.ArtifactInstaller",
},
{
name: "HTTP source with version",
source: "https://example.com/plugin.tgz",
version: "1.0.0",
expectedType: "*installer.ArtifactInstaller",
},
{
name: "OCI source with version and options",
source: "oci://registry.io/plugin",
version: "1.0.0",
certFile: "cert.pem",
keyFile: "key.pem",
caFile: "ca.pem",
insecureSkipTLS: true,
plainHTTP: true,
username: "testuser",
password: "testpass",
expectedType: "*installer.ArtifactInstaller",
expectOCIOptions: true,
},
{
name: "OCI source without extra options",
source: "oci://registry.io/plugin:latest",
expectedType: "*installer.ArtifactInstaller",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := &pluginInstallOptions{
source: tt.source,
version: tt.version,
certFile: tt.certFile,
keyFile: tt.keyFile,
caFile: tt.caFile,
insecureSkipTLSverify: tt.insecureSkipTLS,
plainHTTP: tt.plainHTTP,
username: tt.username,
password: tt.password,
}
installer, err := o.newInstallerForSource()
if err != nil {
t.Fatalf("newInstallerForSource() error = %v", err)
}
// Check the installer type
installerType := fmt.Sprintf("%T", installer)
if installerType != tt.expectedType {
t.Errorf("newInstallerForSource() returned %s, expected %s", installerType, tt.expectedType)
}
// The installer should be created successfully and not be nil
// The version setting is tested in the installer package tests
if installer == nil {
t.Error("installer should not be nil")
}
})
}
}
func TestPluginInstallOptions_Complete(t *testing.T) {
tests := []struct {
name string
args []string
expectedSource string
expectError bool
}{
{
name: "valid source",
args: []string{"https://github.com/user/plugin"},
expectedSource: "https://github.com/user/plugin",
},
{
name: "OCI source",
args: []string{"oci://registry.io/plugin:1.0.0"},
expectedSource: "oci://registry.io/plugin:1.0.0",
},
{
name: "local path",
args: []string{"/path/to/plugin"},
expectedSource: "/path/to/plugin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
o := &pluginInstallOptions{}
err := o.complete(tt.args)
if tt.expectError && err == nil {
t.Error("expected error but got none")
} else if !tt.expectError && err != nil {
t.Errorf("unexpected error: %v", err)
}
if o.source != tt.expectedSource {
t.Errorf("expected source %s, got %s", tt.expectedSource, o.source)
}
})
}
}
// TestPluginInstallCmd_VersionFlag tests that the --version flag is properly handled
func TestPluginInstallCmd_VersionFlag(t *testing.T) {
// Create the plugin install command
cmd := newPluginInstallCmd(&strings.Builder{})
// Test that the version flag exists
versionFlag := cmd.Flags().Lookup("version")
if versionFlag == nil {
t.Fatal("--version flag not found")
}
// Test that the flag has the correct default value
if versionFlag.DefValue != "" {
t.Errorf("expected default version to be empty, got %s", versionFlag.DefValue)
}
// Test that we can set the flag value
err := versionFlag.Value.Set("v1.2.3")
if err != nil {
t.Errorf("failed to set version flag: %v", err)
}
if versionFlag.Value.String() != "v1.2.3" {
t.Errorf("expected version flag value to be 'v1.2.3', got %s", versionFlag.Value.String())
}
}
// TestPluginInstallCmd_OCIFlags tests that OCI-specific flags are properly handled
func TestPluginInstallCmd_OCIFlags(t *testing.T) {
cmd := newPluginInstallCmd(&strings.Builder{})
// Test all OCI-specific flags exist
ociFlags := []string{
"cert-file",
"key-file",
"ca-file",
"insecure-skip-tls-verify",
"plain-http",
"username",
"password",
}
for _, flagName := range ociFlags {
flag := cmd.Flags().Lookup(flagName)
if flag == nil {
t.Errorf("--%s flag not found", flagName)
}
}
}
// TestPluginInstallOptions_VersionPassedToInstaller verifies version is passed to installer
func TestPluginInstallOptions_VersionPassedToInstaller(t *testing.T) {
testVersion := "v1.2.3"
o := &pluginInstallOptions{
source: "https://github.com/user/plugin",
version: testVersion,
}
installer, err := o.newInstallerForSource()
if err != nil {
t.Fatalf("newInstallerForSource() error = %v", err)
}
// The installer should be an ArtifactInstaller based on the type check
installerType := fmt.Sprintf("%T", installer)
expectedType := "*installer.ArtifactInstaller"
if installerType != expectedType {
t.Errorf("newInstallerForSource() returned %s, expected %s", installerType, expectedType)
}
// This test ensures that if someone removes version support again,
// they'll have to consciously break this test, making the regression visible
if o.version != testVersion {
t.Errorf("version should be %s but got %s", testVersion, o.version)
}
}
// TestFindSource_ReturnsCorrectInstallerType ensures FindSource routing works correctly
func TestFindSource_ReturnsCorrectInstallerType(t *testing.T) {
tests := []struct {
name string
source string
expectedType string
}{
{
name: "VCS source returns ArtifactInstaller",
source: "https://github.com/user/plugin",
expectedType: "*installer.ArtifactInstaller",
},
{
name: "HTTP source returns ArtifactInstaller",
source: "https://example.com/plugin.tgz",
expectedType: "*installer.ArtifactInstaller",
},
{
name: "OCI source returns ArtifactInstaller",
source: fmt.Sprintf("%s://registry.io/plugin:1.0.0", registry.OCIScheme),
expectedType: "*installer.ArtifactInstaller",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i, err := installer.FindSource(tt.source)
if err != nil {
t.Fatalf("FindSource() error = %v", err)
}
installerType := fmt.Sprintf("%T", i)
if installerType != tt.expectedType {
t.Errorf("FindSource() returned %s, expected %s", installerType, tt.expectedType)
}
})
}
}

@ -0,0 +1,303 @@
/*
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.
*/
// Chart-specific artifact downloading capabilities.
package downloader
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
ifs "helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/provenance"
"helm.sh/helm/v4/pkg/registry"
)
// ChartDownloader handles downloading charts with chart-specific features.
type ChartDownloader 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
// Getter collection for the operation
Getters getter.Providers
// Options provide parameters to be passed along to the Getter being initialized.
Options []getter.Option
RegistryClient *registry.Client
RepositoryConfig string
RepositoryCache string
// ContentCache is the location where Cache stores its files by default
// In previous versions of Helm the charts were put in the RepositoryCache. The
// repositories and charts are stored in 2 difference caches.
ContentCache string
// Cache specifies the cache implementation to use.
Cache Cache
// Internal unified downloader
downloader *Downloader
}
// initDownloader initializes the internal downloader if needed
func (c *ChartDownloader) initDownloader() {
if c.downloader == nil {
// Initialize cache if needed
if c.Cache == nil && c.ContentCache != "" {
c.Cache = &DiskCache{Root: c.ContentCache}
}
c.downloader = &Downloader{
Out: c.Out,
Verify: c.Verify,
Keyring: c.Keyring,
Getters: c.Getters,
Options: c.Options,
Cache: c.Cache,
ContentCache: c.ContentCache,
}
c.downloader.SetRepositoryConfig(c.RepositoryConfig, c.RepositoryCache)
if c.RegistryClient != nil {
c.downloader.SetRegistryClient(c.RegistryClient)
}
}
}
// DownloadTo retrieves a chart. Depending on the settings, it may also download a provenance file.
func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) {
c.initDownloader()
return c.downloader.Download(ref, version, dest, TypeChart)
}
// DownloadToCache downloads a chart to the cache.
//
// TODO: This method doesn't call DownloadTo directly due to legacy complexity from the original
// chart downloader's intricate cache management patterns that weren't easily unified. Unlike
// PluginDownloader which has the simpler pattern (DownloadToCache calls DownloadTo), this
// implementation maintains separate logic for:
// - Content-addressable cache paths vs user-specified destinations
// - Complex verification involving temporary files for cached content
// - Different performance optimizations for cache hits vs direct downloads
//
// Investigate whether we can simplify and unify these patterns to match PluginDownloader's
// cleaner approach where DownloadToCache(ref, version) calls DownloadTo(ref, version, cache).
func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provenance.Verification, error) {
if c.Cache == nil {
if c.ContentCache == "" {
return "", nil, errors.New("content cache must be set")
}
c.Cache = &DiskCache{Root: c.ContentCache}
}
digestString, u, err := c.ResolveChartVersion(ref, version)
if err != nil {
return "", nil, err
}
// Get the appropriate getter for this scheme
g, err := c.Getters.ByScheme(u.Scheme)
if err != nil {
return "", nil, err
}
// Check the cache for the file
digest, err := hex.DecodeString(digestString)
if err != nil {
return "", nil, err
}
var digest32 [sha256.Size]byte
copy(digest32[:], digest)
var pth string
// only fetch from the cache if we have a digest
if len(digest) > 0 {
pth, err = c.Cache.Get(digest32, CacheChart)
if err == nil {
// Found in cache, check for verification
ver := &provenance.Verification{}
if c.Verify > VerifyNever {
ppth, err := c.Cache.Get(digest32, CacheProv)
if err == nil && c.Verify != VerifyLater {
name := filepath.Base(u.Path)
if u.Scheme == registry.OCIScheme {
idx := strings.LastIndexByte(name, ':')
name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:])
}
tmpdir := filepath.Dir(filepath.Join(c.ContentCache, "tmp"))
if err := os.MkdirAll(tmpdir, 0755); err != nil {
return pth, ver, err
}
tmpfile := filepath.Join(tmpdir, name)
if err := ifs.CopyFile(pth, tmpfile); err != nil {
return pth, ver, err
}
defer os.RemoveAll(tmpfile)
ver, err = VerifyChart(tmpfile, ppth, c.Keyring)
if err != nil {
return pth, ver, err
}
}
}
return pth, ver, nil
}
}
if len(digest) == 0 || err != nil {
if err != nil && !os.IsNotExist(err) {
return "", nil, err
}
// Get file not in the cache
opts := append(c.Options, getter.WithURL(u.String()))
data, gerr := g.Get(u.String(), opts...)
if gerr != nil {
return "", nil, gerr
}
// Generate the digest
if len(digest) == 0 {
digest32 = sha256.Sum256(data.Bytes())
}
pth, err = c.Cache.Put(digest32, data, CacheChart)
if err != nil {
return "", nil, err
}
}
// If provenance is requested, verify it.
ver := &provenance.Verification{}
if c.Verify > VerifyNever {
ppth, err := c.Cache.Get(digest32, CacheProv)
if err != nil {
if !os.IsNotExist(err) {
return pth, ver, err
}
body, err := g.Get(u.String() + ".prov") // No options for provenance request (matches original implementation)
if err != nil {
if c.Verify == VerifyAlways {
return pth, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov")
}
fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err)
return pth, ver, nil
}
ppth, err = c.Cache.Put(digest32, body, CacheProv)
if err != nil {
return "", nil, err
}
}
if c.Verify != VerifyLater {
name := filepath.Base(u.Path)
if u.Scheme == registry.OCIScheme {
idx := strings.LastIndexByte(name, ':')
name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:])
}
tmpdir := filepath.Dir(filepath.Join(c.ContentCache, "tmp"))
if err := os.MkdirAll(tmpdir, 0755); err != nil {
return pth, ver, err
}
tmpfile := filepath.Join(tmpdir, name)
err = ifs.CopyFile(pth, tmpfile)
if err != nil {
return pth, ver, err
}
defer os.RemoveAll(tmpfile)
ver, err = VerifyChart(tmpfile, ppth, c.Keyring)
if err != nil {
return pth, ver, err
}
}
}
return pth, ver, nil
}
// ResolveChartVersion resolves a chart reference to a URL.
// This delegates to the unified artifact downloader.
func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url.URL, error) {
c.initDownloader()
return c.downloader.ResolveArtifactVersion(ref, version, TypeChart)
}
// NewChartDownloader creates a new ChartDownloader.
func NewChartDownloader() *ChartDownloader {
return &ChartDownloader{}
}
// VerifyChart takes a path to a chart archive and a keyring, and verifies the chart.
//
// It assumes that a chart archive file is accompanied by a provenance file whose
// name is the archive file name plus the ".prov" extension.
func VerifyChart(path, provfile, keyring string) (*provenance.Verification, error) {
// For now, error out if it's not a tar file.
switch fi, err := os.Stat(path); {
case err != nil:
return nil, err
case fi.IsDir():
return nil, errors.New("unpacked charts cannot be verified")
case !isTar(path):
return nil, errors.New("chart must be a tgz file")
}
if keyring == "" {
keyring = defaultKeyring()
}
if _, err := os.Stat(provfile); err != nil {
return nil, fmt.Errorf("could not load provenance file %s: %w", provfile, err)
}
sig, err := provenance.NewFromKeyring(keyring, "")
if err != nil {
return nil, fmt.Errorf("failed to load keyring: %w", err)
}
// Read archive and provenance files
archiveData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read chart archive: %w", err)
}
provData, err := os.ReadFile(provfile)
if err != nil {
return nil, fmt.Errorf("failed to read provenance file: %w", err)
}
return sig.Verify(archiveData, provData, filepath.Base(path))
}
// isTar tests whether the given file is a tar file.
//
// Currently, this simply checks extension, since a subsequent function will
// untar the file and validate its binary format.
func isTar(filename string) bool {
return strings.EqualFold(filepath.Ext(filename), ".tgz")
}
func defaultKeyring() string {
return os.ExpandEnv("$PGP_KEYRING")
}

@ -1,583 +0,0 @@
/*
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"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"helm.sh/helm/v4/internal/fileutil"
ifs "helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/internal/urlutil"
"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"
)
// VerificationStrategy describes a strategy for determining whether to verify a chart.
type VerificationStrategy int
const (
// VerifyNever will skip all verification of a chart.
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 chart URL can't be found in any repos.
var ErrNoOwnerRepo = errors.New("could not find a repo containing the given URL")
// ChartDownloader handles downloading a chart.
//
// It is capable of performing verifications on charts as well.
type ChartDownloader 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
// Getter collection for the operation
Getters getter.Providers
// Options provide parameters to be passed along to the Getter being initialized.
Options []getter.Option
RegistryClient *registry.Client
RepositoryConfig string
RepositoryCache string
// ContentCache is the location where Cache stores its files by default
// In previous versions of Helm the charts were put in the RepositoryCache. The
// repositories and charts are stored in 2 difference caches.
ContentCache string
// Cache specifies the cache implementation to use.
Cache Cache
}
// DownloadTo retrieves a chart. 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.
//
// For VerifyNever and VerifyIfPossible, the Verification may be empty.
//
// 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 (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *provenance.Verification, error) {
if c.Cache == nil {
if c.ContentCache == "" {
return "", nil, errors.New("content cache must be set")
}
c.Cache = &DiskCache{Root: c.ContentCache}
slog.Debug("setup up default downloader cache")
}
hash, u, err := c.ResolveChartVersion(ref, version)
if err != nil {
return "", nil, err
}
g, err := c.Getters.ByScheme(u.Scheme)
if err != nil {
return "", nil, err
}
// Check the cache for the content. Otherwise download it.
// Note, this process will pull from the cache but does not automatically populate
// the cache with the file it downloads.
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 := c.Cache.Get(digest32, CacheChart); err == nil {
fdata, err := os.ReadFile(pth)
if err == nil {
found = true
data = bytes.NewBuffer(fdata)
slog.Debug("found chart in cache", "id", hash)
}
}
}
if !found {
c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream"))
data, err = g.Get(u.String(), c.Options...)
if err != nil {
return "", nil, err
}
}
name := filepath.Base(u.Path)
if u.Scheme == registry.OCIScheme {
idx := strings.LastIndexByte(name, ':')
name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:])
}
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 c.Verify > VerifyNever {
found = false
var body *bytes.Buffer
if hash != "" {
if pth, err := c.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)
}
}
}
if !found {
body, err = g.Get(u.String() + ".prov")
if err != nil {
if c.Verify == VerifyAlways {
return destfile, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov")
}
fmt.Fprintf(c.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 c.Verify != VerifyLater {
ver, err = VerifyChart(destfile, destfile+".prov", c.Keyring)
if err != nil {
// Fail always in this case, since it means the verification step
// failed.
return destfile, ver, err
}
}
}
return destfile, ver, nil
}
// DownloadToCache retrieves resources while using a content based cache.
func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provenance.Verification, error) {
if c.Cache == nil {
if c.ContentCache == "" {
return "", nil, errors.New("content cache must be set")
}
c.Cache = &DiskCache{Root: c.ContentCache}
slog.Debug("setup up default downloader cache")
}
digestString, u, err := c.ResolveChartVersion(ref, version)
if err != nil {
return "", nil, err
}
g, err := c.Getters.ByScheme(u.Scheme)
if err != nil {
return "", nil, err
}
c.Options = append(c.Options, getter.WithAcceptHeader("application/gzip,application/octet-stream"))
// Check the cache for the file
digest, err := hex.DecodeString(digestString)
if err != nil {
return "", nil, err
}
var digest32 [32]byte
copy(digest32[:], digest)
if err != nil {
return "", nil, fmt.Errorf("unable to decode digest: %w", err)
}
var pth string
// only fetch from the cache if we have a digest
if len(digest) > 0 {
pth, err = c.Cache.Get(digest32, CacheChart)
if err == nil {
slog.Debug("found chart in cache", "id", digestString)
}
}
if len(digest) == 0 || err != nil {
slog.Debug("attempting to download chart", "ref", ref, "version", version)
if err != nil && !os.IsNotExist(err) {
return "", nil, err
}
// Get file not in the cache
data, gerr := g.Get(u.String(), c.Options...)
if gerr != nil {
return "", nil, gerr
}
// Generate the digest
if len(digest) == 0 {
digest32 = sha256.Sum256(data.Bytes())
}
pth, err = c.Cache.Put(digest32, data, CacheChart)
if err != nil {
return "", nil, err
}
slog.Debug("put downloaded chart in cache", "id", hex.EncodeToString(digest32[:]))
}
// If provenance is requested, verify it.
ver := &provenance.Verification{}
if c.Verify > VerifyNever {
ppth, err := c.Cache.Get(digest32, CacheProv)
if err == nil {
slog.Debug("found provenance in cache", "id", digestString)
} else {
if !os.IsNotExist(err) {
return pth, ver, err
}
body, err := g.Get(u.String() + ".prov")
if err != nil {
if c.Verify == VerifyAlways {
return pth, ver, fmt.Errorf("failed to fetch provenance %q", u.String()+".prov")
}
fmt.Fprintf(c.Out, "WARNING: Verification not found for %s: %s\n", ref, err)
return pth, ver, nil
}
ppth, err = c.Cache.Put(digest32, body, CacheProv)
if err != nil {
return "", nil, err
}
slog.Debug("put downloaded provenance file in cache", "id", hex.EncodeToString(digest32[:]))
}
if c.Verify != VerifyLater {
// provenance files pin to a specific name so this needs to be accounted for
// when verifying.
// Note, this does make an assumption that the name/version is unique to a
// hash when a provenance file is used. If this isn't true, this section of code
// will need to be reworked.
name := filepath.Base(u.Path)
if u.Scheme == registry.OCIScheme {
idx := strings.LastIndexByte(name, ':')
name = fmt.Sprintf("%s-%s.tgz", name[:idx], name[idx+1:])
}
// Copy chart to a known location with the right name for verification and then
// clean it up.
tmpdir := filepath.Dir(filepath.Join(c.ContentCache, "tmp"))
if err := os.MkdirAll(tmpdir, 0755); err != nil {
return pth, ver, err
}
tmpfile := filepath.Join(tmpdir, name)
err = ifs.CopyFile(pth, tmpfile)
if err != nil {
return pth, ver, err
}
// Not removing the tmp dir itself because a concurrent process may be using it
defer os.RemoveAll(tmpfile)
ver, err = VerifyChart(tmpfile, ppth, c.Keyring)
if err != nil {
// Fail always in this case, since it means the verification step
// failed.
return pth, ver, err
}
}
}
return pth, ver, nil
}
// ResolveChartVersion resolves a chart reference to a URL.
//
// It returns:
// - A hash of the content if available
// - The URL and sets the ChartDownloader's Options that can fetch the URL using the appropriate Getter.
// - An error if there is one
//
// A reference may be an HTTP URL, an oci reference URL, a 'reponame/chartname'
// reference, or a local path.
//
// A version is a SemVer string (1.2.3-beta.1+f334a6789).
//
// - For fully qualified URLs, the version will be ignored (since URLs aren't versioned)
// - For a chart reference
// - If version is non-empty, this will return the URL for that version
// - If version is empty, this will return the URL for the latest version
// - If no version can be found, an error is returned
//
// TODO: support OCI hash
func (c *ChartDownloader) ResolveChartVersion(ref, version string) (string, *url.URL, error) {
u, err := url.Parse(ref)
if err != nil {
return "", nil, fmt.Errorf("invalid chart URL format: %s", ref)
}
if registry.IsOCI(u.String()) {
if c.RegistryClient == nil {
return "", nil, fmt.Errorf("unable to lookup ref %s at version '%s', missing registry client", ref, version)
}
digest, OCIref, err := c.RegistryClient.ValidateReference(ref, version, u)
return digest, OCIref, err
}
rf, err := loadRepoConfig(c.RepositoryConfig)
if err != nil {
return "", u, err
}
if u.IsAbs() && len(u.Host) > 0 && len(u.Path) > 0 {
// In this case, we have to find the parent repo that contains this chart
// URL. And this is an unfortunate problem, as it requires actually going
// through each repo cache file and finding a matching URL. But basically
// we want to find the repo in case we have special SSL cert config
// for that repo.
rc, err := c.scanReposForURL(ref, rf)
if err != nil {
// If there is no special config, return the default HTTP client and
// swallow the error.
if err == ErrNoOwnerRepo {
// Make sure to add the ref URL as the URL for the getter
c.Options = append(c.Options, getter.WithURL(ref))
return "", u, nil
}
return "", u, err
}
// If we get here, we don't need to go through the next phase of looking
// up the URL. We have it already. So we just set the parameters and return.
c.Options = append(
c.Options,
getter.WithURL(rc.URL),
)
if rc.CertFile != "" || rc.KeyFile != "" || rc.CAFile != "" {
c.Options = append(c.Options, getter.WithTLSClientConfig(rc.CertFile, rc.KeyFile, rc.CAFile))
}
if rc.Username != "" && rc.Password != "" {
c.Options = append(
c.Options,
getter.WithBasicAuth(rc.Username, rc.Password),
getter.WithPassCredentialsAll(rc.PassCredentialsAll),
)
}
return "", u, nil
}
// See if it's of the form: repo/path_to_chart
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
}
// Now that we have the chart repository information we can use that URL
// to set the URL for the getter.
c.Options = append(c.Options, getter.WithURL(rc.URL))
r, err := repo.NewChartRepository(rc, c.Getters)
if err != nil {
return "", u, err
}
if r != nil && r.Config != nil {
if r.Config.CertFile != "" || r.Config.KeyFile != "" || r.Config.CAFile != "" {
c.Options = append(c.Options, getter.WithTLSClientConfig(r.Config.CertFile, r.Config.KeyFile, r.Config.CAFile))
}
if r.Config.Username != "" && r.Config.Password != "" {
c.Options = append(c.Options,
getter.WithBasicAuth(r.Config.Username, r.Config.Password),
getter.WithPassCredentialsAll(r.Config.PassCredentialsAll),
)
}
}
// Next, we need to load the index, and actually look up the chart.
idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.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, r.Config.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
}
// VerifyChart takes a path to a chart archive and a keyring, and verifies the chart.
//
// It assumes that a chart archive file is accompanied by a provenance file whose
// name is the archive file name plus the ".prov" extension.
func VerifyChart(path, provfile, keyring string) (*provenance.Verification, error) {
// For now, error out if it's not a tar file.
switch fi, err := os.Stat(path); {
case err != nil:
return nil, err
case fi.IsDir():
return nil, errors.New("unpacked charts cannot be verified")
case !isTar(path):
return nil, errors.New("chart must be a tgz file")
}
if _, err := os.Stat(provfile); err != nil {
return nil, fmt.Errorf("could not load provenance file %s: %w", provfile, err)
}
sig, err := provenance.NewFromKeyring(keyring, "")
if err != nil {
return nil, fmt.Errorf("failed to load keyring: %w", err)
}
// Read archive and provenance files
archiveData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read chart archive: %w", err)
}
provData, err := os.ReadFile(provfile)
if err != nil {
return nil, fmt.Errorf("failed to read provenance file: %w", err)
}
return sig.Verify(archiveData, provData, filepath.Base(path))
}
// isTar tests whether the given file is a tar file.
//
// Currently, this simply checks extension, since a subsequent function will
// untar the file and validate its binary format.
func isTar(filename string) bool {
return strings.EqualFold(filepath.Ext(filename), ".tgz")
}
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("repo %s not found", name)
}
// scanReposForURL scans all repos to find which repo contains the given URL.
//
// This will attempt to find the given URL in all of the known repositories files.
//
// If the URL is found, this will return the repo entry that contained that URL.
//
// If all of the repos are checked, but the URL is not found, an ErrNoOwnerRepo
// error is returned.
//
// Other errors may be returned when repositories cannot be loaded or searched.
//
// Technically, the fact that a URL is not found in a repo is not a failure indication.
// Charts are not required to be included in an index before they are valid. So
// be mindful of this case.
//
// The same URL can technically exist in two or more repositories. This algorithm
// will return the first one it finds. Order is determined by the order of repositories
// in the repositories.yaml file.
func (c *ChartDownloader) scanReposForURL(u string, rf *repo.File) (*repo.Entry, error) {
// FIXME: This is far from optimal. Larger installations and index files will
// incur a performance hit for this type of scanning.
for _, rc := range rf.Repositories {
r, err := repo.NewChartRepository(rc, c.Getters)
if err != nil {
return nil, err
}
idxFile := filepath.Join(c.RepositoryCache, helmpath.CacheIndexFile(r.Config.Name))
i, err := repo.LoadIndexFile(idxFile)
if err != nil {
return nil, fmt.Errorf("no cached repo found. (try 'helm repo update'): %w", err)
}
for _, entry := range i.Entries {
for _, ver := range entry {
for _, dl := range ver.URLs {
if urlutil.Equal(u, dl) {
return rc, nil
}
}
}
}
}
// This means that there is no repo file for the given URL.
return nil, ErrNoOwnerRepo
}
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
}

@ -28,7 +28,6 @@ import (
"helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli"
"helm.sh/helm/v4/pkg/getter" "helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/registry" "helm.sh/helm/v4/pkg/registry"
"helm.sh/helm/v4/pkg/repo/v1"
"helm.sh/helm/v4/pkg/repo/v1/repotest" "helm.sh/helm/v4/pkg/repo/v1/repotest"
) )
@ -122,12 +121,9 @@ func TestResolveChartOpts(t *testing.T) {
}), }),
} }
// snapshot options
snapshotOpts := c.Options
for _, tt := range tests { for _, tt := range tests {
// reset chart downloader options for each test case // reset internal downloader for each test case
c.Options = snapshotOpts c.downloader = nil
expect, err := getter.NewHTTPGetter(tt.expect...) expect, err := getter.NewHTTPGetter(tt.expect...)
if err != nil { if err != nil {
@ -141,9 +137,15 @@ func TestResolveChartOpts(t *testing.T) {
continue continue
} }
// Check that internal downloader has the expected options
if c.downloader == nil {
t.Errorf("%s: internal downloader should be initialized", tt.name)
continue
}
got, err := getter.NewHTTPGetter( got, err := getter.NewHTTPGetter(
append( append(
c.Options, c.downloader.Options,
getter.WithURL(u.String()), getter.WithURL(u.String()),
)..., )...,
) )
@ -350,7 +352,7 @@ func TestDownloadTo_VerifyLater(t *testing.T) {
func TestScanReposForURL(t *testing.T) { func TestScanReposForURL(t *testing.T) {
c := ChartDownloader{ c := ChartDownloader{
Out: os.Stderr, Out: os.Stderr,
Verify: VerifyLater, Verify: VerifyNever,
RepositoryConfig: repoConfig, RepositoryConfig: repoConfig,
RepositoryCache: repoCache, RepositoryCache: repoCache,
Getters: getter.All(&cli.EnvSettings{ Getters: getter.All(&cli.EnvSettings{
@ -359,28 +361,169 @@ func TestScanReposForURL(t *testing.T) {
}), }),
} }
u := "http://example.com/alpine-0.2.0.tgz" // Initialize the internal downloader to access scanReposForURL
rf, err := repo.LoadFile(repoConfig) if c.downloader == nil {
if err != nil { c.downloader = &Downloader{
t.Fatal(err) Getters: c.Getters,
Options: make([]getter.Option, 0),
}
c.downloader.SetRepositoryConfig(c.RepositoryConfig, c.RepositoryCache)
} }
entry, err := c.scanReposForURL(u, rf) // Load test repository configuration
rf, err := loadRepoConfig(repoConfig)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal("Failed to load test repo config:", err)
} }
if entry.Name != "testing" { tests := []struct {
t.Errorf("Unexpected repo %q for URL %q", entry.Name, u) name string
url string
expectRepo string
expectError bool
}{
{
name: "URL matches testing repo (first match)",
url: "http://example.com/foo-1.0.0.tgz",
expectRepo: "testing", // First repo with http://example.com
},
{
name: "URL matches testing repo (even for charts path)",
url: "http://example.com/charts/bar-1.0.0.tgz",
expectRepo: "testing", // testing comes before kubernetes-charts in the list
},
{
name: "URL matches testing repo (even for helm path)",
url: "http://example.com/helm/baz-1.0.0.tgz",
expectRepo: "testing", // testing comes before testing-relative
},
{
name: "URL matches testing repo (even for querystring)",
url: "http://example.com?key=value&chart=test.tgz",
expectRepo: "testing", // testing comes before testing-querystring
},
{
name: "URL matches testing repo (even for encoded path)",
url: "http://example.com/with%2Fslash/chart.tgz",
expectRepo: "testing", // testing comes before encoded-url
},
{
name: "URL matches https repo",
url: "https://example.com/secure-chart.tgz",
expectRepo: "testing-https", // Different protocol, won't match testing
},
{
name: "URL matches basicauth repo",
url: "http://username:password@example.com/auth-chart.tgz",
expectRepo: "testing-basicauth", // Different authority, won't match testing
},
{
name: "URL matches malformed repo",
url: "http://dl.example.com/chart.tgz",
expectRepo: "malformed", // Different domain
},
{
name: "URL doesn't match any repo",
url: "http://unknown.com/chart.tgz",
expectError: true,
},
{
name: "Empty URL",
url: "",
expectError: true,
},
} }
// A lookup failure should produce an ErrNoOwnerRepo for _, tt := range tests {
u = "https://no.such.repo/foo/bar-1.23.4.tgz" t.Run(tt.name, func(t *testing.T) {
if _, err = c.scanReposForURL(u, rf); err != ErrNoOwnerRepo { repo, err := c.downloader.scanReposForURL(tt.url, rf)
t.Fatalf("expected ErrNoOwnerRepo, got %v", err)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for URL %q, but got none", tt.url)
}
if err != ErrNoOwnerRepo && tt.url != "" {
t.Errorf("Expected ErrNoOwnerRepo, got: %v", err)
}
return
}
if err != nil {
t.Errorf("Unexpected error for URL %q: %v", tt.url, err)
return
}
if repo == nil {
t.Errorf("Expected repo entry for URL %q, got nil", tt.url)
return
}
if repo.Name != tt.expectRepo {
t.Errorf("Expected repo %q for URL %q, got %q", tt.expectRepo, tt.url, repo.Name)
}
})
} }
} }
func TestScanReposForURL_Integration(t *testing.T) {
// Test that scanReposForURL integration with ResolveChartVersion
// properly applies repository configuration
c := ChartDownloader{
Out: os.Stderr,
Verify: VerifyNever,
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
Getters: getter.All(&cli.EnvSettings{
RepositoryConfig: repoConfig,
RepositoryCache: repoCache,
}),
}
// Test that a URL matching a repository with TLS config gets the options applied
t.Run("TLS repository config applied", func(t *testing.T) {
// Reset downloader for clean test
c.downloader = nil
// Since both testing-https and testing-ca-file have the same URL,
// and testing-https comes first, we need to trigger the repository resolution
// through a repository-based reference that forces scanning.
// Let's use a repository-based reference to force the repo config lookup
_, _, err := c.ResolveChartVersion("testing-ca-file/chart", "1.0.0")
// This will fail because the chart doesn't exist in the index, but that's OK
// We're testing that the repository configuration was applied
if err == nil {
t.Error("Expected error for non-existent chart, but got none")
}
// Even though resolution failed, the repository config should have been applied
if c.downloader == nil {
t.Error("Expected downloader to be initialized")
return
}
// Check that TLS transport options were added during repo config
if len(c.downloader.Options) == 0 {
t.Error("Expected transport options to be configured from repository")
}
// The getter options sync only happens when resolution succeeds
// For this test, we just care that the transport options were configured
// The sync behavior is tested separately in the successful resolution tests
})
t.Run("Unknown URL no special config", func(t *testing.T) {
// Reset downloader for clean test
c.downloader = nil
// URL that doesn't match any repository should work without special config
_, _, err := c.ResolveChartVersion("http://unknown.example.com/chart.tgz", "")
if err != nil {
t.Errorf("Expected unknown URL to be handled, got: %v", err)
}
})
}
func TestDownloadToCache(t *testing.T) { func TestDownloadToCache(t *testing.T) {
srv := repotest.NewTempServer(t, srv := repotest.NewTempServer(t,
repotest.WithChartSourceGlob("testdata/*.tgz*"), repotest.WithChartSourceGlob("testdata/*.tgz*"),

@ -13,12 +13,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
/* // Package artifact provides unified downloading and verification for Helm artifacts.
Package downloader provides a library for downloading charts. //
// This package implements a generic artifact downloader that works with charts,
This package contains various tools for downloading charts from repository // plugins, and future artifact types. It uses the transport package for
servers, and then storing them in Helm-specific directory structures. This // protocol abstraction and provides unified verification via provenance files.
library contains many functions that depend on a specific //
filesystem layout. // The core Downloader type handles:
*/ // - Repository resolution (reponame/artifact → URL)
// - Protocol abstraction via transports
// - Content-addressable caching
// - Provenance file verification
// - Artifact naming conventions
//
// Type-specific convenience wrappers (ChartDownloader, PluginDownloader) provide
// backward-compatible APIs while using the unified implementation.
package downloader package downloader

@ -0,0 +1,486 @@
/*
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))
}
}

@ -0,0 +1,348 @@
/*
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"
"crypto/sha256"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v4/pkg/getter"
)
// MockGetter implements getter.Getter for testing.
type MockGetter struct {
data map[string][]byte
err error
supportedTypes []string
}
func (m *MockGetter) Get(url string, _ ...getter.Option) (*bytes.Buffer, error) {
if m.err != nil {
return nil, m.err
}
data, exists := m.data[url]
if !exists {
data = []byte("mock data")
}
return bytes.NewBuffer(data), nil
}
// RestrictToArtifactTypes implements getter.Restricted.
func (m *MockGetter) RestrictToArtifactTypes() []string {
if m.supportedTypes != nil {
return m.supportedTypes
}
return nil
}
// MockCache implements Cache for testing.
type MockCache struct {
data map[[sha256.Size]byte][]byte
}
func (c *MockCache) Get(key [sha256.Size]byte, cacheType string) (string, error) {
if data, exists := c.data[key]; exists {
pattern := "cache-*" + cacheType
tmpfile, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
defer tmpfile.Close()
_, err = tmpfile.Write(data)
return tmpfile.Name(), err
}
return "", os.ErrNotExist
}
func (c *MockCache) Put(key [sha256.Size]byte, data io.Reader, cacheType string) (string, error) {
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, data); err != nil {
return "", err
}
c.data[key] = buf.Bytes()
pattern := "cache-*" + cacheType
tmpfile, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
defer tmpfile.Close()
_, err = tmpfile.Write(buf.Bytes())
return tmpfile.Name(), err
}
func TestDownloader_Download(t *testing.T) {
// Create temporary directory for test
tmpdir := t.TempDir()
// Create mock getter
mockGetter := &MockGetter{
data: map[string][]byte{
"http://example.com/chart.tgz": []byte("chart data"),
"oci://example.com/plugin:v1": []byte("plugin data"),
},
}
// Create downloader with mock getters
downloader := &Downloader{
Getters: getter.Providers{
{Schemes: []string{"http"}, New: func(...getter.Option) (getter.Getter, error) { return mockGetter, nil }},
{Schemes: []string{"oci"}, New: func(...getter.Option) (getter.Getter, error) { return mockGetter, nil }},
},
Cache: &MockCache{data: make(map[[sha256.Size]byte][]byte)},
ContentCache: tmpdir,
Verify: VerifyNever, // Skip verification for this test
}
// Test chart download
chartPath, verification, err := downloader.Download("http://example.com/chart.tgz", "", tmpdir, TypeChart)
if err != nil {
t.Errorf("Expected no error downloading chart, got %v", err)
}
if chartPath == "" {
t.Error("Expected chart path, got empty string")
}
if verification == nil {
t.Error("Expected verification object, got nil")
}
// Verify file was created
if _, err := os.Stat(chartPath); os.IsNotExist(err) {
t.Errorf("Expected chart file to exist at %s", chartPath)
}
// Test plugin download
pluginPath, _, err := downloader.Download("oci://example.com/plugin:v1", "", tmpdir, TypePlugin)
if err != nil {
t.Errorf("Expected no error downloading plugin, got %v", err)
}
if pluginPath == "" {
t.Error("Expected plugin path, got empty string")
}
// Verify file was created
if _, err := os.Stat(pluginPath); os.IsNotExist(err) {
t.Errorf("Expected plugin file to exist at %s", pluginPath)
}
}
// Note: ChartDownloader and PluginDownloader compatibility tests moved to
// their respective test files (chart_test.go, plugin_test.go) since they
// now have proper implementations rather than wrappers
func TestDownloader_ResolveArtifactVersion_DirectURL(t *testing.T) {
downloader := &Downloader{}
// Test direct HTTP URL resolution
hash, u, err := downloader.ResolveArtifactVersion("https://example.com/chart.tgz", "", TypeChart)
if err != nil {
t.Errorf("Expected no error for direct URL, got %v", err)
}
if hash != "" {
t.Errorf("Expected empty hash for direct URL, got %s", hash)
}
if u == nil || u.String() != "https://example.com/chart.tgz" {
t.Errorf("Expected URL https://example.com/chart.tgz, got %v", u)
}
// Test OCI URL resolution without registry client
hash, u, err = downloader.ResolveArtifactVersion("oci://example.com/chart:v1.0.0", "", TypeChart)
if err != nil {
t.Errorf("Expected no error for OCI URL without client, got %v", err)
}
if hash != "" {
t.Errorf("Expected empty hash for OCI URL without client, got %s", hash)
}
if u == nil || u.String() != "oci://example.com/chart:v1.0.0" {
t.Errorf("Expected URL oci://example.com/chart:v1.0.0, got %v", u)
}
// Test plugin repository reference rejection
_, _, err = downloader.ResolveArtifactVersion("repo/plugin", "1.0.0", TypePlugin)
if err == nil {
t.Error("Expected error for plugin repository reference")
}
if !strings.Contains(err.Error(), "repository-based plugin distribution is not supported") {
t.Errorf("Expected plugin distribution error message, got: %v", err)
}
}
func TestDownloader_ResolveArtifactVersion_RepositoryChart(t *testing.T) {
// Create temporary directory for repository config
tmpDir := t.TempDir()
downloader := &Downloader{
repositoryConfig: filepath.Join(tmpDir, "repositories.yaml"),
repositoryCache: filepath.Join(tmpDir, "cache"),
}
// Test chart repository reference when config doesn't exist
_, _, err := downloader.ResolveArtifactVersion("stable/nginx", "1.0.0", TypeChart)
// Should not panic and should handle missing config gracefully
if err == nil {
t.Error("Expected error when repository config doesn't exist")
}
// The error should be about missing config or repository not found, not a panic
if strings.Contains(err.Error(), "panic") {
t.Errorf("Got panic-related error: %v", err)
}
}
func TestDownloader_ValidateArtifactTypeForScheme(t *testing.T) {
// Mock VCS getter that only supports plugins
vcsGetter := &MockGetter{data: make(map[string][]byte)}
vcsGetter.supportedTypes = []string{"plugin"}
// Mock general getter that supports all types
generalGetter := &MockGetter{data: make(map[string][]byte)}
getters := getter.Providers{
{Schemes: []string{"git"}, New: func(...getter.Option) (getter.Getter, error) { return vcsGetter, nil }},
{Schemes: []string{"git+http"}, New: func(...getter.Option) (getter.Getter, error) { return vcsGetter, nil }},
{Schemes: []string{"git+https"}, New: func(...getter.Option) (getter.Getter, error) { return vcsGetter, nil }},
{Schemes: []string{"git+ssh"}, New: func(...getter.Option) (getter.Getter, error) { return vcsGetter, nil }},
{Schemes: []string{"http"}, New: func(...getter.Option) (getter.Getter, error) { return generalGetter, nil }},
{Schemes: []string{"https"}, New: func(...getter.Option) (getter.Getter, error) { return generalGetter, nil }},
{Schemes: []string{"oci"}, New: func(...getter.Option) (getter.Getter, error) { return generalGetter, nil }},
}
d := &Downloader{Getters: getters}
tests := []struct {
name string
scheme string
artifactType Type
expectError bool
errorContains string
}{
{
name: "AllowVCSForPlugins",
scheme: "git",
artifactType: TypePlugin,
expectError: false,
},
{
name: "AllowGitHTTPForPlugins",
scheme: "git+http",
artifactType: TypePlugin,
expectError: false,
},
{
name: "AllowGitHTTPSForPlugins",
scheme: "git+https",
artifactType: TypePlugin,
expectError: false,
},
{
name: "AllowGitSSHForPlugins",
scheme: "git+ssh",
artifactType: TypePlugin,
expectError: false,
},
{
name: "RejectVCSForCharts",
scheme: "git",
artifactType: TypeChart,
expectError: true,
errorContains: "scheme git does not support artifact type chart",
},
{
name: "RejectGitHTTPForCharts",
scheme: "git+http",
artifactType: TypeChart,
expectError: true,
errorContains: "scheme git+http does not support artifact type chart",
},
{
name: "RejectGitHTTPSForCharts",
scheme: "git+https",
artifactType: TypeChart,
expectError: true,
errorContains: "scheme git+https does not support artifact type chart",
},
{
name: "RejectGitSSHForCharts",
scheme: "git+ssh",
artifactType: TypeChart,
expectError: true,
errorContains: "scheme git+ssh does not support artifact type chart",
},
{
name: "AllowHTTPForCharts",
scheme: "http",
artifactType: TypeChart,
expectError: false,
},
{
name: "AllowHTTPForPlugins",
scheme: "http",
artifactType: TypePlugin,
expectError: false,
},
{
name: "AllowHTTPSForCharts",
scheme: "https",
artifactType: TypeChart,
expectError: false,
},
{
name: "AllowHTTPSForPlugins",
scheme: "https",
artifactType: TypePlugin,
expectError: false,
},
{
name: "AllowOCIForCharts",
scheme: "oci",
artifactType: TypeChart,
expectError: false,
},
{
name: "AllowOCIForPlugins",
scheme: "oci",
artifactType: TypePlugin,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := d.validateArtifactTypeForScheme(tt.scheme, tt.artifactType)
if tt.expectError {
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.errorContains)
} else {
assert.NoError(t, err)
}
})
}
}
// Cache tests moved to cache_test.go for better organization

@ -0,0 +1,44 @@
/*
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 (
"testing"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v4/pkg/getter"
)
func TestChartDownloader_NoVCSTransport(t *testing.T) {
cd := &ChartDownloader{
Out: nil,
Verify: VerifyNever,
Getters: getter.Providers{},
Cache: &MockCache{data: make(map[[32]byte][]byte)},
}
// Chart downloaders should not have VCS schemes available
// This tests that the old ChartDownloader doesn't accidentally get VCS support
providers := cd.Getters
vcsSchemes := []string{"git", "git+http", "git+https", "git+ssh"}
for _, scheme := range vcsSchemes {
_, err := providers.ByScheme(scheme)
assert.Error(t, err, "Chart downloader should not support VCS scheme %s", scheme)
assert.Contains(t, err.Error(), "not supported", "Error should indicate scheme is not supported")
}
}

@ -0,0 +1,263 @@
/*
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{}
}

@ -0,0 +1,527 @@
/*
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"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/internal/test/ensure"
"helm.sh/helm/v4/pkg/getter"
)
// MockHTTPClient implements HTTPClient for testing
type MockHTTPClient struct {
responses map[string]*http.Response
errors map[string]error
}
func (m *MockHTTPClient) Head(url string) (*http.Response, error) {
if err, exists := m.errors[url]; exists {
return nil, err
}
if resp, exists := m.responses[url]; exists {
// Ensure response has required fields
if resp.Body == nil {
resp.Body = http.NoBody
}
return resp, nil
}
// Default: return 404
return &http.Response{
StatusCode: http.StatusNotFound,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}
// MockHTTPGetter simulates HTTP operations for testing
type MockHTTPGetter struct {
responses map[string]*bytes.Buffer
shouldError bool
}
func (m *MockHTTPGetter) Get(url string, _ ...getter.Option) (*bytes.Buffer, error) {
if m.shouldError {
return nil, fmt.Errorf("mock HTTP error")
}
if resp, exists := m.responses[url]; exists {
return resp, nil
}
return nil, fmt.Errorf("URL not found: %s", url)
}
// MockVCSGetter simulates VCS operations for testing
type MockVCSGetter struct {
testRepoPath string
shouldError bool
response *bytes.Buffer // For direct response control
}
func (m *MockVCSGetter) Get(_ string, _ ...getter.Option) (*bytes.Buffer, error) {
if m.shouldError {
return nil, fmt.Errorf("mock VCS error")
}
// If direct response is set, use it
if m.response != nil {
return m.response, nil
}
// Read plugin.yaml from test repository (for file-based tests)
if m.testRepoPath != "" {
pluginYamlPath := filepath.Join(m.testRepoPath, "plugin.yaml")
content, err := os.ReadFile(pluginYamlPath)
if err != nil {
return nil, fmt.Errorf("failed to read plugin.yaml: %w", err)
}
return bytes.NewBuffer(content), nil
}
return bytes.NewBufferString("default mock VCS content"), nil
}
// RestrictToArtifactTypes implements getter.Restricted for MockVCSGetter
func (m *MockVCSGetter) RestrictToArtifactTypes() []string {
return []string{"plugin"}
}
func TestPluginDownloader_VCSGetterRegistration(t *testing.T) {
pd := &PluginDownloader{
Cache: &MockCache{data: make(map[[32]byte][]byte)},
ContentCache: t.TempDir(),
}
// Initialize the downloader by accessing internal initialization logic
// This mirrors what happens in DownloadTo but without the actual network operation
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"))...)
},
})
}
pd.downloader = &Downloader{
Verify: pd.Verify,
Keyring: pd.Keyring,
Getters: getters,
Cache: pd.Cache,
ContentCache: pd.ContentCache,
}
assert.NotNil(t, pd.downloader)
// Verify VCS getters are registered
vcsSchemes := []string{"git", "git+http", "git+https", "git+ssh"}
for _, scheme := range vcsSchemes {
getter, err := pd.downloader.Getters.ByScheme(scheme)
assert.NoError(t, err, "VCS getter should be registered for scheme %s", scheme)
assert.NotNil(t, getter)
}
}
func TestPluginDownloader_SmartHTTPDownloader_BothSchemes(t *testing.T) {
pd := &PluginDownloader{
Cache: &MockCache{data: make(map[[32]byte][]byte)},
ContentCache: t.TempDir(),
Verify: VerifyNever,
}
// Initialize the downloader to set up getters
_, _, _ = pd.DownloadTo("https://example.com/plugin.tgz", "", t.TempDir())
// Verify that both HTTP and HTTPS schemes are supported by the same provider
// Since ByScheme creates new instances, we test by checking that both schemes work
httpGetter, err := pd.downloader.Getters.ByScheme("http")
assert.NoError(t, err, "HTTP getter should be registered")
assert.NotNil(t, httpGetter)
httpsGetter, err := pd.downloader.Getters.ByScheme("https")
assert.NoError(t, err, "HTTPS getter should be registered")
assert.NotNil(t, httpsGetter)
// Both should implement the same artifact type restrictions
if httpRestricted, ok := httpGetter.(getter.Restricted); ok {
httpTypes := httpRestricted.RestrictToArtifactTypes()
assert.Equal(t, []string{"plugin"}, httpTypes, "HTTP getter should be restricted to plugins")
}
if httpsRestricted, ok := httpsGetter.(getter.Restricted); ok {
httpsTypes := httpsRestricted.RestrictToArtifactTypes()
assert.Equal(t, []string{"plugin"}, httpsTypes, "HTTPS getter should be restricted to plugins")
}
}
func TestPluginDownloader_VCSInstallationWorkflow(t *testing.T) {
// This test demonstrates that plugin installation works through the new architecture
// It mirrors the original VCS installer test patterns but uses the unified downloader
ensure.HelmHome(t)
// Create a test plugin directory structure (simulates a Git repository)
tempDir := t.TempDir()
testRepoPath := filepath.Join(tempDir, "test-plugin-repo")
err := os.MkdirAll(testRepoPath, 0755)
require.NoError(t, err)
// Create plugin.yaml (what would be in the Git repository)
pluginContent := `name: test-vcs-plugin
version: 1.2.3
usage: A VCS-installed test plugin
description: Plugin installed via VCS getter through unified downloader
command: echo "VCS plugin executed successfully"
`
pluginYamlPath := filepath.Join(testRepoPath, "plugin.yaml")
err = os.WriteFile(pluginYamlPath, []byte(pluginContent), 0644)
require.NoError(t, err)
// Mock the VCS getter to return our test plugin directory
mockVCSGetter := &MockVCSGetter{
testRepoPath: testRepoPath,
shouldError: false,
}
// Create plugin downloader with VCS transport
pd := &PluginDownloader{
Cache: &MockCache{data: make(map[[32]byte][]byte)},
ContentCache: t.TempDir(),
Verify: VerifyNever,
}
// Initialize downloader with our mock VCS getter
getters := getter.Providers{
{Schemes: []string{"git"}, New: func(...getter.Option) (getter.Getter, error) { return mockVCSGetter, nil }},
{Schemes: []string{"git+https"}, New: func(...getter.Option) (getter.Getter, error) { return mockVCSGetter, nil }},
}
pd.downloader = &Downloader{
Verify: pd.Verify,
Keyring: pd.Keyring,
Getters: getters,
Cache: pd.Cache,
ContentCache: pd.ContentCache,
}
// Test the download workflow
destDir := t.TempDir()
// VCS downloads don't involve provenance verification
downloadedPath, _, err := pd.DownloadTo("git+https://github.com/example/test-plugin.git", "", destDir)
// Verify the download succeeded
assert.NoError(t, err, "Plugin download should succeed")
assert.NotEmpty(t, downloadedPath, "Should return downloaded file path")
// Verify content was written
assert.FileExists(t, downloadedPath, "Downloaded plugin file should exist")
// Verify content matches expectations
downloadedContent, err := os.ReadFile(downloadedPath)
assert.NoError(t, err)
assert.Contains(t, string(downloadedContent), "name: test-vcs-plugin", "Downloaded content should contain plugin metadata")
assert.Contains(t, string(downloadedContent), "version: 1.2.3", "Downloaded content should contain version")
}
func TestSmartHTTPDownloader_ArchiveDetection(t *testing.T) {
tests := []struct {
name string
url string
contentType string
disposition string
expectError bool
expectArchive bool
}{
{"TarballContentType", "https://example.com/plugin.tgz", "application/gzip", "", false, true},
{"OctetStreamContentType", "https://example.com/plugin.tar.gz", "application/octet-stream", "", false, true},
{"AttachmentWithTgzExtension", "https://example.com/download", "text/html", "attachment; filename=plugin.tgz", false, true},
{"HTMLContentType", "https://example.com/repo", "text/html", "", false, false},
{"ProvFile", "https://example.com/plugin.tgz.prov", "text/plain", "", false, true}, // .prov files always use HTTP
{"NetworkError", "https://example.com/error", "", "", true, false},
{"NotFound", "https://example.com/notfound", "", "", false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &MockHTTPClient{
responses: make(map[string]*http.Response),
errors: make(map[string]error),
}
if tt.expectError {
mockClient.errors[tt.url] = fmt.Errorf("network error")
} else {
header := make(http.Header)
if tt.contentType != "" {
header.Set("Content-Type", tt.contentType)
}
if tt.disposition != "" {
header.Set("Content-Disposition", tt.disposition)
}
mockClient.responses[tt.url] = &http.Response{
StatusCode: http.StatusOK,
Header: header,
Body: http.NoBody,
}
}
transport := &SmartHTTPDownloader{
httpClient: mockClient,
}
isArchive := transport.servesArchiveContent(tt.url)
assert.Equal(t, tt.expectArchive, isArchive, "Archive detection mismatch for %s", tt.url)
})
}
}
func TestSmartHTTPDownloader_ArtifactTypes(t *testing.T) {
transport := &SmartHTTPDownloader{}
// Should only support plugins
supported := transport.RestrictToArtifactTypes()
assert.Equal(t, []string{"plugin"}, supported, "SmartHTTPDownloader should only support plugins")
}
func TestSmartHTTPDownloader_DefaultHTTPClient(t *testing.T) {
// Test that SmartHTTPDownloader uses default HTTP client when none injected
transport := &SmartHTTPDownloader{}
// This should not panic and should return false for non-existent URLs
// (since default client will fail to connect)
isArchive := transport.servesArchiveContent("https://nonexistent.example.com/file.tgz")
assert.False(t, isArchive, "Non-existent URL should return false")
}
func TestSmartHTTPDownloader_ProvFileHandling(t *testing.T) {
// Test that .prov files are always routed to HTTP getter, regardless of scheme
// This prevents VCS getter from being attempted on provenance files
mockVCSGetter := &MockVCSGetter{shouldError: true} // VCS should never be called
mockHTTPGetter := &MockHTTPGetter{
responses: map[string]*bytes.Buffer{
"http://example.com/plugin.tgz.prov": bytes.NewBufferString("provenance signature data"),
"https://example.com/plugin.tgz.prov": bytes.NewBufferString("provenance signature data"),
"https://github.com/user/repo/releases/download/v1.0.0/plugin.tgz.prov": bytes.NewBufferString("github prov data"),
},
}
transport := &SmartHTTPDownloader{
vcsGetter: mockVCSGetter,
httpGetter: mockHTTPGetter,
httpClient: nil, // .prov files bypass HTTP HEAD request entirely
}
tests := []struct {
name string
url string
}{
{"HTTPProvFile", "http://example.com/plugin.tgz.prov"},
{"HTTPSProvFile", "https://example.com/plugin.tgz.prov"},
{"GitHubReleaseProvFile", "https://github.com/user/repo/releases/download/v1.0.0/plugin.tgz.prov"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Should use HTTP getter directly without trying VCS
result, err := transport.Get(tt.url)
assert.NoError(t, err, "Prov file download should succeed")
assert.NotNil(t, result, "Should return content")
assert.Contains(t, result.String(), "prov")
// Verify VCS getter was never called (would have returned error)
// If VCS was called, we'd get an error since mockVCSGetter.shouldError = true
})
}
}
func TestSmartHTTPDownloader_ContentDetection(t *testing.T) {
transport := &SmartHTTPDownloader{}
tests := []struct {
name string
content []byte
isValid bool
}{
{"ValidGzipTarball", append([]byte{0x1f, 0x8b, 0x08, 0x00}, make([]byte, 100)...), true},
{"HTMLContent", []byte("<!DOCTYPE html><html><head><title>Test</title></head><body>This is a test HTML page with sufficient length for the test.</body></html>"), false},
{"SmallContent", []byte("small"), false},
{"EmptyContent", []byte{}, false},
{"BinaryContent", append([]byte{0x89, 0x50, 0x4E, 0x47}, make([]byte, 100)...), true}, // Conservative: assume valid
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buffer := bytes.NewBuffer(tt.content)
isValid := transport.isValidPluginContent(buffer)
assert.Equal(t, tt.isValid, isValid, "Content detection mismatch for %s", tt.name)
})
}
}
func TestPluginDownloader_NewPluginDownloader(t *testing.T) {
pd := NewPluginDownloader()
assert.NotNil(t, pd, "Should create new plugin downloader")
assert.Nil(t, pd.downloader, "Internal downloader should be nil until first use")
}
func TestPluginDownloader_DownloadToCache(t *testing.T) {
tempDir := t.TempDir()
pd := &PluginDownloader{
Cache: &MockCache{data: make(map[[32]byte][]byte)},
ContentCache: tempDir,
Verify: VerifyNever,
}
// Mock a simple HTTP getter for this test
mockHTTPGetter := &MockHTTPGetter{
responses: map[string]*bytes.Buffer{
"https://example.com/plugin.tgz": bytes.NewBufferString("mock plugin content"),
},
}
// Set up minimal getter
getters := getter.Providers{
{Schemes: []string{"https"}, New: func(...getter.Option) (getter.Getter, error) { return mockHTTPGetter, nil }},
}
pd.downloader = &Downloader{
Verify: pd.Verify,
Getters: getters,
Cache: pd.Cache,
ContentCache: pd.ContentCache,
}
// Test that DownloadToCache delegates to DownloadTo with ContentCache
path, _, err := pd.DownloadToCache("https://example.com/plugin.tgz", "")
assert.NoError(t, err, "DownloadToCache should succeed")
assert.Contains(t, path, tempDir, "Should download to ContentCache directory")
}
func TestSmartHTTPDownloader_Get_EdgeCases(t *testing.T) {
tests := []struct {
name string
url string
headError bool
vcsResponse *bytes.Buffer
vcsError error
httpResponse *bytes.Buffer
httpError error
expectError bool
expectedErrMsg string
}{
{
name: "VCSSucceedsAfterHEADFails",
url: "https://github.com/user/repo",
headError: true,
vcsResponse: bytes.NewBufferString("vcs plugin content"),
vcsError: nil,
httpResponse: nil,
httpError: fmt.Errorf("http error"),
expectError: false,
expectedErrMsg: "",
},
{
name: "HTTPFallbackWithValidContent",
url: "https://example.com/might-be-repo",
headError: true,
vcsResponse: nil,
vcsError: fmt.Errorf("vcs error"),
httpResponse: bytes.NewBuffer(append([]byte{0x1f, 0x8b}, make([]byte, 100)...)), // Valid gzip
httpError: nil,
expectError: false,
expectedErrMsg: "",
},
{
name: "HTTPFallbackWithInvalidContent",
url: "https://example.com/might-be-repo",
headError: true,
vcsResponse: nil,
vcsError: fmt.Errorf("vcs error"),
httpResponse: bytes.NewBufferString("<!DOCTYPE html><html>Not a plugin</html>"),
httpError: nil,
expectError: true,
expectedErrMsg: "URL does not contain a valid plugin archive",
},
{
name: "BothFail",
url: "https://example.com/nowhere",
headError: true,
vcsResponse: nil,
vcsError: fmt.Errorf("vcs error"),
httpResponse: nil,
httpError: fmt.Errorf("http error"),
expectError: true,
expectedErrMsg: "failed to download plugin: HTTP failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockHTTPClient := &MockHTTPClient{
responses: make(map[string]*http.Response),
errors: make(map[string]error),
}
if tt.headError {
mockHTTPClient.errors[tt.url] = fmt.Errorf("head request failed")
}
mockVCSGetter := &MockVCSGetter{
shouldError: tt.vcsError != nil,
response: tt.vcsResponse,
}
mockHTTPGetter := &MockHTTPGetter{
responses: make(map[string]*bytes.Buffer),
shouldError: tt.httpError != nil,
}
if tt.httpResponse != nil {
mockHTTPGetter.responses[tt.url] = tt.httpResponse
}
transport := &SmartHTTPDownloader{
vcsGetter: mockVCSGetter,
httpGetter: mockHTTPGetter,
httpClient: mockHTTPClient,
}
result, err := transport.Get(tt.url)
if tt.expectError {
assert.Error(t, err, "Should return error")
if tt.expectedErrMsg != "" {
assert.Contains(t, err.Error(), tt.expectedErrMsg, "Error message should contain expected text")
}
} else {
assert.NoError(t, err, "Should not return error")
assert.NotNil(t, result, "Should return result")
}
})
}
}

@ -158,6 +158,13 @@ type Getter interface {
Get(url string, options ...Option) (*bytes.Buffer, error) Get(url string, options ...Option) (*bytes.Buffer, error)
} }
// Restricted is an optional interface that getters can implement
// to restrict which artifact types they support. If not implemented, the
// getter supports all artifact types.
type Restricted interface {
RestrictToArtifactTypes() []string
}
// Constructor is the function for every getter which creates a specific instance // Constructor is the function for every getter which creates a specific instance
// according to the configuration // according to the configuration
type Constructor func(options ...Option) (Getter, error) type Constructor func(options ...Option) (Getter, error)
@ -218,6 +225,14 @@ func Getters(extraOpts ...Option) Providers {
return NewOCIGetter(options...) return NewOCIGetter(options...)
}, },
}, },
Provider{
Schemes: []string{"git", "git+http", "git+https", "git+ssh"},
New: func(options ...Option) (Getter, error) {
options = append(options, defaultOptions...)
options = append(options, extraOpts...)
return NewVCSGetter(options...)
},
},
} }
} }

@ -75,8 +75,8 @@ func TestAll(t *testing.T) {
env.PluginsDirectory = pluginDir env.PluginsDirectory = pluginDir
all := All(env) all := All(env)
if len(all) != 4 { if len(all) != 5 {
t.Errorf("expected 4 providers (default plus three plugins), got %d", len(all)) t.Errorf("expected 5 providers (default plus VCS plus three plugins), got %d", len(all))
} }
if _, err := all.ByScheme("test2"); err != nil { if _, err := all.ByScheme("test2"); err != nil {

@ -0,0 +1,205 @@
/*
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"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/Masterminds/vcs"
"helm.sh/helm/v4/internal/plugin/cache"
"helm.sh/helm/v4/pkg/helmpath"
)
type VCSGetter struct {
opts getterOptions
}
func (v *VCSGetter) Get(href string, options ...Option) (*bytes.Buffer, error) {
for _, opt := range options {
opt(&v.opts)
}
if v.opts.artifactType != "plugin" {
return nil, fmt.Errorf("VCS getter can only be used for plugins, not %s", v.opts.artifactType)
}
return v.get(href)
}
func (v *VCSGetter) get(href string) (*bytes.Buffer, error) {
repoURL := v.normalizeURL(href)
key, err := cache.Key(repoURL)
if err != nil {
return nil, fmt.Errorf("failed to generate cache key: %w", err)
}
localPath := helmpath.CachePath("plugins", key)
repo, err := vcs.NewRepo(repoURL, localPath)
if err != nil {
return nil, fmt.Errorf("failed to create VCS repo: %w", err)
}
if err := v.syncRepo(repo); err != nil {
return nil, fmt.Errorf("failed to sync repository: %w", err)
}
if v.opts.version != "" {
ref, err := v.solveVersion(repo, v.opts.version)
if err != nil {
return nil, fmt.Errorf("failed to resolve version %q: %w", v.opts.version, err)
}
if ref != "" {
if err := v.setVersion(repo, ref); err != nil {
return nil, fmt.Errorf("failed to set version %q: %w", ref, err)
}
}
}
return v.archiveRepo(repo)
}
func (v *VCSGetter) normalizeURL(href string) string {
// Handle git+ prefixed schemes
if strings.HasPrefix(href, "git+") {
return strings.TrimPrefix(href, "git+")
}
// Convert git:// to https://
if strings.HasPrefix(href, "git://") {
return strings.Replace(href, "git://", "https://", 1)
}
// Add .git suffix to github.com URLs if missing
if strings.Contains(href, "github.com") && !strings.HasSuffix(href, ".git") {
return href + ".git"
}
return href
}
// syncRepo clones or updates a remote repo using vcs library
func (v *VCSGetter) syncRepo(repo vcs.Repo) error {
if _, err := os.Stat(repo.LocalPath()); os.IsNotExist(err) {
slog.Debug("cloning repository", "url", repo.Remote(), "path", repo.LocalPath())
return repo.Get()
}
slog.Debug("updating repository", "url", repo.Remote(), "path", repo.LocalPath())
return repo.Update()
}
// solveVersion determines the version to checkout based on constraints
func (v *VCSGetter) solveVersion(repo vcs.Repo, version string) (string, error) {
if version == "" {
return "", nil
}
if repo.IsReference(version) {
return version, nil
}
// Create the constraint first to make sure it's valid before working on the repo
constraint, err := semver.NewConstraint(version)
if err != nil {
return "", err
}
// Get the tags
refs, err := repo.Tags()
if err != nil {
return "", err
}
slog.Debug("found refs", "refs", refs)
// Convert and filter the list to semver.Version instances
semvers := v.getSemVers(refs)
// Sort semver list
sort.Sort(sort.Reverse(semver.Collection(semvers)))
for _, sv := range semvers {
if constraint.Check(sv) {
// If the constraint passes get the original reference
ver := sv.Original()
slog.Debug("setting to version", "version", ver)
return ver, nil
}
}
return "", fmt.Errorf("requested version %q does not exist for plugin %q", version, repo.Remote())
}
// setVersion attempts to checkout the version
func (v *VCSGetter) setVersion(repo vcs.Repo, ref string) error {
slog.Debug("setting version", "version", ref)
return repo.UpdateVersion(ref)
}
func (v *VCSGetter) getSemVers(refs []string) []*semver.Version {
var sv []*semver.Version
for _, r := range refs {
if ver, err := semver.NewVersion(r); err == nil {
sv = append(sv, ver)
}
}
return sv
}
// archiveRepo creates a tar.gz archive from the repository content
func (v *VCSGetter) archiveRepo(repo vcs.Repo) (*bytes.Buffer, error) {
// For now, create a simple tar from the local path
// In a full implementation, this would create a proper archive
// but for the getter interface, we need to return the raw plugin content
// Read the entire directory content into a buffer
// This is a simplified approach - the original installer copied files directly
localPath := repo.LocalPath()
// Check if this looks like a plugin directory structure
if _, err := os.Stat(filepath.Join(localPath, "plugin.yaml")); err == nil {
// Read the plugin.yaml to return as content
content, err := os.ReadFile(filepath.Join(localPath, "plugin.yaml"))
if err != nil {
return nil, fmt.Errorf("failed to read plugin.yaml: %w", err)
}
return bytes.NewBuffer(content), nil
}
return nil, errors.New("not a valid plugin repository - missing plugin.yaml")
}
// RestrictToArtifactTypes implements the getter.Restricted interface.
// VCS getters only support plugins.
func (v *VCSGetter) RestrictToArtifactTypes() []string {
return []string{"plugin"}
}
func NewVCSGetter(options ...Option) (Getter, error) {
var getter VCSGetter
for _, opt := range options {
opt(&getter.opts)
}
return &getter, nil
}

@ -0,0 +1,423 @@
/*
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 (
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/Masterminds/vcs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"helm.sh/helm/v4/internal/test/ensure"
)
const testPluginYAML = `name: test-plugin
version: 1.0.0
usage: A test plugin
description: Test plugin for VCS getter
command: echo
`
func TestNewVCSGetter(t *testing.T) {
g, err := NewVCSGetter()
require.NoError(t, err)
assert.NotNil(t, g)
assert.IsType(t, &VCSGetter{}, g)
}
func TestVCSGetter_ArtifactTypeValidation(t *testing.T) {
g, err := NewVCSGetter()
require.NoError(t, err)
t.Run("RejectsCharts", func(t *testing.T) {
_, err := g.Get("git://example.com/repo.git", WithArtifactType("chart"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "VCS getter can only be used for plugins, not chart")
})
t.Run("RejectsOtherTypes", func(t *testing.T) {
_, err := g.Get("git://example.com/repo.git", WithArtifactType("unknown"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "VCS getter can only be used for plugins, not unknown")
})
t.Run("AllowsPlugins", func(t *testing.T) {
// Test that plugin artifact type validation passes
// We don't need to test actual network calls, just that the validation allows it
g := &VCSGetter{opts: getterOptions{artifactType: "plugin"}}
// Test the artifact type check directly
if g.opts.artifactType != "plugin" {
t.Error("Should allow plugins")
}
})
}
func TestVCSGetter_NormalizeURL(t *testing.T) {
g := &VCSGetter{}
tests := []struct {
name string
input string
expected string
}{
{
name: "GitScheme",
input: "git://github.com/user/repo.git",
expected: "https://github.com/user/repo.git",
},
{
name: "GitPlusHTTPS",
input: "git+https://github.com/user/repo.git",
expected: "https://github.com/user/repo.git",
},
{
name: "GitPlusSSH",
input: "git+ssh://git@github.com:user/repo.git",
expected: "ssh://git@github.com:user/repo.git",
},
{
name: "GitHubWithoutGitSuffix",
input: "github.com/user/repo",
expected: "github.com/user/repo.git",
},
{
name: "AlreadyNormalized",
input: "https://github.com/user/repo.git",
expected: "https://github.com/user/repo.git",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := g.normalizeURL(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestVCSGetter_GetSemVers(t *testing.T) {
g := &VCSGetter{}
tests := []struct {
name string
tags []string
expected int
}{
{
name: "ValidSemverTags",
tags: []string{"v1.0.0", "v1.1.0", "v2.0.0", "invalid-tag", "v1.0.0-alpha"},
expected: 4, // All except "invalid-tag"
},
{
name: "NoValidTags",
tags: []string{"main", "develop", "feature-branch"},
expected: 0,
},
{
name: "EmptyTags",
tags: []string{},
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := g.getSemVers(tt.tags)
assert.Len(t, result, tt.expected)
})
}
}
// Mock repository implementation based on original VCS installer tests
type testRepo struct {
local, remote, current string
tags, branches []string
err error
vcs.Repo
}
func (r *testRepo) LocalPath() string { return r.local }
func (r *testRepo) Remote() string { return r.remote }
func (r *testRepo) Update() error { return r.err }
func (r *testRepo) Get() error { return r.err }
func (r *testRepo) IsReference(string) bool { return false }
func (r *testRepo) Tags() ([]string, error) { return r.tags, r.err }
func (r *testRepo) Branches() ([]string, error) { return r.branches, r.err }
func (r *testRepo) UpdateVersion(version string) error {
r.current = version
return r.err
}
func TestVCSGetter_WithMockRepo(t *testing.T) {
ensure.HelmHome(t)
// Create a test plugin directory structure
tempDir := t.TempDir()
testRepoPath := filepath.Join(tempDir, "test-plugin")
err := os.MkdirAll(testRepoPath, 0755)
require.NoError(t, err)
// Create plugin.yaml
err = os.WriteFile(filepath.Join(testRepoPath, "plugin.yaml"), []byte(testPluginYAML), 0644)
require.NoError(t, err)
source := "https://github.com/example/test-plugin"
repo := &testRepo{
local: testRepoPath,
remote: source,
tags: []string{"0.1.0", "0.1.1", "1.0.0"},
}
g := &VCSGetter{
opts: getterOptions{
artifactType: "plugin",
version: "~0.1.0",
},
}
t.Run("VersionResolution", func(t *testing.T) {
resolvedVersion, err := g.solveVersion(repo, "~0.1.0")
require.NoError(t, err)
assert.Equal(t, "0.1.1", resolvedVersion, "Should resolve to highest patch version")
})
t.Run("ExactVersionMatch", func(t *testing.T) {
resolvedVersion, err := g.solveVersion(repo, "1.0.0")
require.NoError(t, err)
assert.Equal(t, "1.0.0", resolvedVersion)
})
t.Run("NonExistentVersion", func(t *testing.T) {
_, err := g.solveVersion(repo, "2.0.0")
assert.Error(t, err)
assert.Contains(t, err.Error(), "requested version \"2.0.0\" does not exist")
})
t.Run("SetVersion", func(t *testing.T) {
err := g.setVersion(repo, "0.1.1")
assert.NoError(t, err)
assert.Equal(t, "0.1.1", repo.current)
})
t.Run("ArchiveRepo", func(t *testing.T) {
buffer, err := g.archiveRepo(repo)
assert.NoError(t, err)
assert.NotNil(t, buffer)
// Should contain the plugin.yaml content
content := buffer.String()
assert.Contains(t, content, "name: test-plugin")
})
}
func TestVCSGetter_ErrorHandling(t *testing.T) {
ensure.HelmHome(t)
source := "https://github.com/nonexistent/plugin"
t.Run("RepoWithError", func(t *testing.T) {
repo := &testRepo{
remote: source,
err: fmt.Errorf("network error"),
}
g := &VCSGetter{opts: getterOptions{artifactType: "plugin"}}
err := g.syncRepo(repo)
assert.Error(t, err)
assert.Contains(t, err.Error(), "network error")
})
t.Run("InvalidVersionConstraint", func(t *testing.T) {
repo := &testRepo{
remote: source,
tags: []string{"1.0.0"},
}
g := &VCSGetter{opts: getterOptions{artifactType: "plugin"}}
_, err := g.solveVersion(repo, "invalid-constraint")
assert.Error(t, err)
assert.Contains(t, err.Error(), "improper constraint")
})
}
func TestVCSGetter_RestrictToArtifactTypes(t *testing.T) {
getter, err := NewVCSGetter()
assert.NoError(t, err)
vcsGetter := getter.(*VCSGetter)
supported := vcsGetter.RestrictToArtifactTypes()
assert.Equal(t, []string{"plugin"}, supported, "VCS getter should only support plugins")
}
func TestVCSGetter_LegacyCompatibility(t *testing.T) {
ensure.HelmHome(t)
// Test cases that mirror the original VCS installer behavior
tests := []struct {
name string
sourceURL string
version string
expectedVersion string
tags []string
shouldError bool
}{
{
name: "SemanticVersioning",
sourceURL: "https://github.com/example/helm-env",
version: "~0.1.0",
expectedVersion: "0.1.1",
tags: []string{"0.1.0", "0.1.1"},
shouldError: false,
},
{
name: "NonExistentVersion",
sourceURL: "https://github.com/example/helm-env",
version: "0.2.0",
tags: []string{"0.1.0", "0.1.1"},
shouldError: true,
},
{
name: "NoVersionSpecified",
sourceURL: "https://github.com/example/helm-env",
version: "",
expectedVersion: "",
tags: []string{"0.1.0", "0.1.1"},
shouldError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock plugin directory
tempDir := t.TempDir()
testRepoPath := filepath.Join(tempDir, "test-plugin")
err := os.MkdirAll(testRepoPath, 0755)
require.NoError(t, err)
// Create plugin.yaml
err = os.WriteFile(filepath.Join(testRepoPath, "plugin.yaml"), []byte(testPluginYAML), 0644)
require.NoError(t, err)
repo := &testRepo{
local: testRepoPath,
remote: tt.sourceURL,
tags: tt.tags,
}
g := &VCSGetter{
opts: getterOptions{
artifactType: "plugin",
version: tt.version,
},
}
if tt.version != "" {
resolvedVersion, err := g.solveVersion(repo, tt.version)
if tt.shouldError {
assert.Error(t, err)
if tt.version == "0.2.0" {
expectedError := fmt.Sprintf("requested version %q does not exist for plugin %q", tt.version, tt.sourceURL)
assert.Equal(t, expectedError, err.Error())
}
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectedVersion, resolvedVersion)
}
}
})
}
}
func TestVCSGetter_Integration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Check if git is available
if _, err := exec.LookPath("git"); err != nil {
t.Skip("Git not available in test environment")
}
ensure.HelmHome(t)
// Create a temporary git repository for testing
tempDir := t.TempDir()
repoDir := filepath.Join(tempDir, "test-repo")
// Initialize git repo
err := os.MkdirAll(repoDir, 0755)
require.NoError(t, err)
// Initialize git repo and add some content
commands := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@example.com"},
{"git", "config", "user.name", "Test User"},
{"git", "checkout", "-b", "main"},
}
for _, cmd := range commands {
execCmd := exec.Command(cmd[0], cmd[1:]...)
execCmd.Dir = repoDir
err := execCmd.Run()
require.NoError(t, err, "Failed to run: %v", cmd)
}
// Create a plugin.yaml file to make it a valid plugin
pluginFile := filepath.Join(repoDir, "plugin.yaml")
err = os.WriteFile(pluginFile, []byte(testPluginYAML), 0644)
require.NoError(t, err)
// Add and commit the plugin file
commitCommands := [][]string{
{"git", "add", "plugin.yaml"},
{"git", "commit", "-m", "Initial commit"},
{"git", "tag", "-a", "v1.0.0", "-m", "Version 1.0.0"},
}
for _, cmd := range commitCommands {
execCmd := exec.Command(cmd[0], cmd[1:]...)
execCmd.Dir = repoDir
err := execCmd.Run()
require.NoError(t, err, "Failed to run: %v", cmd)
}
t.Run("GetFromLocalRepo", func(t *testing.T) {
g, err := NewVCSGetter(WithArtifactType("plugin"))
require.NoError(t, err)
// Use file:// URL for local repository
repoURL := "file://" + repoDir
buffer, err := g.Get(repoURL)
if err != nil {
// This is expected to fail in test environment, but we verify the error isn't about artifact type
assert.NotContains(t, err.Error(), "can only be used for plugins")
} else {
// If it succeeds, verify we got some content
assert.NotNil(t, buffer)
assert.Greater(t, buffer.Len(), 0)
}
})
}
Loading…
Cancel
Save