mirror of https://github.com/helm/helm
parent
e3124e488f
commit
7d22bb25fa
@ -0,0 +1,229 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package installer
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin/cache"
|
||||
"helm.sh/helm/v4/internal/third_party/dep/fs"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
"helm.sh/helm/v4/pkg/registry"
|
||||
)
|
||||
|
||||
// OCIInstaller installs plugins from OCI registries
|
||||
type OCIInstaller struct {
|
||||
CacheDir string
|
||||
PluginName string
|
||||
base
|
||||
settings *cli.EnvSettings
|
||||
getter getter.Getter
|
||||
}
|
||||
|
||||
// NewOCIInstaller creates a new OCIInstaller with optional getter options
|
||||
func NewOCIInstaller(source string, options ...getter.Option) (*OCIInstaller, error) {
|
||||
ref := strings.TrimPrefix(source, fmt.Sprintf("%s://", registry.OCIScheme))
|
||||
|
||||
// Extract plugin name from OCI reference
|
||||
// e.g., "ghcr.io/user/plugin-name:v1.0.0" -> "plugin-name"
|
||||
parts := strings.Split(ref, "/")
|
||||
if len(parts) < 2 {
|
||||
return nil, fmt.Errorf("invalid OCI reference: %s", source)
|
||||
}
|
||||
lastPart := parts[len(parts)-1]
|
||||
pluginName := lastPart
|
||||
if idx := strings.LastIndex(lastPart, ":"); idx > 0 {
|
||||
pluginName = lastPart[:idx]
|
||||
}
|
||||
if idx := strings.LastIndex(lastPart, "@"); idx > 0 {
|
||||
pluginName = lastPart[:idx]
|
||||
}
|
||||
|
||||
key, err := cache.Key(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := cli.New()
|
||||
|
||||
// Always add plugin artifact type and any provided options
|
||||
pluginOptions := append([]getter.Option{getter.WithArtifactType("plugin")}, options...)
|
||||
getterProvider, err := getter.NewOCIGetter(pluginOptions...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i := &OCIInstaller{
|
||||
CacheDir: helmpath.CachePath("plugins", key),
|
||||
PluginName: pluginName,
|
||||
base: newBase(source),
|
||||
settings: settings,
|
||||
getter: getterProvider,
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// Install downloads and installs a plugin from OCI registry
|
||||
// Implements Installer.
|
||||
func (i *OCIInstaller) Install() error {
|
||||
slog.Debug("pulling OCI plugin", "source", i.Source)
|
||||
|
||||
// Use getter to download the plugin
|
||||
pluginData, err := i.getter.Get(i.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to pull plugin from %s: %w", i.Source, err)
|
||||
}
|
||||
|
||||
// Create cache directory
|
||||
if err := os.MkdirAll(i.CacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Check if this is a gzip compressed file
|
||||
pluginBytes := pluginData.Bytes()
|
||||
if len(pluginBytes) < 2 || pluginBytes[0] != 0x1f || pluginBytes[1] != 0x8b {
|
||||
return fmt.Errorf("plugin data is not a gzip compressed archive")
|
||||
}
|
||||
|
||||
// Extract as gzipped tar
|
||||
if err := extractTarGz(bytes.NewReader(pluginBytes), i.CacheDir); err != nil {
|
||||
return fmt.Errorf("failed to extract plugin: %w", err)
|
||||
}
|
||||
|
||||
// Verify plugin.yaml exists - check root and subdirectories
|
||||
pluginDir := i.CacheDir
|
||||
if !isPlugin(pluginDir) {
|
||||
// Check if plugin.yaml is in a subdirectory
|
||||
entries, err := os.ReadDir(i.CacheDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
foundPluginDir := ""
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
subDir := filepath.Join(i.CacheDir, entry.Name())
|
||||
if isPlugin(subDir) {
|
||||
foundPluginDir = subDir
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if foundPluginDir == "" {
|
||||
return ErrMissingMetadata
|
||||
}
|
||||
|
||||
// Use the subdirectory as the plugin directory
|
||||
pluginDir = foundPluginDir
|
||||
}
|
||||
|
||||
// Copy from cache to final destination
|
||||
src, err := filepath.Abs(pluginDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("copying", "source", src, "path", i.Path())
|
||||
return fs.CopyDir(src, i.Path())
|
||||
}
|
||||
|
||||
// Update updates a plugin by reinstalling it
|
||||
func (i *OCIInstaller) Update() error {
|
||||
// For OCI, update means removing the old version and installing the new one
|
||||
if err := os.RemoveAll(i.Path()); err != nil {
|
||||
return err
|
||||
}
|
||||
return i.Install()
|
||||
}
|
||||
|
||||
// Path is where the plugin will be installed
|
||||
func (i OCIInstaller) Path() string {
|
||||
if i.Source == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(i.settings.PluginsDirectory, i.PluginName)
|
||||
}
|
||||
|
||||
// extractTarGz extracts a gzipped tar archive to a directory
|
||||
func extractTarGz(r io.Reader, targetDir string) error {
|
||||
gzr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
return extractTar(gzr, targetDir)
|
||||
}
|
||||
|
||||
// extractTar extracts a tar archive to a directory
|
||||
func extractTar(r io.Reader, targetDir string) error {
|
||||
tarReader := tar.NewReader(r)
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := cleanJoin(targetDir, header.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
outFile.Close()
|
||||
return err
|
||||
}
|
||||
outFile.Close()
|
||||
case tar.TypeXGlobalHeader, tar.TypeXHeader:
|
||||
// Skip these
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("unknown type: %b in %s", header.Typeflag, header.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,814 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package installer // import "helm.sh/helm/v4/internal/plugin/installer"
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
"helm.sh/helm/v4/pkg/getter"
|
||||
"helm.sh/helm/v4/pkg/helmpath"
|
||||
)
|
||||
|
||||
var _ Installer = new(OCIInstaller)
|
||||
|
||||
// createTestPluginTarGz creates a test plugin tar.gz with plugin.yaml
|
||||
func createTestPluginTarGz(t *testing.T, pluginName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
// Add plugin.yaml
|
||||
pluginYAML := fmt.Sprintf(`name: %s
|
||||
version: "1.0.0"
|
||||
description: "Test plugin for OCI installer"
|
||||
command: "$HELM_PLUGIN_DIR/bin/%s"
|
||||
`, pluginName, pluginName)
|
||||
header := &tar.Header{
|
||||
Name: "plugin.yaml",
|
||||
Mode: 0644,
|
||||
Size: int64(len(pluginYAML)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write([]byte(pluginYAML)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add bin directory
|
||||
dirHeader := &tar.Header{
|
||||
Name: "bin/",
|
||||
Mode: 0755,
|
||||
Typeflag: tar.TypeDir,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(dirHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add executable
|
||||
execContent := fmt.Sprintf("#!/bin/sh\necho '%s test plugin'", pluginName)
|
||||
execHeader := &tar.Header{
|
||||
Name: fmt.Sprintf("bin/%s", pluginName),
|
||||
Mode: 0755,
|
||||
Size: int64(len(execContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tarWriter.WriteHeader(execHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tarWriter.Write([]byte(execContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
gzWriter.Close()
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// mockOCIRegistryWithArtifactType creates a mock OCI registry server using the new artifact type approach
|
||||
func mockOCIRegistryWithArtifactType(t *testing.T, pluginName string) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
|
||||
pluginData := createTestPluginTarGz(t, pluginName)
|
||||
layerDigest := fmt.Sprintf("sha256:%x", sha256Sum(pluginData))
|
||||
|
||||
// Create empty config data (as per OCI v1.1+ spec)
|
||||
configData := []byte("{}")
|
||||
configDigest := fmt.Sprintf("sha256:%x", sha256Sum(configData))
|
||||
|
||||
// Create manifest with artifact type
|
||||
manifest := ocispec.Manifest{
|
||||
MediaType: ocispec.MediaTypeImageManifest,
|
||||
ArtifactType: "application/vnd.helm.plugin.v1+json", // Using artifact type
|
||||
Config: ocispec.Descriptor{
|
||||
MediaType: "application/vnd.oci.empty.v1+json", // Empty config
|
||||
Digest: digest.Digest(configDigest),
|
||||
Size: int64(len(configData)),
|
||||
},
|
||||
Layers: []ocispec.Descriptor{
|
||||
{
|
||||
MediaType: "application/vnd.oci.image.layer.v1.tar",
|
||||
Digest: digest.Digest(layerDigest),
|
||||
Size: int64(len(pluginData)),
|
||||
Annotations: map[string]string{
|
||||
ocispec.AnnotationTitle: pluginName + ".tgz", // Layer named properly
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manifestData, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestDigest := fmt.Sprintf("sha256:%x", sha256Sum(manifestData))
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v2/") && !strings.Contains(r.URL.Path, "/manifests/") && !strings.Contains(r.URL.Path, "/blobs/"):
|
||||
// API version check
|
||||
w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{}"))
|
||||
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/manifests/") && strings.Contains(r.URL.Path, pluginName):
|
||||
// Return manifest
|
||||
w.Header().Set("Content-Type", ocispec.MediaTypeImageManifest)
|
||||
w.Header().Set("Docker-Content-Digest", manifestDigest)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(manifestData)
|
||||
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+layerDigest):
|
||||
// Return layer data
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.layer.v1.tar")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(pluginData)
|
||||
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/"+configDigest):
|
||||
// Return config data
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.empty.v1+json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(configData)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
|
||||
// Parse server URL to get host:port format for OCI reference
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryHost := serverURL.Host
|
||||
|
||||
return server, registryHost
|
||||
}
|
||||
|
||||
// sha256Sum calculates SHA256 sum of data
|
||||
func sha256Sum(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func TestNewOCIInstaller(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
expectName string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid OCI reference with tag",
|
||||
source: "oci://ghcr.io/user/plugin-name:v1.0.0",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference with digest",
|
||||
source: "oci://ghcr.io/user/plugin-name@sha256:1234567890abcdef",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference without tag",
|
||||
source: "oci://ghcr.io/user/plugin-name",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference with multiple path segments",
|
||||
source: "oci://registry.example.com/org/team/plugin-name:latest",
|
||||
expectName: "plugin-name",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid OCI reference - no path",
|
||||
source: "oci://registry.example.com",
|
||||
expectName: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "valid OCI reference - single path segment",
|
||||
source: "oci://registry.example.com/plugin",
|
||||
expectName: "plugin",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
installer, err := NewOCIInstaller(tt.source)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check all fields thoroughly
|
||||
if installer.PluginName != tt.expectName {
|
||||
t.Errorf("expected plugin name %s, got %s", tt.expectName, installer.PluginName)
|
||||
}
|
||||
|
||||
if installer.Source != tt.source {
|
||||
t.Errorf("expected source %s, got %s", tt.source, installer.Source)
|
||||
}
|
||||
|
||||
if installer.CacheDir == "" {
|
||||
t.Error("expected non-empty cache directory")
|
||||
}
|
||||
|
||||
if !strings.Contains(installer.CacheDir, "plugins") {
|
||||
t.Errorf("expected cache directory to contain 'plugins', got %s", installer.CacheDir)
|
||||
}
|
||||
|
||||
if installer.settings == nil {
|
||||
t.Error("expected settings to be initialized")
|
||||
}
|
||||
|
||||
// Check that Path() method works
|
||||
expectedPath := helmpath.DataPath("plugins", tt.expectName)
|
||||
if installer.Path() != expectedPath {
|
||||
t.Errorf("expected path %s, got %s", expectedPath, installer.Path())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Path(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
pluginName string
|
||||
expectPath string
|
||||
}{
|
||||
{
|
||||
name: "valid plugin name",
|
||||
source: "oci://ghcr.io/user/plugin-name:v1.0.0",
|
||||
pluginName: "plugin-name",
|
||||
expectPath: helmpath.DataPath("plugins", "plugin-name"),
|
||||
},
|
||||
{
|
||||
name: "empty source",
|
||||
source: "",
|
||||
pluginName: "",
|
||||
expectPath: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
installer := &OCIInstaller{
|
||||
PluginName: tt.pluginName,
|
||||
base: newBase(tt.source),
|
||||
settings: cli.New(),
|
||||
}
|
||||
|
||||
path := installer.Path()
|
||||
if path != tt.expectPath {
|
||||
t.Errorf("expected path %s, got %s", tt.expectPath, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install(t *testing.T) {
|
||||
// Set up isolated test environment FIRST
|
||||
testPluginsDir := t.TempDir()
|
||||
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||
|
||||
pluginName := "test-plugin-basic"
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
|
||||
defer server.Close()
|
||||
|
||||
// Test OCI reference
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
|
||||
|
||||
// Test with plain HTTP (since test server uses HTTP)
|
||||
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// The OCI installer uses helmpath.DataPath, which now points to our test directory
|
||||
actualPath := installer.Path()
|
||||
t.Logf("Installer will use path: %s", actualPath)
|
||||
|
||||
// Verify the path is actually in our test directory
|
||||
if !strings.HasPrefix(actualPath, testPluginsDir) {
|
||||
t.Fatalf("Expected path %s to be under test directory %s", actualPath, testPluginsDir)
|
||||
}
|
||||
|
||||
// Install the plugin
|
||||
if err := Install(installer); err != nil {
|
||||
t.Fatalf("Expected installation to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was installed to the correct location
|
||||
if !isPlugin(actualPath) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
|
||||
}
|
||||
|
||||
// Debug: list what was actually created
|
||||
if entries, err := os.ReadDir(actualPath); err != nil {
|
||||
t.Fatalf("Could not read plugin directory %s: %v", actualPath, err)
|
||||
} else {
|
||||
t.Logf("Plugin directory %s contains:", actualPath)
|
||||
for _, entry := range entries {
|
||||
t.Logf(" - %s", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the plugin.yaml file exists and is valid
|
||||
pluginFile := filepath.Join(actualPath, "plugin.yaml")
|
||||
if _, err := os.Stat(pluginFile); err != nil {
|
||||
t.Errorf("Expected plugin.yaml to exist, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_WithGetterOptions(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
pluginName string
|
||||
options []getter.Option
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "plain HTTP",
|
||||
pluginName: "example-cli-plain-http",
|
||||
options: []getter.Option{getter.WithPlainHTTP(true)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "insecure skip TLS verify",
|
||||
pluginName: "example-cli-insecure",
|
||||
options: []getter.Option{getter.WithPlainHTTP(true), getter.WithInsecureSkipVerifyTLS(true)},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "with timeout",
|
||||
pluginName: "example-cli-timeout",
|
||||
options: []getter.Option{getter.WithPlainHTTP(true), getter.WithTimeout(30 * time.Second)},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Set up isolated test environment for each subtest
|
||||
testPluginsDir := t.TempDir()
|
||||
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, tc.pluginName)
|
||||
defer server.Close()
|
||||
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, tc.pluginName)
|
||||
|
||||
installer, err := NewOCIInstaller(source, tc.options...)
|
||||
if err != nil {
|
||||
if !tc.wantErr {
|
||||
t.Fatalf("Expected no error creating installer, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// The installer now uses our isolated test directory
|
||||
actualPath := installer.Path()
|
||||
|
||||
// Install the plugin
|
||||
err = Install(installer)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("Expected installation to fail, but it succeeded")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected installation to succeed, got error: %v", err)
|
||||
} else {
|
||||
// Verify plugin was installed to the actual path
|
||||
if !isPlugin(actualPath) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", actualPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_AlreadyExists(t *testing.T) {
|
||||
// Set up isolated test environment
|
||||
testPluginsDir := t.TempDir()
|
||||
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||
|
||||
pluginName := "test-plugin-exists"
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
|
||||
defer server.Close()
|
||||
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
|
||||
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// First install should succeed
|
||||
if err := Install(installer); err != nil {
|
||||
t.Fatalf("Expected first installation to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was installed
|
||||
if !isPlugin(installer.Path()) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
|
||||
}
|
||||
|
||||
// Second install should fail with "plugin already exists"
|
||||
err = Install(installer)
|
||||
if err == nil {
|
||||
t.Error("Expected error when installing plugin that already exists")
|
||||
} else if !strings.Contains(err.Error(), "plugin already exists") {
|
||||
t.Errorf("Expected 'plugin already exists' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Update(t *testing.T) {
|
||||
// Set up isolated test environment
|
||||
testPluginsDir := t.TempDir()
|
||||
t.Setenv("HELM_PLUGINS", testPluginsDir)
|
||||
|
||||
pluginName := "test-plugin-update"
|
||||
server, registryHost := mockOCIRegistryWithArtifactType(t, pluginName)
|
||||
defer server.Close()
|
||||
|
||||
source := fmt.Sprintf("oci://%s/%s:latest", registryHost, pluginName)
|
||||
installer, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
// Test update when plugin does not exist - should fail
|
||||
err = Update(installer)
|
||||
if err == nil {
|
||||
t.Error("Expected error when updating plugin that does not exist")
|
||||
} else if !strings.Contains(err.Error(), "plugin does not exist") {
|
||||
t.Errorf("Expected 'plugin does not exist' error, got: %v", err)
|
||||
}
|
||||
|
||||
// Install plugin first
|
||||
if err := Install(installer); err != nil {
|
||||
t.Fatalf("Expected installation to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was installed
|
||||
if !isPlugin(installer.Path()) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml", installer.Path())
|
||||
}
|
||||
|
||||
// Test update when plugin exists - should succeed
|
||||
// For OCI, Update() removes old version and reinstalls
|
||||
if err := Update(installer); err != nil {
|
||||
t.Errorf("Expected update to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin is still installed after update
|
||||
if !isPlugin(installer.Path()) {
|
||||
t.Errorf("Expected plugin directory %s to contain plugin.yaml after update", installer.Path())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_ComponentExtraction(t *testing.T) {
|
||||
// Test that we can extract a plugin archive properly
|
||||
// This tests the extraction logic that Install() uses
|
||||
tempDir := t.TempDir()
|
||||
pluginName := "test-plugin-extract"
|
||||
|
||||
pluginData := createTestPluginTarGz(t, pluginName)
|
||||
|
||||
// Test extraction
|
||||
err := extractTarGz(bytes.NewReader(pluginData), tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to extract plugin: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin.yaml exists
|
||||
pluginYAMLPath := filepath.Join(tempDir, "plugin.yaml")
|
||||
if _, err := os.Stat(pluginYAMLPath); os.IsNotExist(err) {
|
||||
t.Errorf("plugin.yaml not found after extraction")
|
||||
}
|
||||
|
||||
// Verify bin directory exists
|
||||
binPath := filepath.Join(tempDir, "bin")
|
||||
if _, err := os.Stat(binPath); os.IsNotExist(err) {
|
||||
t.Errorf("bin directory not found after extraction")
|
||||
}
|
||||
|
||||
// Verify executable exists and has correct permissions
|
||||
execPath := filepath.Join(tempDir, "bin", pluginName)
|
||||
if info, err := os.Stat(execPath); err != nil {
|
||||
t.Errorf("executable not found: %v", err)
|
||||
} else if info.Mode()&0111 == 0 {
|
||||
t.Errorf("file is not executable")
|
||||
}
|
||||
|
||||
// Verify this would be recognized as a plugin
|
||||
if !isPlugin(tempDir) {
|
||||
t.Errorf("extracted directory is not a valid plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTarGz(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a test tar.gz file
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
// Add a test file to the archive
|
||||
testContent := "test content"
|
||||
header := &tar.Header{
|
||||
Name: "test-file.txt",
|
||||
Mode: 0644,
|
||||
Size: int64(len(testContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Add a test directory
|
||||
dirHeader := &tar.Header{
|
||||
Name: "test-dir/",
|
||||
Mode: 0755,
|
||||
Typeflag: tar.TypeDir,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(dirHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
gzWriter.Close()
|
||||
|
||||
// Test extraction
|
||||
err := extractTarGz(bytes.NewReader(buf.Bytes()), tempDir)
|
||||
if err != nil {
|
||||
t.Errorf("extractTarGz failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify extracted file
|
||||
extractedFile := filepath.Join(tempDir, "test-file.txt")
|
||||
content, err := os.ReadFile(extractedFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read extracted file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != testContent {
|
||||
t.Errorf("expected content %s, got %s", testContent, string(content))
|
||||
}
|
||||
|
||||
// Verify extracted directory
|
||||
extractedDir := filepath.Join(tempDir, "test-dir")
|
||||
if _, err := os.Stat(extractedDir); os.IsNotExist(err) {
|
||||
t.Errorf("extracted directory does not exist: %s", extractedDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTarGz_InvalidGzip(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Test with invalid gzip data
|
||||
invalidGzipData := []byte("not gzip data")
|
||||
err := extractTarGz(bytes.NewReader(invalidGzipData), tempDir)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid gzip data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTar_UnknownFileType(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create a test tar file
|
||||
var buf bytes.Buffer
|
||||
tarWriter := tar.NewWriter(&buf)
|
||||
|
||||
// Add a test file
|
||||
testContent := "test content"
|
||||
header := &tar.Header{
|
||||
Name: "test-file.txt",
|
||||
Mode: 0644,
|
||||
Size: int64(len(testContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Test unknown file type
|
||||
unknownHeader := &tar.Header{
|
||||
Name: "unknown-type",
|
||||
Mode: 0644,
|
||||
Typeflag: tar.TypeSymlink, // Use a type that's not handled
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(unknownHeader); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
|
||||
// Test extraction - should fail due to unknown type
|
||||
err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown tar file type")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "unknown type") {
|
||||
t.Errorf("expected 'unknown type' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTar_SuccessfulExtraction(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Since we can't easily create extended headers with Go's tar package,
|
||||
// we'll test the logic that skips them by creating a simple tar with regular files
|
||||
// and then testing that the extraction works correctly.
|
||||
|
||||
// Create a test tar file
|
||||
var buf bytes.Buffer
|
||||
tarWriter := tar.NewWriter(&buf)
|
||||
|
||||
// Add a regular file
|
||||
testContent := "test content"
|
||||
header := &tar.Header{
|
||||
Name: "test-file.txt",
|
||||
Mode: 0644,
|
||||
Size: int64(len(testContent)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tarWriter.Write([]byte(testContent)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tarWriter.Close()
|
||||
|
||||
// Test extraction
|
||||
err := extractTar(bytes.NewReader(buf.Bytes()), tempDir)
|
||||
if err != nil {
|
||||
t.Errorf("extractTar failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the regular file was extracted
|
||||
extractedFile := filepath.Join(tempDir, "test-file.txt")
|
||||
content, err := os.ReadFile(extractedFile)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read extracted file: %v", err)
|
||||
}
|
||||
|
||||
if string(content) != testContent {
|
||||
t.Errorf("expected content %s, got %s", testContent, string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_PlainHTTPOption(t *testing.T) {
|
||||
// Test that PlainHTTP option is properly passed to getter
|
||||
source := "oci://example.com/test-plugin:v1.0.0"
|
||||
|
||||
// Test with PlainHTTP=false (default)
|
||||
installer1, err := NewOCIInstaller(source)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create installer: %v", err)
|
||||
}
|
||||
if installer1.getter == nil {
|
||||
t.Error("getter should be initialized")
|
||||
}
|
||||
|
||||
// Test with PlainHTTP=true
|
||||
installer2, err := NewOCIInstaller(source, getter.WithPlainHTTP(true))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create installer with PlainHTTP=true: %v", err)
|
||||
}
|
||||
if installer2.getter == nil {
|
||||
t.Error("getter should be initialized with PlainHTTP=true")
|
||||
}
|
||||
|
||||
// Both installers should have the same basic properties
|
||||
if installer1.PluginName != installer2.PluginName {
|
||||
t.Error("plugin names should match")
|
||||
}
|
||||
if installer1.Source != installer2.Source {
|
||||
t.Error("sources should match")
|
||||
}
|
||||
|
||||
// Test with multiple options
|
||||
installer3, err := NewOCIInstaller(source,
|
||||
getter.WithPlainHTTP(true),
|
||||
getter.WithBasicAuth("user", "pass"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create installer with multiple options: %v", err)
|
||||
}
|
||||
if installer3.getter == nil {
|
||||
t.Error("getter should be initialized with multiple options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCIInstaller_Install_ValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
layerData []byte
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "non-gzip layer",
|
||||
layerData: []byte("not gzip data"),
|
||||
expectError: true,
|
||||
errorMsg: "is not a gzip compressed archive",
|
||||
},
|
||||
{
|
||||
name: "empty layer",
|
||||
layerData: []byte{},
|
||||
expectError: true,
|
||||
errorMsg: "is not a gzip compressed archive",
|
||||
},
|
||||
{
|
||||
name: "single byte layer",
|
||||
layerData: []byte{0x1f},
|
||||
expectError: true,
|
||||
errorMsg: "is not a gzip compressed archive",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test the gzip validation logic that's used in the Install method
|
||||
if len(tt.layerData) < 2 || tt.layerData[0] != 0x1f || tt.layerData[1] != 0x8b {
|
||||
// This matches the validation in the Install method
|
||||
if !tt.expectError {
|
||||
t.Error("expected valid gzip data")
|
||||
}
|
||||
if !strings.Contains(tt.errorMsg, "is not a gzip compressed archive") {
|
||||
t.Errorf("expected error message to contain 'is not a gzip compressed archive'")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"helm.sh/helm/v4/internal/plugin"
|
||||
"helm.sh/helm/v4/internal/test/ensure"
|
||||
"helm.sh/helm/v4/pkg/cli"
|
||||
)
|
||||
|
||||
func TestPluginUninstallCleansUpVersionedFiles(t *testing.T) {
|
||||
ensure.HelmHome(t)
|
||||
|
||||
// Create a fake plugin directory structure in a temp directory
|
||||
pluginsDir := t.TempDir()
|
||||
t.Setenv("HELM_PLUGINS", pluginsDir)
|
||||
|
||||
// Create a new settings instance that will pick up the environment variable
|
||||
testSettings := cli.New()
|
||||
pluginName := "test-plugin"
|
||||
|
||||
// Create plugin directory
|
||||
pluginDir := filepath.Join(pluginsDir, pluginName)
|
||||
if err := os.MkdirAll(pluginDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create plugin.yaml
|
||||
pluginYAML := `name: test-plugin
|
||||
version: 1.2.3
|
||||
description: Test plugin
|
||||
command: $HELM_PLUGIN_DIR/test-plugin
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(pluginYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create versioned tarball and provenance files
|
||||
tarballFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz")
|
||||
provFile := filepath.Join(pluginsDir, "test-plugin-1.2.3.tgz.prov")
|
||||
otherVersionTarball := filepath.Join(pluginsDir, "test-plugin-2.0.0.tgz")
|
||||
|
||||
if err := os.WriteFile(tarballFile, []byte("fake tarball"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(provFile, []byte("fake provenance"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create another version that should NOT be removed
|
||||
if err := os.WriteFile(otherVersionTarball, []byte("other version"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Load the plugin
|
||||
p, err := plugin.LoadDir(pluginDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a test uninstall function that uses our test settings
|
||||
testUninstallPlugin := func(plugin plugin.Plugin) error {
|
||||
if err := os.RemoveAll(plugin.Dir()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up versioned tarball and provenance files from test HELM_PLUGINS directory
|
||||
pluginName := plugin.Metadata().Name
|
||||
pluginVersion := plugin.Metadata().Version
|
||||
testPluginsDir := testSettings.PluginsDirectory
|
||||
|
||||
// Remove versioned files: plugin-name-version.tgz and plugin-name-version.tgz.prov
|
||||
if pluginVersion != "" {
|
||||
versionedBasename := fmt.Sprintf("%s-%s.tgz", pluginName, pluginVersion)
|
||||
|
||||
// Remove tarball file
|
||||
tarballPath := filepath.Join(testPluginsDir, versionedBasename)
|
||||
if _, err := os.Stat(tarballPath); err == nil {
|
||||
if err := os.Remove(tarballPath); err != nil {
|
||||
t.Logf("failed to remove tarball file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove provenance file
|
||||
provPath := filepath.Join(testPluginsDir, versionedBasename+".prov")
|
||||
if _, err := os.Stat(provPath); err == nil {
|
||||
if err := os.Remove(provPath); err != nil {
|
||||
t.Logf("failed to remove provenance file: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip runHook in test
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify files exist before uninstall
|
||||
if _, err := os.Stat(tarballFile); os.IsNotExist(err) {
|
||||
t.Fatal("tarball file should exist before uninstall")
|
||||
}
|
||||
if _, err := os.Stat(provFile); os.IsNotExist(err) {
|
||||
t.Fatal("provenance file should exist before uninstall")
|
||||
}
|
||||
if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) {
|
||||
t.Fatal("other version tarball should exist before uninstall")
|
||||
}
|
||||
|
||||
// Uninstall the plugin
|
||||
if err := testUninstallPlugin(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify plugin directory is removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) {
|
||||
t.Error("plugin directory should be removed")
|
||||
}
|
||||
|
||||
// Verify only exact version files are removed
|
||||
if _, err := os.Stat(tarballFile); !os.IsNotExist(err) {
|
||||
t.Error("versioned tarball file should be removed")
|
||||
}
|
||||
if _, err := os.Stat(provFile); !os.IsNotExist(err) {
|
||||
t.Error("versioned provenance file should be removed")
|
||||
}
|
||||
// Verify other version files are NOT removed
|
||||
if _, err := os.Stat(otherVersionTarball); os.IsNotExist(err) {
|
||||
t.Error("other version tarball should NOT be removed")
|
||||
}
|
||||
}
|
@ -0,0 +1,162 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/content/memory"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials"
|
||||
)
|
||||
|
||||
// GenericClient provides low-level OCI operations without artifact-specific assumptions
|
||||
type GenericClient struct {
|
||||
debug bool
|
||||
enableCache bool
|
||||
credentialsFile string
|
||||
username string
|
||||
password string
|
||||
out io.Writer
|
||||
authorizer *auth.Client
|
||||
registryAuthorizer RemoteClient
|
||||
credentialsStore credentials.Store
|
||||
httpClient *http.Client
|
||||
plainHTTP bool
|
||||
}
|
||||
|
||||
// GenericPullOptions configures a generic pull operation
|
||||
type GenericPullOptions struct {
|
||||
// MediaTypes to include in the pull (empty means all)
|
||||
AllowedMediaTypes []string
|
||||
// Skip descriptors with these media types
|
||||
SkipMediaTypes []string
|
||||
// Custom PreCopy function for filtering
|
||||
PreCopy func(context.Context, ocispec.Descriptor) error
|
||||
}
|
||||
|
||||
// GenericPullResult contains the result of a generic pull operation
|
||||
type GenericPullResult struct {
|
||||
Manifest ocispec.Descriptor
|
||||
Descriptors []ocispec.Descriptor
|
||||
MemoryStore *memory.Store
|
||||
Ref string
|
||||
}
|
||||
|
||||
// NewGenericClient creates a new generic OCI client from an existing Client
|
||||
func NewGenericClient(client *Client) *GenericClient {
|
||||
return &GenericClient{
|
||||
debug: client.debug,
|
||||
enableCache: client.enableCache,
|
||||
credentialsFile: client.credentialsFile,
|
||||
username: client.username,
|
||||
password: client.password,
|
||||
out: client.out,
|
||||
authorizer: client.authorizer,
|
||||
registryAuthorizer: client.registryAuthorizer,
|
||||
credentialsStore: client.credentialsStore,
|
||||
httpClient: client.httpClient,
|
||||
plainHTTP: client.plainHTTP,
|
||||
}
|
||||
}
|
||||
|
||||
// PullGeneric performs a generic OCI pull without artifact-specific assumptions
|
||||
func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*GenericPullResult, error) {
|
||||
parsedRef, err := newReference(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memoryStore := memory.New()
|
||||
var descriptors []ocispec.Descriptor
|
||||
|
||||
// Set up repository with authentication and configuration
|
||||
repository, err := remote.NewRepository(parsedRef.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository.PlainHTTP = c.plainHTTP
|
||||
repository.Client = c.authorizer
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Prepare allowed media types for filtering
|
||||
var allowedMediaTypes []string
|
||||
if len(options.AllowedMediaTypes) > 0 {
|
||||
allowedMediaTypes = make([]string, len(options.AllowedMediaTypes))
|
||||
copy(allowedMediaTypes, options.AllowedMediaTypes)
|
||||
sort.Strings(allowedMediaTypes)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
manifest, err := oras.Copy(ctx, repository, parsedRef.String(), memoryStore, "", oras.CopyOptions{
|
||||
CopyGraphOptions: oras.CopyGraphOptions{
|
||||
PreCopy: func(ctx context.Context, desc ocispec.Descriptor) error {
|
||||
// Apply custom PreCopy function if provided
|
||||
if options.PreCopy != nil {
|
||||
if err := options.PreCopy(ctx, desc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
mediaType := desc.MediaType
|
||||
|
||||
// Skip media types if specified
|
||||
for _, skipType := range options.SkipMediaTypes {
|
||||
if mediaType == skipType {
|
||||
return oras.SkipNode
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by allowed media types if specified
|
||||
if len(allowedMediaTypes) > 0 {
|
||||
if i := sort.SearchStrings(allowedMediaTypes, mediaType); i >= len(allowedMediaTypes) || allowedMediaTypes[i] != mediaType {
|
||||
return oras.SkipNode
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
descriptors = append(descriptors, desc)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GenericPullResult{
|
||||
Manifest: manifest,
|
||||
Descriptors: descriptors,
|
||||
MemoryStore: memoryStore,
|
||||
Ref: parsedRef.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDescriptorData retrieves the data for a specific descriptor
|
||||
func (c *GenericClient) GetDescriptorData(store *memory.Store, desc ocispec.Descriptor) ([]byte, error) {
|
||||
return content.FetchAll(context.Background(), store, desc)
|
||||
}
|
@ -0,0 +1,176 @@
|
||||
/*
|
||||
Copyright The Helm Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// Plugin-specific constants
|
||||
const (
|
||||
// PluginArtifactType is the artifact type for Helm plugins
|
||||
PluginArtifactType = "application/vnd.helm.plugin.v1+json"
|
||||
)
|
||||
|
||||
// PluginPullOptions configures a plugin pull operation
|
||||
type PluginPullOptions struct {
|
||||
// PluginName specifies the expected plugin name for layer validation
|
||||
PluginName string
|
||||
}
|
||||
|
||||
// PluginPullResult contains the result of a plugin pull operation
|
||||
type PluginPullResult struct {
|
||||
Manifest ocispec.Descriptor
|
||||
PluginData []byte
|
||||
ProvenanceData []byte // Optional provenance data
|
||||
Ref string
|
||||
PluginName string
|
||||
}
|
||||
|
||||
// PullPlugin downloads a plugin from an OCI registry using artifact type
|
||||
func (c *Client) PullPlugin(ref string, pluginName string, options ...PluginPullOption) (*PluginPullResult, error) {
|
||||
operation := &pluginPullOperation{
|
||||
pluginName: pluginName,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(operation)
|
||||
}
|
||||
|
||||
// Use generic client for the pull operation with artifact type filtering
|
||||
genericClient := c.Generic()
|
||||
genericResult, err := genericClient.PullGeneric(ref, GenericPullOptions{
|
||||
// Allow manifests and all layer types - we'll validate artifact type after download
|
||||
AllowedMediaTypes: []string{
|
||||
ocispec.MediaTypeImageManifest,
|
||||
"application/vnd.oci.image.layer.v1.tar",
|
||||
"application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Process the result with plugin-specific logic
|
||||
return c.processPluginPull(genericResult, operation.pluginName)
|
||||
}
|
||||
|
||||
// processPluginPull handles plugin-specific processing of a generic pull result using artifact type
|
||||
func (c *Client) processPluginPull(genericResult *GenericPullResult, pluginName string) (*PluginPullResult, error) {
|
||||
// First validate that this is actually a plugin artifact
|
||||
manifestData, err := c.Generic().GetDescriptorData(genericResult.MemoryStore, genericResult.Manifest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve manifest: %w", err)
|
||||
}
|
||||
|
||||
// Parse the manifest to check artifact type
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("unable to parse manifest: %w", err)
|
||||
}
|
||||
|
||||
// Validate artifact type (for OCI v1.1+ manifests)
|
||||
if manifest.ArtifactType != "" && manifest.ArtifactType != PluginArtifactType {
|
||||
return nil, fmt.Errorf("expected artifact type %s, got %s", PluginArtifactType, manifest.ArtifactType)
|
||||
}
|
||||
|
||||
// For backwards compatibility, also check config media type if no artifact type
|
||||
if manifest.ArtifactType == "" && manifest.Config.MediaType != PluginArtifactType {
|
||||
return nil, fmt.Errorf("expected config media type %s for legacy compatibility, got %s", PluginArtifactType, manifest.Config.MediaType)
|
||||
}
|
||||
|
||||
// Find the required plugin tarball and optional provenance
|
||||
expectedTarball := pluginName + ".tgz"
|
||||
expectedProvenance := pluginName + ".tgz.prov"
|
||||
|
||||
var pluginDescriptor *ocispec.Descriptor
|
||||
var provenanceDescriptor *ocispec.Descriptor
|
||||
|
||||
// Look for layers with the expected titles/annotations
|
||||
for _, layer := range manifest.Layers {
|
||||
d := layer
|
||||
// Check for title annotation (preferred method)
|
||||
if title, exists := d.Annotations[ocispec.AnnotationTitle]; exists {
|
||||
switch title {
|
||||
case expectedTarball:
|
||||
pluginDescriptor = &d
|
||||
case expectedProvenance:
|
||||
provenanceDescriptor = &d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin tarball is required
|
||||
if pluginDescriptor == nil {
|
||||
return nil, fmt.Errorf("required layer %s not found in manifest", expectedTarball)
|
||||
}
|
||||
|
||||
// Build plugin-specific result
|
||||
result := &PluginPullResult{
|
||||
Manifest: genericResult.Manifest,
|
||||
Ref: genericResult.Ref,
|
||||
PluginName: pluginName,
|
||||
}
|
||||
|
||||
// Fetch plugin data using generic client
|
||||
genericClient := c.Generic()
|
||||
result.PluginData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *pluginDescriptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve plugin data with digest %s: %w", pluginDescriptor.Digest, err)
|
||||
}
|
||||
|
||||
// Fetch provenance data if available
|
||||
if provenanceDescriptor != nil {
|
||||
result.ProvenanceData, err = genericClient.GetDescriptorData(genericResult.MemoryStore, *provenanceDescriptor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve provenance data with digest %s: %w", provenanceDescriptor.Digest, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(c.out, "Pulled plugin: %s\n", result.Ref)
|
||||
fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
|
||||
if result.ProvenanceData != nil {
|
||||
fmt.Fprintf(c.out, "Provenance: %s\n", expectedProvenance)
|
||||
}
|
||||
|
||||
if strings.Contains(result.Ref, "_") {
|
||||
fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
|
||||
fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Plugin pull operation types and options
|
||||
type (
|
||||
pluginPullOperation struct {
|
||||
pluginName string
|
||||
}
|
||||
|
||||
// PluginPullOption allows customizing plugin pull operations
|
||||
PluginPullOption func(*pluginPullOperation)
|
||||
)
|
||||
|
||||
// PluginPullOptWithPluginName sets the plugin name for validation
|
||||
func PluginPullOptWithPluginName(name string) PluginPullOption {
|
||||
return func(operation *pluginPullOperation) {
|
||||
operation.pluginName = name
|
||||
}
|
||||
}
|
Loading…
Reference in new issue