From 7d22bb25faea807a4d2162e1a5c7f61ea3877f8b Mon Sep 17 00:00:00 2001 From: Scott Rigby Date: Thu, 21 Aug 2025 03:18:32 -0400 Subject: [PATCH] Plugin OCI installer Signed-off-by: Scott Rigby --- internal/plugin/installer/oci_installer.go | 229 +++++ .../plugin/installer/oci_installer_test.go | 814 ++++++++++++++++++ pkg/cmd/plugin_install.go | 40 +- pkg/cmd/plugin_uninstall.go | 31 + pkg/cmd/plugin_uninstall_test.go | 146 ++++ pkg/getter/getter.go | 8 + pkg/getter/ocigetter.go | 29 + pkg/getter/plugingetter_test.go | 3 +- pkg/registry/client.go | 144 ++-- pkg/registry/generic.go | 162 ++++ pkg/registry/plugin.go | 176 ++++ 11 files changed, 1705 insertions(+), 77 deletions(-) create mode 100644 internal/plugin/installer/oci_installer.go create mode 100644 internal/plugin/installer/oci_installer_test.go create mode 100644 pkg/cmd/plugin_uninstall_test.go create mode 100644 pkg/registry/generic.go create mode 100644 pkg/registry/plugin.go diff --git a/internal/plugin/installer/oci_installer.go b/internal/plugin/installer/oci_installer.go new file mode 100644 index 000000000..acb28ccf9 --- /dev/null +++ b/internal/plugin/installer/oci_installer.go @@ -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 +} diff --git a/internal/plugin/installer/oci_installer_test.go b/internal/plugin/installer/oci_installer_test.go new file mode 100644 index 000000000..1ed10ff8e --- /dev/null +++ b/internal/plugin/installer/oci_installer_test.go @@ -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'") + } + } + }) + } +} diff --git a/pkg/cmd/plugin_install.go b/pkg/cmd/plugin_install.go index 7dae39505..960404a76 100644 --- a/pkg/cmd/plugin_install.go +++ b/pkg/cmd/plugin_install.go @@ -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 } diff --git a/pkg/cmd/plugin_uninstall.go b/pkg/cmd/plugin_uninstall.go index a925c66dd..85eb46219 100644 --- a/pkg/cmd/plugin_uninstall.go +++ b/pkg/cmd/plugin_uninstall.go @@ -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) } diff --git a/pkg/cmd/plugin_uninstall_test.go b/pkg/cmd/plugin_uninstall_test.go new file mode 100644 index 000000000..93d4dc8a8 --- /dev/null +++ b/pkg/cmd/plugin_uninstall_test.go @@ -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") + } +} diff --git a/pkg/getter/getter.go b/pkg/getter/getter.go index 8585ac449..a2d0f0ee2 100644 --- a/pkg/getter/getter.go +++ b/pkg/getter/getter.go @@ -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 diff --git a/pkg/getter/ocigetter.go b/pkg/getter/ocigetter.go index 45e7263fe..121e000c8 100644 --- a/pkg/getter/ocigetter.go +++ b/pkg/getter/ocigetter.go @@ -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 +} diff --git a/pkg/getter/plugingetter_test.go b/pkg/getter/plugingetter_test.go index 85c847752..1c0f5593f 100644 --- a/pkg/getter/plugingetter_test.go +++ b/pkg/getter/plugingetter_test.go @@ -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{ { diff --git a/pkg/registry/client.go b/pkg/registry/client.go index 169900750..7ba26ac5c 100644 --- a/pkg/registry/client.go +++ b/pkg/registry/client.go @@ -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) { diff --git a/pkg/registry/generic.go b/pkg/registry/generic.go new file mode 100644 index 000000000..b82132338 --- /dev/null +++ b/pkg/registry/generic.go @@ -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) +} diff --git a/pkg/registry/plugin.go b/pkg/registry/plugin.go new file mode 100644 index 000000000..a92aaf452 --- /dev/null +++ b/pkg/registry/plugin.go @@ -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 + } +}