1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-18 21:09:40 +02:00

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

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

View file

@ -121,6 +121,10 @@
"Ecr": { "Ecr": {
"Region": "" "Region": ""
}, },
"Github": {
"OrganisationName": "",
"UseOrganisation": false
},
"Gitlab": { "Gitlab": {
"InstanceURL": "", "InstanceURL": "",
"ProjectId": 0, "ProjectId": 0,

View file

@ -5,10 +5,10 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/registryutils/access"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions" "github.com/portainer/portainer/api/pendingactions"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
@ -17,6 +17,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rs/zerolog/log"
) )
func hideFields(registry *portainer.Registry, hideAccesses bool) { func hideFields(registry *portainer.Registry, hideAccesses bool) {
@ -56,17 +57,20 @@ func newHandler(bouncer security.BouncerService) *Handler {
func (handler *Handler) initRouter(bouncer accessGuard) { func (handler *Handler) initRouter(bouncer accessGuard) {
adminRouter := handler.NewRoute().Subrouter() adminRouter := handler.NewRoute().Subrouter()
adminRouter.Use(bouncer.AdminAccess) adminRouter.Use(bouncer.AdminAccess)
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet) adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet)
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost) adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost)
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost)
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete)
authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) // Use registry-specific access bouncer for inspect and repositories endpoints
registryAccessRouter := handler.NewRoute().Subrouter()
registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess)
registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet)
// Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry)) authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
} }
@ -88,9 +92,7 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
} }
// this function validates that // this function validates that
//
// 1. user has the appropriate authorizations to perform the request // 1. user has the appropriate authorizations to perform the request
//
// 2. user has a direct or indirect access to the registry // 2. user has a direct or indirect access to the registry
func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) { func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) {
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -98,11 +100,6 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
return false, false, err return false, false, err
} }
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return false, false, err
}
// Portainer admins always have access to everything // Portainer admins always have access to everything
if securityContext.IsAdmin { if securityContext.IsAdmin {
return true, true, nil return true, true, nil
@ -128,47 +125,68 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
return false, false, err return false, false, err
} }
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) // Use the enhanced registry access utility function that includes namespace validation
_, err = access.GetAccessibleRegistry(
handler.DataStore,
handler.K8sClientFactory,
securityContext.UserID,
endpointId,
registry.ID,
)
if err != nil { if err != nil {
return false, false, nil return false, false, nil // No access
} }
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) return true, false, nil
if endpointutils.IsKubernetesEndpoint(endpoint) { }
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil { // RegistryAccess defines a security check for registry-specific API endpoints.
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access") // Authentication is required to access these endpoints.
} // The user must have direct or indirect access to the specific registry being requested.
accessPolicies, err := kcl.GetNamespaceAccessPolicies() // This bouncer validates registry access using the userHasRegistryAccess logic.
if err != nil { func (handler *Handler) RegistryAccess(next http.Handler) http.Handler {
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
} // First ensure the user is authenticated
tokenData, err := security.RetrieveTokenData(r)
authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized)
for _, namespace := range authorizedNamespaces { return
// when the default namespace is authorized to use a registry, all users have the ability to use it }
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { // Extract registry ID from the route
return true, false, nil registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
} if err != nil {
httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err)
namespacePolicy := accessPolicies[namespace] return
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { }
return true, false, nil
} // Get the registry from the database
} registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
return false, false, nil if handler.DataStore.IsErrObjectNotFound(err) {
} httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err)
return
// validate access for docker environments } else if err != nil {
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) return
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) { }
return true, false, nil
} // Check if user has access to this registry
hasAccess, _, err := handler.userHasRegistryAccess(r, registry)
// when user has no access via their role, direct grant or indirect grant if err != nil {
// then they don't have access to the registry httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err)
return false, false, nil return
}
if !hasAccess {
log.Debug().
Int("registry_id", registryID).
Str("registry_name", registry.Name).
Int("user_id", int(tokenData.ID)).
Str("context", "RegistryAccessBouncer").
Msg("User access denied to registry")
httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied)
return
}
next.ServeHTTP(w, r)
})
} }

View file

@ -0,0 +1,89 @@
package registries
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)
func Test_RegistryAccess_RequiresAuthentication(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
registry := &portainer.Registry{
ID: 1,
Name: "test-registry",
URL: "https://registry.test.com",
}
err := store.Registry().Create(registry)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
}
req := httptest.NewRequest(http.MethodGet, "/registries/1", nil)
req = mux.SetURLVars(req, map[string]string{"id": "1"})
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func Test_RegistryAccess_InvalidRegistryID(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
err := store.User().Create(user)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
}
req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil)
req = mux.SetURLVars(req, map[string]string{"id": "invalid"})
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
req = req.WithContext(security.StoreTokenData(req, tokenData))
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func Test_RegistryAccess_RegistryNotFound(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
err := store.User().Create(user)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
req := httptest.NewRequest(http.MethodGet, "/registries/999", nil)
req = mux.SetURLVars(req, map[string]string{"id": "999"})
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
req = req.WithContext(security.StoreTokenData(req, tokenData))
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
}

View file

@ -4,10 +4,12 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
) )
// @id RegistryInspect // @id RegistryInspect
@ -31,6 +33,11 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid registry identifier route variable", err) return httperror.BadRequest("Invalid registry identifier route variable", err)
} }
log.Debug().
Int("registry_id", registryID).
Str("context", "RegistryInspectHandler").
Msg("Starting registry inspection")
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
if handler.DataStore.IsErrObjectNotFound(err) { if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err)
@ -38,14 +45,12 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err)
} }
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry) // Check if user is admin to determine if we should hide sensitive fields
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err) return httperror.InternalServerError("Unable to retrieve info from request context", err)
} }
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
hideFields(registry, !isAdmin) hideFields(registry, !securityContext.IsAdmin)
return response.JSON(w, registry) return response.JSON(w, registry)
} }

View file

@ -80,7 +80,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Unable to retrieve user authentication token", err) return httperror.InternalServerError("Unable to retrieve user authentication token", err)
} }
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID) _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID)
if err != nil { if err != nil {
return httperror.Forbidden("Permission deny to access registry", err) return httperror.Forbidden("Permission deny to access registry", err)
} }

View file

@ -69,7 +69,7 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Unable to retrieve user authentication token", err) return httperror.InternalServerError("Unable to retrieve user authentication token", err)
} }
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID) _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID)
if err != nil { if err != nil {
return httperror.Forbidden("Permission deny to access registry", err) return httperror.Forbidden("Permission deny to access registry", err)
} }

View file

@ -55,12 +55,13 @@ func createRegistryAuthenticationHeader(
return return
} }
if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil { if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil {
return return
} }
authenticationHeader.Serveraddress = matchingRegistry.URL authenticationHeader.Serveraddress = matchingRegistry.URL
authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry) authenticationHeader.Username = matchingRegistry.Username
authenticationHeader.Password = matchingRegistry.Password
return return
} }

View file

@ -0,0 +1,108 @@
package github
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
const GitHubAPIHost = "https://api.github.com"
// Package represents a GitHub container package
type Package struct {
Name string `json:"name"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
// Client represents a GitHub API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitHub API client
func NewClient(token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: GitHubAPIHost,
}
}
// GetContainerPackages fetches container packages for the configured namespace
// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) {
// Determine the namespace (user or organisation) for the request
namespace := "user"
if useOrganisation {
namespace = "orgs/" + organisationName
}
// Build the full URL for listing container packages
url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var packages []Package
if err := json.Unmarshal(body, &packages); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names in the form "owner/name"
repositories := make([]string, len(packages))
for i, pkg := range packages {
repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name))
}
return repositories, nil
}
// NewHTTPClient creates a new HTTP client configured for GitHub API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Bearer token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.token != "" {
req.Header.Set("Authorization", "Bearer "+t.token)
req.Header.Set("Accept", "application/vnd.github+json")
}
return t.transport.RoundTrip(req)
}

View file

@ -0,0 +1,130 @@
package gitlab
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
// Repository represents a GitLab registry repository
type Repository struct {
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
ProjectID int `json:"project_id"`
Location string `json:"location"`
CreatedAt string `json:"created_at"`
Status string `json:"status"`
}
// Client represents a GitLab API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitLab API client
// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands).
// if we need to support other commands, consider using the gitlab client library.
func NewClient(baseURL, token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: baseURL,
}
}
// GetRegistryRepositoryNames fetches registry repository names for a given project.
// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) {
url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var repositories []Repository
if err := json.Unmarshal(body, &repositories); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names
names := make([]string, len(repositories))
for i, repo := range repositories {
// the full path is required for further repo operations
names[i] = repo.Path
}
return names, nil
}
type Transport struct {
httpTransport *http.Transport
}
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: &http.Transport{},
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
token := request.Header.Get("Private-Token")
if token == "" {
return nil, errors.New("no gitlab token provided")
}
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
if err != nil {
return nil, err
}
r.Header.Set("Private-Token", token)
return transport.httpTransport.RoundTrip(r)
}
// NewHTTPClient creates a new HTTP client configured for GitLab API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Private-Token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Private-Token", t.token)
return t.transport.RoundTrip(req)
}

View file

@ -1,34 +0,0 @@
package gitlab
import (
"errors"
"net/http"
)
type Transport struct {
httpTransport *http.Transport
}
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: &http.Transport{},
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
token := request.Header.Get("Private-Token")
if token == "" {
return nil, errors.New("no gitlab token provided")
}
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
if err != nil {
return nil, err
}
r.Header.Set("Private-Token", token)
return transport.httpTransport.RoundTrip(r)
}

View file

@ -2,40 +2,82 @@ package access
import ( import (
"errors" "errors"
"fmt"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
) )
func hasPermission( func hasPermission(
dataStore dataservices.DataStore, dataStore dataservices.DataStore,
k8sClientFactory *cli.ClientFactory,
userID portainer.UserID, userID portainer.UserID,
endpointID portainer.EndpointID, endpointID portainer.EndpointID,
registry *portainer.Registry, registry *portainer.Registry,
) (hasPermission bool, err error) { ) (hasPermission bool, err error) {
user, err := dataStore.User().Read(userID) user, err := dataStore.User().Read(userID)
if err != nil { if err != nil {
return return false, err
} }
if user.Role == portainer.AdministratorRole { if user.Role == portainer.AdministratorRole {
return true, err return true, nil
}
endpoint, err := dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return false, err
} }
teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID) teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil { if err != nil {
return return false, err
} }
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil {
kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err)
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err)
}
authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces
for _, namespace := range authorizedNamespaces {
// when the default namespace is authorized to use a registry, all users have the ability to use it
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
return true, nil
}
namespacePolicy := accessPolicies[namespace]
if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
return true, nil
}
}
return false, nil
}
// validate access for docker environments
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID) hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID)
return return hasPermission, nil
} }
// GetAccessibleRegistry get the registry if the user has permission // GetAccessibleRegistry get the registry if the user has permission
func GetAccessibleRegistry( func GetAccessibleRegistry(
dataStore dataservices.DataStore, dataStore dataservices.DataStore,
k8sClientFactory *cli.ClientFactory,
userID portainer.UserID, userID portainer.UserID,
endpointID portainer.EndpointID, endpointID portainer.EndpointID,
registryID portainer.RegistryID, registryID portainer.RegistryID,
@ -46,7 +88,7 @@ func GetAccessibleRegistry(
return return
} }
hasPermission, err := hasPermission(dataStore, userID, endpointID, registry) hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry)
if err != nil { if err != nil {
return return
} }

View file

@ -62,3 +62,26 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
return return
} }
// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token
// and setting effective credentials on the registry when authentication is enabled.
// This function modifies the registry in-place by setting Username and Password to the effective values.
func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if !registry.Authentication {
return nil
}
if err := EnsureRegTokenValid(tx, registry); err != nil {
return err
}
username, password, err := GetRegEffectiveCredential(registry)
if err != nil {
return err
}
registry.Username = username
registry.Password = password
return nil
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"time" "time"
@ -437,5 +438,10 @@ func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portaine
namespaceSlice = append(namespaceSlice, namespace) namespaceSlice = append(namespaceSlice, namespace)
} }
// Sort namespaces by name
sort.Slice(namespaceSlice, func(i, j int) bool {
return namespaceSlice[i].Name < namespaceSlice[j].Name
})
return namespaceSlice return namespaceSlice
} }

View file

@ -603,6 +603,12 @@ type (
ProjectPath string `json:"ProjectPath"` ProjectPath string `json:"ProjectPath"`
} }
// GithubRegistryData represents data required for Github registry to work
GithubRegistryData struct {
UseOrganisation bool `json:"UseOrganisation"`
OrganisationName string `json:"OrganisationName"`
}
HelmUserRepositoryID int HelmUserRepositoryID int
// HelmUserRepositories stores a Helm repository URL for the given user // HelmUserRepositories stores a Helm repository URL for the given user
@ -823,6 +829,7 @@ type (
Password string `json:"Password,omitempty" example:"registry_password"` Password string `json:"Password,omitempty" example:"registry_password"`
ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"`
Gitlab GitlabRegistryData `json:"Gitlab"` Gitlab GitlabRegistryData `json:"Gitlab"`
Github GithubRegistryData `json:"Github"`
Quay QuayRegistryData `json:"Quay"` Quay QuayRegistryData `json:"Quay"`
Ecr EcrData `json:"Ecr"` Ecr EcrData `json:"Ecr"`
RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` RegistryAccesses RegistryAccesses `json:"RegistryAccesses"`
@ -1972,6 +1979,8 @@ const (
DockerHubRegistry DockerHubRegistry
// EcrRegistry represents an ECR registry // EcrRegistry represents an ECR registry
EcrRegistry EcrRegistry
// Github container registry
GithubRegistry
) )
const ( const (

View file

@ -92,6 +92,7 @@ export const ngModule = angular
'onChange', 'onChange',
'placeholder', 'placeholder',
'value', 'value',
'allowSelectAll',
]) ])
) )
.component( .component(

View file

@ -19,6 +19,7 @@
namespaces="$ctrl.resourcePools" namespaces="$ctrl.resourcePools"
placeholder="'Select one or more namespaces'" placeholder="'Select one or more namespaces'"
on-change="($ctrl.onChangeResourcePools)" on-change="($ctrl.onChangeResourcePools)"
allow-select-all="true"
></namespaces-selector> ></namespaces-selector>
</div> </div>
<div class="col-sm-12 small text-muted vertical-center"> <div class="col-sm-12 small text-muted vertical-center">

View file

@ -40,17 +40,15 @@
</div> </div>
<div class="form-group" ng-if="ctrl.formValues.Namespace"> <div class="form-group" ng-if="ctrl.formValues.Namespace">
<label for="target_node" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label> <label for="target_namespace" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<select <namespace-portainer-select
ng-if="!ctrl.formValues.namespace_toggle || ctrl.state.BuildMethod === ctrl.BuildMethods.HELM" ng-if="!ctrl.formValues.namespace_toggle || ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"
data-cy="namespace-select" is-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM || ctrl.state.isNamespaceInputDisabled"
ng-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" value="ctrl.formValues.Namespace"
class="form-control" on-change="(ctrl.onChangeNamespace)"
ng-model="ctrl.formValues.Namespace" options="ctrl.namespaceOptions"
ng-change="ctrl.onChangeNamespace()" ></namespace-portainer-select>
ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces"
></select>
<span ng-if="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" class="small text-muted pt-[7px]" <span ng-if="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" class="small text-muted pt-[7px]"
>Namespaces specified in the manifest will be used</span >Namespaces specified in the manifest will be used</span
> >
@ -186,7 +184,6 @@
<!-- Helm --> <!-- Helm -->
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"> <div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.selectedHelmChart">Selected Helm chart</div>
<helm-templates-view on-select-helm-chart="(ctrl.onSelectHelmChart)" namespace="ctrl.formValues.Namespace" name="ctrl.formValues.Name" /> <helm-templates-view on-select-helm-chart="(ctrl.onSelectHelmChart)" namespace="ctrl.formValues.Namespace" name="ctrl.formValues.Name" />
</div> </div>
<!-- !Helm --> <!-- !Helm -->

View file

@ -101,9 +101,10 @@ class KubernetesDeployController {
this.onChangeNamespace = this.onChangeNamespace.bind(this); this.onChangeNamespace = this.onChangeNamespace.bind(this);
} }
onChangeNamespace() { onChangeNamespace(namespaceName) {
return this.$async(async () => { return this.$async(async () => {
const applications = await this.KubernetesApplicationService.get(this.formValues.Namespace); this.formValues.Namespace = namespaceName;
const applications = await this.KubernetesApplicationService.get(namespaceName);
const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== ''); const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== '');
this.stacks = _.uniq(stacks); this.stacks = _.uniq(stacks);
}); });
@ -371,6 +372,10 @@ class KubernetesDeployController {
if (this.namespaces.length > 0) { if (this.namespaces.length > 0) {
this.formValues.Namespace = this.namespaces[0].Name; this.formValues.Namespace = this.namespaces[0].Name;
} }
this.namespaceOptions = _.map(namespaces, (namespace) => ({
label: namespace.Name,
value: namespace.Name,
}));
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to load namespaces data'); this.Notifications.error('Failure', err, 'Unable to load namespaces data');
} }
@ -404,7 +409,8 @@ class KubernetesDeployController {
} }
} }
this.onChangeNamespace(); this.onChangeNamespace(this.formValues.Namespace);
this.state.viewReady = true; this.state.viewReady = true;
this.$window.onbeforeunload = () => { this.$window.onbeforeunload = () => {

View file

@ -9,6 +9,7 @@ import { withFormValidation } from '@/react-tools/withFormValidation';
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable'; import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector'; import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
import { withControlledInput } from '@/react-tools/withControlledInput'; import { withControlledInput } from '@/react-tools/withControlledInput';
import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
import { import {
EnvironmentVariablesFieldset, EnvironmentVariablesFieldset,
@ -199,11 +200,22 @@ export const ngModule = angular
'onChange', 'onChange',
'options', 'options',
'isMulti', 'isMulti',
'filterOption',
'isClearable', 'isClearable',
'components', 'components',
'isLoading', 'isLoading',
'noOptionsMessage', 'noOptionsMessage',
'aria-label', 'aria-label',
'loadingMessage',
])
)
.component(
'namespacePortainerSelect',
r2a(NamespacePortainerSelect, [
'value',
'onChange',
'isDisabled',
'options',
]) ])
) )
.component( .component(

View file

@ -1,20 +1,20 @@
import { ExternalLink as ExternalLinkIcon } from 'lucide-react'; import { ArrowUpRight } from 'lucide-react';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon';
interface Props { interface Props {
to: string; to: string;
className?: string; className?: string;
showIcon?: boolean;
} }
export function ExternalLink({ export function ExternalLink({
to, to,
className, className,
children, children,
showIcon = true,
'data-cy': dataCy, 'data-cy': dataCy,
}: PropsWithChildren<Props & AutomationTestingProps>) { }: PropsWithChildren<Props & AutomationTestingProps>) {
return ( return (
@ -23,10 +23,10 @@ export function ExternalLink({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
data-cy={dataCy} data-cy={dataCy}
className={clsx('inline-flex items-center gap-1', className)} className={clsx('inline-flex align-baseline', className)}
> >
<Icon icon={ExternalLinkIcon} /> {children}
<span>{children}</span> {showIcon && <ArrowUpRight className="align-top" />}
</a> </a>
); );
} }

View file

@ -27,5 +27,5 @@ export function FallbackImage({ src, fallbackIcon, alt, className }: Props) {
} }
// fallback icon if there is an error loading the image // fallback icon if there is an error loading the image
return <>{fallbackIcon}</>; return <div className={className}>{fallbackIcon}</div>;
} }

View file

@ -5,15 +5,25 @@ import {
} from 'react-select'; } from 'react-select';
import _ from 'lodash'; import _ from 'lodash';
import { AriaAttributes } from 'react'; import { AriaAttributes } from 'react';
import { FilterOptionOption } from 'react-select/dist/declarations/src/filters';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
import { Select as ReactSelect } from '@@/form-components/ReactSelect'; import {
Creatable,
Select as ReactSelect,
} from '@@/form-components/ReactSelect';
export interface Option<TValue> { export interface Option<TValue> {
value: TValue; value: TValue;
label: string; label: string;
disabled?: boolean; disabled?: boolean;
[key: string]: unknown;
}
export interface GroupOption<TValue> {
label: string;
options: Option<TValue>[];
} }
type Options<TValue> = OptionsOrGroups< type Options<TValue> = OptionsOrGroups<
@ -21,7 +31,7 @@ type Options<TValue> = OptionsOrGroups<
GroupBase<Option<TValue>> GroupBase<Option<TValue>>
>; >;
interface SharedProps interface SharedProps<TValue>
extends AutomationTestingProps, extends AutomationTestingProps,
Pick<AriaAttributes, 'aria-label'> { Pick<AriaAttributes, 'aria-label'> {
name?: string; name?: string;
@ -32,9 +42,14 @@ interface SharedProps
bindToBody?: boolean; bindToBody?: boolean;
isLoading?: boolean; isLoading?: boolean;
noOptionsMessage?: () => string; noOptionsMessage?: () => string;
loadingMessage?: () => string;
filterOption?: (
option: FilterOptionOption<Option<TValue>>,
rawInput: string
) => boolean;
} }
interface MultiProps<TValue> extends SharedProps { interface MultiProps<TValue> extends SharedProps<TValue> {
value: readonly TValue[]; value: readonly TValue[];
onChange(value: TValue[]): void; onChange(value: TValue[]): void;
options: Options<TValue>; options: Options<TValue>;
@ -44,9 +59,12 @@ interface MultiProps<TValue> extends SharedProps {
true, true,
GroupBase<Option<TValue>> GroupBase<Option<TValue>>
>; >;
formatCreateLabel?: (input: string) => string;
onCreateOption?: (input: string) => void;
isCreatable?: boolean;
} }
interface SingleProps<TValue> extends SharedProps { interface SingleProps<TValue> extends SharedProps<TValue> {
value: TValue; value: TValue;
onChange(value: TValue | null): void; onChange(value: TValue | null): void;
options: Options<TValue>; options: Options<TValue>;
@ -58,9 +76,13 @@ interface SingleProps<TValue> extends SharedProps {
>; >;
} }
type Props<TValue> = MultiProps<TValue> | SingleProps<TValue>; export type PortainerSelectProps<TValue> =
| MultiProps<TValue>
| SingleProps<TValue>;
export function PortainerSelect<TValue = string>(props: Props<TValue>) { export function PortainerSelect<TValue = string>(
props: PortainerSelectProps<TValue>
) {
return isMultiProps(props) ? ( return isMultiProps(props) ? (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<MultiSelect {...props} /> <MultiSelect {...props} />
@ -71,7 +93,7 @@ export function PortainerSelect<TValue = string>(props: Props<TValue>) {
} }
function isMultiProps<TValue>( function isMultiProps<TValue>(
props: Props<TValue> props: PortainerSelectProps<TValue>
): props is MultiProps<TValue> { ): props is MultiProps<TValue> {
return 'isMulti' in props && !!props.isMulti; return 'isMulti' in props && !!props.isMulti;
} }
@ -87,9 +109,11 @@ export function SingleSelect<TValue = string>({
placeholder, placeholder,
isClearable, isClearable,
bindToBody, bindToBody,
filterOption,
components, components,
isLoading, isLoading,
noOptionsMessage, noOptionsMessage,
loadingMessage,
isMulti, isMulti,
...aria ...aria
}: SingleProps<TValue>) { }: SingleProps<TValue>) {
@ -116,9 +140,11 @@ export function SingleSelect<TValue = string>({
placeholder={placeholder} placeholder={placeholder}
isDisabled={disabled} isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined} menuPortalTarget={bindToBody ? document.body : undefined}
filterOption={filterOption}
components={components} components={components}
isLoading={isLoading} isLoading={isLoading}
noOptionsMessage={noOptionsMessage} noOptionsMessage={noOptionsMessage}
loadingMessage={loadingMessage}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...aria} {...aria}
/> />
@ -159,14 +185,20 @@ export function MultiSelect<TValue = string>({
disabled, disabled,
isClearable, isClearable,
bindToBody, bindToBody,
filterOption,
components, components,
isLoading, isLoading,
noOptionsMessage, noOptionsMessage,
loadingMessage,
formatCreateLabel,
onCreateOption,
isCreatable,
...aria ...aria
}: Omit<MultiProps<TValue>, 'isMulti'>) { }: Omit<MultiProps<TValue>, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value); const selectedOptions = findSelectedOptions(options, value);
const SelectComponent = isCreatable ? Creatable : ReactSelect;
return ( return (
<ReactSelect <SelectComponent
name={name} name={name}
isMulti isMulti
isClearable={isClearable} isClearable={isClearable}
@ -183,9 +215,13 @@ export function MultiSelect<TValue = string>({
placeholder={placeholder} placeholder={placeholder}
isDisabled={disabled} isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined} menuPortalTarget={bindToBody ? document.body : undefined}
filterOption={filterOption}
components={components} components={components}
isLoading={isLoading} isLoading={isLoading}
noOptionsMessage={noOptionsMessage} noOptionsMessage={noOptionsMessage}
loadingMessage={loadingMessage}
formatCreateLabel={formatCreateLabel}
onCreateOption={onCreateOption}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...aria} {...aria}
/> />

View file

@ -51,3 +51,29 @@ export function NamespaceSelector({
</FormControl> </FormControl>
); );
} }
/** NamespacePortainerSelect is exported for use by angular views, so that the data-cy attribute is set correctly */
export function NamespacePortainerSelect({
value,
onChange,
isDisabled,
options,
}: {
value: string;
onChange: (value: string) => void;
isDisabled: boolean;
options: { label: string; value: string }[];
}) {
return (
<PortainerSelect
value={value}
options={options}
onChange={onChange}
disabled={isDisabled}
noOptionsMessage={() => 'No namespaces found'}
placeholder="No namespaces found" // will only show when there are no options
inputId="namespace-selector"
data-cy="namespace-select"
/>
);
}

View file

@ -1,4 +1,5 @@
import _ from 'lodash'; import _ from 'lodash';
import { useMemo } from 'react';
import { Select } from '@@/form-components/ReactSelect'; import { Select } from '@@/form-components/ReactSelect';
@ -15,6 +16,7 @@ interface Props {
dataCy: string; dataCy: string;
inputId?: string; inputId?: string;
placeholder?: string; placeholder?: string;
allowSelectAll?: boolean;
} }
export function NamespacesSelector({ export function NamespacesSelector({
@ -25,23 +27,34 @@ export function NamespacesSelector({
dataCy, dataCy,
inputId, inputId,
placeholder, placeholder,
allowSelectAll,
}: Props) { }: Props) {
const options = useMemo(() => {
if (allowSelectAll) {
return [{ id: 'all', name: 'Select all' }, ...namespaces];
}
return namespaces;
}, [namespaces, allowSelectAll]);
return ( return (
<Select <Select
name={name} name={name}
isMulti isMulti
getOptionLabel={(namespace) => namespace.name} getOptionLabel={(namespace) => namespace.name}
getOptionValue={(namespace) => String(namespace.id)} getOptionValue={(namespace) => String(namespace.id)}
options={namespaces} options={options}
value={_.compact( value={_.compact(
value.map((namespaceName) => value.map((namespaceName) =>
namespaces.find((namespace) => namespace.name === namespaceName) namespaces.find((namespace) => namespace.name === namespaceName)
) )
)} )}
closeMenuOnSelect={false} closeMenuOnSelect={false}
onChange={(selectedTeams) => onChange={(selectedNamespaces) => {
onChange(selectedTeams.map((namespace) => namespace.name)) if (selectedNamespaces.some((namespace) => namespace.id === 'all')) {
} onChange(namespaces.map((namespace) => namespace.name));
} else {
onChange(selectedNamespaces.map((namespace) => namespace.name));
}
}}
data-cy={dataCy} data-cy={dataCy}
id={dataCy} id={dataCy}
inputId={inputId} inputId={inputId}

View file

@ -39,11 +39,6 @@ export function ChartActions({
release={release} release={release}
updateRelease={updateRelease} updateRelease={updateRelease}
/> />
<UninstallButton
environmentId={environmentId}
releaseName={releaseName}
namespace={namespace}
/>
{showRollbackButton && ( {showRollbackButton && (
<RollbackButton <RollbackButton
latestRevision={latestRevision} latestRevision={latestRevision}
@ -53,6 +48,11 @@ export function ChartActions({
namespace={namespace} namespace={namespace}
/> />
)} )}
<UninstallButton
environmentId={environmentId}
releaseName={releaseName}
namespace={namespace}
/>
</div> </div>
); );
} }

View file

@ -25,8 +25,8 @@ vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(), notifySuccess: vi.fn(),
})); }));
vi.mock('../../queries/useHelmRegistries', () => ({ vi.mock('../../queries/useHelmRepositories', () => ({
useHelmRegistries: vi.fn(() => ({ useUserHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'], data: ['repo1', 'repo2'],
isInitialLoading: false, isInitialLoading: false,
isError: false, isError: false,
@ -146,7 +146,7 @@ describe('UpgradeButton', () => {
renderButton(); renderButton();
expect(screen.getByText('No versions available')).toBeInTheDocument(); expect(screen.getByText(/No versions available/)).toBeInTheDocument();
}); });
test('should open upgrade modal when clicked', async () => { test('should open upgrade modal when clicked', async () => {

View file

@ -15,7 +15,7 @@ import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation'; import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions'; import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions';
import { useHelmRelease } from '../queries/useHelmRelease'; import { useHelmRelease } from '../queries/useHelmRelease';
import { useHelmRegistries } from '../../queries/useHelmRegistries'; import { useUserHelmRepositories } from '../../queries/useHelmRepositories';
import { openUpgradeHelmModal } from './UpgradeHelmModal'; import { openUpgradeHelmModal } from './UpgradeHelmModal';
@ -36,19 +36,22 @@ export function UpgradeButton({
const [useCache, setUseCache] = useState(true); const [useCache, setUseCache] = useState(true);
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId); const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const registriesQuery = useHelmRegistries(); const userRepositoriesQuery = useUserHelmRepositories();
const helmRepoVersionsQuery = useHelmRepoVersions( const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '', release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour 60 * 60 * 1000, // 1 hour
registriesQuery.data, userRepositoriesQuery.data?.map((repo) => ({
repo,
})),
useCache useCache
); );
const versions = helmRepoVersionsQuery.data; const versions = helmRepoVersionsQuery.data;
// Combined loading state // Combined loading state
const isLoading = const isLoading =
registriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching userRepositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
const isError = registriesQuery.isError || helmRepoVersionsQuery.isError; const isError =
userRepositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersionQuery = useHelmRelease( const latestVersionQuery = useHelmRelease(
environmentId, environmentId,
releaseName, releaseName,
@ -101,7 +104,7 @@ export function UpgradeButton({
icon={ArrowUp} icon={ArrowUp}
size="medium" size="medium"
> >
Upgrade Edit/Upgrade
</LoadingButton> </LoadingButton>
{isLoading && ( {isLoading && (
<InlineLoader <InlineLoader

View file

@ -137,6 +137,47 @@ const helmReleaseHistory = [
}, },
]; ];
// Common MSW handlers for all tests
function createCommonHandlers() {
return [
http.get('/api/users/undefined/helm/repositories', () =>
HttpResponse.json({
GlobalRepository: 'https://charts.helm.sh/stable',
UserRepositories: [{ Id: '1', URL: 'https://charts.helm.sh/stable' }],
})
),
http.get('/api/templates/helm', () =>
HttpResponse.json({
entries: {
'test-chart': [{ version: '1.0.0' }],
},
})
),
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
),
http.get('/api/kubernetes/3/namespaces/default', () =>
HttpResponse.json({
Id: 'default',
Name: 'default',
Status: { phase: 'Active' },
Annotations: {},
CreationDate: '2021-01-01T00:00:00Z',
NamespaceOwner: '',
IsSystem: false,
IsDefault: true,
})
),
];
}
function setupMockHandlers(helmReleaseHandler: ReturnType<typeof http.get>) {
server.use(helmReleaseHandler, ...createCommonHandlers());
}
function renderComponent() { function renderComponent() {
const user = new UserViewModel({ Username: 'user' }); const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider( const Wrapped = withTestQueryProvider(
@ -162,30 +203,9 @@ describe(
it('should display helm release details for minimal release when data is loaded', async () => { it('should display helm release details for minimal release when data is loaded', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {});
server.use( setupMockHandlers(
http.get('/api/endpoints/3/kubernetes/helm/test-release', () => http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
HttpResponse.json(minimalHelmRelease) HttpResponse.json(minimalHelmRelease)
),
http.get('/api/users/undefined/helm/repositories', () =>
HttpResponse.json({
GlobalRepository: 'https://charts.helm.sh/stable',
UserRepositories: [
{ Id: '1', URL: 'https://charts.helm.sh/stable' },
],
})
),
http.get('/api/templates/helm', () =>
HttpResponse.json({
entries: {
'test-chart': [{ version: '1.0.0' }],
},
})
),
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
) )
); );
@ -224,13 +244,9 @@ describe(
it('should display error message when API request fails', async () => { it('should display error message when API request fails', async () => {
// Mock API failure // Mock API failure
server.use( setupMockHandlers(
http.get('/api/endpoints/3/kubernetes/helm/test-release', () => http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
HttpResponse.error() HttpResponse.error()
),
// Add mock for events endpoint
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
) )
); );
@ -253,15 +269,9 @@ describe(
}); });
it('should display additional details when available in helm release', async () => { it('should display additional details when available in helm release', async () => {
server.use( setupMockHandlers(
http.get('/api/endpoints/3/kubernetes/helm/test-release', () => http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
HttpResponse.json(completeHelmRelease) HttpResponse.json(completeHelmRelease)
),
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
) )
); );

View file

@ -11,6 +11,7 @@ import { Card } from '@@/Card';
import { Alert } from '@@/Alert'; import { Alert } from '@@/Alert';
import { HelmRelease } from '../types'; import { HelmRelease } from '../types';
import { useIsSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace';
import { HelmSummary } from './HelmSummary'; import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs'; import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
@ -37,6 +38,8 @@ export function HelmApplicationView() {
revision: selectedRevision, revision: selectedRevision,
}); });
const isSystemNamespace = useIsSystemNamespace(namespace);
return ( return (
<> <>
<PageHeader <PageHeader
@ -63,28 +66,30 @@ export function HelmApplicationView() {
/> />
</div> </div>
<Authorized authorizations="K8sApplicationsW"> <Authorized authorizations="K8sApplicationsW">
<ChartActions {!isSystemNamespace && (
environmentId={environmentId} <ChartActions
releaseName={String(name)} environmentId={environmentId}
namespace={String(namespace)} releaseName={String(name)}
latestRevision={latestRevision ?? 1} namespace={String(namespace)}
earlistRevision={earlistRevision} latestRevision={latestRevision ?? 1}
selectedRevision={selectedRevision} earlistRevision={earlistRevision}
release={helmReleaseQuery.data} selectedRevision={selectedRevision}
updateRelease={(updatedRelease: HelmRelease) => { release={helmReleaseQuery.data}
queryClient.setQueryData( updateRelease={(updatedRelease: HelmRelease) => {
[ queryClient.setQueryData(
environmentId, [
'helm', environmentId,
'releases', 'helm',
namespace, 'releases',
name, namespace,
true, name,
], true,
updatedRelease ],
); updatedRelease
}} );
/> }}
/>
)}
</Authorized> </Authorized>
</div> </div>
</WidgetTitle> </WidgetTitle>

View file

@ -98,6 +98,7 @@ function renderComponent({
selectedChart={selectedChart} selectedChart={selectedChart}
namespace={namespace} namespace={namespace}
name={name} name={name}
isRepoAvailable
/> />
)), )),
user user

View file

@ -12,6 +12,10 @@ import { Option } from '@@/form-components/PortainerSelect';
import { Chart } from '../types'; import { Chart } from '../types';
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation'; import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
import {
ChartVersion,
useHelmRepoVersions,
} from '../queries/useHelmRepoVersions';
import { HelmInstallInnerForm } from './HelmInstallInnerForm'; import { HelmInstallInnerForm } from './HelmInstallInnerForm';
import { HelmInstallFormValues } from './types'; import { HelmInstallFormValues } from './types';
@ -20,22 +24,39 @@ type Props = {
selectedChart: Chart; selectedChart: Chart;
namespace?: string; namespace?: string;
name?: string; name?: string;
isRepoAvailable: boolean;
}; };
export function HelmInstallForm({ selectedChart, namespace, name }: Props) { export function HelmInstallForm({
selectedChart,
namespace,
name,
isRepoAvailable,
}: Props) {
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics(); const analytics = useAnalytics();
const versionOptions: Option<string>[] = selectedChart.versions.map( const helmRepoVersionsQuery = useHelmRepoVersions(
selectedChart.name,
60 * 60 * 1000, // 1 hour
[
{
repo: selectedChart.repo,
},
]
);
const versions = helmRepoVersionsQuery.data;
const versionOptions: Option<ChartVersion>[] = versions.map(
(version, index) => ({ (version, index) => ({
label: index === 0 ? `${version} (latest)` : version, label: index === 0 ? `${version.Version} (latest)` : version.Version,
value: version, value: version,
}) })
); );
const defaultVersion = versionOptions[0]?.value; const defaultVersion = versionOptions[0]?.value;
const initialValues: HelmInstallFormValues = { const initialValues: HelmInstallFormValues = {
values: '', values: '',
version: defaultVersion ?? '', version: defaultVersion?.Version ?? '',
repo: defaultVersion?.Repo ?? selectedChart.repo ?? '',
}; };
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId); const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
@ -55,6 +76,8 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
namespace={namespace} namespace={namespace}
name={name} name={name}
versionOptions={versionOptions} versionOptions={versionOptions}
isVersionsLoading={helmRepoVersionsQuery.isInitialLoading}
isRepoAvailable={isRepoAvailable}
/> />
</Formik> </Formik>
); );

View file

@ -1,14 +1,15 @@
import { Form, useFormikContext } from 'formik'; import { Form, useFormikContext } from 'formik';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FormActions } from '@@/form-components/FormActions';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
import { LoadingButton } from '@@/buttons';
import { Chart } from '../types'; import { Chart } from '../types';
import { useHelmChartValues } from '../queries/useHelmChartValues'; import { useHelmChartValues } from '../queries/useHelmChartValues';
import { HelmValuesInput } from '../components/HelmValuesInput'; import { HelmValuesInput } from '../components/HelmValuesInput';
import { ChartVersion } from '../queries/useHelmRepoVersions';
import { HelmInstallFormValues } from './types'; import { HelmInstallFormValues } from './types';
@ -16,7 +17,9 @@ type Props = {
selectedChart: Chart; selectedChart: Chart;
namespace?: string; namespace?: string;
name?: string; name?: string;
versionOptions: Option<string>[]; versionOptions: Option<ChartVersion>[];
isVersionsLoading: boolean;
isRepoAvailable: boolean;
}; };
export function HelmInstallInnerForm({ export function HelmInstallInnerForm({
@ -24,21 +27,39 @@ export function HelmInstallInnerForm({
namespace, namespace,
name, name,
versionOptions, versionOptions,
isVersionsLoading,
isRepoAvailable,
}: Props) { }: Props) {
const { values, setFieldValue, isSubmitting } = const { values, setFieldValue, isSubmitting } =
useFormikContext<HelmInstallFormValues>(); useFormikContext<HelmInstallFormValues>();
const chartValuesRefQuery = useHelmChartValues({ const selectedVersion: ChartVersion | undefined = useMemo(
chart: selectedChart.name,
repo: selectedChart.repo,
version: values?.version,
});
const selectedVersion = useMemo(
() => () =>
versionOptions.find((v) => v.value === values.version)?.value ?? versionOptions.find(
versionOptions[0]?.value, (v) =>
[versionOptions, values.version] v.value.Version === values.version &&
v.value.Repo === selectedChart.repo
)?.value ?? versionOptions[0]?.value,
[versionOptions, values.version, selectedChart.repo]
);
const repoParams = {
repo: selectedChart.repo,
};
// use isLatestVersionFetched to cache the latest version, to avoid duplicate fetches
const isLatestVersionFetched =
// if no version is selected, the latest version gets fetched
!versionOptions.length ||
// otherwise check if the selected version is the latest version
(selectedVersion?.Version === versionOptions[0]?.value.Version &&
selectedVersion?.Repo === versionOptions[0]?.value.Repo);
const chartValuesRefQuery = useHelmChartValues(
{
chart: selectedChart.name,
version: values?.version,
...repoParams,
},
isLatestVersionFetched
); );
return ( return (
@ -48,14 +69,18 @@ export function HelmInstallInnerForm({
<FormControl <FormControl
label="Version" label="Version"
inputId="version-input" inputId="version-input"
isLoading={isVersionsLoading}
loadingText="Loading versions..." loadingText="Loading versions..."
> >
<PortainerSelect<string> <PortainerSelect<ChartVersion>
value={selectedVersion} value={selectedVersion}
options={versionOptions} options={versionOptions}
noOptionsMessage={() => 'No versions found'}
placeholder="Select a version"
onChange={(version) => { onChange={(version) => {
if (version) { if (version) {
setFieldValue('version', version); setFieldValue('version', version.Version);
setFieldValue('repo', version.Repo);
} }
}} }}
data-cy="helm-version-input" data-cy="helm-version-input"
@ -70,13 +95,15 @@ export function HelmInstallInnerForm({
</FormSection> </FormSection>
</div> </div>
<FormActions <LoadingButton
submitLabel="Install" className="!ml-0"
loadingText="Installing Helm chart" loadingText="Installing Helm chart"
isLoading={isSubmitting} isLoading={isSubmitting}
isValid={!!namespace && !!name} disabled={!namespace || !name || !isRepoAvailable}
data-cy="helm-install" data-cy="helm-install"
/> >
Install
</LoadingButton>
</Form> </Form>
); );
} }

View file

@ -1,15 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { compact } from 'lodash';
import { useCurrentUser } from '@/react/hooks/useUser'; import { useCurrentUser } from '@/react/hooks/useUser';
import { Chart } from '../types'; import { FormSection } from '@@/form-components/FormSection';
import { useHelmChartList } from '../queries/useHelmChartList';
import { useHelmRegistries } from '../queries/useHelmRegistries'; import { useHelmHTTPChartList } from '../queries/useHelmChartList';
import { Chart } from '../types';
import {
HelmRegistrySelect,
RepoValue,
} from '../components/HelmRegistrySelect';
import { useHelmRepoOptions } from '../queries/useHelmRepositories';
import { HelmTemplatesList } from './HelmTemplatesList';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
import { HelmInstallForm } from './HelmInstallForm'; import { HelmInstallForm } from './HelmInstallForm';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
import { HelmTemplatesList } from './HelmTemplatesList';
interface Props { interface Props {
onSelectHelmChart: (chartName: string) => void; onSelectHelmChart: (chartName: string) => void;
@ -19,11 +24,60 @@ interface Props {
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) { export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
const [selectedChart, setSelectedChart] = useState<Chart | null>(null); const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
const [selectedRegistry, setSelectedRegistry] = useState<string | null>(null); const [selectedRepo, setSelectedRepo] = useState<RepoValue | null>(null);
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const helmReposQuery = useHelmRegistries(); const chartListQuery = useHelmHTTPChartList(
const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry])); user.Id,
selectedRepo?.repoUrl ?? '',
!!selectedRepo?.repoUrl
);
const repoOptionsQuery = useHelmRepoOptions();
const isRepoAvailable =
!!repoOptionsQuery.data && repoOptionsQuery.data.length > 0;
return (
<div className="row">
<div className="col-sm-12 p-0">
<FormSection title="Helm chart">
{selectedChart ? (
<>
<HelmTemplatesSelectedItem
selectedChart={selectedChart}
clearHelmChart={clearHelmChart}
/>
<HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
isRepoAvailable={isRepoAvailable}
/>
</>
) : (
<>
<HelmRegistrySelect
selectedRegistry={selectedRepo}
onRegistryChange={setSelectedRepo}
namespace={namespace}
isRepoAvailable={isRepoAvailable}
isLoading={repoOptionsQuery.isLoading}
isError={repoOptionsQuery.isError}
repoOptions={repoOptionsQuery.data ?? []}
/>
{selectedRepo && (
<HelmTemplatesList
charts={chartListQuery.data ?? []}
selectAction={handleChartSelection}
isLoadingCharts={chartListQuery.isInitialLoading}
selectedRegistry={selectedRepo}
/>
)}
</>
)}
</FormSection>
</div>
</div>
);
function clearHelmChart() { function clearHelmChart() {
setSelectedChart(null); setSelectedChart(null);
onSelectHelmChart(''); onSelectHelmChart('');
@ -33,33 +87,4 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
setSelectedChart(chart); setSelectedChart(chart);
onSelectHelmChart(chart.name); onSelectHelmChart(chart.name);
} }
return (
<div className="row">
<div className="col-sm-12 p-0">
{selectedChart ? (
<>
<HelmTemplatesSelectedItem
selectedChart={selectedChart}
clearHelmChart={clearHelmChart}
/>
<HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
/>
</>
) : (
<HelmTemplatesList
charts={chartListQuery.data}
selectAction={handleChartSelection}
isLoading={chartListQuery.isInitialLoading}
registries={helmReposQuery.data ?? []}
selectedRegistry={selectedRegistry}
setSelectedRegistry={setSelectedRegistry}
/>
)}
</div>
</div>
);
} }

View file

@ -46,25 +46,63 @@ const mockCharts: Chart[] = [
const selectActionMock = vi.fn(); const selectActionMock = vi.fn();
const mockUseEnvironmentId = vi.fn(() => 1);
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
// Mock the helm registries query
vi.mock('../queries/useHelmRegistries', () => ({
useHelmRegistries: vi.fn(() => ({
data: ['https://example.com', 'https://example.com/2'],
isInitialLoading: false,
isError: false,
})),
}));
// Mock the environment registries query
vi.mock(
'@/react/portainer/environments/queries/useEnvironmentRegistries',
() => ({
useEnvironmentRegistries: vi.fn(() => ({
data: [
{ Id: 1, URL: 'https://registry.example.com' },
{ Id: 2, URL: 'https://registry2.example.com' },
],
isInitialLoading: false,
isError: false,
})),
})
);
function renderComponent({ function renderComponent({
loading = false, loading = false,
charts = mockCharts, charts = mockCharts,
selectAction = selectActionMock, selectAction = selectActionMock,
selectedRegistry = '', selectedRegistry = {
repoUrl: 'https://example.com',
name: 'Test Registry',
},
}: {
loading?: boolean;
charts?: Chart[];
selectAction?: (chart: Chart) => void;
selectedRegistry?: {
repoUrl?: string;
name?: string;
} | null;
} = {}) { } = {}) {
const user = new UserViewModel({ Username: 'user' }); const user = new UserViewModel({ Username: 'user' });
const registries = ['https://example.com', 'https://example.com/2'];
const Wrapped = withTestQueryProvider( const Wrapped = withTestQueryProvider(
withUserProvider( withUserProvider(
withTestRouter(() => ( withTestRouter(() => (
<HelmTemplatesList <HelmTemplatesList
isLoading={loading} isLoadingCharts={loading}
charts={charts} charts={charts}
selectAction={selectAction} selectAction={selectAction}
registries={registries}
selectedRegistry={selectedRegistry} selectedRegistry={selectedRegistry}
setSelectedRegistry={() => {}}
/> />
)), )),
user user
@ -81,8 +119,10 @@ describe('HelmTemplatesList', () => {
it('should display title and charts list', async () => { it('should display title and charts list', async () => {
renderComponent(); renderComponent();
// Check for the title // Check for the title with registry name
expect(screen.getByText('Helm chart')).toBeInTheDocument(); expect(
screen.getByText('Select a helm chart from Test Registry')
).toBeInTheDocument();
// Check for charts // Check for charts
expect(screen.getByText('test-chart-1')).toBeInTheDocument(); expect(screen.getByText('test-chart-1')).toBeInTheDocument();
@ -160,21 +200,27 @@ describe('HelmTemplatesList', () => {
}); });
it('should show empty message when no charts are available and a registry is selected', async () => { it('should show empty message when no charts are available and a registry is selected', async () => {
renderComponent({ charts: [], selectedRegistry: 'https://example.com' }); renderComponent({
charts: [],
selectedRegistry: {
repoUrl: 'https://example.com',
name: 'Test Registry',
},
});
// Check for empty message // Check for empty message
expect( expect(
screen.getByText('No helm charts available in this registry.') screen.getByText('No helm charts available in this repository.')
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("should show 'select registry' message when no charts are available and no registry is selected", async () => { it("should show 'select registry' message when no charts are available and no registry is selected", async () => {
renderComponent({ charts: [] }); renderComponent({ charts: [], selectedRegistry: null });
// Check for message // Check for message
expect( expect(
screen.getByText( screen.getByText(
'Please select a registry to view available Helm charts.' 'Please select a repository to view available Helm charts.'
) )
).toBeInTheDocument(); ).toBeInTheDocument();
}); });

View file

@ -1,59 +1,47 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { components, OptionProps } from 'react-select';
import { import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
PortainerSelect,
Option,
} from '@/react/components/form-components/PortainerSelect';
import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar'; import { SearchBar } from '@@/datatables/SearchBar';
import { InlineLoader } from '@@/InlineLoader'; import { InlineLoader } from '@@/InlineLoader';
import { Chart } from '../types'; import { Chart } from '../types';
import { RepoValue } from '../components/HelmRegistrySelect';
import { HelmTemplatesListItem } from './HelmTemplatesListItem'; import { HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props { interface Props {
isLoading: boolean; isLoadingCharts: boolean;
charts?: Chart[]; charts?: Chart[];
selectAction: (chart: Chart) => void; selectAction: (chart: Chart) => void;
registries: string[]; selectedRegistry: RepoValue | null;
selectedRegistry: string | null;
setSelectedRegistry: (registry: string | null) => void;
} }
export function HelmTemplatesList({ export function HelmTemplatesList({
isLoading, isLoadingCharts,
charts = [], charts = [],
selectAction, selectAction,
registries,
selectedRegistry, selectedRegistry,
setSelectedRegistry,
}: Props) { }: Props) {
const [textFilter, setTextFilter] = useState(''); const [textFilter, setTextFilter] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null); const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = useMemo(() => getCategories(charts), [charts]); const categories = useMemo(() => getCategories(charts), [charts]);
const registryOptions = useMemo(
() =>
registries.map((registry) => ({
label: registry,
value: registry,
})),
[registries]
);
const filteredCharts = useMemo( const filteredCharts = useMemo(
() => getFilteredCharts(charts, textFilter, selectedCategory), () => getFilteredCharts(charts, textFilter, selectedCategory),
[charts, textFilter, selectedCategory] [charts, textFilter, selectedCategory]
); );
const isSelectedRegistryEmpty =
!isLoadingCharts && charts.length === 0 && selectedRegistry;
return ( return (
<section className="datatable" aria-label="Helm charts"> <section className="datatable" aria-label="Helm charts">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0 !overflow-visible"> <div className="toolBar vertical-center relative w-full !gap-x-5 !gap-y-1 !px-0 overflow-auto">
<div className="toolBarTitle vertical-center">Helm chart</div> <div className="toolBarTitle vertical-center whitespace-nowrap">
Select a helm chart from {selectedRegistry?.name}
</div>
<SearchBar <SearchBar
value={textFilter} value={textFilter}
@ -63,20 +51,7 @@ export function HelmTemplatesList({
className="!mr-0 h-9" className="!mr-0 h-9"
/> />
<div className="w-full sm:w-1/4"> <div className="w-full sm:w-1/4 flex-none">
<PortainerSelect
placeholder="Select a registry"
value={selectedRegistry ?? ''}
options={registryOptions}
onChange={setSelectedRegistry}
isClearable
bindToBody
components={{ Option: RegistryOption }}
data-cy="helm-registry-select"
/>
</div>
<div className="w-full sm:w-1/4">
<PortainerSelect <PortainerSelect
placeholder="Select a category" placeholder="Select a category"
value={selectedCategory} value={selectedCategory}
@ -88,42 +63,6 @@ export function HelmTemplatesList({
/> />
</div> </div>
</div> </div>
<div className="w-fit">
<div className="small text-muted mb-2">
Select the Helm chart to use. Bring further Helm charts into your
selection list via{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="helm-repositories-link"
>
User settings - Helm repositories
</Link>
.
</div>
<InsightsBox
header="Disclaimer"
type="slim"
content={
<>
At present Portainer does not support OCI format Helm charts.
Support for OCI charts will be available in a future release.
<br />
If you would like to provide feedback on OCI support or get access
to early releases to test this functionality,{' '}
<a
href="https://bit.ly/3WVkayl"
target="_blank"
rel="noopener noreferrer"
>
please get in touch
</a>
.
</>
}
/>
</div>
<div className="blocklist !px-0" role="list"> <div className="blocklist !px-0" role="list">
{filteredCharts.map((chart) => ( {filteredCharts.map((chart) => (
@ -138,7 +77,7 @@ export function HelmTemplatesList({
<div className="text-muted small mt-4">No Helm charts found</div> <div className="text-muted small mt-4">No Helm charts found</div>
)} )}
{isLoading && ( {isLoadingCharts && (
<div className="flex flex-col"> <div className="flex flex-col">
<InlineLoader className="justify-center"> <InlineLoader className="justify-center">
Loading helm charts... Loading helm charts...
@ -151,15 +90,15 @@ export function HelmTemplatesList({
</div> </div>
)} )}
{!isLoading && charts.length === 0 && selectedRegistry && ( {isSelectedRegistryEmpty && (
<div className="text-muted text-center"> <div className="text-muted text-center">
No helm charts available in this registry. No helm charts available in this repository.
</div> </div>
)} )}
{!selectedRegistry && ( {!selectedRegistry && (
<div className="text-muted text-center"> <div className="text-muted text-center">
Please select a registry to view available Helm charts. Please select a repository to view available Helm charts.
</div> </div>
)} )}
</div> </div>
@ -167,20 +106,6 @@ export function HelmTemplatesList({
); );
} }
// truncate the registry text, because some registry names are urls, which are too long
function RegistryOption(props: OptionProps<Option<string>>) {
const { data: registry } = props;
return (
<div title={registry.value}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<components.Option {...props} className="whitespace-nowrap truncate">
{registry.value}
</components.Option>
</div>
);
}
/** /**
* Get categories from charts * Get categories from charts
* @param charts - The charts to get the categories from * @param charts - The charts to get the categories from

View file

@ -26,7 +26,7 @@ export function HelmTemplatesSelectedItem({
<FallbackImage <FallbackImage
src={selectedChart.icon} src={selectedChart.icon}
fallbackIcon={HelmIcon} fallbackIcon={HelmIcon}
className="h-16 w-16" className="h-16 w-16 flex-none"
/> />
<div className="col-sm-12"> <div className="col-sm-12">
<div> <div>

View file

@ -1,4 +1,5 @@
export type HelmInstallFormValues = { export type HelmInstallFormValues = {
values: string; values: string;
version: string; version: string;
repo: string;
}; };

View file

@ -0,0 +1,242 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import selectEvent from '@/react/test-utils/react-select';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { RegistryTypes } from '@/react/portainer/registries/types/registry';
import { useCurrentUser } from '@/react/hooks/useUser';
import { User, Role } from '@/portainer/users/types';
import { HelmRegistrySelect, RepoValue } from './HelmRegistrySelect';
// Mock the hooks with factory functions - preserve other exports
vi.mock('@/react/hooks/useUser', async () => {
const actual = await vi.importActual('@/react/hooks/useUser');
return {
...actual,
useCurrentUser: vi.fn(),
};
});
const mockOnRegistryChange = vi.fn();
const defaultProps = {
selectedRegistry: null,
onRegistryChange: mockOnRegistryChange,
isRepoAvailable: true,
isLoading: false,
isError: false,
repoOptions: [],
};
const mockRepoOptions = [
{
value: {
repoUrl: 'https://charts.bitnami.com/bitnami',
name: 'Bitnami',
type: RegistryTypes.CUSTOM,
},
label: 'Bitnami',
},
{
value: {
repoUrl: 'https://kubernetes-charts.storage.googleapis.com',
name: 'Stable',
type: RegistryTypes.CUSTOM,
},
label: 'Stable',
},
];
interface MockUserHookReturn {
user: User;
isPureAdmin: boolean;
}
interface UserProps {
isPureAdmin?: boolean;
}
// Get the mocked functions
const mockUseCurrentUser = vi.mocked(useCurrentUser);
function renderComponent(props = {}, userProps: UserProps = {}) {
const userResult: MockUserHookReturn = {
user: {
Id: 1,
Username: 'admin',
Role: Role.Admin,
EndpointAuthorizations: {},
UseCache: false,
ThemeSettings: {
color: 'auto',
},
},
isPureAdmin: userProps.isPureAdmin || false,
};
mockUseCurrentUser.mockReturnValue(userResult);
const Component = withTestQueryProvider(
withUserProvider(
withTestRouter(HelmRegistrySelect),
new UserViewModel({ Username: 'admin', Role: 1 })
)
);
return render(<Component {...defaultProps} {...props} />);
}
describe('HelmRegistrySelect', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseCurrentUser.mockClear();
});
describe('Basic rendering', () => {
it('should render with default placeholder', () => {
renderComponent();
expect(screen.getByText('Select a repository')).toBeInTheDocument();
});
it('should render with custom placeholder', () => {
renderComponent({ placeholder: 'Custom placeholder' });
expect(screen.getByText('Custom placeholder')).toBeInTheDocument();
});
it('should render loading state', () => {
renderComponent({ isLoading: true });
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('should render error state', () => {
renderComponent({ isError: true });
expect(
screen.getByText('Unable to load registry options.')
).toBeInTheDocument();
});
});
describe('Repository options', () => {
it('should display repository options', async () => {
const user = userEvent.setup();
renderComponent({ repoOptions: mockRepoOptions });
const select = screen.getByRole('combobox');
await user.click(select);
expect(screen.getByText('Bitnami')).toBeInTheDocument();
expect(screen.getByText('Stable')).toBeInTheDocument();
});
it.skip('should call onRegistryChange when option is selected', async () => {
// Skipping this test due to react-select testing complexity
// The onChange functionality is covered by integration tests
renderComponent({ repoOptions: mockRepoOptions });
const select = screen.getByRole('combobox');
await selectEvent.select(select, 'Bitnami');
expect(mockOnRegistryChange).toHaveBeenCalledWith({
repoUrl: 'https://charts.bitnami.com/bitnami',
name: 'Bitnami',
type: RegistryTypes.CUSTOM,
});
});
it('should show selected repository value', () => {
const selectedRegistry: RepoValue = {
repoUrl: 'https://charts.bitnami.com/bitnami',
name: 'Bitnami',
type: RegistryTypes.CUSTOM,
};
renderComponent({
selectedRegistry,
repoOptions: mockRepoOptions,
});
// Since the component uses PortainerSelect which manages the display value,
// we verify the props are correctly passed by checking the select element exists
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
describe('No repositories warning', () => {
it('should show no repositories warning when no repos are available', () => {
renderComponent({
isRepoAvailable: false,
namespace: 'test-namespace',
});
expect(
screen.getByText(/There are no repositories available./)
).toBeInTheDocument();
});
it('should not show warning when loading', () => {
renderComponent({
isRepoAvailable: false,
namespace: 'test-namespace',
isLoading: true,
});
expect(
screen.queryByText('There are no repositories available.')
).not.toBeInTheDocument();
});
it('should not show warning when no namespace is provided', () => {
renderComponent({
isRepoAvailable: false,
});
expect(
screen.queryByText('There are no repositories available.')
).not.toBeInTheDocument();
});
});
describe('Tooltip content', () => {
it('should render the component with label and tooltip', () => {
renderComponent({}, { isPureAdmin: true });
// Verify that the component renders the main label
expect(screen.getByText('Helm chart source')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
});
describe('Loading and error states', () => {
it('should not show no repos warning when loading', () => {
renderComponent({
isLoading: true,
isRepoAvailable: false,
repoOptions: [],
namespace: 'test-namespace',
});
expect(
screen.queryByText('There are no repositories available.')
).not.toBeInTheDocument();
});
it('should show error when API fails', () => {
renderComponent({
isLoading: false,
isError: true,
isRepoAvailable: false,
namespace: 'test-namespace',
});
expect(
screen.getByText('Unable to load registry options.')
).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,156 @@
import { GroupBase } from 'react-select';
import {
PortainerSelect,
Option,
} from '@/react/components/form-components/PortainerSelect';
import { useCurrentUser } from '@/react/hooks/useUser';
import { RegistryTypes } from '@/react/portainer/registries/types/registry';
import { FormControl } from '@@/form-components/FormControl';
import { Alert } from '@@/Alert';
import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip';
export type RepoValue = {
repoUrl?: string; // set for traditional https helm repos
name?: string;
type?: RegistryTypes;
};
interface Props {
selectedRegistry: RepoValue | null;
onRegistryChange: (registry: RepoValue | null) => void;
namespace?: string;
placeholder?: string;
'data-cy'?: string;
isRepoAvailable: boolean;
isLoading: boolean;
isError: boolean;
repoOptions: GroupBase<Option<RepoValue>>[];
}
export function HelmRegistrySelect({
selectedRegistry,
onRegistryChange,
namespace,
placeholder = 'Select a repository',
'data-cy': dataCy = 'helm-registry-select',
isRepoAvailable,
isLoading,
isError,
repoOptions,
}: Props) {
const { isPureAdmin } = useCurrentUser();
return (
<FormControl
label="Helm chart source"
tooltip={<HelmChartSourceTooltip isPureAdmin={isPureAdmin} />}
>
<PortainerSelect<RepoValue>
placeholder={placeholder}
value={selectedRegistry ?? {}}
options={repoOptions}
isLoading={isLoading}
onChange={onRegistryChange}
isClearable
bindToBody
data-cy={dataCy}
/>
<NoReposWarning
hasNoRepos={!isRepoAvailable}
isLoading={isLoading}
namespace={namespace}
isPureAdmin={isPureAdmin}
/>
{isError && <Alert color="error">Unable to load registry options.</Alert>}
</FormControl>
);
}
function HelmChartSourceTooltip({ isPureAdmin }: { isPureAdmin: boolean }) {
if (isPureAdmin) {
return (
<>
<CreateUserRepoMessage />
<br />
<CreateGlobalRepoMessage />
</>
);
}
// Non-admin
return <CreateUserRepoMessage />;
}
function NoReposWarning({
hasNoRepos,
isLoading,
namespace,
isPureAdmin,
}: {
hasNoRepos: boolean;
isLoading: boolean;
namespace?: string;
isPureAdmin: boolean;
}) {
if (!hasNoRepos || isLoading || !namespace) {
return null;
}
return (
<TextTip color="blue" className="mt-2">
There are no repositories available.
<CreateRepoMessage isPureAdmin={isPureAdmin} />
</TextTip>
);
}
function CreateRepoMessage({ isPureAdmin }: { isPureAdmin: boolean }) {
if (isPureAdmin) {
return (
<>
<CreateUserRepoMessage />
<br />
<CreateGlobalRepoMessage />
</>
);
}
// Non-admin
return <CreateUserRepoMessage />;
}
function CreateUserRepoMessage() {
return (
<>
You can define <b>repositories</b> in the{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="helm-repositories-link"
>
User settings - Helm repositories
</Link>
.
</>
);
}
function CreateGlobalRepoMessage() {
return (
<>
You can also define repositories in the{' '}
<Link
to="portainer.settings"
params={{ '#': 'kubernetes-settings' }}
data-cy="portainer-settings-link"
target="_blank"
>
Portainer settings
</Link>
.
</>
);
}

View file

@ -1,6 +1,5 @@
import { useQueries } from '@tanstack/react-query'; import { compact } from 'lodash';
import { compact, flatMap } from 'lodash'; import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import axios from '@/portainer/services/axios'; import axios from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
@ -8,45 +7,27 @@ import { withGlobalError } from '@/react-tools/react-query';
import { Chart, HelmChartsResponse } from '../types'; import { Chart, HelmChartsResponse } from '../types';
/** /**
* React hook to fetch helm charts from the provided repositories * React hook to fetch helm charts from the provided HTTP repository.
* Charts from each repository are loaded independently, allowing the UI * Charts are loaded from the specified repository URL.
* to show charts as they become available instead of waiting for all
* repositories to load
* *
* @param userId User ID * @param userId User ID
* @param repositories List of repository URLs to fetch charts from * @param repository Repository URL to fetch charts from
* @param enabled Flag indicating if the query should be enabled
* @returns Query result containing helm charts
*/ */
export function useHelmChartList(userId: number, repositories: string[] = []) { export function useHelmHTTPChartList(
// Fetch charts from each repository in parallel as separate queries userId: number,
const chartQueries = useQueries({ repository: string,
queries: useMemo( enabled: boolean
() => ) {
repositories.map((repo) => ({ return useQuery({
queryKey: [userId, repo, 'helm-charts'], queryKey: [userId, repository, 'helm-charts'],
queryFn: () => getChartsFromRepo(repo), queryFn: () => getChartsFromRepo(repository),
enabled: !!userId && repositories.length > 0, enabled: !!userId && !!repository && enabled,
// one request takes a long time, so fail early to get feedback to the user faster // one request takes a long time, so fail early to get feedback to the user faster
retries: false, retry: false,
...withGlobalError(`Unable to retrieve Helm charts from ${repo}`), ...withGlobalError(`Unable to retrieve Helm charts from ${repository}`),
})),
[repositories, userId]
),
}); });
// Combine the results for easier consumption by components
const allCharts = useMemo(
() => flatMap(compact(chartQueries.map((q) => q.data))),
[chartQueries]
);
return {
// Data from all repositories that have loaded so far
data: allCharts,
// Overall loading state
isInitialLoading: chartQueries.some((q) => q.isInitialLoading),
// Overall error state
isError: chartQueries.some((q) => q.isError),
};
} }
async function getChartsFromRepo(repo: string): Promise<Chart[]> { async function getChartsFromRepo(repo: string): Promise<Chart[]> {

View file

@ -4,8 +4,11 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
type Params = { type Params = {
/** The name of the chart to get the values for */
chart: string; chart: string;
/** The repository URL or registry ID */
repo: string; repo: string;
/** The version of the chart to get the values for */
version?: string; version?: string;
}; };
@ -16,18 +19,26 @@ async function getHelmChartValues(params: Params) {
}); });
return response.data; return response.data;
} catch (err) { } catch (err) {
throw parseAxiosError(err as Error, 'Unable to get Helm chart values'); throw parseAxiosError(err, 'Unable to get Helm chart values');
} }
} }
export function useHelmChartValues(params: Params) { export function useHelmChartValues(params: Params, isLatestVersion = false) {
const hasValidRepoUrl = !!params.repo;
return useQuery({ return useQuery({
queryKey: ['helm-chart-values', params.repo, params.chart, params.version], queryKey: [
'helm-chart-values',
params.repo,
params.chart,
// if the latest version is fetched, use the latest version key to cache the latest version
isLatestVersion ? 'latest' : params.version,
],
queryFn: () => getHelmChartValues(params), queryFn: () => getHelmChartValues(params),
enabled: !!params.chart && !!params.repo, enabled: !!params.chart && hasValidRepoUrl,
select: (data) => ({ select: (data) => ({
values: data, values: data,
}), }),
retry: 1,
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
...withGlobalError('Unable to get Helm chart values'), ...withGlobalError('Unable to get Helm chart values'),
}); });

View file

@ -1,43 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { compact } from 'lodash';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { UserId } from '@/portainer/users/types';
import { withGlobalError } from '@/react-tools/react-query';
import { useCurrentUser } from '@/react/hooks/useUser';
import { HelmRegistriesResponse } from '../types';
/**
* Hook to fetch all Helm registries for the current user
*/
export function useHelmRegistries() {
const { user } = useCurrentUser();
return useQuery(
['helm', 'registries'],
async () => getHelmRegistries(user.Id),
{
enabled: !!user.Id,
...withGlobalError('Unable to retrieve helm registries'),
}
);
}
/**
* Get Helm registries for user
*/
async function getHelmRegistries(userId: UserId) {
try {
const { data } = await axios.get<HelmRegistriesResponse>(
`users/${userId}/helm/repositories`
);
const repos = compact([
// compact will remove the global repository if it's empty
data.GlobalRepository.toLowerCase(),
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
]);
return [...new Set(repos)];
} catch (err) {
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
}
}

View file

@ -21,6 +21,10 @@ export interface ChartVersion {
AppVersion?: string; AppVersion?: string;
} }
type RepoSource = {
repo?: string;
};
/** /**
* React hook to get a list of available versions for a chart from specified repositories * React hook to get a list of available versions for a chart from specified repositories
* *
@ -32,21 +36,21 @@ export interface ChartVersion {
export function useHelmRepoVersions( export function useHelmRepoVersions(
chart: string, chart: string,
staleTime: number, staleTime: number,
repositories: string[] = [], repoSources: RepoSource[] = [],
useCache: boolean = true useCache: boolean = true
) { ) {
// Fetch versions from each repository in parallel as separate queries // Fetch versions from each repository in parallel as separate queries
const versionQueries = useQueries({ const versionQueries = useQueries({
queries: useMemo( queries: useMemo(
() => () =>
repositories.map((repo) => ({ repoSources.map(({ repo }) => ({
queryKey: ['helm', 'repositories', chart, repo, useCache], queryKey: ['helm', 'repositories', chart, repo, useCache],
queryFn: () => getSearchHelmRepo(repo, chart, useCache), queryFn: () => getSearchHelmRepo({ repo, chart, useCache }),
enabled: !!chart && repositories.length > 0, enabled: !!chart && repoSources.length > 0,
staleTime, staleTime,
...withGlobalError(`Unable to retrieve versions from ${repo}`), ...withGlobalError(`Unable to retrieve versions from ${repo}`),
})), })),
[repositories, chart, staleTime, useCache] [repoSources, chart, staleTime, useCache]
), ),
}); });
@ -58,30 +62,35 @@ export function useHelmRepoVersions(
return { return {
data: allVersions, data: allVersions,
isInitialLoading: versionQueries.some((q) => q.isLoading), isInitialLoading: versionQueries.some((q) => q.isInitialLoading),
isError: versionQueries.some((q) => q.isError), isError: versionQueries.some((q) => q.isError),
isFetching: versionQueries.some((q) => q.isFetching), isFetching: versionQueries.some((q) => q.isFetching),
refetch: () => Promise.all(versionQueries.map((q) => q.refetch())), refetch: () => Promise.all(versionQueries.map((q) => q.refetch())),
}; };
} }
type SearchRepoParams = {
repo?: string;
chart: string;
useCache?: boolean;
};
/** /**
* Get Helm repositories for user * Get Helm repositories for user
*/ */
async function getSearchHelmRepo( async function getSearchHelmRepo(
repo: string, params: SearchRepoParams
chart: string,
useCache: boolean = true
): Promise<ChartVersion[]> { ): Promise<ChartVersion[]> {
try { try {
const { data } = await axios.get<HelmSearch>(`templates/helm`, { const { data } = await axios.get<HelmSearch>(`templates/helm`, {
params: { repo, chart, useCache }, params,
}); });
const versions = data.entries[chart]; // if separated by '/', take the last part
const chartKey = params.chart.split('/').pop() || params.chart;
const versions = data.entries[chartKey];
return ( return (
versions?.map((v) => ({ versions?.map((v) => ({
Chart: chart, Repo: params.repo ?? '',
Repo: repo,
Version: v.version, Version: v.version,
AppVersion: v.appVersion, AppVersion: v.appVersion,
})) ?? [] })) ?? []

View file

@ -0,0 +1,84 @@
import { useQuery } from '@tanstack/react-query';
import { compact } from 'lodash';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { UserId } from '@/portainer/users/types';
import { withGlobalError } from '@/react-tools/react-query';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Option } from '@/react/components/form-components/PortainerSelect';
import { HelmRegistriesResponse } from '../types';
import { RepoValue } from '../components/HelmRegistrySelect';
/**
* Hook to fetch all Helm registries for the current user
*/
export function useUserHelmRepositories<T = string[]>({
select,
}: {
select?: (registries: string[]) => T;
} = {}) {
const { user } = useCurrentUser();
return useQuery(
['helm', 'registries'],
async () => getUserHelmRepositories(user.Id),
{
enabled: !!user.Id,
select,
...withGlobalError('Unable to retrieve helm registries'),
}
);
}
export function useHelmRepoOptions() {
return useUserHelmRepositories({
select: (registries) => {
const repoOptions = registries
.map<Option<RepoValue>>((registry) => ({
label: registry,
value: {
repoUrl: registry,
isOCI: false,
name: registry,
},
}))
.sort((a, b) => a.label.localeCompare(b.label));
return [
{
label: 'Helm Repositories',
options: repoOptions,
},
{
label: 'OCI Registries',
options: [
{
label:
'Installing from an OCI registry is a Portainer Business Feature',
value: {},
disabled: true,
},
],
},
];
},
});
}
/**
* Get Helm repositories for user
*/
async function getUserHelmRepositories(userId: UserId) {
try {
const { data } = await axios.get<HelmRegistriesResponse>(
`users/${userId}/helm/repositories`
);
// compact will remove the global repository if it's empty
const repos = compact([
data.GlobalRepository.toLowerCase(),
...data.UserRepositories.map((repo) => repo.URL.toLowerCase()),
]);
return [...new Set(repos)];
} catch (err) {
throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
}
}

View file

@ -91,7 +91,7 @@ export interface HelmChartResponse {
versions: string[]; versions: string[];
} }
export interface HelmRepositoryResponse { export interface HelmRegistryResponse {
Id: number; Id: number;
UserId: number; UserId: number;
URL: string; URL: string;
@ -99,7 +99,7 @@ export interface HelmRepositoryResponse {
export interface HelmRegistriesResponse { export interface HelmRegistriesResponse {
GlobalRepository: string; GlobalRepository: string;
UserRepositories: HelmRepositoryResponse[]; UserRepositories: HelmRegistryResponse[];
} }
export interface HelmChartsResponse { export interface HelmChartsResponse {
@ -108,15 +108,6 @@ export interface HelmChartsResponse {
generated: string; generated: string;
} }
export interface InstallChartPayload {
Name: string;
Repo: string;
Chart: string;
Values: string;
Namespace: string;
Version?: string;
}
export interface UpdateHelmReleasePayload { export interface UpdateHelmReleasePayload {
namespace: string; namespace: string;
values?: string; values?: string;

View file

@ -201,8 +201,10 @@ function InheritanceMessage({
return ( return (
<tr> <tr>
<td colSpan={2} aria-label="inheritance-message"> <td colSpan={2} aria-label="inheritance-message">
<Icon icon={Info} mode="primary" className="mr-1" /> <div className="inline-flex items-center gap-1">
{children} <Icon icon={Info} mode="primary" />
{children}
</div>
<Tooltip message={tooltip} /> <Tooltip message={tooltip} />
</td> </td>
</tr> </tr>

View file

@ -81,21 +81,25 @@ export function HelmRepositoryDatatable() {
function HelmDatatableDescription({ isAdmin }: { isAdmin: boolean }) { function HelmDatatableDescription({ isAdmin }: { isAdmin: boolean }) {
return ( return (
<TextTip color="blue" className="mb-3"> <TextTip color="blue" className="mb-3">
Adding a Helm repo here only makes it available in your own user <p>
account&apos;s Portainer UI. Helm charts are pulled down from these repos Adding a Helm repository here makes it available only in your Portainer
(plus the{' '} user account. The Helm charts from these repositories (along with the
{isAdmin ? ( globally set Helm repository) are shown in the &apos;Create from
<Link Code&apos; screen.
to="portainer.settings" </p>
params={{ '#': 'kubernetes-settings' }} {isAdmin && (
data-cy="k8s-globally-select-repo-link" <>
> To manage your helm repositories globally, navigate to{' '}
<span>globally-set Helm repo</span> <Link
</Link> to="portainer.settings"
) : ( params={{ '#': 'kubernetes-settings' }}
<span>globally-set Helm repo</span> data-cy="helm-settings-link"
>
Settings &gt; General
</Link>
.
</>
)} )}
) and shown in the Create from code screen&apos;s Helm charts list.
</TextTip> </TextTip>
); );
} }

View file

@ -3,6 +3,11 @@ import { EnvironmentId } from '../types';
export const environmentQueryKeys = { export const environmentQueryKeys = {
base: () => ['environments'] as const, base: () => ['environments'] as const,
item: (id: EnvironmentId) => [...environmentQueryKeys.base(), id] as const, item: (id: EnvironmentId) => [...environmentQueryKeys.base(), id] as const,
registries: (environmentId: EnvironmentId) => registries: (environmentId: EnvironmentId, namespace?: string) =>
[...environmentQueryKeys.base(), environmentId, 'registries'] as const, [
...environmentQueryKeys.base(),
environmentId,
'registries',
namespace,
] as const,
}; };

View file

@ -14,17 +14,28 @@ export function useEnvironmentRegistries<T = Array<Registry>>(
environmentId: EnvironmentId, environmentId: EnvironmentId,
queryOptions: GenericRegistriesQueryOptions<T> = {} queryOptions: GenericRegistriesQueryOptions<T> = {}
) { ) {
const { namespace } = queryOptions;
return useGenericRegistriesQuery( return useGenericRegistriesQuery(
environmentQueryKeys.registries(environmentId), environmentQueryKeys.registries(environmentId, namespace),
() => getEnvironmentRegistries(environmentId), () => getEnvironmentRegistries(environmentId, { namespace }),
queryOptions queryOptions
); );
} }
async function getEnvironmentRegistries(environmentId: EnvironmentId) { type Params = {
namespace?: string;
};
async function getEnvironmentRegistries(
environmentId: EnvironmentId,
params: Params
) {
try { try {
const { data } = await axios.get<Array<Registry>>( const { data } = await axios.get<Array<Registry>>(
buildUrl(environmentId, 'registries') buildUrl(environmentId, 'registries'),
{
params,
}
); );
return data; return data;
} catch (err) { } catch (err) {

View file

@ -1,62 +1,56 @@
import { Edit } from 'lucide-react';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import Ecr from '@/assets/ico/vendor/ecr.svg?c';
import Quay from '@/assets/ico/vendor/quay.svg?c';
import Proget from '@/assets/ico/vendor/proget.svg?c';
import Azure from '@/assets/ico/vendor/azure.svg?c';
import Gitlab from '@/assets/ico/vendor/gitlab.svg?c';
import { BadgeIcon } from '@@/BadgeIcon'; import { BadgeIcon } from '@@/BadgeIcon';
import { RegistryTypes } from '../types/registry';
import { registryIconMap, registryLabelMap } from '../utils/constants';
export const options = [ export const options = [
{ {
id: 'registry_dockerhub', id: 'registry_dockerhub',
icon: Docker, icon: registryIconMap[RegistryTypes.DOCKERHUB],
label: 'DockerHub', label: registryLabelMap[RegistryTypes.DOCKERHUB],
description: 'DockerHub authenticated account', description: 'DockerHub authenticated account',
value: '6', value: String(RegistryTypes.DOCKERHUB),
}, },
{ {
id: 'registry_aws_ecr', id: 'registry_aws_ecr',
icon: Ecr, icon: registryIconMap[RegistryTypes.ECR],
label: 'AWS ECR', label: registryLabelMap[RegistryTypes.ECR],
description: 'Amazon elastic container registry', description: 'Amazon elastic container registry',
value: '7', value: String(RegistryTypes.ECR),
}, },
{ {
id: 'registry_quay', id: 'registry_quay',
icon: Quay, icon: registryIconMap[RegistryTypes.QUAY],
label: 'Quay.io', label: registryLabelMap[RegistryTypes.QUAY],
description: 'Quay container registry', description: 'Quay container registry',
value: '1', value: String(RegistryTypes.QUAY),
}, },
{ {
id: 'registry_proget', id: 'registry_proget',
icon: Proget, icon: registryIconMap[RegistryTypes.PROGET],
label: 'ProGet', label: registryLabelMap[RegistryTypes.PROGET],
description: 'ProGet container registry', description: 'ProGet container registry',
value: '5', value: String(RegistryTypes.PROGET),
}, },
{ {
id: 'registry_azure', id: 'registry_azure',
icon: Azure, icon: registryIconMap[RegistryTypes.AZURE],
label: 'Azure', label: registryLabelMap[RegistryTypes.AZURE],
description: 'Azure container registry', description: 'Azure container registry',
value: '2', value: String(RegistryTypes.AZURE),
}, },
{ {
id: 'registry_gitlab', id: 'registry_gitlab',
icon: Gitlab, icon: registryIconMap[RegistryTypes.GITLAB],
label: 'GitLab', label: registryLabelMap[RegistryTypes.GITLAB],
description: 'GitLab container registry', description: 'GitLab container registry',
value: '4', value: String(RegistryTypes.GITLAB),
}, },
{ {
id: 'registry_custom', id: 'registry_custom',
icon: <BadgeIcon icon={Edit} />, icon: <BadgeIcon icon={registryIconMap[RegistryTypes.CUSTOM]} />,
label: 'Custom registry', label: registryLabelMap[RegistryTypes.CUSTOM],
description: 'Define your own registry', description: 'Define your own registry',
value: '3', value: String(RegistryTypes.CUSTOM),
}, },
]; ];

View file

@ -1,13 +1,17 @@
import { RegistryId } from '../types/registry'; import { RegistryId } from '../types/registry';
export function buildUrl(registryId: RegistryId) { export function buildUrl(registryId: RegistryId, resource?: 'repositories') {
const base = '/registries'; let url = '/registries';
if (registryId) { if (registryId) {
return `${base}/${registryId}`; url += `/${registryId}`;
} }
return base; if (resource) {
url += `/${resource}`;
}
return url;
} }
export function buildProxyUrl(registryId: RegistryId) { export function buildProxyUrl(registryId: RegistryId) {

View file

@ -24,6 +24,8 @@ export type GenericRegistriesQueryOptions<T> = {
onSuccess?: (data: T) => void; onSuccess?: (data: T) => void;
/** is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this. */ /** is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this. */
hideDefault?: boolean; hideDefault?: boolean;
/** is used to filter the registries by namespace. Kubernetes views use this. */
namespace?: string;
}; };
export function useGenericRegistriesQuery<T = Registry[]>( export function useGenericRegistriesQuery<T = Registry[]>(

View file

@ -0,0 +1,35 @@
import { Edit } from 'lucide-react';
import Docker from '@/assets/ico/vendor/docker.svg?c';
import Ecr from '@/assets/ico/vendor/ecr.svg?c';
import Quay from '@/assets/ico/vendor/quay.svg?c';
import Proget from '@/assets/ico/vendor/proget.svg?c';
import Azure from '@/assets/ico/vendor/azure.svg?c';
import Gitlab from '@/assets/ico/vendor/gitlab.svg?c';
import { RegistryTypes } from '../types/registry';
export const registryLabelMap: Record<RegistryTypes, string> = {
[RegistryTypes.ANONYMOUS]: 'Anonymous',
[RegistryTypes.DOCKERHUB]: 'DockerHub',
[RegistryTypes.ECR]: 'AWS ECR',
[RegistryTypes.QUAY]: 'Quay.io',
[RegistryTypes.PROGET]: 'ProGet',
[RegistryTypes.AZURE]: 'Azure',
[RegistryTypes.GITLAB]: 'GitLab',
[RegistryTypes.CUSTOM]: 'Custom registry',
[RegistryTypes.GITHUB]: 'GitHub',
};
export const registryIconMap = {
[RegistryTypes.DOCKERHUB]: Docker,
[RegistryTypes.ECR]: Ecr,
[RegistryTypes.QUAY]: Quay,
[RegistryTypes.PROGET]: Proget,
[RegistryTypes.AZURE]: Azure,
[RegistryTypes.GITLAB]: Gitlab,
[RegistryTypes.CUSTOM]: Edit,
// github and anonymous don't have an icon
[RegistryTypes.GITHUB]: null,
[RegistryTypes.ANONYMOUS]: null,
};

View file

@ -4,7 +4,7 @@ import { TextTip } from '@@/Tip/TextTip';
import { FormControl } from '@@/form-components/FormControl'; import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection'; import { FormSection } from '@@/form-components/FormSection';
import { Input } from '@@/form-components/Input'; import { Input } from '@@/form-components/Input';
import { InsightsBox } from '@@/InsightsBox'; import { ExternalLink } from '@@/ExternalLink';
export function HelmSection() { export function HelmSection() {
const [{ name }, { error }] = useField<string>('helmRepositoryUrl'); const [{ name }, { error }] = useField<string>('helmRepositoryUrl');
@ -13,39 +13,17 @@ export function HelmSection() {
<FormSection title="Helm repository"> <FormSection title="Helm repository">
<div className="mb-2"> <div className="mb-2">
<TextTip color="blue"> <TextTip color="blue">
You can specify the URL to your own Helm repository here. See the{' '} You can specify the URL to your own{' '}
<a <ExternalLink
href="https://helm.sh/docs/topics/chart_repository/" to="https://helm.sh/docs/topics/chart_repository/"
target="_blank" data-cy="helm-repository-link"
rel="noreferrer"
> >
official documentation Helm repository
</a>{' '} </ExternalLink>{' '}
for more details. here.
</TextTip> </TextTip>
</div> </div>
<InsightsBox
header="Disclaimer"
content={
<>
At present Portainer does not support OCI format Helm charts.
Support for OCI charts will be available in a future release. If you
would like to provide feedback on OCI support or get access to early
releases to test this functionality,{' '}
<a
href="https://bit.ly/3WVkayl"
target="_blank"
rel="noopener noreferrer"
>
please get in touch
</a>
.
</>
}
className="block w-fit mt-2 mb-1"
/>
<FormControl label="URL" errors={error} inputId="helm-repo-url"> <FormControl label="URL" errors={error} inputId="helm-repo-url">
<Field <Field
as={Input} as={Input}

4
go.mod
View file

@ -36,6 +36,7 @@ require (
github.com/klauspost/compress v1.18.0 github.com/klauspost/compress v1.18.0
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/orcaman/concurrent-map v1.0.0 github.com/orcaman/concurrent-map v1.0.0
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
@ -63,6 +64,7 @@ require (
k8s.io/kubectl v0.33.2 k8s.io/kubectl v0.33.2
k8s.io/kubelet v0.33.2 k8s.io/kubelet v0.33.2
k8s.io/metrics v0.33.2 k8s.io/metrics v0.33.2
oras.land/oras-go/v2 v2.6.0
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
) )
@ -225,7 +227,6 @@ require (
github.com/morikuni/aec v1.0.0 // indirect github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opencontainers/runtime-spec v1.2.1 // indirect github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
@ -305,7 +306,6 @@ require (
k8s.io/klog/v2 v2.130.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
oras.land/oras-go/v2 v2.6.0 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/kustomize/api v0.19.0 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect
sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,6 +45,8 @@ type Release struct {
// Labels of the release. // Labels of the release.
// Disabled encoding into Json cause labels are stored in storage driver metadata field. // Disabled encoding into Json cause labels are stored in storage driver metadata field.
Labels map[string]string `json:"-"` 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 are the values used to deploy the chart.
Values Values `json:"values,omitempty"` Values Values `json:"values,omitempty"`
} }
@ -54,6 +56,12 @@ type Values struct {
ComputedValues string `json:"computedValues,omitempty"` 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 // Chart is a helm package that contains metadata, a default config, zero or more
// optionally parameterizable templates, and zero or more charts (dependencies). // optionally parameterizable templates, and zero or more charts (dependencies).
type Chart struct { type Chart struct {

View file

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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release" "github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log" "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") 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) installClient, err := initInstallClient(actionConfig, installOpts)
if err != nil { if err != nil {
log.Error(). 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") 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 { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). 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") 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 { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Err(err). Err(err).
Msg("Failed to load and validate chart for helm release installation") 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") 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 // Run the installation
log.Info(). log.Info().
Str("context", "HelmClient"). Str("context", "HelmClient").
@ -76,7 +104,6 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
Str("name", installOpts.Name). Str("name", installOpts.Name).
Str("namespace", installOpts.Namespace). Str("namespace", installOpts.Namespace).
Msg("Running chart installation for helm release") Msg("Running chart installation for helm release")
helmRelease, err := installClient.Run(chart, values) helmRelease, err := installClient.Run(chart, values)
if err != nil { if err != nil {
log.Error(). log.Error().
@ -94,9 +121,10 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
Namespace: helmRelease.Namespace, Namespace: helmRelease.Namespace,
Chart: release.Chart{ Chart: release.Chart{
Metadata: &release.Metadata{ Metadata: &release.Metadata{
Name: helmRelease.Chart.Metadata.Name, Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version, Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion, AppVersion: helmRelease.Chart.Metadata.AppVersion,
Annotations: helmRelease.Chart.Metadata.Annotations,
}, },
}, },
Labels: helmRelease.Labels, Labels: helmRelease.Labels,
@ -111,13 +139,17 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I
installClient := action.NewInstall(actionConfig) installClient := action.NewInstall(actionConfig)
installClient.DependencyUpdate = true installClient.DependencyUpdate = true
installClient.ReleaseName = installOpts.Name installClient.ReleaseName = installOpts.Name
installClient.ChartPathOptions.RepoURL = installOpts.Repo
installClient.Wait = installOpts.Wait installClient.Wait = installOpts.Wait
installClient.Timeout = installOpts.Timeout 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 // Set default values if not specified
if installOpts.Timeout == 0 { if installOpts.Timeout == 0 {
installClient.Timeout = 5 * time.Minute installClient.Timeout = 15 * time.Minute // set a bigger timeout for large charts
} else { } else {
installClient.Timeout = installOpts.Timeout installClient.Timeout = installOpts.Timeout
} }

View file

@ -1,24 +1,30 @@
package sdk package sdk
import ( import (
"net/url" "context"
"os" "fmt"
"io"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/liboras"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"helm.sh/helm/v3/pkg/chart"
"helm.sh/helm/v3/pkg/cli" "helm.sh/helm/v3/pkg/cli"
"helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/repo" "helm.sh/helm/v3/pkg/repo"
"oras.land/oras-go/v2/registry"
) )
var ( var (
errRequiredSearchOptions = errors.New("repo is required") 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 { type RepoIndex struct {
@ -40,7 +46,6 @@ var (
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller. // 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) { func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options
if err := validateSearchRepoOptions(searchRepoOpts); err != nil { if err := validateSearchRepoOptions(searchRepoOpts); err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
@ -55,33 +60,8 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
Str("repo", searchRepoOpts.Repo). Str("repo", searchRepoOpts.Repo).
Msg("Searching repository") 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 // Set up Helm CLI environment
repoSettings := cli.New() repoSettings := cli.New()
// Ensure all required Helm directories exist
if err := ensureHelmDirectoriesExist(repoSettings); err != nil { if err := ensureHelmDirectoriesExist(repoSettings); err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). 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") 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 { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
@ -99,70 +160,55 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err return nil, err
} }
// Download the index file and update repository configuration indexPath, err := downloadRepoIndexFromHttpRepo(parsedURL.String(), repoSettings, repoName)
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, repoName)
if err != nil { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Str("repo_url", repoURL.String()). Str("repo_url", parsedURL.String()).
Err(err). Err(err).
Msg("Failed to download repository index") Msg("Failed to download repository index")
return nil, err return nil, err
} }
// Load and parse the index file return loadIndexFile(indexPath)
log.Debug(). }
Str("context", "HelmClient").
Str("index_path", indexPath).
Msg("Loading index file")
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 { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Str("index_path", indexPath). Str("repo", registry.URL).
Err(err). Err(err).
Msg("Failed to load index file") Msg("Failed to download repository index")
return nil, err return nil, err
} }
// Update cache and remove old entries return loadIndexFile(indexPath)
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)
} }
// validateSearchRepoOptions validates the required search repository options. // validateSearchRepoOptions validates the required search repository options.
func validateSearchRepoOptions(opts options.SearchRepoOptions) error { func validateSearchRepoOptions(opts options.SearchRepoOptions) error {
if opts.Repo == "" { if opts.Repo == "" && IsHTTPRepository(opts.Registry) {
return errRequiredSearchOptions return errRequiredSearchOptions
} }
return nil return nil
} }
// parseRepoURL parses and validates the repository URL. // downloadRepoIndexFromHttpRepo downloads the index.yaml file from the repository and updates
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
// the repository configuration. // 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(). log.Debug().
Str("context", "helm_sdk_repo_index"). Str("context", "helm_sdk_repo_index").
Str("repo_url", repoURLString). Str("repo_url", repoURLString).
@ -183,7 +229,7 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
Str("repo_url", repoURLString). Str("repo_url", repoURLString).
Err(err). Err(err).
Msg("Failed to create chart repository object") 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 // Load repository configuration file
@ -239,13 +285,168 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
return indexPath, nil return indexPath, nil
} }
// loadIndexFile loads the index file from the given path. func downloadRepoIndexFromOciRegistry(registry *portainer.Registry, repoSettings *cli.EnvSettings, chartPath string) (string, error) {
func loadIndexFile(indexPath string) (*repo.IndexFile, error) { if IsHTTPRepository(registry) {
indexFile, err := repo.LoadIndexFile(indexPath) return "", errors.New("registry information is required for OCI search")
if err != nil {
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
} }
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. // 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 // Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries { 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) result.Entries[name] = convertChartsToChartInfo(charts)
} }
} }
@ -304,87 +505,6 @@ type ChartInfo struct {
Annotations any `json:"annotations,omitempty"` 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) { func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) {
// Convert the index file to our response format // Convert the index file to our response format
result, err := convertIndexToResponse(indexFile, chartName) result, err := convertIndexToResponse(indexFile, chartName)

View file

@ -2,21 +2,20 @@ package sdk
import ( import (
"fmt" "fmt"
"net/url"
"strings"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action" "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. // 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. // It supports showing chart values, readme, and chart details based on the provided ShowOptions.
func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) { 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(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Str("chart", showOpts.Chart). Str("chart", showOpts.Chart).
@ -33,31 +32,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
Str("output_format", string(showOpts.OutputFormat)). Str("output_format", string(showOpts.OutputFormat)).
Msg("Showing chart information") 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) actionConfig := new(action.Configuration)
err = hspm.initActionConfig(actionConfig, "", nil) err := authenticateChartSource(actionConfig, showOpts.Registry)
if err != nil { if err != nil {
// error is already logged in initActionConfig return nil, fmt.Errorf("failed to setup chart source: %w", err)
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
} }
// Create showClient action // 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) return nil, fmt.Errorf("failed to initialize helm show client: %w", err)
} }
// Locate and load the chart chartRef, _, err := parseChartRef(showOpts.Chart, showOpts.Repo, showOpts.Registry)
log.Debug(). if err != nil {
Str("context", "HelmClient"). return nil, fmt.Errorf("failed to parse chart reference: %w", err)
Str("chart", showOpts.Chart). }
Str("repo", showOpts.Repo). chartPath, err := showClient.ChartPathOptions.LocateChart(chartRef, hspm.settings)
Msg("Locating chart")
fullChartPath := fmt.Sprintf("%s/%s", repoName, showOpts.Chart)
chartPath, err := showClient.ChartPathOptions.LocateChart(fullChartPath, hspm.settings)
if err != nil { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Str("chart", fullChartPath). Str("chart", chartRef).
Str("repo", showOpts.Repo). Str("repo", showOpts.Repo).
Err(err). Err(err).
Msg("Failed to locate chart") 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) 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)). Str("output_format", string(showOpts.OutputFormat)).
Err(err). Err(err).
Msg("Failed to show chart info") 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) 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. // and return the show client.
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) { func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig) 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 // Set output type based on ShowOptions
switch showOpts.OutputFormat { switch showOpts.OutputFormat {
@ -134,26 +131,3 @@ func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOpt
return showClient, nil return showClient, nil
} }
// getRepoNameFromURL extracts a unique repository identifier from a URL string.
// It combines hostname and path to ensure uniqueness across different repositories on the same host.
// Examples:
// - https://portainer.github.io/test-public-repo/ -> portainer.github.io-test-public-repo
// - https://portainer.github.io/another-repo/ -> portainer.github.io-another-repo
// - https://charts.helm.sh/stable -> charts.helm.sh-stable
func getRepoNameFromURL(urlStr string) (string, error) {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}
hostname := parsedURL.Hostname()
path := parsedURL.Path
path = strings.Trim(path, "/")
path = strings.ReplaceAll(path, "/", "-")
if path == "" {
return hostname, nil
}
return fmt.Sprintf("%s-%s", hostname, path), nil
}

View file

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

View file

@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release" "github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log" "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") 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) upgradeClient, err := initUpgradeClient(actionConfig, upgradeOpts)
if err != nil { if err != nil {
log.Error(). 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") 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 { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). 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") 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 { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Err(err). Err(err).
Msg("Failed to load and validate chart for helm release upgrade") 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") 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(). log.Info().
Str("context", "HelmClient"). Str("context", "HelmClient").
Str("chart", upgradeOpts.Chart). Str("chart", upgradeOpts.Chart).
@ -117,9 +145,10 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
Namespace: helmRelease.Namespace, Namespace: helmRelease.Namespace,
Chart: release.Chart{ Chart: release.Chart{
Metadata: &release.Metadata{ Metadata: &release.Metadata{
Name: helmRelease.Chart.Metadata.Name, Name: helmRelease.Chart.Metadata.Name,
Version: helmRelease.Chart.Metadata.Version, Version: helmRelease.Chart.Metadata.Version,
AppVersion: helmRelease.Chart.Metadata.AppVersion, AppVersion: helmRelease.Chart.Metadata.AppVersion,
Annotations: helmRelease.Chart.Metadata.Annotations,
}, },
}, },
Labels: helmRelease.Labels, Labels: helmRelease.Labels,
@ -134,12 +163,20 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
upgradeClient := action.NewUpgrade(actionConfig) upgradeClient := action.NewUpgrade(actionConfig)
upgradeClient.DependencyUpdate = true upgradeClient.DependencyUpdate = true
upgradeClient.Atomic = upgradeOpts.Atomic upgradeClient.Atomic = upgradeOpts.Atomic
upgradeClient.ChartPathOptions.RepoURL = upgradeOpts.Repo
upgradeClient.Wait = upgradeOpts.Wait 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 // Set default values if not specified
if upgradeOpts.Timeout == 0 { 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 { } else {
upgradeClient.Timeout = upgradeOpts.Timeout upgradeClient.Timeout = upgradeOpts.Timeout
} }

View file

@ -11,9 +11,9 @@ import (
"helm.sh/helm/v3/pkg/action" "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. // 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 var vals map[string]any
if valuesFile != "" { if valuesFile != "" {
log.Debug(). log.Debug().

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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