mirror of https://github.com/helm/helm
parent
e3124e488f
commit
7d22bb25fa
@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OCIInstaller installs plugins from OCI registries
|
||||||
|
type OCIInstaller struct {
|
||||||
|
CacheDir string
|
||||||
|
PluginName string
|
||||||
|
base
|
||||||
|
settings *cli.EnvSettings
|
||||||
|
getter getter.Getter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOCIInstaller creates a new OCIInstaller with optional getter options
|
||||||
|
func NewOCIInstaller(source string, options ...getter.Option) (*OCIInstaller, error) {
|
||||||
|
ref := strings.TrimPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme))
|
||||||
|
|
||||||
|
// Extract plugin name from OCI reference
|
||||||
|
// e.g., "ghcr.io/user/plugin-name:v1.0.0" -> "plugin-name"
|
||||||
|
parts := strings.Split(ref, "/")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, fmt.Errorf("invalid OCI reference: %s", source)
|
||||||
|
}
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
pluginName := lastPart
|
||||||
|
if idx := strings.LastIndex(lastPart, ":"); idx > 0 {
|
||||||
|
pluginName = lastPart[:idx]
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(lastPart, "@"); idx > 0 {
|
||||||
|
pluginName = lastPart[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Use getter to download the plugin
|
||||||
|
pluginData, err := i.getter.Get(i.Source)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache directory
|
||||||
|
if err := os.MkdirAll(i.CacheDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a gzip compressed file
|
||||||
|
pluginBytes := pluginData.Bytes()
|
||||||
|
if len(pluginBytes) < 2 || pluginBytes[0] != 0x1f || pluginBytes[1] != 0x8b {
|
||||||
|
return fmt.Errorf("plugin data is not a gzip compressed archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract as gzipped tar
|
||||||
|
if err := extractTarGz(bytes.NewReader(pluginBytes), 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
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||||
|
outFile.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
case tar.TypeXGlobalHeader, tar.TypeXHeader:
|
||||||
|
// Skip these
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -0,0 +1,814 @@
|
|||||||
|
/*
|
||||||
|
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/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 + ".tgz", // Layer named properly
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 FIRST
|
||||||
|
testPluginsDir := t.TempDir()
|
||||||
|
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||||
|
|
||||||
|
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 now points to our test directory
|
||||||
|
actualPath := installer.Path()
|
||||||
|
t.Logf("Installer will use path: %s", actualPath)
|
||||||
|
|
||||||
|
// Verify the path is actually in our test directory
|
||||||
|
if !strings.HasPrefix(actualPath, testPluginsDir) {
|
||||||
|
t.Fatalf("Expected path %s to be under test directory %s", actualPath, testPluginsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
testPluginsDir := t.TempDir()
|
||||||
|
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||||
|
|
||||||
|
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
|
||||||
|
testPluginsDir := t.TempDir()
|
||||||
|
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||||
|
|
||||||
|
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
|
||||||
|
testPluginsDir := t.TempDir()
|
||||||
|
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||||
|
|
||||||
|
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'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,146 @@
|
|||||||
|
/*
|
||||||
|
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"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"helm.sh/helm/v4/internal/plugin"
|
||||||
|
"helm.sh/helm/v4/internal/test/ensure"
|
||||||
|
"helm.sh/helm/v4/pkg/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPluginUninstallCleansUpVersionedFiles(t *testing.T) {
|
||||||
|
ensure.HelmHome(t)
|
||||||
|
|
||||||
|
// Create a fake plugin directory structure in a temp directory
|
||||||
|
pluginsDir := t.TempDir()
|
||||||
|
t.Setenv("HELM_PLUGINS", pluginsDir)
|
||||||
|
|
||||||
|
// Create a new settings instance that will pick up the environment variable
|
||||||
|
testSettings := cli.New()
|
||||||
|
pluginName := "test-plugin"
|
||||||
|
|
||||||
|
// Create plugin directory
|
||||||
|
pluginDir := filepath.Join(pluginsDir, pluginName)
|
||||||
|
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create plugin.yaml
|
||||||
|
pluginYAML := `name: test-plugin
|
||||||
|
version: 1.2.3
|
||||||
|
description: Test plugin
|
||||||
|
command: $HELM_PLUGIN_DIR/test-plugin
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYAML), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create versioned tarball and provenance files
|
||||||
|
tarballFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz")
|
||||||
|
provFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz.prov")
|
||||||
|
otherVersionTarball := filepath.Join(pluginsDir, "test-plugin-2.0.0.tgz")
|
||||||
|
|
||||||
|
if err := os.WriteFile(tarballFile, []byte("fake tarball"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(provFile, []byte("fake provenance"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Create another version that should NOT be removed
|
||||||
|
if err := os.WriteFile(otherVersionTarball, []byte("other version"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the plugin
|
||||||
|
p, err := plugin.LoadDir(pluginDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test uninstall function that uses our test settings
|
||||||
|
testUninstallPlugin := func(plugin plugin.Plugin) error {
|
||||||
|
if err := os.RemoveAll(plugin.Dir()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up versioned tarball and provenance files from test HELM_PLUGINS directory
|
||||||
|
pluginName := plugin.Metadata().Name
|
||||||
|
pluginVersion := plugin.Metadata().Version
|
||||||
|
testPluginsDir := testSettings.PluginsDirectory
|
||||||
|
|
||||||
|
// Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov
|
||||||
|
if pluginVersion != "" {
|
||||||
|
versionedBasename := fmt.Sprintf("%s-%s.tgz", pluginName, pluginVersion)
|
||||||
|
|
||||||
|
// Remove tarball file
|
||||||
|
tarballPath := filepath.Join(testPluginsDir, versionedBasename)
|
||||||
|
if _, err := os.Stat(tarballPath); err == nil {
|
||||||
|
if err := os.Remove(tarballPath); err != nil {
|
||||||
|
t.Logf("failed to remove tarball file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove provenance file
|
||||||
|
provPath := filepath.Join(testPluginsDir, versionedBasename+".prov")
|
||||||
|
if _, err := os.Stat(provPath); err == nil {
|
||||||
|
if err := os.Remove(provPath); err != nil {
|
||||||
|
t.Logf("failed to remove provenance file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip runHook in test
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify files exist before uninstall
|
||||||
|
if _, err := os.Stat(tarballFile); os.IsNotExist(err) {
|
||||||
|
t.Fatal("tarball file should exist before uninstall")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(provFile); os.IsNotExist(err) {
|
||||||
|
t.Fatal("provenance file should exist before uninstall")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) {
|
||||||
|
t.Fatal("other version tarball should exist before uninstall")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstall the plugin
|
||||||
|
if err := testUninstallPlugin(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify plugin directory is removed
|
||||||
|
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||||
|
t.Error("plugin directory should be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only exact version files are removed
|
||||||
|
if _, err := os.Stat(tarballFile); !os.IsNotExist(err) {
|
||||||
|
t.Error("versioned tarball file should be removed")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(provFile); !os.IsNotExist(err) {
|
||||||
|
t.Error("versioned provenance file should be removed")
|
||||||
|
}
|
||||||
|
// Verify other version files are NOT removed
|
||||||
|
if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) {
|
||||||
|
t.Error("other version tarball should NOT be removed")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
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 registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"oras.land/oras-go/v2"
|
||||||
|
"oras.land/oras-go/v2/content"
|
||||||
|
"oras.land/oras-go/v2/content/memory"
|
||||||
|
"oras.land/oras-go/v2/registry/remote"
|
||||||
|
"oras.land/oras-go/v2/registry/remote/auth"
|
||||||
|
"oras.land/oras-go/v2/registry/remote/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenericClient provides low-level OCI operations without artifact-specific assumptions
|
||||||
|
type GenericClient struct {
|
||||||
|
debug bool
|
||||||
|
enableCache bool
|
||||||
|
credentialsFile string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
out io.Writer
|
||||||
|
authorizer *auth.Client
|
||||||
|
registryAuthorizer RemoteClient
|
||||||
|
credentialsStore credentials.Store
|
||||||
|
httpClient *http.Client
|
||||||
|
plainHTTP bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenericPullOptions configures a generic pull operation
|
||||||
|
type GenericPullOptions struct {
|
||||||
|
// MediaTypes to include in the pull (empty means all)
|
||||||
|
AllowedMediaTypes []string
|
||||||
|
// Skip descriptors with these media types
|
||||||
|
SkipMediaTypes []string
|
||||||
|
// Custom PreCopy function for filtering
|
||||||
|
PreCopy func(context.Context, ocispec.Descriptor) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenericPullResult contains the result of a generic pull operation
|
||||||
|
type GenericPullResult struct {
|
||||||
|
Manifest ocispec.Descriptor
|
||||||
|
Descriptors []ocispec.Descriptor
|
||||||
|
MemoryStore *memory.Store
|
||||||
|
Ref string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGenericClient creates a new generic OCI client from an existing Client
|
||||||
|
func NewGenericClient(client *Client) *GenericClient {
|
||||||
|
return &GenericClient{
|
||||||
|
debug: client.debug,
|
||||||
|
enableCache: client.enableCache,
|
||||||
|
credentialsFile: client.credentialsFile,
|
||||||
|
username: client.username,
|
||||||
|
password: client.password,
|
||||||
|
out: client.out,
|
||||||
|
authorizer: client.authorizer,
|
||||||
|
registryAuthorizer: client.registryAuthorizer,
|
||||||
|
credentialsStore: client.credentialsStore,
|
||||||
|
httpClient: client.httpClient,
|
||||||
|
plainHTTP: client.plainHTTP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullGeneric performs a generic OCI pull without artifact-specific assumptions
|
||||||
|
func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) {
|
||||||
|
parsedRef, err := newReference(ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
memoryStore := memory.New()
|
||||||
|
var descriptors []ocispec.Descriptor
|
||||||
|
|
||||||
|
// Set up repository with authentication and configuration
|
||||||
|
repository, err := remote.NewRepository(parsedRef.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
repository.PlainHTTP = c.plainHTTP
|
||||||
|
repository.Client = c.authorizer
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Prepare allowed media types for filtering
|
||||||
|
var allowedMediaTypes []string
|
||||||
|
if len(options.AllowedMediaTypes) > 0 {
|
||||||
|
allowedMediaTypes = make([]string, len(options.AllowedMediaTypes))
|
||||||
|
copy(allowedMediaTypes, options.AllowedMediaTypes)
|
||||||
|
sort.Strings(allowedMediaTypes)
|
||||||
|
}
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{
|
||||||
|
CopyGraphOptions: oras.CopyGraphOptions{
|
||||||
|
PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error {
|
||||||
|
// Apply custom PreCopy function if provided
|
||||||
|
if options.PreCopy != nil {
|
||||||
|
if err := options.PreCopy(ctx, desc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType := desc.MediaType
|
||||||
|
|
||||||
|
// Skip media types if specified
|
||||||
|
for _, skipType := range options.SkipMediaTypes {
|
||||||
|
if mediaType == skipType {
|
||||||
|
return oras.SkipNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by allowed media types if specified
|
||||||
|
if len(allowedMediaTypes) > 0 {
|
||||||
|
if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType {
|
||||||
|
return oras.SkipNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
descriptors = append(descriptors, desc)
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &GenericPullResult{
|
||||||
|
Manifest: manifest,
|
||||||
|
Descriptors: descriptors,
|
||||||
|
MemoryStore: memoryStore,
|
||||||
|
Ref: parsedRef.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDescriptorData retrieves the data for a specific descriptor
|
||||||
|
func (c *GenericClient) GetDescriptorData(store *memory.Store, desc ocispec.Descriptor) ([]byte, error) {
|
||||||
|
return content.FetchAll(context.Background(), store, desc)
|
||||||
|
}
|
@ -0,0 +1,176 @@
|
|||||||
|
/*
|
||||||
|
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 registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugin-specific constants
|
||||||
|
const (
|
||||||
|
// PluginArtifactType is the artifact type for Helm plugins
|
||||||
|
PluginArtifactType = "application/vnd.helm.plugin.v1+json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginPullOptions configures a plugin pull operation
|
||||||
|
type PluginPullOptions struct {
|
||||||
|
// PluginName specifies the expected plugin name for layer validation
|
||||||
|
PluginName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginPullResult contains the result of a plugin pull operation
|
||||||
|
type PluginPullResult struct {
|
||||||
|
Manifest ocispec.Descriptor
|
||||||
|
PluginData []byte
|
||||||
|
ProvenanceData []byte // Optional provenance data
|
||||||
|
Ref string
|
||||||
|
PluginName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullPlugin downloads a plugin from an OCI registry using artifact type
|
||||||
|
func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPullOption) (*PluginPullResult, error) {
|
||||||
|
operation := &pluginPullOperation{
|
||||||
|
pluginName: pluginName,
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
option(operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use generic client for the pull operation with artifact type filtering
|
||||||
|
genericClient := c.Generic()
|
||||||
|
genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
|
||||||
|
// Allow manifests and all layer types - we'll validate artifact type after download
|
||||||
|
AllowedMediaTypes: []string{
|
||||||
|
ocispec.MediaTypeImageManifest,
|
||||||
|
"application/vnd.oci.image.layer.v1.tar",
|
||||||
|
"application/vnd.oci.image.layer.v1.tar+gzip",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the result with plugin-specific logic
|
||||||
|
return c.processPluginPull(genericResult, operation.pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processPluginPull handles plugin-specific processing of a generic pull result using artifact type
|
||||||
|
func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName string) (*PluginPullResult, error) {
|
||||||
|
// First validate that this is actually a plugin artifact
|
||||||
|
manifestData, err := c.Generic().GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the manifest to check artifact type
|
||||||
|
var manifest ocispec.Manifest
|
||||||
|
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to parse manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate artifact type (for OCI v1.1+ manifests)
|
||||||
|
if manifest.ArtifactType != "" && manifest.ArtifactType != PluginArtifactType {
|
||||||
|
return nil, fmt.Errorf("expected artifact type %s, got %s", PluginArtifactType, manifest.ArtifactType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backwards compatibility, also check config media type if no artifact type
|
||||||
|
if manifest.ArtifactType == "" && manifest.Config.MediaType != PluginArtifactType {
|
||||||
|
return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the required plugin tarball and optional provenance
|
||||||
|
expectedTarball := pluginName + ".tgz"
|
||||||
|
expectedProvenance := pluginName + ".tgz.prov"
|
||||||
|
|
||||||
|
var pluginDescriptor *ocispec.Descriptor
|
||||||
|
var provenanceDescriptor *ocispec.Descriptor
|
||||||
|
|
||||||
|
// Look for layers with the expected titles/annotations
|
||||||
|
for _, layer := range manifest.Layers {
|
||||||
|
d := layer
|
||||||
|
// Check for title annotation (preferred method)
|
||||||
|
if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists {
|
||||||
|
switch title {
|
||||||
|
case expectedTarball:
|
||||||
|
pluginDescriptor = &d
|
||||||
|
case expectedProvenance:
|
||||||
|
provenanceDescriptor = &d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin tarball is required
|
||||||
|
if pluginDescriptor == nil {
|
||||||
|
return nil, fmt.Errorf("required layer %s not found in manifest", expectedTarball)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build plugin-specific result
|
||||||
|
result := &PluginPullResult{
|
||||||
|
Manifest: genericResult.Manifest,
|
||||||
|
Ref: genericResult.Ref,
|
||||||
|
PluginName: pluginName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch plugin data using generic client
|
||||||
|
genericClient := c.Generic()
|
||||||
|
result.PluginData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *pluginDescriptor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve plugin data with digest %s: %w", pluginDescriptor.Digest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch provenance data if available
|
||||||
|
if provenanceDescriptor != nil {
|
||||||
|
result.ProvenanceData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve provenance data with digest %s: %w", provenanceDescriptor.Digest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref)
|
||||||
|
fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
|
||||||
|
if result.ProvenanceData != nil {
|
||||||
|
fmt.Fprintf(c.out, "Provenance: %s\n", expectedProvenance)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(result.Ref, "_") {
|
||||||
|
fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
|
||||||
|
fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin pull operation types and options
|
||||||
|
type (
|
||||||
|
pluginPullOperation struct {
|
||||||
|
pluginName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginPullOption allows customizing plugin pull operations
|
||||||
|
PluginPullOption func(*pluginPullOperation)
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginPullOptWithPluginName sets the plugin name for validation
|
||||||
|
func PluginPullOptWithPluginName(name string) PluginPullOption {
|
||||||
|
return func(operation *pluginPullOperation) {
|
||||||
|
operation.pluginName = name
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue