mirror of
https://github.com/portainer/portainer.git
synced 2025-07-21 06:19:41 +02:00
298 lines
10 KiB
Go
298 lines
10 KiB
Go
|
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
|
||
|
}
|