Plugin OCI installer

Signed-off-by: Scott Rigby <scott@r6by.com>
pull/10364/merge
Scott Rigby 2 weeks ago
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'")
}
}
})
}
}

@ -19,17 +19,28 @@ import (
"fmt"
"io"
"log/slog"
"strings"
"github.com/spf13/cobra"
"helm.sh/helm/v4/internal/plugin"
"helm.sh/helm/v4/internal/plugin/installer"
"helm.sh/helm/v4/pkg/cmd/require"
"helm.sh/helm/v4/pkg/getter"
"helm.sh/helm/v4/pkg/registry"
)
type pluginInstallOptions struct {
source string
version string
// OCI-specific options
certFile string
keyFile string
caFile string
insecureSkipTLSverify bool
plainHTTP bool
password string
username string
}
const pluginInstallDesc = `
@ -60,6 +71,15 @@ func newPluginInstallCmd(out io.Writer) *cobra.Command {
},
}
cmd.Flags().StringVar(&o.version, "version", "", "specify a version constraint. If this is not specified, the latest version is installed")
// Add OCI-specific flags
cmd.Flags().StringVar(&o.certFile, "cert-file", "", "identify registry client using this SSL certificate file")
cmd.Flags().StringVar(&o.keyFile, "key-file", "", "identify registry client using this SSL key file")
cmd.Flags().StringVar(&o.caFile, "ca-file", "", "verify certificates of HTTPS-enabled servers using this CA bundle")
cmd.Flags().BoolVar(&o.insecureSkipTLSverify, "insecure-skip-tls-verify", false, "skip tls certificate checks for the plugin download")
cmd.Flags().BoolVar(&o.plainHTTP, "plain-http", false, "use insecure HTTP connections for the plugin download")
cmd.Flags().StringVar(&o.username, "username", "", "registry username")
cmd.Flags().StringVar(&o.password, "password", "", "registry password")
return cmd
}
@ -68,10 +88,28 @@ func (o *pluginInstallOptions) complete(args []string) error {
return nil
}
func (o *pluginInstallOptions) newInstallerForSource() (installer.Installer, error) {
// Check if source is an OCI registry reference
if strings.HasPrefix(o.source, fmt.Sprintf("%s://", registry.OCIScheme)) {
// Build getter options for OCI
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),
}
return installer.NewOCIInstaller(o.source, options...)
}
// For non-OCI sources, use the original logic
return installer.NewForSource(o.source, o.version)
}
func (o *pluginInstallOptions) run(out io.Writer) error {
installer.Debug = settings.Debug
i, err := installer.NewForSource(o.source, o.version)
i, err := o.newInstallerForSource()
if err != nil {
return err
}

@ -21,6 +21,7 @@ import (
"io"
"log/slog"
"os"
"path/filepath"
"github.com/spf13/cobra"
@ -87,6 +88,36 @@ func uninstallPlugin(p plugin.Plugin) error {
if err := os.RemoveAll(p.Dir()); err != nil {
return err
}
// Clean up versioned tarball and provenance files from HELM_PLUGINS directory
// These files are saved with pattern: PLUGIN_NAME-VERSION.tgz and PLUGIN_NAME-VERSION.tgz.prov
pluginName := p.Metadata().Name
pluginVersion := p.Metadata().Version
pluginsDir := settings.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(pluginsDir, versionedBasename)
if _, err := os.Stat(tarballPath); err == nil {
slog.Debug("removing versioned tarball", "path", tarballPath)
if err := os.Remove(tarballPath); err != nil {
slog.Debug("failed to remove tarball file", "path", tarballPath, "error", err)
}
}
// Remove provenance file
provPath := filepath.Join(pluginsDir, versionedBasename+".prov")
if _, err := os.Stat(provPath); err == nil {
slog.Debug("removing versioned provenance", "path", provPath)
if err := os.Remove(provPath); err != nil {
slog.Debug("failed to remove provenance file", "path", provPath, "error", err)
}
}
}
return runHook(p, plugin.Delete)
}

@ -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")
}
}

@ -48,6 +48,7 @@ type getterOptions struct {
registryClient *registry.Client
timeout time.Duration
transport *http.Transport
artifactType string
}
// Option allows specifying various settings configurable by the user for overriding the defaults
@ -144,6 +145,13 @@ func WithTransport(transport *http.Transport) Option {
}
}
// WithArtifactType sets the type of OCI artifact ("chart" or "plugin")
func WithArtifactType(artifactType string) Option {
return func(opts *getterOptions) {
opts.artifactType = artifactType
}
}
// Getter is an interface to support GET to the specified URL.
type Getter interface {
// Get file content by url string

@ -63,6 +63,10 @@ func (g *OCIGetter) get(href string) (*bytes.Buffer, error) {
if version := g.opts.version; version != "" && !strings.Contains(path.Base(ref), ":") {
ref = fmt.Sprintf("%s:%s", ref, version)
}
// Check if this is a plugin request
if g.opts.artifactType == "plugin" {
return g.getPlugin(client, ref)
}
// Default to chart behavior for backward compatibility
var pullOpts []registry.PullOption
@ -168,3 +172,28 @@ func (g *OCIGetter) newRegistryClient() (*registry.Client, error) {
return client, nil
}
// getPlugin handles plugin-specific OCI pulls
func (g *OCIGetter) getPlugin(client *registry.Client, ref string) (*bytes.Buffer, error) {
// Extract plugin name from the 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", ref)
}
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]
}
result, err := client.PullPlugin(ref, pluginName)
if err != nil {
return nil, err
}
return bytes.NewBuffer(result.PluginData), nil
}

@ -110,8 +110,7 @@ func (t *TestPlugin) Metadata() plugin.Metadata {
Type: "cli/v1",
APIVersion: "v1",
Runtime: "subprocess",
// TODO: either change Config to plugin.ConfigCLI, or change APIVersion to getter/v1?
Config: &plugin.ConfigGetter{},
Config: &plugin.ConfigCLI{},
RuntimeConfig: &plugin.RuntimeConfigSubprocess{
PlatformCommands: []plugin.PlatformCommand{
{

@ -29,13 +29,11 @@ import (
"os"
"sort"
"strings"
"sync"
"github.com/Masterminds/semver/v3"
"github.com/opencontainers/image-spec/specs-go"
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"
"oras.land/oras-go/v2/registry/remote"
@ -147,6 +145,11 @@ func NewClient(options ...ClientOption) (*Client, error) {
return client, nil
}
// Generic returns a GenericClient for low-level OCI operations
func (c *Client) Generic() *GenericClient {
return NewGenericClient(c)
}
// ClientOptDebug returns a function that sets the debug setting on client options set
func ClientOptDebug(debug bool) ClientOption {
return func(client *Client) {
@ -418,84 +421,31 @@ type (
}
)
// Pull downloads a chart from a registry
func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
parsedRef, err := newReference(ref)
if err != nil {
return nil, err
}
// processChartPull handles chart-specific processing of a generic pull result
func (c *Client) processChartPull(genericResult *GenericPullResult, operation *pullOperation) (*PullResult, error) {
var err error
operation := &pullOperation{
withChart: true, // By default, always download the chart layer
}
for _, option := range options {
option(operation)
}
if !operation.withChart && !operation.withProv {
return nil, errors.New(
"must specify at least one layer to pull (chart/prov)")
}
memoryStore := memory.New()
allowedMediaTypes := []string{
ocispec.MediaTypeImageManifest,
ConfigMediaType,
}
// Chart-specific validation
minNumDescriptors := 1 // 1 for the config
if operation.withChart {
minNumDescriptors++
allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType)
}
if operation.withProv {
if !operation.ignoreMissingProv {
minNumDescriptors++
}
allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType)
}
var descriptors, layers []ocispec.Descriptor
repository, err := remote.NewRepository(parsedRef.String())
if err != nil {
return nil, err
}
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
ctx := context.Background()
sort.Strings(allowedMediaTypes)
var mu sync.Mutex
manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{
CopyGraphOptions: oras.CopyGraphOptions{
PreCopy: func(_ context.Context, desc ocispec.Descriptor) error {
mediaType := desc.MediaType
if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType {
return oras.SkipNode
}
mu.Lock()
layers = append(layers, desc)
mu.Unlock()
return nil
},
},
})
if err != nil {
return nil, err
if operation.withProv && !operation.ignoreMissingProv {
minNumDescriptors++
}
descriptors = append(descriptors, layers...)
numDescriptors := len(descriptors)
numDescriptors := len(genericResult.Descriptors)
if numDescriptors < minNumDescriptors {
return nil, fmt.Errorf("manifest does not contain minimum number of descriptors (%d), descriptors found: %d",
minNumDescriptors, numDescriptors)
}
// Find chart-specific descriptors
var configDescriptor *ocispec.Descriptor
var chartDescriptor *ocispec.Descriptor
var provDescriptor *ocispec.Descriptor
for _, descriptor := range descriptors {
for _, descriptor := range genericResult.Descriptors {
d := descriptor
switch d.MediaType {
case ConfigMediaType:
@ -509,6 +459,8 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
fmt.Fprintf(c.out, "Warning: chart media type %s is deprecated\n", LegacyChartLayerMediaType)
}
}
// Chart-specific validation
if configDescriptor == nil {
return nil, fmt.Errorf("could not load config with mediatype %s", ConfigMediaType)
}
@ -516,6 +468,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
return nil, fmt.Errorf("manifest does not contain a layer with mediatype %s",
ChartLayerMediaType)
}
var provMissing bool
if operation.withProv && provDescriptor == nil {
if operation.ignoreMissingProv {
@ -525,10 +478,12 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
ProvLayerMediaType)
}
}
// Build chart-specific result
result := &PullResult{
Manifest: &DescriptorPullSummary{
Digest: manifest.Digest.String(),
Size: manifest.Size,
Digest: genericResult.Manifest.Digest.String(),
Size: genericResult.Manifest.Size,
},
Config: &DescriptorPullSummary{
Digest: configDescriptor.Digest.String(),
@ -536,15 +491,18 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
},
Chart: &DescriptorPullSummaryWithMeta{},
Prov: &DescriptorPullSummary{},
Ref: parsedRef.String(),
Ref: genericResult.Ref,
}
result.Manifest.Data, err = content.FetchAll(ctx, memoryStore, manifest)
// Fetch data using generic client
genericClient := c.Generic()
result.Manifest.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", manifest.Digest, err)
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", genericResult.Manifest.Digest, err)
}
result.Config.Data, err = content.FetchAll(ctx, memoryStore, *configDescriptor)
result.Config.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *configDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", configDescriptor.Digest, err)
}
@ -554,7 +512,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
}
if operation.withChart {
result.Chart.Data, err = content.FetchAll(ctx, memoryStore, *chartDescriptor)
result.Chart.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *chartDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", chartDescriptor.Digest, err)
}
@ -563,7 +521,7 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
}
if operation.withProv && !provMissing {
result.Prov.Data, err = content.FetchAll(ctx, memoryStore, *provDescriptor)
result.Prov.Data, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provDescriptor)
if err != nil {
return nil, fmt.Errorf("unable to retrieve blob with digest %s: %w", provDescriptor.Digest, err)
}
@ -582,6 +540,44 @@ func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
return result, nil
}
// Pull downloads a chart from a registry
func (c *Client) Pull(ref string, options ...PullOption) (*PullResult, error) {
operation := &pullOperation{
withChart: true, // By default, always download the chart layer
}
for _, option := range options {
option(operation)
}
if !operation.withChart && !operation.withProv {
return nil, errors.New(
"must specify at least one layer to pull (chart/prov)")
}
// Build allowed media types for chart pull
allowedMediaTypes := []string{
ocispec.MediaTypeImageManifest,
ConfigMediaType,
}
if operation.withChart {
allowedMediaTypes = append(allowedMediaTypes, ChartLayerMediaType, LegacyChartLayerMediaType)
}
if operation.withProv {
allowedMediaTypes = append(allowedMediaTypes, ProvLayerMediaType)
}
// Use generic client for the pull operation
genericClient := c.Generic()
genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
AllowedMediaTypes: allowedMediaTypes,
})
if err != nil {
return nil, err
}
// Process the result with chart-specific logic
return c.processChartPull(genericResult, operation)
}
// PullOptWithChart returns a function that sets the withChart setting on pull
func PullOptWithChart(withChart bool) PullOption {
return func(operation *pullOperation) {

@ -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…
Cancel
Save