1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-20 22:09:41 +02:00
portainer/pkg/libhelm/sdk/chartsources.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

298 lines
10 KiB
Go
Raw Normal View History

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
}