You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
helm/internal/plugin/installer/oci_installer_test.go

807 lines
22 KiB

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