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:
parent
b6a6ce9aaf
commit
2697d6c5d7
80 changed files with 4264 additions and 812 deletions
126
pkg/libhelm/cache/cache.go
vendored
Normal file
126
pkg/libhelm/cache/cache.go
vendored
Normal 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
81
pkg/libhelm/cache/manager.go
vendored
Normal 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()
|
||||
}
|
38
pkg/libhelm/options/chart_reference.go
Normal file
38
pkg/libhelm/options/chart_reference.go
Normal 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
|
||||
}
|
100
pkg/libhelm/options/chart_reference_test.go
Normal file
100
pkg/libhelm/options/chart_reference_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
297
pkg/libhelm/sdk/chartsources.go
Normal file
297
pkg/libhelm/sdk/chartsources.go
Normal 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
|
||||
}
|
752
pkg/libhelm/sdk/chartsources_test.go
Normal file
752
pkg/libhelm/sdk/chartsources_test.go
Normal 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 := ®istry.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 := ®istry.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 := ®istry.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, ®istry.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 := ®istry.Client{}
|
||||
client2 := ®istry.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 := ®istry.Client{}
|
||||
client2 := ®istry.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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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().
|
||||
|
|
47
pkg/liboras/generic_listrepo_client.go
Normal file
47
pkg/liboras/generic_listrepo_client.go
Normal 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
|
||||
}
|
57
pkg/liboras/github_listrepo_client.go
Normal file
57
pkg/liboras/github_listrepo_client.go
Normal 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
|
||||
}
|
47
pkg/liboras/gitlab_listrepo_client.go
Normal file
47
pkg/liboras/gitlab_listrepo_client.go
Normal 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
|
||||
}
|
39
pkg/liboras/listrepo_client.go
Normal file
39
pkg/liboras/listrepo_client.go
Normal 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
79
pkg/liboras/registry.go
Normal 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"
|
||||
}
|
||||
}
|
252
pkg/liboras/registry_test.go
Normal file
252
pkg/liboras/registry_test.go
Normal 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
126
pkg/liboras/repository.go
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue