1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-03 04:45:21 +02:00

feat(oci): oci helm support [r8s-361] (#787)

This commit is contained in:
Ali 2025-07-13 10:37:43 +12:00 committed by GitHub
parent b6a6ce9aaf
commit 2697d6c5d7
80 changed files with 4264 additions and 812 deletions

126
pkg/libhelm/cache/cache.go vendored Normal file
View file

@ -0,0 +1,126 @@
package cache
import (
"fmt"
"time"
"github.com/patrickmn/go-cache"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/registry"
)
// Cache manages Helm registry clients with TTL-based expiration
// Registry clients are cached per registry ID rather than per user session
// to optimize rate limiting - one login per registry per Portainer instance
type Cache struct {
cache *cache.Cache
}
// CachedRegistryClient wraps a registry client with metadata
type CachedRegistryClient struct {
Client *registry.Client
RegistryID portainer.RegistryID
CreatedAt time.Time
}
// newCache creates a new Helm registry client cache with the specified timeout
func newCache(userSessionTimeout string) (*Cache, error) {
timeout, err := time.ParseDuration(userSessionTimeout)
if err != nil {
return nil, fmt.Errorf("invalid user session timeout: %w", err)
}
return &Cache{
cache: cache.New(timeout, timeout),
}, nil
}
// getByRegistryID retrieves a cached registry client by registry ID
// Cache key strategy: use registryID for maximum efficiency against rate limits
// This means one login per registry per Portainer instance, regardless of user/environment
func (c *Cache) getByRegistryID(registryID portainer.RegistryID) (*registry.Client, bool) {
key := generateRegistryIDCacheKey(registryID)
cachedClient, found := c.cache.Get(key)
if !found {
log.Debug().
Str("cache_key", key).
Int("registry_id", int(registryID)).
Str("context", "HelmRegistryCache").
Msg("Cache miss for registry client")
return nil, false
}
client := cachedClient.(CachedRegistryClient)
log.Debug().
Str("cache_key", key).
Int("registry_id", int(registryID)).
Str("context", "HelmRegistryCache").
Msg("Cache hit for registry client")
return client.Client, true
}
// setByRegistryID stores a registry client in the cache with registry ID context
func (c *Cache) setByRegistryID(registryID portainer.RegistryID, client *registry.Client) {
if client == nil {
log.Warn().
Int("registry_id", int(registryID)).
Str("context", "HelmRegistryCache").
Msg("Attempted to cache nil registry client")
return
}
key := generateRegistryIDCacheKey(registryID)
cachedClient := CachedRegistryClient{
Client: client,
RegistryID: registryID,
CreatedAt: time.Now(),
}
c.cache.Set(key, cachedClient, cache.DefaultExpiration)
log.Debug().
Str("cache_key", key).
Int("registry_id", int(registryID)).
Str("context", "HelmRegistryCache").
Msg("Cached registry client")
}
// flushRegistry removes cached registry client for a specific registry ID
// This should be called whenever registry credentials change
func (c *Cache) flushRegistry(registryID portainer.RegistryID) {
key := generateRegistryIDCacheKey(registryID)
c.cache.Delete(key)
log.Info().
Int("registry_id", int(registryID)).
Str("context", "HelmRegistryCache").
Msg("Flushed registry client due to registry change")
}
// flushAll removes all cached registry clients
func (c *Cache) flushAll() {
itemCount := c.cache.ItemCount()
c.cache.Flush()
if itemCount > 0 {
log.Info().
Int("cached_clients_removed", itemCount).
Str("context", "HelmRegistryCache").
Msg("Flushed all registry clients")
}
}
// generateRegistryIDCacheKey creates a cache key from registry ID
// Key strategy decision: Use registry ID instead of user sessions or URL+username
// This provides optimal rate limiting protection since each registry only gets
// logged into once per Portainer instance, regardless of how many users access it
// RBAC security is enforced before reaching this caching layer
// When a new user needs access, they reuse the same cached client
func generateRegistryIDCacheKey(registryID portainer.RegistryID) string {
return fmt.Sprintf("registry:%d", registryID)
}

81
pkg/libhelm/cache/manager.go vendored Normal file
View file

@ -0,0 +1,81 @@
package cache
import (
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/registry"
)
var (
// Global singleton instance
instance *Cache
once sync.Once
)
// Initialize creates and initializes the global cache instance
func Initialize(userSessionTimeout string) error {
var err error
once.Do(func() {
instance, err = newCache(userSessionTimeout)
if err != nil {
log.Error().
Err(err).
Str("user_session_timeout", userSessionTimeout).
Msg("Failed to initialize Helm registry cache")
} else {
log.Info().
Str("user_session_timeout", userSessionTimeout).
Msg("Helm registry cache initialized")
}
})
return err
}
// Registry-based cache management functions
// GetCachedRegistryClientByID retrieves a cached registry client by registry ID
func GetCachedRegistryClientByID(registryID portainer.RegistryID) (*registry.Client, bool) {
if instance == nil {
log.Debug().
Str("context", "HelmRegistryCache").
Msg("Cache not initialized, returning nil")
return nil, false
}
return instance.getByRegistryID(registryID)
}
// SetCachedRegistryClientByID stores a registry client in the cache by registry ID
func SetCachedRegistryClientByID(registryID portainer.RegistryID, client *registry.Client) {
if instance == nil {
log.Warn().
Str("context", "HelmRegistryCache").
Msg("Cannot set cache entry - cache not initialized")
return
}
instance.setByRegistryID(registryID, client)
}
// FlushRegistryByID removes cached registry client for a specific registry ID
// This should be called whenever registry credentials change
func FlushRegistryByID(registryID portainer.RegistryID) {
if instance == nil {
log.Debug().
Str("context", "HelmRegistryCache").
Msg("Cache not initialized, nothing to flush")
return
}
instance.flushRegistry(registryID)
}
// FlushAll removes all cached registry clients
func FlushAll() {
if instance == nil {
log.Debug().
Str("context", "HelmRegistryCache").
Msg("Cache not initialized, nothing to flush")
return
}
instance.flushAll()
}

View file

@ -0,0 +1,38 @@
package options
import (
"strings"
)
const (
// OCIProtocolPrefix is the standard OCI protocol prefix
OCIProtocolPrefix = "oci://"
)
// ConstructChartReference constructs the appropriate chart reference based on registry type
func ConstructChartReference(registryURL string, chartName string) string {
if registryURL == "" {
return chartName
}
// Don't double-prefix if chart already contains the registry URL
if strings.HasPrefix(chartName, OCIProtocolPrefix) {
return chartName
}
baseURL := ConstructOCIRegistryReference(registryURL)
// Handle cases where chartName might already have a path separator
if strings.HasPrefix(chartName, "/") {
return baseURL + chartName
}
return baseURL + "/" + chartName
}
func ConstructOCIRegistryReference(registryURL string) string {
// Remove oci:// prefix if present to avoid duplication
registryURL = strings.TrimPrefix(registryURL, OCIProtocolPrefix)
// Ensure we have oci:// prefix for OCI registries
return OCIProtocolPrefix + registryURL
}

View file

@ -0,0 +1,100 @@
package options
import (
"testing"
)
func TestConstructChartReference(t *testing.T) {
tests := []struct {
name string
registryURL string
chartName string
expected string
}{
{
name: "empty registry URL returns chart name as-is",
registryURL: "",
chartName: "nginx",
expected: "nginx",
},
{
name: "basic OCI registry with chart name",
registryURL: "registry.example.com",
chartName: "nginx",
expected: "oci://registry.example.com/nginx",
},
{
name: "registry with project path",
registryURL: "harbor.example.com",
chartName: "library/nginx",
expected: "oci://harbor.example.com/library/nginx",
},
{
name: "chart name already has oci prefix returns as-is",
registryURL: "registry.example.com",
chartName: "oci://registry.example.com/nginx",
expected: "oci://registry.example.com/nginx",
},
{
name: "chart name with leading slash",
registryURL: "registry.example.com",
chartName: "/nginx",
expected: "oci://registry.example.com/nginx",
},
{
name: "registry URL already has oci prefix",
registryURL: "oci://registry.example.com",
chartName: "nginx",
expected: "oci://registry.example.com/nginx",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConstructChartReference(tt.registryURL, tt.chartName)
if result != tt.expected {
t.Errorf("ConstructChartReference(%q, %q) = %q, want %q",
tt.registryURL, tt.chartName, result, tt.expected)
}
})
}
}
func TestConstructOCIRegistryReference(t *testing.T) {
tests := []struct {
name string
registryURL string
expected string
}{
{
name: "simple registry URL",
registryURL: "registry.example.com",
expected: "oci://registry.example.com",
},
{
name: "registry URL with oci prefix",
registryURL: "oci://registry.example.com",
expected: "oci://registry.example.com",
},
{
name: "registry URL with port",
registryURL: "registry.example.com:5000",
expected: "oci://registry.example.com:5000",
},
{
name: "empty registry URL",
registryURL: "",
expected: "oci://",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ConstructOCIRegistryReference(tt.registryURL)
if result != tt.expected {
t.Errorf("ConstructOCIRegistryReference(%q) = %q, want %q",
tt.registryURL, result, tt.expected)
}
})
}
}

View file

@ -1,6 +1,10 @@
package options
import "time"
import (
"time"
portainer "github.com/portainer/portainer/api"
)
type InstallOptions struct {
Name string
@ -8,6 +12,7 @@ type InstallOptions struct {
Version string
Namespace string
Repo string
Registry *portainer.Registry
Wait bool
ValuesFile string
PostRenderer string

View file

@ -1,10 +1,15 @@
package options
import "net/http"
import (
"net/http"
portainer "github.com/portainer/portainer/api"
)
type SearchRepoOptions struct {
Repo string `example:"https://charts.gitlab.io/"`
Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
Chart string `example:"my-chart"`
UseCache bool `example:"false"`
Registry *portainer.Registry
}

View file

@ -1,5 +1,7 @@
package options
import portainer "github.com/portainer/portainer/api"
// ShowOutputFormat is the format of the output of `helm show`
type ShowOutputFormat string
@ -20,6 +22,6 @@ type ShowOptions struct {
Chart string
Repo string
Version string
Env []string
Env []string
Registry *portainer.Registry // Registry credentials for authentication
}

View file

@ -45,6 +45,8 @@ type Release struct {
// Labels of the release.
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
Labels map[string]string `json:"-"`
// ChartReference are the labels that are used to identify the chart source.
ChartReference ChartReference `json:"chartReference,omitempty"`
// Values are the values used to deploy the chart.
Values Values `json:"values,omitempty"`
}
@ -54,6 +56,12 @@ type Values struct {
ComputedValues string `json:"computedValues,omitempty"`
}
type ChartReference struct {
ChartPath string `json:"chartPath,omitempty"`
RepoURL string `json:"repoURL,omitempty"`
RegistryID int64 `json:"registryID,omitempty"`
}
// Chart is a helm package that contains metadata, a default config, zero or more
// optionally parameterizable templates, and zero or more charts (dependencies).
type Chart struct {

View file

@ -0,0 +1,297 @@
package sdk
// Helm Registry Client Caching Strategy
//
// This package implements a registry-based caching mechanism for Helm OCI registry clients
// to address rate limiting issues caused by repeated registry authentication.
//
// Key Design Decisions:
//
// 1. Cache Key Strategy: Registry ID
// - Uses portainer.RegistryID as the cache key instead of user sessions or URL+username
// - One cached client per registry per Portainer instance, regardless of users
// - Optimal for rate limiting: each registry only gets one login per Portainer instance
// - New users reuse existing cached clients rather than creating new ones
//
// 2. Cache Invalidation: Registry Change Events
// - Cache is flushed when registry credentials are updated (registryUpdate handler)
// - Cache is flushed when registry is reconfigured (registryConfigure handler)
// - Cache is flushed when registry is deleted (registryDelete handler)
// - Cache is flushed when registry authentication fails (show, install, upgrade)
// - No time-based expiration needed since registry credentials rarely change
//
// 3. Alternative Approaches NOT Used:
// - registry.ClientOptCredentialsFile(): Still requires token exchange on each client creation
// - User/session-based caching: Less efficient for rate limiting, creates unnecessary logins
// - URL+username caching: More complex, harder to invalidate, doesn't handle registry updates
//
// 4. Security Model:
// - RBAC security is enforced BEFORE reaching this caching layer (handler.getRegistryWithAccess)
import (
"strings"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/registry"
"oras.land/oras-go/v2/registry/remote/retry"
)
// IsOCIRegistry returns true if the registry is an OCI registry (not nil), false if it's an HTTP repository (nil)
func IsOCIRegistry(registry *portainer.Registry) bool {
return registry != nil
}
// IsHTTPRepository returns true if it's an HTTP repository (registry is nil), false if it's an OCI registry
func IsHTTPRepository(registry *portainer.Registry) bool {
return registry == nil
}
// parseChartRef parses chart and repo references based on the registry type
func parseChartRef(chart, repo string, registry *portainer.Registry) (string, string, error) {
if IsHTTPRepository(registry) {
return parseHTTPRepoChartRef(chart, repo)
}
return parseOCIChartRef(chart, registry)
}
// parseOCIChartRef constructs the full OCI chart reference
func parseOCIChartRef(chart string, registry *portainer.Registry) (string, string, error) {
chartRef := options.ConstructChartReference(registry.URL, chart)
log.Debug().
Str("context", "HelmClient").
Str("chart_ref", chartRef).
Bool("authentication", registry.Authentication).
Msg("Constructed OCI chart reference")
return chartRef, registry.URL, nil
}
// parseHTTPRepoChartRef returns chart and repo as-is for HTTP repositories
func parseHTTPRepoChartRef(chart, repo string) (string, string, error) {
return chart, repo, nil
}
// shouldFlushCacheOnError determines if a registry client should be removed from cache based on the error
// This helps handle cases where cached credentials have become invalid
func shouldFlushCacheOnError(err error, registryID portainer.RegistryID) bool {
if err == nil || registryID == 0 {
return false
}
errorStr := strings.ToLower(err.Error())
// Authentication/authorization errors that indicate invalid cached credentials
authenticationErrors := []string{
"unauthorized",
"authentication",
"login failed",
"invalid credentials",
"access denied",
"forbidden",
"401",
"403",
"token",
"auth",
}
for _, authErr := range authenticationErrors {
if strings.Contains(errorStr, authErr) {
log.Info().
Int("registry_id", int(registryID)).
Str("error", err.Error()).
Str("context", "HelmClient").
Msg("Detected authentication error - will flush registry cache")
return true
}
}
return false
}
// authenticateChartSource handles both HTTP repositories and OCI registries
func authenticateChartSource(actionConfig *action.Configuration, registry *portainer.Registry) error {
// For HTTP repositories, no authentication needed (CE and EE)
if IsHTTPRepository(registry) {
return nil
}
// If RegistryClient is already set, we're done
if actionConfig.RegistryClient != nil {
log.Debug().
Str("context", "HelmClient").
Msg("Registry client already set in action config")
return nil
}
// Validate registry credentials first
err := validateRegistryCredentials(registry)
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Registry credential validation failed")
return errors.Wrap(err, "registry credential validation failed")
}
// No authentication required
if !registry.Authentication {
log.Debug().
Str("context", "HelmClient").
Msg("No OCI registry authentication required")
return nil
}
// Cache Strategy Decision: Use registry ID as cache key
// This provides optimal rate limiting protection since each registry only gets
// logged into once per Portainer instance, regardless of how many users access it.
// RBAC security is enforced before reaching this caching layer.
// When a new user needs access, they reuse the same cached client.
//
// Alternative approach (NOT used): registry.ClientOptCredentialsFile()
// We don't use Helm's credential file approach because:
// 1. It still requires token exchange with registry on each new client creation
// 2. Rate limiting occurs during token exchange, not credential loading
// 3. Our caching approach reuses existing authenticated clients completely
// 4. Credential files add complexity without solving the core rate limiting issue
// Try to get cached registry client (registry ID-based key)
if cachedClient, found := cache.GetCachedRegistryClientByID(registry.ID); found {
log.Debug().
Int("registry_id", int(registry.ID)).
Str("registry_url", registry.URL).
Str("context", "HelmClient").
Msg("Using cached registry client")
actionConfig.RegistryClient = cachedClient
return nil
}
// Cache miss - perform login and cache the result
log.Debug().
Int("registry_id", int(registry.ID)).
Str("registry_url", registry.URL).
Str("context", "HelmClient").
Msg("Cache miss - creating new registry client")
registryClient, err := loginToOCIRegistry(registry)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", registry.URL).
Err(err).
Msg("Failed to login to registry")
return errors.Wrap(err, "failed to login to registry")
}
// Cache the client if login was successful (registry ID-based key)
if registryClient != nil {
cache.SetCachedRegistryClientByID(registry.ID, registryClient)
log.Debug().
Int("registry_id", int(registry.ID)).
Str("registry_url", registry.URL).
Str("context", "HelmClient").
Msg("Registry client cached successfully")
}
actionConfig.RegistryClient = registryClient
return nil
}
// configureChartPathOptions sets chart path options based on registry type
func configureChartPathOptions(chartPathOptions *action.ChartPathOptions, version, repo string, registry *portainer.Registry) error {
chartPathOptions.Version = version
// Set chart path options based on registry type
if IsHTTPRepository(registry) {
configureHTTPRepoChartPathOptions(chartPathOptions, repo)
} else {
configureOCIChartPathOptions(chartPathOptions, registry)
}
return nil
}
// configureHTTPRepoChartPathOptions sets chart path options for HTTP repositories
func configureHTTPRepoChartPathOptions(chartPathOptions *action.ChartPathOptions, repo string) {
chartPathOptions.RepoURL = repo
}
// configureOCIChartPathOptions sets chart path options for OCI registries
func configureOCIChartPathOptions(chartPathOptions *action.ChartPathOptions, registry *portainer.Registry) {
if registry.Authentication {
chartPathOptions.Username = registry.Username
chartPathOptions.Password = registry.Password
}
}
// loginToOCIRegistry performs registry login for OCI-based registries using Helm SDK
// Tries to get a cached registry client if available, otherwise creates and caches a new one
func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client, error) {
if IsHTTPRepository(portainerRegistry) || !portainerRegistry.Authentication {
return nil, nil // No authentication needed
}
// Check cache first using registry ID-based key
if cachedClient, found := cache.GetCachedRegistryClientByID(portainerRegistry.ID); found {
return cachedClient, nil
}
log.Debug().
Str("context", "loginToRegistry").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Attempting to login to OCI registry")
registryClient, err := registry.NewClient(registry.ClientOptHTTPClient(retry.DefaultClient))
if err != nil {
return nil, errors.Wrap(err, "failed to create registry client")
}
loginOpts := []registry.LoginOption{
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
}
err = registryClient.Login(portainerRegistry.URL, loginOpts...)
if err != nil {
return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL)
}
log.Debug().
Str("context", "loginToRegistry").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Successfully logged in to OCI registry")
// Cache using registry ID-based key
cache.SetCachedRegistryClientByID(portainerRegistry.ID, registryClient)
return registryClient, nil
}
// validateRegistryCredentials validates registry authentication settings
func validateRegistryCredentials(registry *portainer.Registry) error {
if IsHTTPRepository(registry) {
return nil // No registry means no validation needed
}
if !registry.Authentication {
return nil // No authentication required
}
// Authentication is enabled - validate credentials
if strings.TrimSpace(registry.Username) == "" {
return errors.New("username is required when registry authentication is enabled")
}
if strings.TrimSpace(registry.Password) == "" {
return errors.New("password is required when registry authentication is enabled")
}
return nil
}

View file

@ -0,0 +1,752 @@
package sdk
import (
"strings"
"testing"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
helmregistrycache "github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/stretchr/testify/assert"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/registry"
)
func TestIsOCIRegistry(t *testing.T) {
t.Run("should return false for nil registry (HTTP repo)", func(t *testing.T) {
assert.False(t, IsOCIRegistry(nil))
})
t.Run("should return true for non-nil registry (OCI registry)", func(t *testing.T) {
assert.True(t, IsOCIRegistry(&portainer.Registry{}))
})
}
func TestIsHTTPRepository(t *testing.T) {
t.Run("should return true for nil registry (HTTP repo)", func(t *testing.T) {
assert.True(t, IsHTTPRepository(nil))
})
t.Run("should return false for non-nil registry (OCI registry)", func(t *testing.T) {
assert.False(t, IsHTTPRepository(&portainer.Registry{}))
})
}
func TestParseHTTPRepoChartRef(t *testing.T) {
is := assert.New(t)
chartRef, repoURL, err := parseHTTPRepoChartRef("my-chart", "https://my.repo/charts")
is.NoError(err)
is.Equal("my-chart", chartRef)
is.Equal("https://my.repo/charts", repoURL)
}
func TestParseOCIChartRef(t *testing.T) {
is := assert.New(t)
registry := &portainer.Registry{
URL: "my-registry.io/my-namespace",
Authentication: true,
Username: "user",
Password: "pass",
}
chartRef, repoURL, err := parseOCIChartRef("my-chart", registry)
is.NoError(err)
is.Equal("oci://my-registry.io/my-namespace/my-chart", chartRef)
is.Equal("my-registry.io/my-namespace", repoURL)
}
func TestParseOCIChartRef_GitLab(t *testing.T) {
is := assert.New(t)
registry := &portainer.Registry{
Type: portainer.GitlabRegistry,
URL: "registry.gitlab.com",
Authentication: true,
Username: "gitlab-ci-token",
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
Gitlab: portainer.GitlabRegistryData{
ProjectID: 12345,
InstanceURL: "https://gitlab.com",
ProjectPath: "my-group/my-project",
},
}
chartRef, repoURL, err := parseOCIChartRef("my-chart", registry)
is.NoError(err)
is.Equal("oci://registry.gitlab.com/my-chart", chartRef)
is.Equal("registry.gitlab.com", repoURL)
}
func TestParseChartRef(t *testing.T) {
t.Run("should parse HTTP repo chart ref when registry is nil", func(t *testing.T) {
is := assert.New(t)
chartRef, repoURL, err := parseChartRef("my-chart", "https://my.repo/charts", nil)
is.NoError(err)
is.Equal("my-chart", chartRef)
is.Equal("https://my.repo/charts", repoURL)
})
t.Run("should parse OCI chart ref when registry is provided", func(t *testing.T) {
is := assert.New(t)
registry := &portainer.Registry{
URL: "my-registry.io/my-namespace",
Authentication: true,
Username: "user",
Password: "pass",
}
chartRef, repoURL, err := parseChartRef("my-chart", "", registry)
is.NoError(err)
is.Equal("oci://my-registry.io/my-namespace/my-chart", chartRef)
is.Equal("my-registry.io/my-namespace", repoURL)
})
}
func TestConfigureHTTPRepoChartPathOptions(t *testing.T) {
is := assert.New(t)
chartPathOptions := &action.ChartPathOptions{}
configureHTTPRepoChartPathOptions(chartPathOptions, "https://my.repo/charts")
is.Equal("https://my.repo/charts", chartPathOptions.RepoURL)
}
func TestConfigureOCIChartPathOptions(t *testing.T) {
is := assert.New(t)
chartPathOptions := &action.ChartPathOptions{}
registry := &portainer.Registry{
URL: "my-registry.io/my-namespace",
Authentication: true,
Username: "user",
Password: "pass",
}
configureOCIChartPathOptions(chartPathOptions, registry)
is.Equal("user", chartPathOptions.Username)
is.Equal("pass", chartPathOptions.Password)
}
func TestConfigureOCIChartPathOptions_NoAuth(t *testing.T) {
is := assert.New(t)
chartPathOptions := &action.ChartPathOptions{}
registry := &portainer.Registry{
URL: "my-registry.io/my-namespace",
Authentication: false,
}
configureOCIChartPathOptions(chartPathOptions, registry)
is.Empty(chartPathOptions.Username)
is.Empty(chartPathOptions.Password)
}
func TestConfigureChartPathOptions(t *testing.T) {
t.Run("should configure HTTP repo when registry is nil", func(t *testing.T) {
is := assert.New(t)
chartPathOptions := &action.ChartPathOptions{}
err := configureChartPathOptions(chartPathOptions, "1.0.0", "https://my.repo/charts", nil)
is.NoError(err)
is.Equal("https://my.repo/charts", chartPathOptions.RepoURL)
is.Equal("1.0.0", chartPathOptions.Version)
})
t.Run("should configure OCI registry when registry is provided", func(t *testing.T) {
is := assert.New(t)
chartPathOptions := &action.ChartPathOptions{}
registry := &portainer.Registry{
URL: "my-registry.io/my-namespace",
Authentication: true,
Username: "user",
Password: "pass",
}
err := configureChartPathOptions(chartPathOptions, "1.0.0", "", registry)
is.NoError(err)
is.Equal("user", chartPathOptions.Username)
is.Equal("pass", chartPathOptions.Password)
is.Equal("1.0.0", chartPathOptions.Version)
})
}
func TestLoginToOCIRegistry(t *testing.T) {
is := assert.New(t)
t.Run("should return nil for HTTP repository (nil registry)", func(t *testing.T) {
client, err := loginToOCIRegistry(nil)
is.NoError(err)
is.Nil(client)
})
t.Run("should return nil for registry with auth disabled", func(t *testing.T) {
registry := &portainer.Registry{
URL: "my-registry.io",
Authentication: false,
}
client, err := loginToOCIRegistry(registry)
is.NoError(err)
is.Nil(client)
})
t.Run("should return error for invalid credentials", func(t *testing.T) {
registry := &portainer.Registry{
URL: "my-registry.io",
Authentication: true,
Username: " ",
}
client, err := loginToOCIRegistry(registry)
is.Error(err)
is.Nil(client)
// The error might be a validation error or a login error, both are acceptable
is.True(err.Error() == "username is required when registry authentication is enabled" ||
strings.Contains(err.Error(), "failed to login to registry"))
})
t.Run("should attempt login for valid credentials", func(t *testing.T) {
registry := &portainer.Registry{
ID: 123,
URL: "my-registry.io",
Authentication: true,
Username: "user",
Password: "pass",
}
// this will fail because it can't connect to the registry,
// but it proves that the loginToOCIRegistry function is calling the login function.
client, err := loginToOCIRegistry(registry)
is.Error(err)
is.Nil(client)
is.Contains(err.Error(), "failed to login to registry")
})
t.Run("should attempt login for GitLab registry with valid credentials", func(t *testing.T) {
registry := &portainer.Registry{
ID: 456,
Type: portainer.GitlabRegistry,
URL: "registry.gitlab.com",
Authentication: true,
Username: "gitlab-ci-token",
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
Gitlab: portainer.GitlabRegistryData{
ProjectID: 12345,
InstanceURL: "https://gitlab.com",
ProjectPath: "my-group/my-project",
},
}
// this will fail because it can't connect to the registry,
// but it proves that the loginToOCIRegistry function is calling the login function.
client, err := loginToOCIRegistry(registry)
is.Error(err)
is.Nil(client)
is.Contains(err.Error(), "failed to login to registry")
})
}
func TestAuthenticateChartSource(t *testing.T) {
t.Run("should do nothing for HTTP repo (nil registry)", func(t *testing.T) {
is := assert.New(t)
actionConfig := &action.Configuration{}
err := authenticateChartSource(actionConfig, nil)
is.NoError(err)
is.Nil(actionConfig.RegistryClient)
})
t.Run("should do nothing if registry client already set", func(t *testing.T) {
is := assert.New(t)
actionConfig := &action.Configuration{}
// Mock an existing registry client
existingClient := &registry.Client{}
actionConfig.RegistryClient = existingClient
registry := &portainer.Registry{
ID: 123,
Authentication: true,
Username: "user",
Password: "pass",
}
err := authenticateChartSource(actionConfig, registry)
is.NoError(err)
is.Equal(existingClient, actionConfig.RegistryClient)
})
t.Run("should authenticate OCI registry when registry is provided", func(t *testing.T) {
is := assert.New(t)
actionConfig := &action.Configuration{}
registry := &portainer.Registry{
ID: 123,
Authentication: false,
}
err := authenticateChartSource(actionConfig, registry)
is.NoError(err)
})
t.Run("should return error for invalid registry credentials", func(t *testing.T) {
is := assert.New(t)
actionConfig := &action.Configuration{}
registry := &portainer.Registry{
ID: 123,
Authentication: true,
Username: " ", // Invalid username
}
err := authenticateChartSource(actionConfig, registry)
is.Error(err)
is.Contains(err.Error(), "registry credential validation failed")
})
}
func TestGetRegistryClientFromCache(t *testing.T) {
// Initialize cache for testing
err := helmregistrycache.Initialize("24h")
if err != nil {
t.Fatalf("Failed to initialize cache: %v", err)
}
// Clear cache before each test
helmregistrycache.FlushAll()
t.Run("should return nil for invalid registry ID", func(t *testing.T) {
is := assert.New(t)
client, found := helmregistrycache.GetCachedRegistryClientByID(0)
is.False(found)
is.Nil(client)
})
t.Run("should return nil for non-existent registry ID", func(t *testing.T) {
is := assert.New(t)
client, found := helmregistrycache.GetCachedRegistryClientByID(123)
is.False(found)
is.Nil(client)
})
t.Run("should return cached client for valid registry ID", func(t *testing.T) {
is := assert.New(t)
// Create a mock client
mockClient := &registry.Client{}
// Store in cache
helmregistrycache.SetCachedRegistryClientByID(123, mockClient)
// Retrieve from cache
cachedClient, found := helmregistrycache.GetCachedRegistryClientByID(123)
is.True(found)
is.NotNil(cachedClient)
is.Equal(mockClient, cachedClient)
})
}
func TestSetRegistryClientInCache(t *testing.T) {
// Initialize cache for testing
err := helmregistrycache.Initialize("24h")
if err != nil {
t.Fatalf("Failed to initialize cache: %v", err)
}
// Clear cache before each test
helmregistrycache.FlushAll()
t.Run("should store and retrieve client successfully", func(t *testing.T) {
is := assert.New(t)
// Create a mock client
client := &registry.Client{}
// Store in cache
helmregistrycache.SetCachedRegistryClientByID(123, client)
// Verify the cache returns the client
cachedClient, found := helmregistrycache.GetCachedRegistryClientByID(123)
is.True(found)
is.NotNil(cachedClient)
is.Equal(client, cachedClient)
})
t.Run("should handle invalid parameters gracefully", func(t *testing.T) {
// Clear cache to start clean
helmregistrycache.FlushAll()
// These should not panic
helmregistrycache.SetCachedRegistryClientByID(0, nil) // nil client should be rejected
helmregistrycache.SetCachedRegistryClientByID(999, &registry.Client{}) // valid client with registry ID 999 should be accepted
helmregistrycache.SetCachedRegistryClientByID(123, nil) // nil client should be rejected
// Verify that nil clients don't get stored, but valid clients do
is := assert.New(t)
// Registry ID 999 with a valid client should be found (the second call above)
client, found := helmregistrycache.GetCachedRegistryClientByID(999)
is.True(found)
is.NotNil(client)
// Registry ID 0 with nil client should not be found
client, found = helmregistrycache.GetCachedRegistryClientByID(0)
is.False(found)
is.Nil(client)
// Registry ID 123 with nil client should not be found
client, found = helmregistrycache.GetCachedRegistryClientByID(123)
is.False(found)
is.Nil(client)
})
}
func TestFlushRegistryCache(t *testing.T) {
// Initialize cache for testing
err := helmregistrycache.Initialize("24h")
if err != nil {
t.Fatalf("Failed to initialize cache: %v", err)
}
// Clear cache before test
helmregistrycache.FlushAll()
t.Run("should flush specific registry cache", func(t *testing.T) {
is := assert.New(t)
// Create mock clients
client1 := &registry.Client{}
client2 := &registry.Client{}
// Store in cache
helmregistrycache.SetCachedRegistryClientByID(123, client1)
helmregistrycache.SetCachedRegistryClientByID(456, client2)
// Verify both are cached
client, found := helmregistrycache.GetCachedRegistryClientByID(123)
is.True(found)
is.NotNil(client)
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
is.True(found)
is.NotNil(client)
// Flush only one
helmregistrycache.FlushRegistryByID(123)
// Verify only one is flushed
client, found = helmregistrycache.GetCachedRegistryClientByID(123)
is.False(found)
is.Nil(client)
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
is.True(found)
is.NotNil(client)
})
}
func TestFlushAllRegistryCache(t *testing.T) {
// Initialize cache for testing
err := helmregistrycache.Initialize("24h")
if err != nil {
t.Fatalf("Failed to initialize cache: %v", err)
}
t.Run("should flush all registry cache", func(t *testing.T) {
is := assert.New(t)
// Create mock clients
client1 := &registry.Client{}
client2 := &registry.Client{}
// Store in cache
helmregistrycache.SetCachedRegistryClientByID(123, client1)
helmregistrycache.SetCachedRegistryClientByID(456, client2)
// Verify both are cached
client, found := helmregistrycache.GetCachedRegistryClientByID(123)
is.True(found)
is.NotNil(client)
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
is.True(found)
is.NotNil(client)
// Flush all
helmregistrycache.FlushAll()
// Verify both are flushed
client, found = helmregistrycache.GetCachedRegistryClientByID(123)
is.False(found)
is.Nil(client)
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
is.False(found)
is.Nil(client)
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
is.False(found)
is.Nil(client)
})
}
func TestValidateRegistryCredentials(t *testing.T) {
tests := []struct {
name string
registry *portainer.Registry
expectError bool
errorMsg string
}{
{
name: "nil registry should pass validation",
registry: nil,
expectError: false,
},
{
name: "registry with authentication disabled should pass validation",
registry: &portainer.Registry{
Authentication: false,
},
expectError: false,
},
{
name: "registry with authentication enabled and valid credentials should pass",
registry: &portainer.Registry{
Authentication: true,
Username: "testuser",
Password: "testpass",
},
expectError: false,
},
{
name: "registry with authentication enabled but empty username should fail",
registry: &portainer.Registry{
Authentication: true,
Username: "",
Password: "testpass",
},
expectError: true,
errorMsg: "username is required when registry authentication is enabled",
},
{
name: "registry with authentication enabled but whitespace username should fail",
registry: &portainer.Registry{
Authentication: true,
Username: " ",
Password: "testpass",
},
expectError: true,
errorMsg: "username is required when registry authentication is enabled",
},
{
name: "registry with authentication enabled but empty password should fail",
registry: &portainer.Registry{
Authentication: true,
Username: "testuser",
Password: "",
},
expectError: true,
errorMsg: "password is required when registry authentication is enabled",
},
{
name: "registry with authentication enabled but whitespace password should fail",
registry: &portainer.Registry{
Authentication: true,
Username: "testuser",
Password: " ",
},
expectError: true,
errorMsg: "password is required when registry authentication is enabled",
},
{
name: "GitLab registry with authentication enabled and valid credentials should pass",
registry: &portainer.Registry{
Type: portainer.GitlabRegistry,
Authentication: true,
Username: "gitlab-ci-token",
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
Gitlab: portainer.GitlabRegistryData{
ProjectID: 12345,
InstanceURL: "https://gitlab.com",
ProjectPath: "my-group/my-project",
},
},
expectError: false,
},
{
name: "GitLab registry with authentication enabled but empty username should fail",
registry: &portainer.Registry{
Type: portainer.GitlabRegistry,
Authentication: true,
Username: "",
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
Gitlab: portainer.GitlabRegistryData{
ProjectID: 12345,
InstanceURL: "https://gitlab.com",
ProjectPath: "my-group/my-project",
},
},
expectError: true,
errorMsg: "username is required when registry authentication is enabled",
},
{
name: "GitLab registry with authentication enabled but empty password should fail",
registry: &portainer.Registry{
Type: portainer.GitlabRegistry,
Authentication: true,
Username: "gitlab-ci-token",
Password: "",
Gitlab: portainer.GitlabRegistryData{
ProjectID: 12345,
InstanceURL: "https://gitlab.com",
ProjectPath: "my-group/my-project",
},
},
expectError: true,
errorMsg: "password is required when registry authentication is enabled",
},
{
name: "GitLab registry with authentication disabled should pass validation",
registry: &portainer.Registry{
Type: portainer.GitlabRegistry,
Authentication: false,
Gitlab: portainer.GitlabRegistryData{
ProjectID: 12345,
InstanceURL: "https://gitlab.com",
ProjectPath: "my-group/my-project",
},
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateRegistryCredentials(tt.registry)
if tt.expectError {
assert.Error(t, err)
if err != nil {
assert.Equal(t, tt.errorMsg, err.Error())
}
} else {
assert.NoError(t, err)
}
})
}
}
// Note: buildCacheKey function was removed since we now use registry ID-based caching
// instead of endpoint/session-based caching for better rate limiting protection
func TestShouldFlushCacheOnError(t *testing.T) {
tests := []struct {
name string
err error
registryID portainer.RegistryID
shouldFlush bool
}{
{
name: "nil error should not flush",
err: nil,
registryID: 123,
shouldFlush: false,
},
{
name: "zero registry ID should not flush",
err: errors.New("some error"),
registryID: 0,
shouldFlush: false,
},
{
name: "unauthorized error should flush",
err: errors.New("unauthorized access to registry"),
registryID: 123,
shouldFlush: true,
},
{
name: "authentication failed error should flush",
err: errors.New("authentication failed"),
registryID: 123,
shouldFlush: true,
},
{
name: "login failed error should flush",
err: errors.New("login failed for user"),
registryID: 123,
shouldFlush: true,
},
{
name: "invalid credentials error should flush",
err: errors.New("invalid credentials provided"),
registryID: 123,
shouldFlush: true,
},
{
name: "access denied error should flush",
err: errors.New("access denied to repository"),
registryID: 123,
shouldFlush: true,
},
{
name: "forbidden error should flush",
err: errors.New("forbidden: insufficient permissions"),
registryID: 123,
shouldFlush: true,
},
{
name: "401 error should flush",
err: errors.New("HTTP 401 Unauthorized"),
registryID: 123,
shouldFlush: true,
},
{
name: "403 error should flush",
err: errors.New("HTTP 403 Forbidden"),
registryID: 123,
shouldFlush: true,
},
{
name: "token error should flush",
err: errors.New("token expired or invalid"),
registryID: 123,
shouldFlush: true,
},
{
name: "auth error should flush",
err: errors.New("auth validation failed"),
registryID: 123,
shouldFlush: true,
},
{
name: "chart not found error should not flush",
err: errors.New("chart not found in repository"),
registryID: 123,
shouldFlush: false,
},
{
name: "network error should not flush",
err: errors.New("connection timeout"),
registryID: 123,
shouldFlush: false,
},
{
name: "helm validation error should not flush",
err: errors.New("invalid chart values"),
registryID: 123,
shouldFlush: false,
},
{
name: "kubernetes error should not flush",
err: errors.New("namespace not found"),
registryID: 123,
shouldFlush: false,
},
{
name: "case insensitive matching works",
err: errors.New("UNAUTHORIZED access denied"),
registryID: 123,
shouldFlush: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := shouldFlushCacheOnError(tt.err, tt.registryID)
is := assert.New(t)
is.Equal(tt.shouldFlush, result, "Expected shouldFlushCacheOnError to return %v for error: %v", tt.shouldFlush, tt.err)
})
}
}

View file

@ -1,24 +1,38 @@
package sdk
import (
"fmt"
"maps"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/chart/loader"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/downloader"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo"
)
// Helm chart reference label constants
const (
ChartPathAnnotation = "portainer/chart-path"
RepoURLAnnotation = "portainer/repo-url"
RegistryIDAnnotation = "portainer/registry-id"
)
// loadAndValidateChartWithPathOptions locates and loads the chart, and validates it.
// it also checks for chart dependencies and updates them if necessary.
// it returns the chart information.
func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPathOptions *action.ChartPathOptions, chartName, version string, repoURL string, dependencyUpdate bool, operation string) (*chart.Chart, error) {
// Locate and load the chart
chartPathOptions.RepoURL = repoURL
chartPathOptions.Version = version
chartPath, err := chartPathOptions.LocateChart(chartName, hspm.settings)
if err != nil {
log.Error().
@ -26,6 +40,11 @@ func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPath
Str("chart", chartName).
Err(err).
Msg("Failed to locate chart for helm " + operation)
// For OCI charts, chartName already contains the full reference
if strings.HasPrefix(chartName, options.OCIProtocolPrefix) {
return nil, errors.Wrapf(err, "failed to find the helm chart: %s", chartName)
}
return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", repoURL, chartName)
}
@ -86,3 +105,186 @@ func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPath
return chartReq, nil
}
// parseRepoURL parses and validates a Helm repository URL using RFC 3986 standards.
// Used by search and show operations before downloading index.yaml files.
func parseRepoURL(repoURL string) (*url.URL, error) {
parsedURL, err := url.ParseRequestURI(repoURL)
if err != nil {
return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
}
return parsedURL, nil
}
// getRepoNameFromURL generates a unique repository identifier from a URL.
// Combines hostname and path for uniqueness (e.g., "charts.helm.sh/stable" → "charts.helm.sh-stable").
// Used for Helm's repositories.yaml entries, caching, and chart references.
func getRepoNameFromURL(urlStr string) (string, error) {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}
hostname := parsedURL.Hostname()
path := parsedURL.Path
path = strings.Trim(path, "/")
path = strings.ReplaceAll(path, "/", "-")
if path == "" {
return hostname, nil
}
return fmt.Sprintf("%s-%s", hostname, path), nil
}
// loadIndexFile loads and parses a Helm repository index.yaml file.
// Called after downloading from HTTP repos or generating from OCI registries.
// Contains chart metadata used for discovery, version resolution, and caching.
func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
log.Debug().
Str("context", "HelmClient").
Str("index_path", indexPath).
Msg("Loading index file")
indexFile, err := repo.LoadIndexFile(indexPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("index_path", indexPath).
Err(err).
Msg("Failed to load index file")
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
}
return indexFile, nil
}
// ensureHelmDirectoriesExist creates required Helm directories and configuration files.
// Creates repository cache, config directories, and ensures repositories.yaml exists.
// Essential for Helm operations to function properly.
func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Ensuring Helm directories exist")
// List of directories to ensure exist
directories := []string{
filepath.Dir(settings.RepositoryConfig), // Repository config directory
settings.RepositoryCache, // Repository cache directory
filepath.Dir(settings.RegistryConfig), // Registry config directory
settings.PluginsDirectory, // Plugins directory
}
// Create each directory if it doesn't exist
for _, dir := range directories {
if dir == "" {
continue // Skip empty paths
}
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
}
}
// Ensure registry config file exists
if settings.RegistryConfig != "" {
if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
// Create the directory if it doesn't exist
dir := filepath.Dir(settings.RegistryConfig)
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
// Create an empty registry config file
if _, err := os.Create(settings.RegistryConfig); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RegistryConfig).
Err(err).
Msg("Failed to create registry config file")
return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
}
}
}
// Ensure repository config file exists
if settings.RepositoryConfig != "" {
if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
// Create an empty repository config file with default yaml structure
f := repo.NewFile()
if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RepositoryConfig).
Err(err).
Msg("Failed to create repository config file")
return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
}
}
}
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Successfully ensured all Helm directories exist")
return nil
}
// appendChartReferenceAnnotations encodes chart reference values for safe storage in Helm labels.
// It creates a new map with encoded values for specific chart reference labels.
// Preserves existing labels and handles edge cases gracefully.
func appendChartReferenceAnnotations(chartPath, repoURL string, registryID int, existingAnnotations map[string]string) map[string]string {
// Copy existing annotations
annotations := make(map[string]string)
maps.Copy(annotations, existingAnnotations)
// delete the existing portainer specific labels, for a clean overwrite
delete(annotations, ChartPathAnnotation)
delete(annotations, RepoURLAnnotation)
delete(annotations, RegistryIDAnnotation)
if chartPath != "" {
annotations[ChartPathAnnotation] = chartPath
}
if repoURL != "" && registryID == 0 {
annotations[RepoURLAnnotation] = repoURL
}
if registryID != 0 {
annotations[RegistryIDAnnotation] = strconv.Itoa(registryID)
}
return annotations
}
// extractChartReferenceAnnotations decodes chart reference labels for display purposes.
// It handles existing labels gracefully and only decodes known chart reference labels.
// If a chart reference label cannot be decoded, it is omitted entirely from the result.
// Returns a ChartReference struct with decoded values.
func extractChartReferenceAnnotations(annotations map[string]string) release.ChartReference {
if annotations == nil {
return release.ChartReference{}
}
registryID, err := strconv.Atoi(annotations[RegistryIDAnnotation])
if err != nil {
registryID = 0
}
return release.ChartReference{
ChartPath: annotations[ChartPathAnnotation],
RepoURL: annotations[RepoURLAnnotation],
RegistryID: int64(registryID),
}
}

View file

@ -97,6 +97,7 @@ func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Rel
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
},
},
Values: values,
Values: values,
ChartReference: extractChartReferenceAnnotations(sdkRelease.Chart.Metadata.Annotations),
}
}

View file

@ -4,6 +4,7 @@ import (
"time"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
@ -42,6 +43,12 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
}
// Setup chart source
err = authenticateChartSource(actionConfig, installOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to setup chart source for helm release installation")
}
installClient, err := initInstallClient(actionConfig, installOpts)
if err != nil {
log.Error().
@ -51,7 +58,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to initialize helm install client for helm release installation")
}
values, err := hspm.GetHelmValuesFromFile(installOpts.ValuesFile)
values, err := hspm.getHelmValuesFromFile(installOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
@ -60,15 +67,36 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
}
chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Version, installOpts.Repo, installClient.DependencyUpdate, "release installation")
chartRef, repoURL, err := parseChartRef(installOpts.Chart, installOpts.Repo, installOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to parse chart reference for helm release installation")
}
chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, chartRef, installOpts.Version, repoURL, installClient.DependencyUpdate, "release installation")
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to load and validate chart for helm release installation")
// Check if this is an authentication error and flush cache if needed
if installOpts.Registry != nil && shouldFlushCacheOnError(err, installOpts.Registry.ID) {
cache.FlushRegistryByID(installOpts.Registry.ID)
log.Info().
Int("registry_id", int(installOpts.Registry.ID)).
Str("context", "HelmClient").
Msg("Flushed registry cache due to chart loading authentication error during install")
}
return nil, errors.Wrap(err, "failed to load and validate chart for helm release installation")
}
// Add chart references to annotations
var registryID int
if installOpts.Registry != nil {
registryID = int(installOpts.Registry.ID)
}
chart.Metadata.Annotations = appendChartReferenceAnnotations(installOpts.Chart, installOpts.Repo, registryID, chart.Metadata.Annotations)
// Run the installation
log.Info().
Str("context", "HelmClient").
@ -76,7 +104,6 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace).
Msg("Running chart installation for helm release")
helmRelease, err := installClient.Run(chart, values)
if err != nil {
log.Error().
@ -94,9 +121,10 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
Namespace: helmRelease.Namespace,
Chart: release.Chart{
Metadata: &release.Metadata{
Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion,
Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion,
Annotations: helmRelease.Chart.Metadata.Annotations,
},
},
Labels: helmRelease.Labels,
@ -111,13 +139,17 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I
installClient := action.NewInstall(actionConfig)
installClient.DependencyUpdate = true
installClient.ReleaseName = installOpts.Name
installClient.ChartPathOptions.RepoURL = installOpts.Repo
installClient.Wait = installOpts.Wait
installClient.Timeout = installOpts.Timeout
installClient.Version = installOpts.Version
err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation")
}
// Set default values if not specified
if installOpts.Timeout == 0 {
installClient.Timeout = 5 * time.Minute
installClient.Timeout = 15 * time.Minute // set a bigger timeout for large charts
} else {
installClient.Timeout = installOpts.Timeout
}

View file

@ -1,24 +1,30 @@
package sdk
import (
"net/url"
"os"
"context"
"fmt"
"io"
"path/filepath"
"strings"
"sync"
"time"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/liboras"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo"
"oras.land/oras-go/v2/registry"
)
var (
errRequiredSearchOptions = errors.New("repo is required")
errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
)
type RepoIndex struct {
@ -40,7 +46,6 @@ var (
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options
if err := validateSearchRepoOptions(searchRepoOpts); err != nil {
log.Error().
Str("context", "HelmClient").
@ -55,33 +60,8 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
Str("repo", searchRepoOpts.Repo).
Msg("Searching repository")
// Parse and validate the repository URL
repoURL, err := parseRepoURL(searchRepoOpts.Repo)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", searchRepoOpts.Repo).
Err(err).
Msg("Invalid repository URL")
return nil, err
}
// Check cache first
if searchRepoOpts.UseCache {
cacheMutex.RLock()
if cached, exists := indexCache[repoURL.String()]; exists {
if time.Since(cached.Timestamp) < cacheDuration {
cacheMutex.RUnlock()
return convertAndMarshalIndex(cached.Index, searchRepoOpts.Chart)
}
}
cacheMutex.RUnlock()
}
// Set up Helm CLI environment
repoSettings := cli.New()
// Ensure all required Helm directories exist
if err := ensureHelmDirectoriesExist(repoSettings); err != nil {
log.Error().
Str("context", "HelmClient").
@ -90,7 +70,88 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
}
repoName, err := getRepoNameFromURL(repoURL.String())
// Try cache first for HTTP repos
if IsHTTPRepository(searchRepoOpts.Registry) && searchRepoOpts.UseCache {
if cachedResult := hspm.tryGetFromCache(searchRepoOpts.Repo, searchRepoOpts.Chart); cachedResult != nil {
return cachedResult, nil
}
}
// Download index based on source type
indexFile, err := hspm.downloadRepoIndex(searchRepoOpts, repoSettings)
if err != nil {
return nil, err
}
// Update cache for HTTP repos
if IsHTTPRepository(searchRepoOpts.Registry) {
hspm.updateCache(searchRepoOpts.Repo, indexFile)
}
return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
}
// tryGetFromCache attempts to retrieve a cached index file and convert it to the response format
func (hspm *HelmSDKPackageManager) tryGetFromCache(repoURL, chartName string) []byte {
cacheMutex.RLock()
defer cacheMutex.RUnlock()
if cached, exists := indexCache[repoURL]; exists {
if time.Since(cached.Timestamp) < cacheDuration {
result, err := convertAndMarshalIndex(cached.Index, chartName)
if err != nil {
log.Debug().
Str("context", "HelmClient").
Str("repo", repoURL).
Err(err).
Msg("Failed to convert cached index")
return nil
}
return result
}
}
return nil
}
// updateCache updates the cache with the provided index file and cleans up expired entries
func (hspm *HelmSDKPackageManager) updateCache(repoURL string, indexFile *repo.IndexFile) {
cacheMutex.Lock()
defer cacheMutex.Unlock()
indexCache[repoURL] = RepoIndexCache{
Index: indexFile,
Timestamp: time.Now(),
}
// Clean up expired entries
for key, index := range indexCache {
if time.Since(index.Timestamp) > cacheDuration {
delete(indexCache, key)
}
}
}
// downloadRepoIndex downloads the repository index based on the source type (HTTP or OCI)
func (hspm *HelmSDKPackageManager) downloadRepoIndex(opts options.SearchRepoOptions, repoSettings *cli.EnvSettings) (*repo.IndexFile, error) {
if IsOCIRegistry(opts.Registry) {
return hspm.downloadOCIRepoIndex(opts.Registry, repoSettings, opts.Chart)
}
return hspm.downloadHTTPRepoIndex(opts.Repo, repoSettings)
}
// downloadHTTPRepoIndex downloads and loads an index file from an HTTP repository
func (hspm *HelmSDKPackageManager) downloadHTTPRepoIndex(repoURL string, repoSettings *cli.EnvSettings) (*repo.IndexFile, error) {
parsedURL, err := parseRepoURL(repoURL)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", repoURL).
Err(err).
Msg("Invalid repository URL")
return nil, err
}
repoName, err := getRepoNameFromURL(parsedURL.String())
if err != nil {
log.Error().
Str("context", "HelmClient").
@ -99,70 +160,55 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err
}
// Download the index file and update repository configuration
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, repoName)
indexPath, err := downloadRepoIndexFromHttpRepo(parsedURL.String(), repoSettings, repoName)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo_url", repoURL.String()).
Str("repo_url", parsedURL.String()).
Err(err).
Msg("Failed to download repository index")
return nil, err
}
// Load and parse the index file
log.Debug().
Str("context", "HelmClient").
Str("index_path", indexPath).
Msg("Loading index file")
return loadIndexFile(indexPath)
}
indexFile, err := loadIndexFile(indexPath)
// downloadOCIRepoIndex downloads and loads an index file from an OCI registry
func (hspm *HelmSDKPackageManager) downloadOCIRepoIndex(registry *portainer.Registry, repoSettings *cli.EnvSettings, chartPath string) (*repo.IndexFile, error) {
// Validate registry credentials first
if err := validateRegistryCredentials(registry); err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", registry.URL).
Err(err).
Msg("Registry credential validation failed for OCI search")
return nil, fmt.Errorf("registry credential validation failed: %w", err)
}
indexPath, err := downloadRepoIndexFromOciRegistry(registry, repoSettings, chartPath)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("index_path", indexPath).
Str("repo", registry.URL).
Err(err).
Msg("Failed to load index file")
Msg("Failed to download repository index")
return nil, err
}
// Update cache and remove old entries
cacheMutex.Lock()
indexCache[searchRepoOpts.Repo] = RepoIndexCache{
Index: indexFile,
Timestamp: time.Now(),
}
for key, index := range indexCache {
if time.Since(index.Timestamp) > cacheDuration {
delete(indexCache, key)
}
}
cacheMutex.Unlock()
return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
return loadIndexFile(indexPath)
}
// validateSearchRepoOptions validates the required search repository options.
func validateSearchRepoOptions(opts options.SearchRepoOptions) error {
if opts.Repo == "" {
if opts.Repo == "" && IsHTTPRepository(opts.Registry) {
return errRequiredSearchOptions
}
return nil
}
// parseRepoURL parses and validates the repository URL.
func parseRepoURL(repoURL string) (*url.URL, error) {
parsedURL, err := url.ParseRequestURI(repoURL)
if err != nil {
return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
}
return parsedURL, nil
}
// downloadRepoIndex downloads the index.yaml file from the repository and updates
// downloadRepoIndexFromHttpRepo downloads the index.yaml file from the repository and updates
// the repository configuration.
func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
func downloadRepoIndexFromHttpRepo(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
log.Debug().
Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString).
@ -183,7 +229,7 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
Str("repo_url", repoURLString).
Err(err).
Msg("Failed to create chart repository object")
return "", errInvalidRepoURL
return "", errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
}
// Load repository configuration file
@ -239,13 +285,168 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
return indexPath, nil
}
// loadIndexFile loads the index file from the given path.
func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
indexFile, err := repo.LoadIndexFile(indexPath)
if err != nil {
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
func downloadRepoIndexFromOciRegistry(registry *portainer.Registry, repoSettings *cli.EnvSettings, chartPath string) (string, error) {
if IsHTTPRepository(registry) {
return "", errors.New("registry information is required for OCI search")
}
return indexFile, nil
if chartPath == "" {
return "", errors.New("chart path is required for OCI search")
}
ctx := context.Background()
registryClient, err := liboras.CreateClient(*registry)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index_oci").
Str("registry_url", registry.URL).
Err(err).
Msg("Failed to create ORAS registry client")
return "", errors.Wrap(err, "failed to create ORAS registry client")
}
// Obtain repository handle for the specific chart path (relative to registry host)
repository, err := registryClient.Repository(ctx, chartPath)
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index_oci").
Str("repository", chartPath).
Err(err).
Msg("Failed to obtain repository handle")
return "", errors.Wrap(err, "failed to obtain repository handle")
}
// List all tags for this chart repository
var tags []string
err = repository.Tags(ctx, "", func(t []string) error {
tags = append(tags, t...)
return nil
})
if err != nil {
log.Error().
Str("context", "helm_sdk_repo_index_oci").
Str("repository", chartPath).
Err(err).
Msg("Failed to list tags")
return "", errors.Wrap(err, "failed to list tags for repository")
}
if len(tags) == 0 {
return "", errors.Errorf("no tags found for repository %s", chartPath)
}
// Build Helm index file in memory
indexFile := repo.NewIndexFile()
const helmConfigMediaType = "application/vnd.cncf.helm.config.v1+json"
for _, tag := range tags {
chartVersion, err := processOCITag(ctx, repository, registry, chartPath, tag, helmConfigMediaType)
if err != nil {
log.Debug().
Str("context", "helm_sdk_repo_index_oci").
Str("repository", chartPath).
Str("tag", tag).
Err(err).
Msg("Failed to process tag; skipping")
continue
}
if chartVersion != nil {
indexFile.Entries[chartVersion.Name] = append(indexFile.Entries[chartVersion.Name], chartVersion)
}
}
if len(indexFile.Entries) == 0 {
return "", errors.Errorf("no helm chart versions found for repository %s", chartPath)
}
indexFile.SortEntries()
fileNameSafe := strings.ReplaceAll(chartPath, "/", "-")
destPath := filepath.Join(repoSettings.RepositoryCache, fmt.Sprintf("%s-%d-index.yaml", fileNameSafe, time.Now().UnixNano()))
if err := indexFile.WriteFile(destPath, 0644); err != nil {
return "", errors.Wrap(err, "failed to write OCI index file")
}
log.Debug().
Str("context", "helm_sdk_repo_index_oci").
Str("dest_path", destPath).
Int("entries", len(indexFile.Entries)).
Msg("Successfully generated OCI index file")
return destPath, nil
}
// processOCITag processes a single OCI tag and returns a Helm chart version.
func processOCITag(ctx context.Context, repository registry.Repository, registry *portainer.Registry, chartPath string, tag string, helmConfigMediaType string) (*repo.ChartVersion, error) {
// Resolve tag to get descriptor
descriptor, err := repository.Resolve(ctx, tag)
if err != nil {
log.Debug().
Str("context", "helm_sdk_repo_index_oci").
Str("repository", chartPath).
Str("tag", tag).
Err(err).
Msg("Failed to resolve tag; skipping")
return nil, nil
}
// Fetch manifest to validate media type and obtain config descriptor
manifestReader, err := repository.Manifests().Fetch(ctx, descriptor)
if err != nil {
log.Debug().
Str("context", "helm_sdk_repo_index_oci").
Str("repository", chartPath).
Str("tag", tag).
Err(err).
Msg("Failed to fetch manifest; skipping")
return nil, nil
}
manifestContent, err := io.ReadAll(manifestReader)
manifestReader.Close()
if err != nil {
return nil, nil
}
var manifest ocispec.Manifest
if err := json.Unmarshal(manifestContent, &manifest); err != nil {
return nil, nil
}
// Ensure manifest config is Helm chart metadata
if manifest.Config.MediaType != helmConfigMediaType {
return nil, nil
}
// Fetch config blob (chart metadata)
cfgReader, err := repository.Blobs().Fetch(ctx, manifest.Config)
if err != nil {
return nil, nil
}
cfgBytes, err := io.ReadAll(cfgReader)
cfgReader.Close()
if err != nil {
return nil, nil
}
var metadata chart.Metadata
if err := json.Unmarshal(cfgBytes, &metadata); err != nil {
return nil, nil
}
// Build chart version entry
chartVersion := &repo.ChartVersion{
Metadata: &metadata,
URLs: []string{fmt.Sprintf("oci://%s/%s:%s", registry.URL, chartPath, tag)},
Created: time.Now(),
Digest: descriptor.Digest.String(),
}
return chartVersion, nil
}
// convertIndexToResponse converts the Helm index file to our response format.
@ -258,7 +459,7 @@ func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIn
// Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries {
if chartName == "" || name == chartName {
if chartName == "" || strings.Contains(strings.ToLower(chartName), strings.ToLower(name)) {
result.Entries[name] = convertChartsToChartInfo(charts)
}
}
@ -304,87 +505,6 @@ type ChartInfo struct {
Annotations any `json:"annotations,omitempty"`
}
// ensureHelmDirectoriesExist checks and creates required Helm directories if they don't exist
func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Ensuring Helm directories exist")
// List of directories to ensure exist
directories := []string{
filepath.Dir(settings.RepositoryConfig), // Repository config directory
settings.RepositoryCache, // Repository cache directory
filepath.Dir(settings.RegistryConfig), // Registry config directory
settings.PluginsDirectory, // Plugins directory
}
// Create each directory if it doesn't exist
for _, dir := range directories {
if dir == "" {
continue // Skip empty paths
}
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
}
}
// Ensure registry config file exists
if settings.RegistryConfig != "" {
if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
// Create the directory if it doesn't exist
dir := filepath.Dir(settings.RegistryConfig)
if err := os.MkdirAll(dir, 0700); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("directory", dir).
Err(err).
Msg("Failed to create directory")
return errors.Wrapf(err, "failed to create directory: %s", dir)
}
// Create an empty registry config file
if _, err := os.Create(settings.RegistryConfig); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RegistryConfig).
Err(err).
Msg("Failed to create registry config file")
return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
}
}
}
// Ensure repository config file exists
if settings.RepositoryConfig != "" {
if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
// Create an empty repository config file with default yaml structure
f := repo.NewFile()
if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
log.Error().
Str("context", "helm_sdk_dirs").
Str("file", settings.RepositoryConfig).
Err(err).
Msg("Failed to create repository config file")
return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
}
}
}
log.Debug().
Str("context", "helm_sdk_dirs").
Msg("Successfully ensured all Helm directories exist")
return nil
}
func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) {
// Convert the index file to our response format
result, err := convertIndexToResponse(indexFile, chartName)

View file

@ -2,21 +2,20 @@ package sdk
import (
"fmt"
"net/url"
"strings"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
)
var errRequiredShowOptions = errors.New("chart, repo and output format are required")
var errRequiredShowOptions = errors.New("chart, output format and either repo or registry are required")
// Show implements the HelmPackageManager interface by using the Helm SDK to show chart information.
// It supports showing chart values, readme, and chart details based on the provided ShowOptions.
func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
if showOpts.Chart == "" || (showOpts.Repo == "" && IsHTTPRepository(showOpts.Registry)) || showOpts.OutputFormat == "" {
log.Error().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
@ -33,31 +32,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
Str("output_format", string(showOpts.OutputFormat)).
Msg("Showing chart information")
repoURL, err := parseRepoURL(showOpts.Repo)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("repo", showOpts.Repo).
Err(err).
Msg("Invalid repository URL")
return nil, err
}
repoName, err := getRepoNameFromURL(repoURL.String())
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get hostname from URL")
return nil, err
}
// Initialize action configuration (no namespace or cluster access needed)
actionConfig := new(action.Configuration)
err = hspm.initActionConfig(actionConfig, "", nil)
err := authenticateChartSource(actionConfig, showOpts.Registry)
if err != nil {
// error is already logged in initActionConfig
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
return nil, fmt.Errorf("failed to setup chart source: %w", err)
}
// Create showClient action
@ -70,22 +48,28 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
return nil, fmt.Errorf("failed to initialize helm show client: %w", err)
}
// Locate and load the chart
log.Debug().
Str("context", "HelmClient").
Str("chart", showOpts.Chart).
Str("repo", showOpts.Repo).
Msg("Locating chart")
fullChartPath := fmt.Sprintf("%s/%s", repoName, showOpts.Chart)
chartPath, err := showClient.ChartPathOptions.LocateChart(fullChartPath, hspm.settings)
chartRef, _, err := parseChartRef(showOpts.Chart, showOpts.Repo, showOpts.Registry)
if err != nil {
return nil, fmt.Errorf("failed to parse chart reference: %w", err)
}
chartPath, err := showClient.ChartPathOptions.LocateChart(chartRef, hspm.settings)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("chart", fullChartPath).
Str("chart", chartRef).
Str("repo", showOpts.Repo).
Err(err).
Msg("Failed to locate chart")
// Check if this is an authentication error and flush cache if needed
if showOpts.Registry != nil && shouldFlushCacheOnError(err, showOpts.Registry.ID) {
cache.FlushRegistryByID(showOpts.Registry.ID)
log.Info().
Int("registry_id", int(showOpts.Registry.ID)).
Str("context", "HelmClient").
Msg("Flushed registry cache due to chart registry authentication error")
}
return nil, fmt.Errorf("failed to locate chart: %w", err)
}
@ -98,6 +82,16 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
Str("output_format", string(showOpts.OutputFormat)).
Err(err).
Msg("Failed to show chart info")
// Check if this is an authentication error and flush cache if needed
if showOpts.Registry != nil && shouldFlushCacheOnError(err, showOpts.Registry.ID) {
cache.FlushRegistryByID(showOpts.Registry.ID)
log.Info().
Int("registry_id", int(showOpts.Registry.ID)).
Str("context", "HelmClient").
Msg("Flushed registry cache due to chart show authentication error")
}
return nil, fmt.Errorf("failed to show chart info: %w", err)
}
@ -114,7 +108,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
// and return the show client.
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
showClient.ChartPathOptions.Version = showOpts.Version
err := configureChartPathOptions(&showClient.ChartPathOptions, showOpts.Version, showOpts.Repo, showOpts.Registry)
if err != nil {
return nil, fmt.Errorf("failed to configure chart path options: %w", err)
}
// Set output type based on ShowOptions
switch showOpts.OutputFormat {
@ -134,26 +131,3 @@ func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOpt
return showClient, nil
}
// getRepoNameFromURL extracts a unique repository identifier from a URL string.
// It combines hostname and path to ensure uniqueness across different repositories on the same host.
// Examples:
// - https://portainer.github.io/test-public-repo/ -> portainer.github.io-test-public-repo
// - https://portainer.github.io/another-repo/ -> portainer.github.io-another-repo
// - https://charts.helm.sh/stable -> charts.helm.sh-stable
func getRepoNameFromURL(urlStr string) (string, error) {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}
hostname := parsedURL.Hostname()
path := parsedURL.Path
path = strings.Trim(path, "/")
path = strings.ReplaceAll(path, "/", "-")
if path == "" {
return hostname, nil
}
return fmt.Sprintf("%s-%s", hostname, path), nil
}

View file

@ -28,7 +28,7 @@ func Test_Show(t *testing.T) {
})
}
t.Run("show requires chart, repo and output format", func(t *testing.T) {
t.Run("show requires chart, output format and repo or registry", func(t *testing.T) {
showOpts := options.ShowOptions{
Chart: "",
Repo: "",
@ -36,7 +36,7 @@ func Test_Show(t *testing.T) {
}
_, err := hspm.Show(showOpts)
is.Error(err, "should return error when required options are missing")
is.Contains(err.Error(), "chart, repo and output format are required", "error message should indicate required options")
is.Contains(err.Error(), "chart, output format and either repo or registry are required", "error message should indicate required options")
})
t.Run("show chart values", func(t *testing.T) {

View file

@ -4,6 +4,7 @@ import (
"time"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
@ -66,6 +67,12 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
}
// Setup chart source
err = authenticateChartSource(actionConfig, upgradeOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to setup chart source for helm release upgrade")
}
upgradeClient, err := initUpgradeClient(actionConfig, upgradeOpts)
if err != nil {
log.Error().
@ -75,7 +82,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to initialize helm upgrade client for helm release upgrade")
}
values, err := hspm.GetHelmValuesFromFile(upgradeOpts.ValuesFile)
values, err := hspm.getHelmValuesFromFile(upgradeOpts.ValuesFile)
if err != nil {
log.Error().
Str("context", "HelmClient").
@ -84,15 +91,36 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release upgrade")
}
chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Chart, upgradeOpts.Version, upgradeOpts.Repo, upgradeClient.DependencyUpdate, "release upgrade")
chartRef, repoURL, err := parseChartRef(upgradeOpts.Chart, upgradeOpts.Repo, upgradeOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to parse chart reference for helm release upgrade")
}
chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, chartRef, upgradeOpts.Version, repoURL, upgradeClient.DependencyUpdate, "release upgrade")
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to load and validate chart for helm release upgrade")
// Check if this is an authentication error and flush cache if needed
if upgradeOpts.Registry != nil && shouldFlushCacheOnError(err, upgradeOpts.Registry.ID) {
cache.FlushRegistryByID(upgradeOpts.Registry.ID)
log.Info().
Int("registry_id", int(upgradeOpts.Registry.ID)).
Str("context", "HelmClient").
Msg("Flushed registry cache due to chart loading authentication error during upgrade")
}
return nil, errors.Wrap(err, "failed to load and validate chart for helm release upgrade")
}
// Add chart references to annotations
var registryID int
if upgradeOpts.Registry != nil {
registryID = int(upgradeOpts.Registry.ID)
}
chart.Metadata.Annotations = appendChartReferenceAnnotations(upgradeOpts.Chart, upgradeOpts.Repo, registryID, chart.Metadata.Annotations)
log.Info().
Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart).
@ -117,9 +145,10 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
Namespace: helmRelease.Namespace,
Chart: release.Chart{
Metadata: &release.Metadata{
Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion,
Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion,
Annotations: helmRelease.Chart.Metadata.Annotations,
},
},
Labels: helmRelease.Labels,
@ -134,12 +163,20 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
upgradeClient := action.NewUpgrade(actionConfig)
upgradeClient.DependencyUpdate = true
upgradeClient.Atomic = upgradeOpts.Atomic
upgradeClient.ChartPathOptions.RepoURL = upgradeOpts.Repo
upgradeClient.Wait = upgradeOpts.Wait
upgradeClient.Version = upgradeOpts.Version
err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry)
if err != nil {
return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade")
}
// Set default values if not specified
if upgradeOpts.Timeout == 0 {
upgradeClient.Timeout = 5 * time.Minute
if upgradeClient.Atomic {
upgradeClient.Timeout = 30 * time.Minute // the atomic flag significantly increases the upgrade time
} else {
upgradeClient.Timeout = 15 * time.Minute
}
} else {
upgradeClient.Timeout = upgradeOpts.Timeout
}

View file

@ -11,9 +11,9 @@ import (
"helm.sh/helm/v3/pkg/action"
)
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
// getHelmValuesFromFile reads the values file and parses it into a map[string]any
// and returns the map.
func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map[string]any, error) {
func (hspm *HelmSDKPackageManager) getHelmValuesFromFile(valuesFile string) (map[string]any, error) {
var vals map[string]any
if valuesFile != "" {
log.Debug().

View file

@ -0,0 +1,47 @@
package liboras
import (
"context"
"errors"
portainer "github.com/portainer/portainer/api"
"oras.land/oras-go/v2/registry/remote"
)
// GenericListRepoClient implements RepositoryListClient for standard OCI registries
// This client handles repository listing for registries that follow the standard OCI distribution spec
type GenericListRepoClient struct {
registry *portainer.Registry
registryClient *remote.Registry
}
// NewGenericListRepoClient creates a new generic repository listing client
func NewGenericListRepoClient(registry *portainer.Registry) *GenericListRepoClient {
return &GenericListRepoClient{
registry: registry,
// registryClient will be set when needed
}
}
// SetRegistryClient sets the ORAS registry client for repository listing operations
func (c *GenericListRepoClient) SetRegistryClient(registryClient *remote.Registry) {
c.registryClient = registryClient
}
// ListRepositories fetches repositories from a standard OCI registry using ORAS
func (c *GenericListRepoClient) ListRepositories(ctx context.Context) ([]string, error) {
if c.registryClient == nil {
return nil, errors.New("registry client not initialized for repository listing")
}
var repositories []string
err := c.registryClient.Repositories(ctx, "", func(repos []string) error {
repositories = append(repositories, repos...)
return nil
})
if err != nil {
return nil, errors.New("failed to list repositories")
}
return repositories, nil
}

View file

@ -0,0 +1,57 @@
package liboras
import (
"context"
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/github"
"github.com/rs/zerolog/log"
)
// GithubListRepoClient implements RepositoryListClient specifically for GitHub registries
// This client handles the GitHub Packages API's unique repository listing implementation
type GithubListRepoClient struct {
registry *portainer.Registry
client *github.Client
}
// NewGithubListRepoClient creates a new GitHub repository listing client
func NewGithubListRepoClient(registry *portainer.Registry) *GithubListRepoClient {
// Prefer the management configuration credentials when available
token := registry.Password
if registry.ManagementConfiguration != nil && registry.ManagementConfiguration.Password != "" {
token = registry.ManagementConfiguration.Password
}
client := github.NewClient(token)
return &GithubListRepoClient{
registry: registry,
client: client,
}
}
// ListRepositories fetches repositories from a GitHub registry using the GitHub Packages API
func (c *GithubListRepoClient) ListRepositories(ctx context.Context) ([]string, error) {
repositories, err := c.client.GetContainerPackages(
ctx,
c.registry.Github.UseOrganisation,
c.registry.Github.OrganisationName,
)
if err != nil {
log.Error().
Str("registry_name", c.registry.Name).
Err(err).
Msg("Failed to list GitHub repositories")
return nil, fmt.Errorf("failed to list GitHub repositories: %w", err)
}
log.Debug().
Bool("use_organisation", c.registry.Github.UseOrganisation).
Str("organisation_name", c.registry.Github.OrganisationName).
Int("repository_count", len(repositories)).
Msg("Successfully listed GitHub repositories")
return repositories, nil
}

View file

@ -0,0 +1,47 @@
package liboras
import (
"context"
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/gitlab"
"github.com/rs/zerolog/log"
)
// GitlabListRepoClient implements RepositoryListClient specifically for GitLab registries
// This client handles the GitLab Container Registry API's unique repository listing implementation
type GitlabListRepoClient struct {
registry *portainer.Registry
client *gitlab.Client
}
// NewGitlabListRepoClient creates a new GitLab repository listing client
func NewGitlabListRepoClient(registry *portainer.Registry) *GitlabListRepoClient {
client := gitlab.NewClient(registry.Gitlab.InstanceURL, registry.Password)
return &GitlabListRepoClient{
registry: registry,
client: client,
}
}
// ListRepositories fetches repositories from a GitLab registry using the GitLab API
func (c *GitlabListRepoClient) ListRepositories(ctx context.Context) ([]string, error) {
repositories, err := c.client.GetRegistryRepositoryNames(ctx, c.registry.Gitlab.ProjectID)
if err != nil {
log.Error().
Str("registry_name", c.registry.Name).
Err(err).
Msg("Failed to list GitLab repositories")
return nil, fmt.Errorf("failed to list GitLab repositories: %w", err)
}
log.Debug().
Str("gitlab_url", c.registry.Gitlab.InstanceURL).
Int("project_id", c.registry.Gitlab.ProjectID).
Int("repository_count", len(repositories)).
Msg("Successfully listed GitLab repositories")
return repositories, nil
}

View file

@ -0,0 +1,39 @@
package liboras
import (
"context"
portainer "github.com/portainer/portainer/api"
"oras.land/oras-go/v2/registry/remote"
)
// RepositoryListClient provides an interface specifically for listing repositories
// This exists because listing repositories isn't a standard OCI operation, and we need to handle
// different registry types differently.
type RepositoryListClient interface {
// ListRepositories returns a list of repository names from the registry
ListRepositories(ctx context.Context) ([]string, error)
}
// RepositoryListClientFactory creates repository listing clients based on registry type
type RepositoryListClientFactory struct{}
// NewRepositoryListClientFactory creates a new factory instance
func NewRepositoryListClientFactory() *RepositoryListClientFactory {
return &RepositoryListClientFactory{}
}
// CreateListClientWithRegistry creates a repository listing client based on the registry type
// and automatically configures it with the provided ORAS registry client for generic registries
func (f *RepositoryListClientFactory) CreateListClientWithRegistry(registry *portainer.Registry, registryClient *remote.Registry) (RepositoryListClient, error) {
switch registry.Type {
case portainer.GitlabRegistry:
return NewGitlabListRepoClient(registry), nil
case portainer.GithubRegistry:
return NewGithubListRepoClient(registry), nil
default:
genericClient := NewGenericListRepoClient(registry)
genericClient.SetRegistryClient(registryClient)
return genericClient, nil
}
}

79
pkg/liboras/registry.go Normal file
View file

@ -0,0 +1,79 @@
package liboras
import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/retry"
)
func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
registryClient, err := remote.NewRegistry(registry.URL)
if err != nil {
log.Error().Err(err).Str("registryUrl", registry.URL).Msg("Failed to create registry client")
return nil, err
}
// By default, oras sends multiple requests to get the full list of repos/tags/referrers.
// set a high page size limit for fewer round trips.
// e.g. https://github.com/oras-project/oras-go/blob/v2.6.0/registry/remote/registry.go#L129-L142
registryClient.RepositoryListPageSize = 1000
registryClient.TagListPageSize = 1000
registryClient.ReferrerListPageSize = 1000
// Only apply authentication if explicitly enabled AND credentials are provided
if registry.Authentication &&
strings.TrimSpace(registry.Username) != "" &&
strings.TrimSpace(registry.Password) != "" {
registryClient.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(registry.URL, auth.Credential{
Username: registry.Username,
Password: registry.Password,
}),
}
log.Debug().
Str("registryURL", registry.URL).
Str("registryType", getRegistryTypeName(registry.Type)).
Bool("authentication", true).
Msg("Created ORAS registry client with authentication")
} else {
// Use default client for anonymous access
registryClient.Client = retry.DefaultClient
log.Debug().
Str("registryURL", registry.URL).
Str("registryType", getRegistryTypeName(registry.Type)).
Bool("authentication", false).
Msg("Created ORAS registry client for anonymous access")
}
return registryClient, nil
}
// getRegistryTypeName returns a human-readable name for the registry type
func getRegistryTypeName(registryType portainer.RegistryType) string {
switch registryType {
case portainer.QuayRegistry:
return "Quay"
case portainer.AzureRegistry:
return "Azure"
case portainer.CustomRegistry:
return "Custom"
case portainer.GitlabRegistry:
return "GitLab"
case portainer.ProGetRegistry:
return "ProGet"
case portainer.DockerHubRegistry:
return "DockerHub"
case portainer.EcrRegistry:
return "ECR"
default:
return "Unknown"
}
}

View file

@ -0,0 +1,252 @@
package liboras
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/retry"
)
func TestCreateClient_AuthenticationScenarios(t *testing.T) {
tests := []struct {
name string
registry portainer.Registry
expectAuthenticated bool
description string
}{
{
name: "authentication disabled should create anonymous client",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: false,
Username: "testuser",
Password: "testpass",
},
expectAuthenticated: false,
description: "Even with credentials present, authentication=false should result in anonymous access",
},
{
name: "authentication enabled with valid credentials should create authenticated client",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: true,
Username: "testuser",
Password: "testpass",
},
expectAuthenticated: true,
description: "Valid credentials with authentication=true should result in authenticated access",
},
{
name: "authentication enabled with empty username should create anonymous client",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: true,
Username: "",
Password: "testpass",
},
expectAuthenticated: false,
description: "Empty username should fallback to anonymous access",
},
{
name: "authentication enabled with whitespace-only username should create anonymous client",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: true,
Username: " ",
Password: "testpass",
},
expectAuthenticated: false,
description: "Whitespace-only username should fallback to anonymous access",
},
{
name: "authentication enabled with empty password should create anonymous client",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: true,
Username: "testuser",
Password: "",
},
expectAuthenticated: false,
description: "Empty password should fallback to anonymous access",
},
{
name: "authentication enabled with whitespace-only password should create anonymous client",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: true,
Username: "testuser",
Password: " ",
},
expectAuthenticated: false,
description: "Whitespace-only password should fallback to anonymous access",
},
{
name: "authentication enabled with both credentials empty should create anonymous client",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: true,
Username: "",
Password: "",
},
expectAuthenticated: false,
description: "Both credentials empty should fallback to anonymous access",
},
{
name: "public registry with no authentication should create anonymous client",
registry: portainer.Registry{
URL: "docker.io",
Authentication: false,
Username: "",
Password: "",
},
expectAuthenticated: false,
description: "Public registries without authentication should use anonymous access",
},
{
name: "GitLab registry with valid credentials should create authenticated client",
registry: portainer.Registry{
Type: portainer.GitlabRegistry,
URL: "registry.gitlab.com",
Authentication: true,
Username: "gitlab-ci-token",
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
Gitlab: portainer.GitlabRegistryData{
ProjectID: 12345,
InstanceURL: "https://gitlab.com",
ProjectPath: "my-group/my-project",
},
},
expectAuthenticated: true,
description: "GitLab registry with valid credentials should result in authenticated access",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := CreateClient(tt.registry)
assert.NoError(t, err, "CreateClient should not return an error")
assert.NotNil(t, client, "Client should not be nil")
// Check if the client has authentication configured
if tt.expectAuthenticated {
// Should have auth.Client with credentials
authClient, ok := client.Client.(*auth.Client)
assert.True(t, ok, "Expected auth.Client for authenticated access")
assert.NotNil(t, authClient, "Auth client should not be nil")
assert.NotNil(t, authClient.Credential, "Credential function should be set")
} else {
// Should use retry.DefaultClient (no authentication)
assert.Equal(t, retry.DefaultClient, client.Client,
"Expected retry.DefaultClient for anonymous access")
}
})
}
}
func TestCreateClient_RegistryTypes(t *testing.T) {
registryTypes := []struct {
name string
registryType portainer.RegistryType
expectedName string
}{
{"DockerHub", portainer.DockerHubRegistry, "DockerHub"},
{"Azure", portainer.AzureRegistry, "Azure"},
{"Custom", portainer.CustomRegistry, "Custom"},
{"GitLab", portainer.GitlabRegistry, "GitLab"},
{"Quay", portainer.QuayRegistry, "Quay"},
{"ProGet", portainer.ProGetRegistry, "ProGet"},
{"ECR", portainer.EcrRegistry, "ECR"},
}
for _, rt := range registryTypes {
t.Run(rt.name, func(t *testing.T) {
registry := portainer.Registry{
URL: "registry.example.com",
Type: rt.registryType,
Authentication: false,
}
client, err := CreateClient(registry)
assert.NoError(t, err, "CreateClient should not return an error")
assert.NotNil(t, client, "Client should not be nil")
// Verify that getRegistryTypeName returns the expected name
typeName := getRegistryTypeName(rt.registryType)
assert.Equal(t, rt.expectedName, typeName, "Registry type name mismatch")
})
}
}
func TestGetRegistryTypeName(t *testing.T) {
tests := []struct {
registryType portainer.RegistryType
expectedName string
}{
{portainer.QuayRegistry, "Quay"},
{portainer.AzureRegistry, "Azure"},
{portainer.CustomRegistry, "Custom"},
{portainer.GitlabRegistry, "GitLab"},
{portainer.ProGetRegistry, "ProGet"},
{portainer.DockerHubRegistry, "DockerHub"},
{portainer.EcrRegistry, "ECR"},
{portainer.RegistryType(999), "Unknown"}, // Unknown type
}
for _, tt := range tests {
t.Run(tt.expectedName, func(t *testing.T) {
result := getRegistryTypeName(tt.registryType)
assert.Equal(t, tt.expectedName, result, "Registry type name mismatch")
})
}
}
func TestCreateClient_ErrorHandling(t *testing.T) {
tests := []struct {
name string
registry portainer.Registry
expectError bool
}{
{
name: "valid registry URL should not error",
registry: portainer.Registry{
URL: "registry.example.com",
Authentication: false,
},
expectError: false,
},
{
name: "empty registry URL should error",
registry: portainer.Registry{
URL: "",
Authentication: false,
},
expectError: true,
},
{
name: "invalid registry URL should error",
registry: portainer.Registry{
URL: "://invalid-url",
Authentication: false,
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := CreateClient(tt.registry)
if tt.expectError {
assert.Error(t, err, "Expected an error but got none")
assert.Nil(t, client, "Client should be nil when error occurs")
} else {
assert.NoError(t, err, "Expected no error but got: %v", err)
assert.NotNil(t, client, "Client should not be nil")
}
})
}
}

126
pkg/liboras/repository.go Normal file
View file

@ -0,0 +1,126 @@
package liboras
import (
"context"
"fmt"
"io"
"sort"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/concurrent"
"github.com/segmentio/encoding/json"
"golang.org/x/mod/semver"
"oras.land/oras-go/v2/registry"
"oras.land/oras-go/v2/registry/remote"
)
// ListRepositories retrieves all repositories from a registry using specialized repository listing clients
// Each registry type has different repository listing implementations that require specific API calls
func ListRepositories(ctx context.Context, registry *portainer.Registry, registryClient *remote.Registry) ([]string, error) {
factory := NewRepositoryListClientFactory()
listClient, err := factory.CreateListClientWithRegistry(registry, registryClient)
if err != nil {
return nil, fmt.Errorf("failed to create repository list client: %w", err)
}
return listClient.ListRepositories(ctx)
}
// FilterRepositoriesByMediaType filters repositories to only include those with the expected media type
func FilterRepositoriesByMediaType(ctx context.Context, repositoryNames []string, registryClient *remote.Registry, expectedMediaType string) ([]string, error) {
// Run concurrently as this can take 10s+ to complete in serial
var tasks []concurrent.Func
for _, repoName := range repositoryNames {
name := repoName
task := func(ctx context.Context) (any, error) {
repository, err := registryClient.Repository(ctx, name)
if err != nil {
return nil, err
}
if HasMediaType(ctx, repository, expectedMediaType) {
return name, nil
}
return nil, nil // not a repository with the expected media type
}
tasks = append(tasks, task)
}
// 10 is a reasonable max concurrency limit
results, err := concurrent.Run(ctx, 10, tasks...)
if err != nil {
return nil, err
}
// Collect repository names
var repositories []string
for _, result := range results {
if result.Result != nil {
if repoName, ok := result.Result.(string); ok {
repositories = append(repositories, repoName)
}
}
}
return repositories, nil
}
// HasMediaType checks if a repository has artifacts with the specified media type
func HasMediaType(ctx context.Context, repository registry.Repository, expectedMediaType string) bool {
// Check the first available tag
// Reasonable limitation - it won't work for repos where the latest tag is missing the expected media type but other tags have it
// This tradeoff is worth it for the performance benefits
var latestTag string
err := repository.Tags(ctx, "", func(tagList []string) error {
if len(tagList) > 0 {
// Order the taglist by latest semver, then get the latest tag
// e.g. ["1.0", "1.1"] -> ["1.1", "1.0"] -> "1.1"
sort.Slice(tagList, func(i, j int) bool {
return semver.Compare(tagList[i], tagList[j]) > 0
})
latestTag = tagList[0]
}
return nil
})
if err != nil {
return false
}
if latestTag == "" {
return false
}
descriptor, err := repository.Resolve(ctx, latestTag)
if err != nil {
return false
}
return descriptorHasMediaType(ctx, repository, descriptor, expectedMediaType)
}
// descriptorHasMediaType checks if a descriptor or its manifest contains the expected media type
func descriptorHasMediaType(ctx context.Context, repository registry.Repository, descriptor ocispec.Descriptor, expectedMediaType string) bool {
// Check if the descriptor indicates the expected media type
if descriptor.MediaType == expectedMediaType {
return true
}
// Otherwise, look for the expected media type in the entire manifest content
manifestReader, err := repository.Manifests().Fetch(ctx, descriptor)
if err != nil {
return false
}
defer manifestReader.Close()
content, err := io.ReadAll(manifestReader)
if err != nil {
return false
}
var manifest ocispec.Manifest
if err := json.Unmarshal(content, &manifest); err != nil {
return false
}
return manifest.Config.MediaType == expectedMediaType
}