mirror of
https://github.com/portainer/portainer.git
synced 2025-07-18 12:59:39 +02:00
feat(oci): oci helm support [r8s-361] (#787)
This commit is contained in:
parent
b6a6ce9aaf
commit
2697d6c5d7
80 changed files with 4264 additions and 812 deletions
|
@ -121,6 +121,10 @@
|
|||
"Ecr": {
|
||||
"Region": ""
|
||||
},
|
||||
"Github": {
|
||||
"OrganisationName": "",
|
||||
"UseOrganisation": false
|
||||
},
|
||||
"Gitlab": {
|
||||
"InstanceURL": "",
|
||||
"ProjectId": 0,
|
||||
|
|
|
@ -5,10 +5,10 @@ import (
|
|||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"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/security"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/registryutils/access"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/pendingactions"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func hideFields(registry *portainer.Registry, hideAccesses bool) {
|
||||
|
@ -56,17 +57,20 @@ func newHandler(bouncer security.BouncerService) *Handler {
|
|||
func (handler *Handler) initRouter(bouncer accessGuard) {
|
||||
adminRouter := handler.NewRoute().Subrouter()
|
||||
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.registryCreate)).Methods(http.MethodPost)
|
||||
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}", 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))
|
||||
}
|
||||
|
||||
|
@ -88,9 +92,7 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
|
|||
}
|
||||
|
||||
// this function validates that
|
||||
//
|
||||
// 1. user has the appropriate authorizations to perform the request
|
||||
//
|
||||
// 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) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
|
@ -98,11 +100,6 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
|
|||
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
|
||||
if securityContext.IsAdmin {
|
||||
return true, true, nil
|
||||
|
@ -128,47 +125,68 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
|
|||
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 {
|
||||
return false, false, nil
|
||||
return false, false, nil // No access
|
||||
}
|
||||
|
||||
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
|
||||
if endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access")
|
||||
}
|
||||
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access")
|
||||
}
|
||||
|
||||
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, false, nil
|
||||
}
|
||||
|
||||
namespacePolicy := accessPolicies[namespace]
|
||||
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
|
||||
return true, false, nil
|
||||
}
|
||||
}
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
// validate access for docker environments
|
||||
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
|
||||
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
|
||||
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) {
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
// when user has no access via their role, direct grant or indirect grant
|
||||
// then they don't have access to the registry
|
||||
return false, false, nil
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
// RegistryAccess defines a security check for registry-specific API endpoints.
|
||||
// Authentication is required to access these endpoints.
|
||||
// The user must have direct or indirect access to the specific registry being requested.
|
||||
// This bouncer validates registry access using the userHasRegistryAccess logic.
|
||||
func (handler *Handler) RegistryAccess(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// First ensure the user is authenticated
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract registry ID from the route
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the registry from the database
|
||||
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has access to this registry
|
||||
hasAccess, _, err := handler.userHasRegistryAccess(r, registry)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
89
api/http/handler/registries/registry_access_test.go
Normal file
89
api/http/handler/registries/registry_access_test.go
Normal 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)
|
||||
}
|
|
@ -4,10 +4,12 @@ import (
|
|||
"net/http"
|
||||
|
||||
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"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @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)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("registry_id", registryID).
|
||||
Str("context", "RegistryInspectHandler").
|
||||
Msg("Starting registry inspection")
|
||||
|
||||
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
|
||||
if handler.DataStore.IsErrObjectNotFound(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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID)
|
||||
_, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission deny to access registry", err)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
_, 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 {
|
||||
return httperror.Forbidden("Permission deny to access registry", err)
|
||||
}
|
||||
|
|
|
@ -55,12 +55,13 @@ func createRegistryAuthenticationHeader(
|
|||
return
|
||||
}
|
||||
|
||||
if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil {
|
||||
if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
authenticationHeader.Serveraddress = matchingRegistry.URL
|
||||
authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry)
|
||||
authenticationHeader.Username = matchingRegistry.Username
|
||||
authenticationHeader.Password = matchingRegistry.Password
|
||||
|
||||
return
|
||||
}
|
||||
|
|
108
api/http/proxy/factory/github/client.go
Normal file
108
api/http/proxy/factory/github/client.go
Normal 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)
|
||||
}
|
130
api/http/proxy/factory/gitlab/client.go
Normal file
130
api/http/proxy/factory/gitlab/client.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -2,40 +2,82 @@ package access
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"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(
|
||||
dataStore dataservices.DataStore,
|
||||
k8sClientFactory *cli.ClientFactory,
|
||||
userID portainer.UserID,
|
||||
endpointID portainer.EndpointID,
|
||||
registry *portainer.Registry,
|
||||
) (hasPermission bool, err error) {
|
||||
user, err := dataStore.User().Read(userID)
|
||||
if err != nil {
|
||||
return
|
||||
return false, err
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
return
|
||||
return hasPermission, nil
|
||||
}
|
||||
|
||||
// GetAccessibleRegistry get the registry if the user has permission
|
||||
func GetAccessibleRegistry(
|
||||
dataStore dataservices.DataStore,
|
||||
k8sClientFactory *cli.ClientFactory,
|
||||
userID portainer.UserID,
|
||||
endpointID portainer.EndpointID,
|
||||
registryID portainer.RegistryID,
|
||||
|
@ -46,7 +88,7 @@ func GetAccessibleRegistry(
|
|||
return
|
||||
}
|
||||
|
||||
hasPermission, err := hasPermission(dataStore, userID, endpointID, registry)
|
||||
hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -62,3 +62,26 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
|
@ -437,5 +438,10 @@ func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portaine
|
|||
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
|
||||
}
|
||||
|
|
|
@ -603,6 +603,12 @@ type (
|
|||
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
|
||||
|
||||
// HelmUserRepositories stores a Helm repository URL for the given user
|
||||
|
@ -823,6 +829,7 @@ type (
|
|||
Password string `json:"Password,omitempty" example:"registry_password"`
|
||||
ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"`
|
||||
Gitlab GitlabRegistryData `json:"Gitlab"`
|
||||
Github GithubRegistryData `json:"Github"`
|
||||
Quay QuayRegistryData `json:"Quay"`
|
||||
Ecr EcrData `json:"Ecr"`
|
||||
RegistryAccesses RegistryAccesses `json:"RegistryAccesses"`
|
||||
|
@ -1972,6 +1979,8 @@ const (
|
|||
DockerHubRegistry
|
||||
// EcrRegistry represents an ECR registry
|
||||
EcrRegistry
|
||||
// Github container registry
|
||||
GithubRegistry
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -92,6 +92,7 @@ export const ngModule = angular
|
|||
'onChange',
|
||||
'placeholder',
|
||||
'value',
|
||||
'allowSelectAll',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
namespaces="$ctrl.resourcePools"
|
||||
placeholder="'Select one or more namespaces'"
|
||||
on-change="($ctrl.onChangeResourcePools)"
|
||||
allow-select-all="true"
|
||||
></namespaces-selector>
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted vertical-center">
|
||||
|
|
|
@ -40,17 +40,15 @@
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<select
|
||||
<namespace-portainer-select
|
||||
ng-if="!ctrl.formValues.namespace_toggle || ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"
|
||||
data-cy="namespace-select"
|
||||
ng-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"
|
||||
class="form-control"
|
||||
ng-model="ctrl.formValues.Namespace"
|
||||
ng-change="ctrl.onChangeNamespace()"
|
||||
ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces"
|
||||
></select>
|
||||
is-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM || ctrl.state.isNamespaceInputDisabled"
|
||||
value="ctrl.formValues.Namespace"
|
||||
on-change="(ctrl.onChangeNamespace)"
|
||||
options="ctrl.namespaceOptions"
|
||||
></namespace-portainer-select>
|
||||
<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
|
||||
>
|
||||
|
@ -186,7 +184,6 @@
|
|||
|
||||
<!-- 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" />
|
||||
</div>
|
||||
<!-- !Helm -->
|
||||
|
|
|
@ -101,9 +101,10 @@ class KubernetesDeployController {
|
|||
this.onChangeNamespace = this.onChangeNamespace.bind(this);
|
||||
}
|
||||
|
||||
onChangeNamespace() {
|
||||
onChangeNamespace(namespaceName) {
|
||||
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 !== '');
|
||||
this.stacks = _.uniq(stacks);
|
||||
});
|
||||
|
@ -371,6 +372,10 @@ class KubernetesDeployController {
|
|||
if (this.namespaces.length > 0) {
|
||||
this.formValues.Namespace = this.namespaces[0].Name;
|
||||
}
|
||||
this.namespaceOptions = _.map(namespaces, (namespace) => ({
|
||||
label: namespace.Name,
|
||||
value: namespace.Name,
|
||||
}));
|
||||
} catch (err) {
|
||||
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.$window.onbeforeunload = () => {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { withFormValidation } from '@/react-tools/withFormValidation';
|
|||
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
|
||||
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
|
||||
import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||
import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector';
|
||||
|
||||
import {
|
||||
EnvironmentVariablesFieldset,
|
||||
|
@ -199,11 +200,22 @@ export const ngModule = angular
|
|||
'onChange',
|
||||
'options',
|
||||
'isMulti',
|
||||
'filterOption',
|
||||
'isClearable',
|
||||
'components',
|
||||
'isLoading',
|
||||
'noOptionsMessage',
|
||||
'aria-label',
|
||||
'loadingMessage',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'namespacePortainerSelect',
|
||||
r2a(NamespacePortainerSelect, [
|
||||
'value',
|
||||
'onChange',
|
||||
'isDisabled',
|
||||
'options',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import { ExternalLink as ExternalLinkIcon } from 'lucide-react';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
interface Props {
|
||||
to: string;
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function ExternalLink({
|
||||
to,
|
||||
className,
|
||||
children,
|
||||
showIcon = true,
|
||||
'data-cy': dataCy,
|
||||
}: PropsWithChildren<Props & AutomationTestingProps>) {
|
||||
return (
|
||||
|
@ -23,10 +23,10 @@ export function ExternalLink({
|
|||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-cy={dataCy}
|
||||
className={clsx('inline-flex items-center gap-1', className)}
|
||||
className={clsx('inline-flex align-baseline', className)}
|
||||
>
|
||||
<Icon icon={ExternalLinkIcon} />
|
||||
<span>{children}</span>
|
||||
{children}
|
||||
{showIcon && <ArrowUpRight className="align-top" />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,5 +27,5 @@ export function FallbackImage({ src, fallbackIcon, alt, className }: Props) {
|
|||
}
|
||||
|
||||
// fallback icon if there is an error loading the image
|
||||
return <>{fallbackIcon}</>;
|
||||
return <div className={className}>{fallbackIcon}</div>;
|
||||
}
|
||||
|
|
|
@ -5,15 +5,25 @@ import {
|
|||
} from 'react-select';
|
||||
import _ from 'lodash';
|
||||
import { AriaAttributes } from 'react';
|
||||
import { FilterOptionOption } from 'react-select/dist/declarations/src/filters';
|
||||
|
||||
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> {
|
||||
value: TValue;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface GroupOption<TValue> {
|
||||
label: string;
|
||||
options: Option<TValue>[];
|
||||
}
|
||||
|
||||
type Options<TValue> = OptionsOrGroups<
|
||||
|
@ -21,7 +31,7 @@ type Options<TValue> = OptionsOrGroups<
|
|||
GroupBase<Option<TValue>>
|
||||
>;
|
||||
|
||||
interface SharedProps
|
||||
interface SharedProps<TValue>
|
||||
extends AutomationTestingProps,
|
||||
Pick<AriaAttributes, 'aria-label'> {
|
||||
name?: string;
|
||||
|
@ -32,9 +42,14 @@ interface SharedProps
|
|||
bindToBody?: boolean;
|
||||
isLoading?: boolean;
|
||||
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[];
|
||||
onChange(value: TValue[]): void;
|
||||
options: Options<TValue>;
|
||||
|
@ -44,9 +59,12 @@ interface MultiProps<TValue> extends SharedProps {
|
|||
true,
|
||||
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;
|
||||
onChange(value: TValue | null): void;
|
||||
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) ? (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<MultiSelect {...props} />
|
||||
|
@ -71,7 +93,7 @@ export function PortainerSelect<TValue = string>(props: Props<TValue>) {
|
|||
}
|
||||
|
||||
function isMultiProps<TValue>(
|
||||
props: Props<TValue>
|
||||
props: PortainerSelectProps<TValue>
|
||||
): props is MultiProps<TValue> {
|
||||
return 'isMulti' in props && !!props.isMulti;
|
||||
}
|
||||
|
@ -87,9 +109,11 @@ export function SingleSelect<TValue = string>({
|
|||
placeholder,
|
||||
isClearable,
|
||||
bindToBody,
|
||||
filterOption,
|
||||
components,
|
||||
isLoading,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
isMulti,
|
||||
...aria
|
||||
}: SingleProps<TValue>) {
|
||||
|
@ -116,9 +140,11 @@ export function SingleSelect<TValue = string>({
|
|||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
menuPortalTarget={bindToBody ? document.body : undefined}
|
||||
filterOption={filterOption}
|
||||
components={components}
|
||||
isLoading={isLoading}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
loadingMessage={loadingMessage}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...aria}
|
||||
/>
|
||||
|
@ -159,14 +185,20 @@ export function MultiSelect<TValue = string>({
|
|||
disabled,
|
||||
isClearable,
|
||||
bindToBody,
|
||||
filterOption,
|
||||
components,
|
||||
isLoading,
|
||||
noOptionsMessage,
|
||||
loadingMessage,
|
||||
formatCreateLabel,
|
||||
onCreateOption,
|
||||
isCreatable,
|
||||
...aria
|
||||
}: Omit<MultiProps<TValue>, 'isMulti'>) {
|
||||
const selectedOptions = findSelectedOptions(options, value);
|
||||
const SelectComponent = isCreatable ? Creatable : ReactSelect;
|
||||
return (
|
||||
<ReactSelect
|
||||
<SelectComponent
|
||||
name={name}
|
||||
isMulti
|
||||
isClearable={isClearable}
|
||||
|
@ -183,9 +215,13 @@ export function MultiSelect<TValue = string>({
|
|||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
menuPortalTarget={bindToBody ? document.body : undefined}
|
||||
filterOption={filterOption}
|
||||
components={components}
|
||||
isLoading={isLoading}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
loadingMessage={loadingMessage}
|
||||
formatCreateLabel={formatCreateLabel}
|
||||
onCreateOption={onCreateOption}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...aria}
|
||||
/>
|
||||
|
|
|
@ -51,3 +51,29 @@ export function NamespaceSelector({
|
|||
</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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import _ from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
|
@ -15,6 +16,7 @@ interface Props {
|
|||
dataCy: string;
|
||||
inputId?: string;
|
||||
placeholder?: string;
|
||||
allowSelectAll?: boolean;
|
||||
}
|
||||
|
||||
export function NamespacesSelector({
|
||||
|
@ -25,23 +27,34 @@ export function NamespacesSelector({
|
|||
dataCy,
|
||||
inputId,
|
||||
placeholder,
|
||||
allowSelectAll,
|
||||
}: Props) {
|
||||
const options = useMemo(() => {
|
||||
if (allowSelectAll) {
|
||||
return [{ id: 'all', name: 'Select all' }, ...namespaces];
|
||||
}
|
||||
return namespaces;
|
||||
}, [namespaces, allowSelectAll]);
|
||||
return (
|
||||
<Select
|
||||
name={name}
|
||||
isMulti
|
||||
getOptionLabel={(namespace) => namespace.name}
|
||||
getOptionValue={(namespace) => String(namespace.id)}
|
||||
options={namespaces}
|
||||
options={options}
|
||||
value={_.compact(
|
||||
value.map((namespaceName) =>
|
||||
namespaces.find((namespace) => namespace.name === namespaceName)
|
||||
)
|
||||
)}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(selectedTeams) =>
|
||||
onChange(selectedTeams.map((namespace) => namespace.name))
|
||||
}
|
||||
onChange={(selectedNamespaces) => {
|
||||
if (selectedNamespaces.some((namespace) => namespace.id === 'all')) {
|
||||
onChange(namespaces.map((namespace) => namespace.name));
|
||||
} else {
|
||||
onChange(selectedNamespaces.map((namespace) => namespace.name));
|
||||
}
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
|
|
|
@ -39,11 +39,6 @@ export function ChartActions({
|
|||
release={release}
|
||||
updateRelease={updateRelease}
|
||||
/>
|
||||
<UninstallButton
|
||||
environmentId={environmentId}
|
||||
releaseName={releaseName}
|
||||
namespace={namespace}
|
||||
/>
|
||||
{showRollbackButton && (
|
||||
<RollbackButton
|
||||
latestRevision={latestRevision}
|
||||
|
@ -53,6 +48,11 @@ export function ChartActions({
|
|||
namespace={namespace}
|
||||
/>
|
||||
)}
|
||||
<UninstallButton
|
||||
environmentId={environmentId}
|
||||
releaseName={releaseName}
|
||||
namespace={namespace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -25,8 +25,8 @@ vi.mock('@/portainer/services/notifications', () => ({
|
|||
notifySuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../queries/useHelmRegistries', () => ({
|
||||
useHelmRegistries: vi.fn(() => ({
|
||||
vi.mock('../../queries/useHelmRepositories', () => ({
|
||||
useUserHelmRepositories: vi.fn(() => ({
|
||||
data: ['repo1', 'repo2'],
|
||||
isInitialLoading: false,
|
||||
isError: false,
|
||||
|
@ -146,7 +146,7 @@ describe('UpgradeButton', () => {
|
|||
|
||||
renderButton();
|
||||
|
||||
expect(screen.getByText('No versions available')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No versions available/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should open upgrade modal when clicked', async () => {
|
||||
|
|
|
@ -15,7 +15,7 @@ import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
|
|||
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
|
||||
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions';
|
||||
import { useHelmRelease } from '../queries/useHelmRelease';
|
||||
import { useHelmRegistries } from '../../queries/useHelmRegistries';
|
||||
import { useUserHelmRepositories } from '../../queries/useHelmRepositories';
|
||||
|
||||
import { openUpgradeHelmModal } from './UpgradeHelmModal';
|
||||
|
||||
|
@ -36,19 +36,22 @@ export function UpgradeButton({
|
|||
const [useCache, setUseCache] = useState(true);
|
||||
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||
|
||||
const registriesQuery = useHelmRegistries();
|
||||
const userRepositoriesQuery = useUserHelmRepositories();
|
||||
const helmRepoVersionsQuery = useHelmRepoVersions(
|
||||
release?.chart.metadata?.name || '',
|
||||
60 * 60 * 1000, // 1 hour
|
||||
registriesQuery.data,
|
||||
userRepositoriesQuery.data?.map((repo) => ({
|
||||
repo,
|
||||
})),
|
||||
useCache
|
||||
);
|
||||
const versions = helmRepoVersionsQuery.data;
|
||||
|
||||
// Combined loading state
|
||||
const isLoading =
|
||||
registriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
|
||||
const isError = registriesQuery.isError || helmRepoVersionsQuery.isError;
|
||||
userRepositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
|
||||
const isError =
|
||||
userRepositoriesQuery.isError || helmRepoVersionsQuery.isError;
|
||||
const latestVersionQuery = useHelmRelease(
|
||||
environmentId,
|
||||
releaseName,
|
||||
|
@ -101,7 +104,7 @@ export function UpgradeButton({
|
|||
icon={ArrowUp}
|
||||
size="medium"
|
||||
>
|
||||
Upgrade
|
||||
Edit/Upgrade
|
||||
</LoadingButton>
|
||||
{isLoading && (
|
||||
<InlineLoader
|
||||
|
|
|
@ -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() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const Wrapped = withTestQueryProvider(
|
||||
|
@ -162,30 +203,9 @@ describe(
|
|||
it('should display helm release details for minimal release when data is loaded', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
server.use(
|
||||
setupMockHandlers(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
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 () => {
|
||||
// Mock API failure
|
||||
server.use(
|
||||
setupMockHandlers(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
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 () => {
|
||||
server.use(
|
||||
setupMockHandlers(
|
||||
http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
|
||||
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([])
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Card } from '@@/Card';
|
|||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { HelmRelease } from '../types';
|
||||
import { useIsSystemNamespace } from '../../namespaces/queries/useIsSystemNamespace';
|
||||
|
||||
import { HelmSummary } from './HelmSummary';
|
||||
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
|
||||
|
@ -37,6 +38,8 @@ export function HelmApplicationView() {
|
|||
revision: selectedRevision,
|
||||
});
|
||||
|
||||
const isSystemNamespace = useIsSystemNamespace(namespace);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
@ -63,28 +66,30 @@ export function HelmApplicationView() {
|
|||
/>
|
||||
</div>
|
||||
<Authorized authorizations="K8sApplicationsW">
|
||||
<ChartActions
|
||||
environmentId={environmentId}
|
||||
releaseName={String(name)}
|
||||
namespace={String(namespace)}
|
||||
latestRevision={latestRevision ?? 1}
|
||||
earlistRevision={earlistRevision}
|
||||
selectedRevision={selectedRevision}
|
||||
release={helmReleaseQuery.data}
|
||||
updateRelease={(updatedRelease: HelmRelease) => {
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
environmentId,
|
||||
'helm',
|
||||
'releases',
|
||||
namespace,
|
||||
name,
|
||||
true,
|
||||
],
|
||||
updatedRelease
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{!isSystemNamespace && (
|
||||
<ChartActions
|
||||
environmentId={environmentId}
|
||||
releaseName={String(name)}
|
||||
namespace={String(namespace)}
|
||||
latestRevision={latestRevision ?? 1}
|
||||
earlistRevision={earlistRevision}
|
||||
selectedRevision={selectedRevision}
|
||||
release={helmReleaseQuery.data}
|
||||
updateRelease={(updatedRelease: HelmRelease) => {
|
||||
queryClient.setQueryData(
|
||||
[
|
||||
environmentId,
|
||||
'helm',
|
||||
'releases',
|
||||
namespace,
|
||||
name,
|
||||
true,
|
||||
],
|
||||
updatedRelease
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Authorized>
|
||||
</div>
|
||||
</WidgetTitle>
|
||||
|
|
|
@ -98,6 +98,7 @@ function renderComponent({
|
|||
selectedChart={selectedChart}
|
||||
namespace={namespace}
|
||||
name={name}
|
||||
isRepoAvailable
|
||||
/>
|
||||
)),
|
||||
user
|
||||
|
|
|
@ -12,6 +12,10 @@ import { Option } from '@@/form-components/PortainerSelect';
|
|||
|
||||
import { Chart } from '../types';
|
||||
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
|
||||
import {
|
||||
ChartVersion,
|
||||
useHelmRepoVersions,
|
||||
} from '../queries/useHelmRepoVersions';
|
||||
|
||||
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
|
||||
import { HelmInstallFormValues } from './types';
|
||||
|
@ -20,22 +24,39 @@ type Props = {
|
|||
selectedChart: Chart;
|
||||
namespace?: string;
|
||||
name?: string;
|
||||
isRepoAvailable: boolean;
|
||||
};
|
||||
|
||||
export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
|
||||
export function HelmInstallForm({
|
||||
selectedChart,
|
||||
namespace,
|
||||
name,
|
||||
isRepoAvailable,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const router = useRouter();
|
||||
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) => ({
|
||||
label: index === 0 ? `${version} (latest)` : version,
|
||||
label: index === 0 ? `${version.Version} (latest)` : version.Version,
|
||||
value: version,
|
||||
})
|
||||
);
|
||||
const defaultVersion = versionOptions[0]?.value;
|
||||
const initialValues: HelmInstallFormValues = {
|
||||
values: '',
|
||||
version: defaultVersion ?? '',
|
||||
version: defaultVersion?.Version ?? '',
|
||||
repo: defaultVersion?.Repo ?? selectedChart.repo ?? '',
|
||||
};
|
||||
|
||||
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
|
||||
|
@ -55,6 +76,8 @@ export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
|
|||
namespace={namespace}
|
||||
name={name}
|
||||
versionOptions={versionOptions}
|
||||
isVersionsLoading={helmRepoVersionsQuery.isInitialLoading}
|
||||
isRepoAvailable={isRepoAvailable}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { Form, useFormikContext } from 'formik';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
|
||||
import { Chart } from '../types';
|
||||
import { useHelmChartValues } from '../queries/useHelmChartValues';
|
||||
import { HelmValuesInput } from '../components/HelmValuesInput';
|
||||
import { ChartVersion } from '../queries/useHelmRepoVersions';
|
||||
|
||||
import { HelmInstallFormValues } from './types';
|
||||
|
||||
|
@ -16,7 +17,9 @@ type Props = {
|
|||
selectedChart: Chart;
|
||||
namespace?: string;
|
||||
name?: string;
|
||||
versionOptions: Option<string>[];
|
||||
versionOptions: Option<ChartVersion>[];
|
||||
isVersionsLoading: boolean;
|
||||
isRepoAvailable: boolean;
|
||||
};
|
||||
|
||||
export function HelmInstallInnerForm({
|
||||
|
@ -24,21 +27,39 @@ export function HelmInstallInnerForm({
|
|||
namespace,
|
||||
name,
|
||||
versionOptions,
|
||||
isVersionsLoading,
|
||||
isRepoAvailable,
|
||||
}: Props) {
|
||||
const { values, setFieldValue, isSubmitting } =
|
||||
useFormikContext<HelmInstallFormValues>();
|
||||
|
||||
const chartValuesRefQuery = useHelmChartValues({
|
||||
chart: selectedChart.name,
|
||||
repo: selectedChart.repo,
|
||||
version: values?.version,
|
||||
});
|
||||
|
||||
const selectedVersion = useMemo(
|
||||
const selectedVersion: ChartVersion | undefined = useMemo(
|
||||
() =>
|
||||
versionOptions.find((v) => v.value === values.version)?.value ??
|
||||
versionOptions[0]?.value,
|
||||
[versionOptions, values.version]
|
||||
versionOptions.find(
|
||||
(v) =>
|
||||
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 (
|
||||
|
@ -48,14 +69,18 @@ export function HelmInstallInnerForm({
|
|||
<FormControl
|
||||
label="Version"
|
||||
inputId="version-input"
|
||||
isLoading={isVersionsLoading}
|
||||
loadingText="Loading versions..."
|
||||
>
|
||||
<PortainerSelect<string>
|
||||
<PortainerSelect<ChartVersion>
|
||||
value={selectedVersion}
|
||||
options={versionOptions}
|
||||
noOptionsMessage={() => 'No versions found'}
|
||||
placeholder="Select a version"
|
||||
onChange={(version) => {
|
||||
if (version) {
|
||||
setFieldValue('version', version);
|
||||
setFieldValue('version', version.Version);
|
||||
setFieldValue('repo', version.Repo);
|
||||
}
|
||||
}}
|
||||
data-cy="helm-version-input"
|
||||
|
@ -70,13 +95,15 @@ export function HelmInstallInnerForm({
|
|||
</FormSection>
|
||||
</div>
|
||||
|
||||
<FormActions
|
||||
submitLabel="Install"
|
||||
<LoadingButton
|
||||
className="!ml-0"
|
||||
loadingText="Installing Helm chart"
|
||||
isLoading={isSubmitting}
|
||||
isValid={!!namespace && !!name}
|
||||
disabled={!namespace || !name || !isRepoAvailable}
|
||||
data-cy="helm-install"
|
||||
/>
|
||||
>
|
||||
Install
|
||||
</LoadingButton>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,15 +1,20 @@
|
|||
import { useState } from 'react';
|
||||
import { compact } from 'lodash';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { Chart } from '../types';
|
||||
import { useHelmChartList } from '../queries/useHelmChartList';
|
||||
import { useHelmRegistries } from '../queries/useHelmRegistries';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
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 { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
|
||||
import { HelmTemplatesList } from './HelmTemplatesList';
|
||||
|
||||
interface Props {
|
||||
onSelectHelmChart: (chartName: string) => void;
|
||||
|
@ -19,11 +24,60 @@ interface Props {
|
|||
|
||||
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
||||
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 helmReposQuery = useHelmRegistries();
|
||||
const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry]));
|
||||
const chartListQuery = useHelmHTTPChartList(
|
||||
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() {
|
||||
setSelectedChart(null);
|
||||
onSelectHelmChart('');
|
||||
|
@ -33,33 +87,4 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
|
|||
setSelectedChart(chart);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -46,25 +46,63 @@ const mockCharts: Chart[] = [
|
|||
|
||||
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({
|
||||
loading = false,
|
||||
charts = mockCharts,
|
||||
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 registries = ['https://example.com', 'https://example.com/2'];
|
||||
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withUserProvider(
|
||||
withTestRouter(() => (
|
||||
<HelmTemplatesList
|
||||
isLoading={loading}
|
||||
isLoadingCharts={loading}
|
||||
charts={charts}
|
||||
selectAction={selectAction}
|
||||
registries={registries}
|
||||
selectedRegistry={selectedRegistry}
|
||||
setSelectedRegistry={() => {}}
|
||||
/>
|
||||
)),
|
||||
user
|
||||
|
@ -81,8 +119,10 @@ describe('HelmTemplatesList', () => {
|
|||
it('should display title and charts list', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Check for the title
|
||||
expect(screen.getByText('Helm chart')).toBeInTheDocument();
|
||||
// Check for the title with registry name
|
||||
expect(
|
||||
screen.getByText('Select a helm chart from Test Registry')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check for charts
|
||||
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 () => {
|
||||
renderComponent({ charts: [], selectedRegistry: 'https://example.com' });
|
||||
renderComponent({
|
||||
charts: [],
|
||||
selectedRegistry: {
|
||||
repoUrl: 'https://example.com',
|
||||
name: 'Test Registry',
|
||||
},
|
||||
});
|
||||
|
||||
// Check for empty message
|
||||
expect(
|
||||
screen.getByText('No helm charts available in this registry.')
|
||||
screen.getByText('No helm charts available in this repository.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Please select a registry to view available Helm charts.'
|
||||
'Please select a repository to view available Helm charts.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
@ -1,59 +1,47 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { components, OptionProps } from 'react-select';
|
||||
|
||||
import {
|
||||
PortainerSelect,
|
||||
Option,
|
||||
} from '@/react/components/form-components/PortainerSelect';
|
||||
import { Link } from '@/react/components/Link';
|
||||
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
|
||||
|
||||
import { InsightsBox } from '@@/InsightsBox';
|
||||
import { SearchBar } from '@@/datatables/SearchBar';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
|
||||
import { Chart } from '../types';
|
||||
import { RepoValue } from '../components/HelmRegistrySelect';
|
||||
|
||||
import { HelmTemplatesListItem } from './HelmTemplatesListItem';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
isLoadingCharts: boolean;
|
||||
charts?: Chart[];
|
||||
selectAction: (chart: Chart) => void;
|
||||
registries: string[];
|
||||
selectedRegistry: string | null;
|
||||
setSelectedRegistry: (registry: string | null) => void;
|
||||
selectedRegistry: RepoValue | null;
|
||||
}
|
||||
|
||||
export function HelmTemplatesList({
|
||||
isLoading,
|
||||
isLoadingCharts,
|
||||
charts = [],
|
||||
selectAction,
|
||||
registries,
|
||||
selectedRegistry,
|
||||
setSelectedRegistry,
|
||||
}: Props) {
|
||||
const [textFilter, setTextFilter] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
|
||||
const categories = useMemo(() => getCategories(charts), [charts]);
|
||||
const registryOptions = useMemo(
|
||||
() =>
|
||||
registries.map((registry) => ({
|
||||
label: registry,
|
||||
value: registry,
|
||||
})),
|
||||
[registries]
|
||||
);
|
||||
|
||||
const filteredCharts = useMemo(
|
||||
() => getFilteredCharts(charts, textFilter, selectedCategory),
|
||||
[charts, textFilter, selectedCategory]
|
||||
);
|
||||
|
||||
const isSelectedRegistryEmpty =
|
||||
!isLoadingCharts && charts.length === 0 && selectedRegistry;
|
||||
|
||||
return (
|
||||
<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="toolBarTitle vertical-center">Helm chart</div>
|
||||
<div className="toolBar vertical-center relative w-full !gap-x-5 !gap-y-1 !px-0 overflow-auto">
|
||||
<div className="toolBarTitle vertical-center whitespace-nowrap">
|
||||
Select a helm chart from {selectedRegistry?.name}
|
||||
</div>
|
||||
|
||||
<SearchBar
|
||||
value={textFilter}
|
||||
|
@ -63,20 +51,7 @@ export function HelmTemplatesList({
|
|||
className="!mr-0 h-9"
|
||||
/>
|
||||
|
||||
<div className="w-full sm:w-1/4">
|
||||
<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">
|
||||
<div className="w-full sm:w-1/4 flex-none">
|
||||
<PortainerSelect
|
||||
placeholder="Select a category"
|
||||
value={selectedCategory}
|
||||
|
@ -88,42 +63,6 @@ export function HelmTemplatesList({
|
|||
/>
|
||||
</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">
|
||||
{filteredCharts.map((chart) => (
|
||||
|
@ -138,7 +77,7 @@ export function HelmTemplatesList({
|
|||
<div className="text-muted small mt-4">No Helm charts found</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
{isLoadingCharts && (
|
||||
<div className="flex flex-col">
|
||||
<InlineLoader className="justify-center">
|
||||
Loading helm charts...
|
||||
|
@ -151,15 +90,15 @@ export function HelmTemplatesList({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && charts.length === 0 && selectedRegistry && (
|
||||
{isSelectedRegistryEmpty && (
|
||||
<div className="text-muted text-center">
|
||||
No helm charts available in this registry.
|
||||
No helm charts available in this repository.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedRegistry && (
|
||||
<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>
|
||||
|
@ -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
|
||||
* @param charts - The charts to get the categories from
|
||||
|
|
|
@ -26,7 +26,7 @@ export function HelmTemplatesSelectedItem({
|
|||
<FallbackImage
|
||||
src={selectedChart.icon}
|
||||
fallbackIcon={HelmIcon}
|
||||
className="h-16 w-16"
|
||||
className="h-16 w-16 flex-none"
|
||||
/>
|
||||
<div className="col-sm-12">
|
||||
<div>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export type HelmInstallFormValues = {
|
||||
values: string;
|
||||
version: string;
|
||||
repo: string;
|
||||
};
|
||||
|
|
242
app/react/kubernetes/helm/components/HelmRegistrySelect.test.tsx
Normal file
242
app/react/kubernetes/helm/components/HelmRegistrySelect.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
156
app/react/kubernetes/helm/components/HelmRegistrySelect.tsx
Normal file
156
app/react/kubernetes/helm/components/HelmRegistrySelect.tsx
Normal 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>
|
||||
.
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { useQueries } from '@tanstack/react-query';
|
||||
import { compact, flatMap } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { compact } from 'lodash';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
|
@ -8,45 +7,27 @@ import { withGlobalError } from '@/react-tools/react-query';
|
|||
import { Chart, HelmChartsResponse } from '../types';
|
||||
|
||||
/**
|
||||
* React hook to fetch helm charts from the provided repositories
|
||||
* Charts from each repository are loaded independently, allowing the UI
|
||||
* to show charts as they become available instead of waiting for all
|
||||
* repositories to load
|
||||
* React hook to fetch helm charts from the provided HTTP repository.
|
||||
* Charts are loaded from the specified repository URL.
|
||||
*
|
||||
* @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[] = []) {
|
||||
// Fetch charts from each repository in parallel as separate queries
|
||||
const chartQueries = useQueries({
|
||||
queries: useMemo(
|
||||
() =>
|
||||
repositories.map((repo) => ({
|
||||
queryKey: [userId, repo, 'helm-charts'],
|
||||
queryFn: () => getChartsFromRepo(repo),
|
||||
enabled: !!userId && repositories.length > 0,
|
||||
// one request takes a long time, so fail early to get feedback to the user faster
|
||||
retries: false,
|
||||
...withGlobalError(`Unable to retrieve Helm charts from ${repo}`),
|
||||
})),
|
||||
[repositories, userId]
|
||||
),
|
||||
export function useHelmHTTPChartList(
|
||||
userId: number,
|
||||
repository: string,
|
||||
enabled: boolean
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [userId, repository, 'helm-charts'],
|
||||
queryFn: () => getChartsFromRepo(repository),
|
||||
enabled: !!userId && !!repository && enabled,
|
||||
// one request takes a long time, so fail early to get feedback to the user faster
|
||||
retry: false,
|
||||
...withGlobalError(`Unable to retrieve Helm charts from ${repository}`),
|
||||
});
|
||||
|
||||
// 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[]> {
|
||||
|
|
|
@ -4,8 +4,11 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
|
||||
type Params = {
|
||||
/** The name of the chart to get the values for */
|
||||
chart: string;
|
||||
/** The repository URL or registry ID */
|
||||
repo: string;
|
||||
/** The version of the chart to get the values for */
|
||||
version?: string;
|
||||
};
|
||||
|
||||
|
@ -16,18 +19,26 @@ async function getHelmChartValues(params: Params) {
|
|||
});
|
||||
return response.data;
|
||||
} 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({
|
||||
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),
|
||||
enabled: !!params.chart && !!params.repo,
|
||||
enabled: !!params.chart && hasValidRepoUrl,
|
||||
select: (data) => ({
|
||||
values: data,
|
||||
}),
|
||||
retry: 1,
|
||||
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
|
||||
...withGlobalError('Unable to get Helm chart values'),
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -21,6 +21,10 @@ export interface ChartVersion {
|
|||
AppVersion?: string;
|
||||
}
|
||||
|
||||
type RepoSource = {
|
||||
repo?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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(
|
||||
chart: string,
|
||||
staleTime: number,
|
||||
repositories: string[] = [],
|
||||
repoSources: RepoSource[] = [],
|
||||
useCache: boolean = true
|
||||
) {
|
||||
// Fetch versions from each repository in parallel as separate queries
|
||||
const versionQueries = useQueries({
|
||||
queries: useMemo(
|
||||
() =>
|
||||
repositories.map((repo) => ({
|
||||
repoSources.map(({ repo }) => ({
|
||||
queryKey: ['helm', 'repositories', chart, repo, useCache],
|
||||
queryFn: () => getSearchHelmRepo(repo, chart, useCache),
|
||||
enabled: !!chart && repositories.length > 0,
|
||||
queryFn: () => getSearchHelmRepo({ repo, chart, useCache }),
|
||||
enabled: !!chart && repoSources.length > 0,
|
||||
staleTime,
|
||||
...withGlobalError(`Unable to retrieve versions from ${repo}`),
|
||||
})),
|
||||
[repositories, chart, staleTime, useCache]
|
||||
[repoSources, chart, staleTime, useCache]
|
||||
),
|
||||
});
|
||||
|
||||
|
@ -58,30 +62,35 @@ export function useHelmRepoVersions(
|
|||
|
||||
return {
|
||||
data: allVersions,
|
||||
isInitialLoading: versionQueries.some((q) => q.isLoading),
|
||||
isInitialLoading: versionQueries.some((q) => q.isInitialLoading),
|
||||
isError: versionQueries.some((q) => q.isError),
|
||||
isFetching: versionQueries.some((q) => q.isFetching),
|
||||
refetch: () => Promise.all(versionQueries.map((q) => q.refetch())),
|
||||
};
|
||||
}
|
||||
|
||||
type SearchRepoParams = {
|
||||
repo?: string;
|
||||
chart: string;
|
||||
useCache?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Helm repositories for user
|
||||
*/
|
||||
async function getSearchHelmRepo(
|
||||
repo: string,
|
||||
chart: string,
|
||||
useCache: boolean = true
|
||||
params: SearchRepoParams
|
||||
): Promise<ChartVersion[]> {
|
||||
try {
|
||||
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 (
|
||||
versions?.map((v) => ({
|
||||
Chart: chart,
|
||||
Repo: repo,
|
||||
Repo: params.repo ?? '',
|
||||
Version: v.version,
|
||||
AppVersion: v.appVersion,
|
||||
})) ?? []
|
||||
|
|
84
app/react/kubernetes/helm/queries/useHelmRepositories.ts
Normal file
84
app/react/kubernetes/helm/queries/useHelmRepositories.ts
Normal 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');
|
||||
}
|
||||
}
|
|
@ -91,7 +91,7 @@ export interface HelmChartResponse {
|
|||
versions: string[];
|
||||
}
|
||||
|
||||
export interface HelmRepositoryResponse {
|
||||
export interface HelmRegistryResponse {
|
||||
Id: number;
|
||||
UserId: number;
|
||||
URL: string;
|
||||
|
@ -99,7 +99,7 @@ export interface HelmRepositoryResponse {
|
|||
|
||||
export interface HelmRegistriesResponse {
|
||||
GlobalRepository: string;
|
||||
UserRepositories: HelmRepositoryResponse[];
|
||||
UserRepositories: HelmRegistryResponse[];
|
||||
}
|
||||
|
||||
export interface HelmChartsResponse {
|
||||
|
@ -108,15 +108,6 @@ export interface HelmChartsResponse {
|
|||
generated: string;
|
||||
}
|
||||
|
||||
export interface InstallChartPayload {
|
||||
Name: string;
|
||||
Repo: string;
|
||||
Chart: string;
|
||||
Values: string;
|
||||
Namespace: string;
|
||||
Version?: string;
|
||||
}
|
||||
|
||||
export interface UpdateHelmReleasePayload {
|
||||
namespace: string;
|
||||
values?: string;
|
||||
|
|
|
@ -201,8 +201,10 @@ function InheritanceMessage({
|
|||
return (
|
||||
<tr>
|
||||
<td colSpan={2} aria-label="inheritance-message">
|
||||
<Icon icon={Info} mode="primary" className="mr-1" />
|
||||
{children}
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<Icon icon={Info} mode="primary" />
|
||||
{children}
|
||||
</div>
|
||||
<Tooltip message={tooltip} />
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -81,21 +81,25 @@ export function HelmRepositoryDatatable() {
|
|||
function HelmDatatableDescription({ isAdmin }: { isAdmin: boolean }) {
|
||||
return (
|
||||
<TextTip color="blue" className="mb-3">
|
||||
Adding a Helm repo here only makes it available in your own user
|
||||
account's Portainer UI. Helm charts are pulled down from these repos
|
||||
(plus the{' '}
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
to="portainer.settings"
|
||||
params={{ '#': 'kubernetes-settings' }}
|
||||
data-cy="k8s-globally-select-repo-link"
|
||||
>
|
||||
<span>globally-set Helm repo</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span>globally-set Helm repo</span>
|
||||
<p>
|
||||
Adding a Helm repository here makes it available only in your Portainer
|
||||
user account. The Helm charts from these repositories (along with the
|
||||
globally set Helm repository) are shown in the 'Create from
|
||||
Code' screen.
|
||||
</p>
|
||||
{isAdmin && (
|
||||
<>
|
||||
To manage your helm repositories globally, navigate to{' '}
|
||||
<Link
|
||||
to="portainer.settings"
|
||||
params={{ '#': 'kubernetes-settings' }}
|
||||
data-cy="helm-settings-link"
|
||||
>
|
||||
Settings > General
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
) and shown in the Create from code screen's Helm charts list.
|
||||
</TextTip>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,11 @@ import { EnvironmentId } from '../types';
|
|||
export const environmentQueryKeys = {
|
||||
base: () => ['environments'] as const,
|
||||
item: (id: EnvironmentId) => [...environmentQueryKeys.base(), id] as const,
|
||||
registries: (environmentId: EnvironmentId) =>
|
||||
[...environmentQueryKeys.base(), environmentId, 'registries'] as const,
|
||||
registries: (environmentId: EnvironmentId, namespace?: string) =>
|
||||
[
|
||||
...environmentQueryKeys.base(),
|
||||
environmentId,
|
||||
'registries',
|
||||
namespace,
|
||||
] as const,
|
||||
};
|
||||
|
|
|
@ -14,17 +14,28 @@ export function useEnvironmentRegistries<T = Array<Registry>>(
|
|||
environmentId: EnvironmentId,
|
||||
queryOptions: GenericRegistriesQueryOptions<T> = {}
|
||||
) {
|
||||
const { namespace } = queryOptions;
|
||||
return useGenericRegistriesQuery(
|
||||
environmentQueryKeys.registries(environmentId),
|
||||
() => getEnvironmentRegistries(environmentId),
|
||||
environmentQueryKeys.registries(environmentId, namespace),
|
||||
() => getEnvironmentRegistries(environmentId, { namespace }),
|
||||
queryOptions
|
||||
);
|
||||
}
|
||||
|
||||
async function getEnvironmentRegistries(environmentId: EnvironmentId) {
|
||||
type Params = {
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
async function getEnvironmentRegistries(
|
||||
environmentId: EnvironmentId,
|
||||
params: Params
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<Array<Registry>>(
|
||||
buildUrl(environmentId, 'registries')
|
||||
buildUrl(environmentId, 'registries'),
|
||||
{
|
||||
params,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
|
|
@ -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 { RegistryTypes } from '../types/registry';
|
||||
import { registryIconMap, registryLabelMap } from '../utils/constants';
|
||||
|
||||
export const options = [
|
||||
{
|
||||
id: 'registry_dockerhub',
|
||||
icon: Docker,
|
||||
label: 'DockerHub',
|
||||
icon: registryIconMap[RegistryTypes.DOCKERHUB],
|
||||
label: registryLabelMap[RegistryTypes.DOCKERHUB],
|
||||
description: 'DockerHub authenticated account',
|
||||
value: '6',
|
||||
value: String(RegistryTypes.DOCKERHUB),
|
||||
},
|
||||
{
|
||||
id: 'registry_aws_ecr',
|
||||
icon: Ecr,
|
||||
label: 'AWS ECR',
|
||||
icon: registryIconMap[RegistryTypes.ECR],
|
||||
label: registryLabelMap[RegistryTypes.ECR],
|
||||
description: 'Amazon elastic container registry',
|
||||
value: '7',
|
||||
value: String(RegistryTypes.ECR),
|
||||
},
|
||||
{
|
||||
id: 'registry_quay',
|
||||
icon: Quay,
|
||||
label: 'Quay.io',
|
||||
icon: registryIconMap[RegistryTypes.QUAY],
|
||||
label: registryLabelMap[RegistryTypes.QUAY],
|
||||
description: 'Quay container registry',
|
||||
value: '1',
|
||||
value: String(RegistryTypes.QUAY),
|
||||
},
|
||||
{
|
||||
id: 'registry_proget',
|
||||
icon: Proget,
|
||||
label: 'ProGet',
|
||||
icon: registryIconMap[RegistryTypes.PROGET],
|
||||
label: registryLabelMap[RegistryTypes.PROGET],
|
||||
description: 'ProGet container registry',
|
||||
value: '5',
|
||||
value: String(RegistryTypes.PROGET),
|
||||
},
|
||||
{
|
||||
id: 'registry_azure',
|
||||
icon: Azure,
|
||||
label: 'Azure',
|
||||
icon: registryIconMap[RegistryTypes.AZURE],
|
||||
label: registryLabelMap[RegistryTypes.AZURE],
|
||||
description: 'Azure container registry',
|
||||
value: '2',
|
||||
value: String(RegistryTypes.AZURE),
|
||||
},
|
||||
{
|
||||
id: 'registry_gitlab',
|
||||
icon: Gitlab,
|
||||
label: 'GitLab',
|
||||
icon: registryIconMap[RegistryTypes.GITLAB],
|
||||
label: registryLabelMap[RegistryTypes.GITLAB],
|
||||
description: 'GitLab container registry',
|
||||
value: '4',
|
||||
value: String(RegistryTypes.GITLAB),
|
||||
},
|
||||
{
|
||||
id: 'registry_custom',
|
||||
icon: <BadgeIcon icon={Edit} />,
|
||||
label: 'Custom registry',
|
||||
icon: <BadgeIcon icon={registryIconMap[RegistryTypes.CUSTOM]} />,
|
||||
label: registryLabelMap[RegistryTypes.CUSTOM],
|
||||
description: 'Define your own registry',
|
||||
value: '3',
|
||||
value: String(RegistryTypes.CUSTOM),
|
||||
},
|
||||
];
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { RegistryId } from '../types/registry';
|
||||
|
||||
export function buildUrl(registryId: RegistryId) {
|
||||
const base = '/registries';
|
||||
export function buildUrl(registryId: RegistryId, resource?: 'repositories') {
|
||||
let url = '/registries';
|
||||
|
||||
if (registryId) {
|
||||
return `${base}/${registryId}`;
|
||||
url += `/${registryId}`;
|
||||
}
|
||||
|
||||
return base;
|
||||
if (resource) {
|
||||
url += `/${resource}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export function buildProxyUrl(registryId: RegistryId) {
|
||||
|
|
|
@ -24,6 +24,8 @@ export type GenericRegistriesQueryOptions<T> = {
|
|||
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. */
|
||||
hideDefault?: boolean;
|
||||
/** is used to filter the registries by namespace. Kubernetes views use this. */
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
export function useGenericRegistriesQuery<T = Registry[]>(
|
||||
|
|
35
app/react/portainer/registries/utils/constants.tsx
Normal file
35
app/react/portainer/registries/utils/constants.tsx
Normal 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,
|
||||
};
|
|
@ -4,7 +4,7 @@ import { TextTip } from '@@/Tip/TextTip';
|
|||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { InsightsBox } from '@@/InsightsBox';
|
||||
import { ExternalLink } from '@@/ExternalLink';
|
||||
|
||||
export function HelmSection() {
|
||||
const [{ name }, { error }] = useField<string>('helmRepositoryUrl');
|
||||
|
@ -13,39 +13,17 @@ export function HelmSection() {
|
|||
<FormSection title="Helm repository">
|
||||
<div className="mb-2">
|
||||
<TextTip color="blue">
|
||||
You can specify the URL to your own Helm repository here. See the{' '}
|
||||
<a
|
||||
href="https://helm.sh/docs/topics/chart_repository/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
You can specify the URL to your own{' '}
|
||||
<ExternalLink
|
||||
to="https://helm.sh/docs/topics/chart_repository/"
|
||||
data-cy="helm-repository-link"
|
||||
>
|
||||
official documentation
|
||||
</a>{' '}
|
||||
for more details.
|
||||
Helm repository
|
||||
</ExternalLink>{' '}
|
||||
here.
|
||||
</TextTip>
|
||||
</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">
|
||||
<Field
|
||||
as={Input}
|
||||
|
|
4
go.mod
4
go.mod
|
@ -36,6 +36,7 @@ require (
|
|||
github.com/klauspost/compress v1.18.0
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
|
||||
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/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pkg/errors v0.9.1
|
||||
|
@ -63,6 +64,7 @@ require (
|
|||
k8s.io/kubectl v0.33.2
|
||||
k8s.io/kubelet 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
|
||||
)
|
||||
|
||||
|
@ -225,7 +227,6 @@ require (
|
|||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/pelletier/go-toml v1.9.5 // 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/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // 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/kustomize/api v0.19.0 // indirect
|
||||
sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect
|
||||
|
|
126
pkg/libhelm/cache/cache.go
vendored
Normal file
126
pkg/libhelm/cache/cache.go
vendored
Normal file
|
@ -0,0 +1,126 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/rs/zerolog/log"
|
||||
"helm.sh/helm/v3/pkg/registry"
|
||||
)
|
||||
|
||||
// Cache manages Helm registry clients with TTL-based expiration
|
||||
// Registry clients are cached per registry ID rather than per user session
|
||||
// to optimize rate limiting - one login per registry per Portainer instance
|
||||
type Cache struct {
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
// CachedRegistryClient wraps a registry client with metadata
|
||||
type CachedRegistryClient struct {
|
||||
Client *registry.Client
|
||||
RegistryID portainer.RegistryID
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// newCache creates a new Helm registry client cache with the specified timeout
|
||||
func newCache(userSessionTimeout string) (*Cache, error) {
|
||||
timeout, err := time.ParseDuration(userSessionTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid user session timeout: %w", err)
|
||||
}
|
||||
|
||||
return &Cache{
|
||||
cache: cache.New(timeout, timeout),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getByRegistryID retrieves a cached registry client by registry ID
|
||||
// Cache key strategy: use registryID for maximum efficiency against rate limits
|
||||
// This means one login per registry per Portainer instance, regardless of user/environment
|
||||
func (c *Cache) getByRegistryID(registryID portainer.RegistryID) (*registry.Client, bool) {
|
||||
key := generateRegistryIDCacheKey(registryID)
|
||||
|
||||
cachedClient, found := c.cache.Get(key)
|
||||
if !found {
|
||||
log.Debug().
|
||||
Str("cache_key", key).
|
||||
Int("registry_id", int(registryID)).
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Cache miss for registry client")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
client := cachedClient.(CachedRegistryClient)
|
||||
|
||||
log.Debug().
|
||||
Str("cache_key", key).
|
||||
Int("registry_id", int(registryID)).
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Cache hit for registry client")
|
||||
|
||||
return client.Client, true
|
||||
}
|
||||
|
||||
// setByRegistryID stores a registry client in the cache with registry ID context
|
||||
func (c *Cache) setByRegistryID(registryID portainer.RegistryID, client *registry.Client) {
|
||||
if client == nil {
|
||||
log.Warn().
|
||||
Int("registry_id", int(registryID)).
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Attempted to cache nil registry client")
|
||||
return
|
||||
}
|
||||
|
||||
key := generateRegistryIDCacheKey(registryID)
|
||||
|
||||
cachedClient := CachedRegistryClient{
|
||||
Client: client,
|
||||
RegistryID: registryID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
c.cache.Set(key, cachedClient, cache.DefaultExpiration)
|
||||
|
||||
log.Debug().
|
||||
Str("cache_key", key).
|
||||
Int("registry_id", int(registryID)).
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Cached registry client")
|
||||
}
|
||||
|
||||
// flushRegistry removes cached registry client for a specific registry ID
|
||||
// This should be called whenever registry credentials change
|
||||
func (c *Cache) flushRegistry(registryID portainer.RegistryID) {
|
||||
key := generateRegistryIDCacheKey(registryID)
|
||||
|
||||
c.cache.Delete(key)
|
||||
log.Info().
|
||||
Int("registry_id", int(registryID)).
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Flushed registry client due to registry change")
|
||||
}
|
||||
|
||||
// flushAll removes all cached registry clients
|
||||
func (c *Cache) flushAll() {
|
||||
itemCount := c.cache.ItemCount()
|
||||
c.cache.Flush()
|
||||
|
||||
if itemCount > 0 {
|
||||
log.Info().
|
||||
Int("cached_clients_removed", itemCount).
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Flushed all registry clients")
|
||||
}
|
||||
}
|
||||
|
||||
// generateRegistryIDCacheKey creates a cache key from registry ID
|
||||
// Key strategy decision: Use registry ID instead of user sessions or URL+username
|
||||
// This provides optimal rate limiting protection since each registry only gets
|
||||
// logged into once per Portainer instance, regardless of how many users access it
|
||||
// RBAC security is enforced before reaching this caching layer
|
||||
// When a new user needs access, they reuse the same cached client
|
||||
func generateRegistryIDCacheKey(registryID portainer.RegistryID) string {
|
||||
return fmt.Sprintf("registry:%d", registryID)
|
||||
}
|
81
pkg/libhelm/cache/manager.go
vendored
Normal file
81
pkg/libhelm/cache/manager.go
vendored
Normal file
|
@ -0,0 +1,81 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/rs/zerolog/log"
|
||||
"helm.sh/helm/v3/pkg/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
// Global singleton instance
|
||||
instance *Cache
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// Initialize creates and initializes the global cache instance
|
||||
func Initialize(userSessionTimeout string) error {
|
||||
var err error
|
||||
once.Do(func() {
|
||||
instance, err = newCache(userSessionTimeout)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("user_session_timeout", userSessionTimeout).
|
||||
Msg("Failed to initialize Helm registry cache")
|
||||
} else {
|
||||
log.Info().
|
||||
Str("user_session_timeout", userSessionTimeout).
|
||||
Msg("Helm registry cache initialized")
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Registry-based cache management functions
|
||||
|
||||
// GetCachedRegistryClientByID retrieves a cached registry client by registry ID
|
||||
func GetCachedRegistryClientByID(registryID portainer.RegistryID) (*registry.Client, bool) {
|
||||
if instance == nil {
|
||||
log.Debug().
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Cache not initialized, returning nil")
|
||||
return nil, false
|
||||
}
|
||||
return instance.getByRegistryID(registryID)
|
||||
}
|
||||
|
||||
// SetCachedRegistryClientByID stores a registry client in the cache by registry ID
|
||||
func SetCachedRegistryClientByID(registryID portainer.RegistryID, client *registry.Client) {
|
||||
if instance == nil {
|
||||
log.Warn().
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Cannot set cache entry - cache not initialized")
|
||||
return
|
||||
}
|
||||
instance.setByRegistryID(registryID, client)
|
||||
}
|
||||
|
||||
// FlushRegistryByID removes cached registry client for a specific registry ID
|
||||
// This should be called whenever registry credentials change
|
||||
func FlushRegistryByID(registryID portainer.RegistryID) {
|
||||
if instance == nil {
|
||||
log.Debug().
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Cache not initialized, nothing to flush")
|
||||
return
|
||||
}
|
||||
instance.flushRegistry(registryID)
|
||||
}
|
||||
|
||||
// FlushAll removes all cached registry clients
|
||||
func FlushAll() {
|
||||
if instance == nil {
|
||||
log.Debug().
|
||||
Str("context", "HelmRegistryCache").
|
||||
Msg("Cache not initialized, nothing to flush")
|
||||
return
|
||||
}
|
||||
instance.flushAll()
|
||||
}
|
38
pkg/libhelm/options/chart_reference.go
Normal file
38
pkg/libhelm/options/chart_reference.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
package options
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// OCIProtocolPrefix is the standard OCI protocol prefix
|
||||
OCIProtocolPrefix = "oci://"
|
||||
)
|
||||
|
||||
// ConstructChartReference constructs the appropriate chart reference based on registry type
|
||||
func ConstructChartReference(registryURL string, chartName string) string {
|
||||
if registryURL == "" {
|
||||
return chartName
|
||||
}
|
||||
|
||||
// Don't double-prefix if chart already contains the registry URL
|
||||
if strings.HasPrefix(chartName, OCIProtocolPrefix) {
|
||||
return chartName
|
||||
}
|
||||
|
||||
baseURL := ConstructOCIRegistryReference(registryURL)
|
||||
|
||||
// Handle cases where chartName might already have a path separator
|
||||
if strings.HasPrefix(chartName, "/") {
|
||||
return baseURL + chartName
|
||||
}
|
||||
|
||||
return baseURL + "/" + chartName
|
||||
}
|
||||
|
||||
func ConstructOCIRegistryReference(registryURL string) string {
|
||||
// Remove oci:// prefix if present to avoid duplication
|
||||
registryURL = strings.TrimPrefix(registryURL, OCIProtocolPrefix)
|
||||
// Ensure we have oci:// prefix for OCI registries
|
||||
return OCIProtocolPrefix + registryURL
|
||||
}
|
100
pkg/libhelm/options/chart_reference_test.go
Normal file
100
pkg/libhelm/options/chart_reference_test.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package options
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConstructChartReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registryURL string
|
||||
chartName string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty registry URL returns chart name as-is",
|
||||
registryURL: "",
|
||||
chartName: "nginx",
|
||||
expected: "nginx",
|
||||
},
|
||||
{
|
||||
name: "basic OCI registry with chart name",
|
||||
registryURL: "registry.example.com",
|
||||
chartName: "nginx",
|
||||
expected: "oci://registry.example.com/nginx",
|
||||
},
|
||||
{
|
||||
name: "registry with project path",
|
||||
registryURL: "harbor.example.com",
|
||||
chartName: "library/nginx",
|
||||
expected: "oci://harbor.example.com/library/nginx",
|
||||
},
|
||||
{
|
||||
name: "chart name already has oci prefix returns as-is",
|
||||
registryURL: "registry.example.com",
|
||||
chartName: "oci://registry.example.com/nginx",
|
||||
expected: "oci://registry.example.com/nginx",
|
||||
},
|
||||
{
|
||||
name: "chart name with leading slash",
|
||||
registryURL: "registry.example.com",
|
||||
chartName: "/nginx",
|
||||
expected: "oci://registry.example.com/nginx",
|
||||
},
|
||||
{
|
||||
name: "registry URL already has oci prefix",
|
||||
registryURL: "oci://registry.example.com",
|
||||
chartName: "nginx",
|
||||
expected: "oci://registry.example.com/nginx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConstructChartReference(tt.registryURL, tt.chartName)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ConstructChartReference(%q, %q) = %q, want %q",
|
||||
tt.registryURL, tt.chartName, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructOCIRegistryReference(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registryURL string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple registry URL",
|
||||
registryURL: "registry.example.com",
|
||||
expected: "oci://registry.example.com",
|
||||
},
|
||||
{
|
||||
name: "registry URL with oci prefix",
|
||||
registryURL: "oci://registry.example.com",
|
||||
expected: "oci://registry.example.com",
|
||||
},
|
||||
{
|
||||
name: "registry URL with port",
|
||||
registryURL: "registry.example.com:5000",
|
||||
expected: "oci://registry.example.com:5000",
|
||||
},
|
||||
{
|
||||
name: "empty registry URL",
|
||||
registryURL: "",
|
||||
expected: "oci://",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConstructOCIRegistryReference(tt.registryURL)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ConstructOCIRegistryReference(%q) = %q, want %q",
|
||||
tt.registryURL, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
package options
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type InstallOptions struct {
|
||||
Name string
|
||||
|
@ -8,6 +12,7 @@ type InstallOptions struct {
|
|||
Version string
|
||||
Namespace string
|
||||
Repo string
|
||||
Registry *portainer.Registry
|
||||
Wait bool
|
||||
ValuesFile string
|
||||
PostRenderer string
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
package options
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type SearchRepoOptions struct {
|
||||
Repo string `example:"https://charts.gitlab.io/"`
|
||||
Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
|
||||
Chart string `example:"my-chart"`
|
||||
UseCache bool `example:"false"`
|
||||
Registry *portainer.Registry
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package options
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
// ShowOutputFormat is the format of the output of `helm show`
|
||||
type ShowOutputFormat string
|
||||
|
||||
|
@ -20,6 +22,6 @@ type ShowOptions struct {
|
|||
Chart string
|
||||
Repo string
|
||||
Version string
|
||||
|
||||
Env []string
|
||||
Env []string
|
||||
Registry *portainer.Registry // Registry credentials for authentication
|
||||
}
|
||||
|
|
|
@ -45,6 +45,8 @@ type Release struct {
|
|||
// Labels of the release.
|
||||
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
|
||||
Labels map[string]string `json:"-"`
|
||||
// ChartReference are the labels that are used to identify the chart source.
|
||||
ChartReference ChartReference `json:"chartReference,omitempty"`
|
||||
// Values are the values used to deploy the chart.
|
||||
Values Values `json:"values,omitempty"`
|
||||
}
|
||||
|
@ -54,6 +56,12 @@ type Values struct {
|
|||
ComputedValues string `json:"computedValues,omitempty"`
|
||||
}
|
||||
|
||||
type ChartReference struct {
|
||||
ChartPath string `json:"chartPath,omitempty"`
|
||||
RepoURL string `json:"repoURL,omitempty"`
|
||||
RegistryID int64 `json:"registryID,omitempty"`
|
||||
}
|
||||
|
||||
// Chart is a helm package that contains metadata, a default config, zero or more
|
||||
// optionally parameterizable templates, and zero or more charts (dependencies).
|
||||
type Chart struct {
|
||||
|
|
297
pkg/libhelm/sdk/chartsources.go
Normal file
297
pkg/libhelm/sdk/chartsources.go
Normal file
|
@ -0,0 +1,297 @@
|
|||
package sdk
|
||||
|
||||
// Helm Registry Client Caching Strategy
|
||||
//
|
||||
// This package implements a registry-based caching mechanism for Helm OCI registry clients
|
||||
// to address rate limiting issues caused by repeated registry authentication.
|
||||
//
|
||||
// Key Design Decisions:
|
||||
//
|
||||
// 1. Cache Key Strategy: Registry ID
|
||||
// - Uses portainer.RegistryID as the cache key instead of user sessions or URL+username
|
||||
// - One cached client per registry per Portainer instance, regardless of users
|
||||
// - Optimal for rate limiting: each registry only gets one login per Portainer instance
|
||||
// - New users reuse existing cached clients rather than creating new ones
|
||||
//
|
||||
// 2. Cache Invalidation: Registry Change Events
|
||||
// - Cache is flushed when registry credentials are updated (registryUpdate handler)
|
||||
// - Cache is flushed when registry is reconfigured (registryConfigure handler)
|
||||
// - Cache is flushed when registry is deleted (registryDelete handler)
|
||||
// - Cache is flushed when registry authentication fails (show, install, upgrade)
|
||||
// - No time-based expiration needed since registry credentials rarely change
|
||||
//
|
||||
// 3. Alternative Approaches NOT Used:
|
||||
// - registry.ClientOptCredentialsFile(): Still requires token exchange on each client creation
|
||||
// - User/session-based caching: Less efficient for rate limiting, creates unnecessary logins
|
||||
// - URL+username caching: More complex, harder to invalidate, doesn't handle registry updates
|
||||
//
|
||||
// 4. Security Model:
|
||||
// - RBAC security is enforced BEFORE reaching this caching layer (handler.getRegistryWithAccess)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/libhelm/cache"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/rs/zerolog/log"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/registry"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
// IsOCIRegistry returns true if the registry is an OCI registry (not nil), false if it's an HTTP repository (nil)
|
||||
func IsOCIRegistry(registry *portainer.Registry) bool {
|
||||
return registry != nil
|
||||
}
|
||||
|
||||
// IsHTTPRepository returns true if it's an HTTP repository (registry is nil), false if it's an OCI registry
|
||||
func IsHTTPRepository(registry *portainer.Registry) bool {
|
||||
return registry == nil
|
||||
}
|
||||
|
||||
// parseChartRef parses chart and repo references based on the registry type
|
||||
func parseChartRef(chart, repo string, registry *portainer.Registry) (string, string, error) {
|
||||
if IsHTTPRepository(registry) {
|
||||
return parseHTTPRepoChartRef(chart, repo)
|
||||
}
|
||||
return parseOCIChartRef(chart, registry)
|
||||
}
|
||||
|
||||
// parseOCIChartRef constructs the full OCI chart reference
|
||||
func parseOCIChartRef(chart string, registry *portainer.Registry) (string, string, error) {
|
||||
|
||||
chartRef := options.ConstructChartReference(registry.URL, chart)
|
||||
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Str("chart_ref", chartRef).
|
||||
Bool("authentication", registry.Authentication).
|
||||
Msg("Constructed OCI chart reference")
|
||||
|
||||
return chartRef, registry.URL, nil
|
||||
}
|
||||
|
||||
// parseHTTPRepoChartRef returns chart and repo as-is for HTTP repositories
|
||||
func parseHTTPRepoChartRef(chart, repo string) (string, string, error) {
|
||||
return chart, repo, nil
|
||||
}
|
||||
|
||||
// shouldFlushCacheOnError determines if a registry client should be removed from cache based on the error
|
||||
// This helps handle cases where cached credentials have become invalid
|
||||
func shouldFlushCacheOnError(err error, registryID portainer.RegistryID) bool {
|
||||
if err == nil || registryID == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
errorStr := strings.ToLower(err.Error())
|
||||
|
||||
// Authentication/authorization errors that indicate invalid cached credentials
|
||||
authenticationErrors := []string{
|
||||
"unauthorized",
|
||||
"authentication",
|
||||
"login failed",
|
||||
"invalid credentials",
|
||||
"access denied",
|
||||
"forbidden",
|
||||
"401",
|
||||
"403",
|
||||
"token",
|
||||
"auth",
|
||||
}
|
||||
|
||||
for _, authErr := range authenticationErrors {
|
||||
if strings.Contains(errorStr, authErr) {
|
||||
log.Info().
|
||||
Int("registry_id", int(registryID)).
|
||||
Str("error", err.Error()).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Detected authentication error - will flush registry cache")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// authenticateChartSource handles both HTTP repositories and OCI registries
|
||||
func authenticateChartSource(actionConfig *action.Configuration, registry *portainer.Registry) error {
|
||||
// For HTTP repositories, no authentication needed (CE and EE)
|
||||
if IsHTTPRepository(registry) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If RegistryClient is already set, we're done
|
||||
if actionConfig.RegistryClient != nil {
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Msg("Registry client already set in action config")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate registry credentials first
|
||||
err := validateRegistryCredentials(registry)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Err(err).
|
||||
Msg("Registry credential validation failed")
|
||||
return errors.Wrap(err, "registry credential validation failed")
|
||||
}
|
||||
|
||||
// No authentication required
|
||||
if !registry.Authentication {
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Msg("No OCI registry authentication required")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache Strategy Decision: Use registry ID as cache key
|
||||
// This provides optimal rate limiting protection since each registry only gets
|
||||
// logged into once per Portainer instance, regardless of how many users access it.
|
||||
// RBAC security is enforced before reaching this caching layer.
|
||||
// When a new user needs access, they reuse the same cached client.
|
||||
//
|
||||
// Alternative approach (NOT used): registry.ClientOptCredentialsFile()
|
||||
// We don't use Helm's credential file approach because:
|
||||
// 1. It still requires token exchange with registry on each new client creation
|
||||
// 2. Rate limiting occurs during token exchange, not credential loading
|
||||
// 3. Our caching approach reuses existing authenticated clients completely
|
||||
// 4. Credential files add complexity without solving the core rate limiting issue
|
||||
|
||||
// Try to get cached registry client (registry ID-based key)
|
||||
if cachedClient, found := cache.GetCachedRegistryClientByID(registry.ID); found {
|
||||
log.Debug().
|
||||
Int("registry_id", int(registry.ID)).
|
||||
Str("registry_url", registry.URL).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Using cached registry client")
|
||||
|
||||
actionConfig.RegistryClient = cachedClient
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache miss - perform login and cache the result
|
||||
log.Debug().
|
||||
Int("registry_id", int(registry.ID)).
|
||||
Str("registry_url", registry.URL).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Cache miss - creating new registry client")
|
||||
|
||||
registryClient, err := loginToOCIRegistry(registry)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("registry_url", registry.URL).
|
||||
Err(err).
|
||||
Msg("Failed to login to registry")
|
||||
return errors.Wrap(err, "failed to login to registry")
|
||||
}
|
||||
|
||||
// Cache the client if login was successful (registry ID-based key)
|
||||
if registryClient != nil {
|
||||
cache.SetCachedRegistryClientByID(registry.ID, registryClient)
|
||||
log.Debug().
|
||||
Int("registry_id", int(registry.ID)).
|
||||
Str("registry_url", registry.URL).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Registry client cached successfully")
|
||||
}
|
||||
|
||||
actionConfig.RegistryClient = registryClient
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureChartPathOptions sets chart path options based on registry type
|
||||
func configureChartPathOptions(chartPathOptions *action.ChartPathOptions, version, repo string, registry *portainer.Registry) error {
|
||||
chartPathOptions.Version = version
|
||||
// Set chart path options based on registry type
|
||||
if IsHTTPRepository(registry) {
|
||||
configureHTTPRepoChartPathOptions(chartPathOptions, repo)
|
||||
} else {
|
||||
configureOCIChartPathOptions(chartPathOptions, registry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureHTTPRepoChartPathOptions sets chart path options for HTTP repositories
|
||||
func configureHTTPRepoChartPathOptions(chartPathOptions *action.ChartPathOptions, repo string) {
|
||||
chartPathOptions.RepoURL = repo
|
||||
}
|
||||
|
||||
// configureOCIChartPathOptions sets chart path options for OCI registries
|
||||
func configureOCIChartPathOptions(chartPathOptions *action.ChartPathOptions, registry *portainer.Registry) {
|
||||
if registry.Authentication {
|
||||
chartPathOptions.Username = registry.Username
|
||||
chartPathOptions.Password = registry.Password
|
||||
}
|
||||
}
|
||||
|
||||
// loginToOCIRegistry performs registry login for OCI-based registries using Helm SDK
|
||||
// Tries to get a cached registry client if available, otherwise creates and caches a new one
|
||||
func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client, error) {
|
||||
if IsHTTPRepository(portainerRegistry) || !portainerRegistry.Authentication {
|
||||
return nil, nil // No authentication needed
|
||||
}
|
||||
|
||||
// Check cache first using registry ID-based key
|
||||
if cachedClient, found := cache.GetCachedRegistryClientByID(portainerRegistry.ID); found {
|
||||
return cachedClient, nil
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "loginToRegistry").
|
||||
Int("registry_id", int(portainerRegistry.ID)).
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Msg("Attempting to login to OCI registry")
|
||||
|
||||
registryClient, err := registry.NewClient(registry.ClientOptHTTPClient(retry.DefaultClient))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create registry client")
|
||||
}
|
||||
|
||||
loginOpts := []registry.LoginOption{
|
||||
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
|
||||
}
|
||||
|
||||
err = registryClient.Login(portainerRegistry.URL, loginOpts...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "loginToRegistry").
|
||||
Int("registry_id", int(portainerRegistry.ID)).
|
||||
Str("registry_url", portainerRegistry.URL).
|
||||
Msg("Successfully logged in to OCI registry")
|
||||
|
||||
// Cache using registry ID-based key
|
||||
cache.SetCachedRegistryClientByID(portainerRegistry.ID, registryClient)
|
||||
|
||||
return registryClient, nil
|
||||
}
|
||||
|
||||
// validateRegistryCredentials validates registry authentication settings
|
||||
func validateRegistryCredentials(registry *portainer.Registry) error {
|
||||
if IsHTTPRepository(registry) {
|
||||
return nil // No registry means no validation needed
|
||||
}
|
||||
|
||||
if !registry.Authentication {
|
||||
return nil // No authentication required
|
||||
}
|
||||
|
||||
// Authentication is enabled - validate credentials
|
||||
if strings.TrimSpace(registry.Username) == "" {
|
||||
return errors.New("username is required when registry authentication is enabled")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(registry.Password) == "" {
|
||||
return errors.New("password is required when registry authentication is enabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
752
pkg/libhelm/sdk/chartsources_test.go
Normal file
752
pkg/libhelm/sdk/chartsources_test.go
Normal file
|
@ -0,0 +1,752 @@
|
|||
package sdk
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
helmregistrycache "github.com/portainer/portainer/pkg/libhelm/cache"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/registry"
|
||||
)
|
||||
|
||||
func TestIsOCIRegistry(t *testing.T) {
|
||||
t.Run("should return false for nil registry (HTTP repo)", func(t *testing.T) {
|
||||
assert.False(t, IsOCIRegistry(nil))
|
||||
})
|
||||
|
||||
t.Run("should return true for non-nil registry (OCI registry)", func(t *testing.T) {
|
||||
assert.True(t, IsOCIRegistry(&portainer.Registry{}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsHTTPRepository(t *testing.T) {
|
||||
t.Run("should return true for nil registry (HTTP repo)", func(t *testing.T) {
|
||||
assert.True(t, IsHTTPRepository(nil))
|
||||
})
|
||||
|
||||
t.Run("should return false for non-nil registry (OCI registry)", func(t *testing.T) {
|
||||
assert.False(t, IsHTTPRepository(&portainer.Registry{}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseHTTPRepoChartRef(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
chartRef, repoURL, err := parseHTTPRepoChartRef("my-chart", "https://my.repo/charts")
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal("my-chart", chartRef)
|
||||
is.Equal("https://my.repo/charts", repoURL)
|
||||
}
|
||||
|
||||
func TestParseOCIChartRef(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io/my-namespace",
|
||||
Authentication: true,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}
|
||||
|
||||
chartRef, repoURL, err := parseOCIChartRef("my-chart", registry)
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal("oci://my-registry.io/my-namespace/my-chart", chartRef)
|
||||
is.Equal("my-registry.io/my-namespace", repoURL)
|
||||
}
|
||||
|
||||
func TestParseOCIChartRef_GitLab(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
registry := &portainer.Registry{
|
||||
Type: portainer.GitlabRegistry,
|
||||
URL: "registry.gitlab.com",
|
||||
Authentication: true,
|
||||
Username: "gitlab-ci-token",
|
||||
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
|
||||
Gitlab: portainer.GitlabRegistryData{
|
||||
ProjectID: 12345,
|
||||
InstanceURL: "https://gitlab.com",
|
||||
ProjectPath: "my-group/my-project",
|
||||
},
|
||||
}
|
||||
|
||||
chartRef, repoURL, err := parseOCIChartRef("my-chart", registry)
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal("oci://registry.gitlab.com/my-chart", chartRef)
|
||||
is.Equal("registry.gitlab.com", repoURL)
|
||||
}
|
||||
|
||||
func TestParseChartRef(t *testing.T) {
|
||||
t.Run("should parse HTTP repo chart ref when registry is nil", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
chartRef, repoURL, err := parseChartRef("my-chart", "https://my.repo/charts", nil)
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal("my-chart", chartRef)
|
||||
is.Equal("https://my.repo/charts", repoURL)
|
||||
})
|
||||
|
||||
t.Run("should parse OCI chart ref when registry is provided", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io/my-namespace",
|
||||
Authentication: true,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}
|
||||
|
||||
chartRef, repoURL, err := parseChartRef("my-chart", "", registry)
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal("oci://my-registry.io/my-namespace/my-chart", chartRef)
|
||||
is.Equal("my-registry.io/my-namespace", repoURL)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigureHTTPRepoChartPathOptions(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
chartPathOptions := &action.ChartPathOptions{}
|
||||
|
||||
configureHTTPRepoChartPathOptions(chartPathOptions, "https://my.repo/charts")
|
||||
|
||||
is.Equal("https://my.repo/charts", chartPathOptions.RepoURL)
|
||||
}
|
||||
|
||||
func TestConfigureOCIChartPathOptions(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
chartPathOptions := &action.ChartPathOptions{}
|
||||
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io/my-namespace",
|
||||
Authentication: true,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}
|
||||
|
||||
configureOCIChartPathOptions(chartPathOptions, registry)
|
||||
|
||||
is.Equal("user", chartPathOptions.Username)
|
||||
is.Equal("pass", chartPathOptions.Password)
|
||||
}
|
||||
|
||||
func TestConfigureOCIChartPathOptions_NoAuth(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
chartPathOptions := &action.ChartPathOptions{}
|
||||
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io/my-namespace",
|
||||
Authentication: false,
|
||||
}
|
||||
|
||||
configureOCIChartPathOptions(chartPathOptions, registry)
|
||||
|
||||
is.Empty(chartPathOptions.Username)
|
||||
is.Empty(chartPathOptions.Password)
|
||||
}
|
||||
|
||||
func TestConfigureChartPathOptions(t *testing.T) {
|
||||
t.Run("should configure HTTP repo when registry is nil", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
chartPathOptions := &action.ChartPathOptions{}
|
||||
|
||||
err := configureChartPathOptions(chartPathOptions, "1.0.0", "https://my.repo/charts", nil)
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal("https://my.repo/charts", chartPathOptions.RepoURL)
|
||||
is.Equal("1.0.0", chartPathOptions.Version)
|
||||
})
|
||||
|
||||
t.Run("should configure OCI registry when registry is provided", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
chartPathOptions := &action.ChartPathOptions{}
|
||||
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io/my-namespace",
|
||||
Authentication: true,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}
|
||||
|
||||
err := configureChartPathOptions(chartPathOptions, "1.0.0", "", registry)
|
||||
|
||||
is.NoError(err)
|
||||
is.Equal("user", chartPathOptions.Username)
|
||||
is.Equal("pass", chartPathOptions.Password)
|
||||
is.Equal("1.0.0", chartPathOptions.Version)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginToOCIRegistry(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
t.Run("should return nil for HTTP repository (nil registry)", func(t *testing.T) {
|
||||
client, err := loginToOCIRegistry(nil)
|
||||
is.NoError(err)
|
||||
is.Nil(client)
|
||||
})
|
||||
|
||||
t.Run("should return nil for registry with auth disabled", func(t *testing.T) {
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io",
|
||||
Authentication: false,
|
||||
}
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
is.NoError(err)
|
||||
is.Nil(client)
|
||||
})
|
||||
|
||||
t.Run("should return error for invalid credentials", func(t *testing.T) {
|
||||
registry := &portainer.Registry{
|
||||
URL: "my-registry.io",
|
||||
Authentication: true,
|
||||
Username: " ",
|
||||
}
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
is.Error(err)
|
||||
is.Nil(client)
|
||||
// The error might be a validation error or a login error, both are acceptable
|
||||
is.True(err.Error() == "username is required when registry authentication is enabled" ||
|
||||
strings.Contains(err.Error(), "failed to login to registry"))
|
||||
})
|
||||
|
||||
t.Run("should attempt login for valid credentials", func(t *testing.T) {
|
||||
registry := &portainer.Registry{
|
||||
ID: 123,
|
||||
URL: "my-registry.io",
|
||||
Authentication: true,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}
|
||||
// this will fail because it can't connect to the registry,
|
||||
// but it proves that the loginToOCIRegistry function is calling the login function.
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
is.Error(err)
|
||||
is.Nil(client)
|
||||
is.Contains(err.Error(), "failed to login to registry")
|
||||
})
|
||||
|
||||
t.Run("should attempt login for GitLab registry with valid credentials", func(t *testing.T) {
|
||||
registry := &portainer.Registry{
|
||||
ID: 456,
|
||||
Type: portainer.GitlabRegistry,
|
||||
URL: "registry.gitlab.com",
|
||||
Authentication: true,
|
||||
Username: "gitlab-ci-token",
|
||||
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
|
||||
Gitlab: portainer.GitlabRegistryData{
|
||||
ProjectID: 12345,
|
||||
InstanceURL: "https://gitlab.com",
|
||||
ProjectPath: "my-group/my-project",
|
||||
},
|
||||
}
|
||||
// this will fail because it can't connect to the registry,
|
||||
// but it proves that the loginToOCIRegistry function is calling the login function.
|
||||
client, err := loginToOCIRegistry(registry)
|
||||
is.Error(err)
|
||||
is.Nil(client)
|
||||
is.Contains(err.Error(), "failed to login to registry")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthenticateChartSource(t *testing.T) {
|
||||
t.Run("should do nothing for HTTP repo (nil registry)", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
actionConfig := &action.Configuration{}
|
||||
err := authenticateChartSource(actionConfig, nil)
|
||||
is.NoError(err)
|
||||
is.Nil(actionConfig.RegistryClient)
|
||||
})
|
||||
|
||||
t.Run("should do nothing if registry client already set", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
actionConfig := &action.Configuration{}
|
||||
// Mock an existing registry client
|
||||
existingClient := ®istry.Client{}
|
||||
actionConfig.RegistryClient = existingClient
|
||||
|
||||
registry := &portainer.Registry{
|
||||
ID: 123,
|
||||
Authentication: true,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
}
|
||||
|
||||
err := authenticateChartSource(actionConfig, registry)
|
||||
is.NoError(err)
|
||||
is.Equal(existingClient, actionConfig.RegistryClient)
|
||||
})
|
||||
|
||||
t.Run("should authenticate OCI registry when registry is provided", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
actionConfig := &action.Configuration{}
|
||||
registry := &portainer.Registry{
|
||||
ID: 123,
|
||||
Authentication: false,
|
||||
}
|
||||
err := authenticateChartSource(actionConfig, registry)
|
||||
is.NoError(err)
|
||||
})
|
||||
|
||||
t.Run("should return error for invalid registry credentials", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
actionConfig := &action.Configuration{}
|
||||
registry := &portainer.Registry{
|
||||
ID: 123,
|
||||
Authentication: true,
|
||||
Username: " ", // Invalid username
|
||||
}
|
||||
err := authenticateChartSource(actionConfig, registry)
|
||||
is.Error(err)
|
||||
is.Contains(err.Error(), "registry credential validation failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRegistryClientFromCache(t *testing.T) {
|
||||
// Initialize cache for testing
|
||||
err := helmregistrycache.Initialize("24h")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize cache: %v", err)
|
||||
}
|
||||
// Clear cache before each test
|
||||
helmregistrycache.FlushAll()
|
||||
|
||||
t.Run("should return nil for invalid registry ID", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
client, found := helmregistrycache.GetCachedRegistryClientByID(0)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
})
|
||||
|
||||
t.Run("should return nil for non-existent registry ID", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
client, found := helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
})
|
||||
|
||||
t.Run("should return cached client for valid registry ID", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
// Create a mock client
|
||||
mockClient := ®istry.Client{}
|
||||
|
||||
// Store in cache
|
||||
helmregistrycache.SetCachedRegistryClientByID(123, mockClient)
|
||||
|
||||
// Retrieve from cache
|
||||
cachedClient, found := helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.True(found)
|
||||
is.NotNil(cachedClient)
|
||||
is.Equal(mockClient, cachedClient)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetRegistryClientInCache(t *testing.T) {
|
||||
// Initialize cache for testing
|
||||
err := helmregistrycache.Initialize("24h")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize cache: %v", err)
|
||||
}
|
||||
// Clear cache before each test
|
||||
helmregistrycache.FlushAll()
|
||||
|
||||
t.Run("should store and retrieve client successfully", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
// Create a mock client
|
||||
client := ®istry.Client{}
|
||||
|
||||
// Store in cache
|
||||
helmregistrycache.SetCachedRegistryClientByID(123, client)
|
||||
|
||||
// Verify the cache returns the client
|
||||
cachedClient, found := helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.True(found)
|
||||
is.NotNil(cachedClient)
|
||||
is.Equal(client, cachedClient)
|
||||
})
|
||||
|
||||
t.Run("should handle invalid parameters gracefully", func(t *testing.T) {
|
||||
// Clear cache to start clean
|
||||
helmregistrycache.FlushAll()
|
||||
|
||||
// These should not panic
|
||||
helmregistrycache.SetCachedRegistryClientByID(0, nil) // nil client should be rejected
|
||||
helmregistrycache.SetCachedRegistryClientByID(999, ®istry.Client{}) // valid client with registry ID 999 should be accepted
|
||||
helmregistrycache.SetCachedRegistryClientByID(123, nil) // nil client should be rejected
|
||||
|
||||
// Verify that nil clients don't get stored, but valid clients do
|
||||
is := assert.New(t)
|
||||
|
||||
// Registry ID 999 with a valid client should be found (the second call above)
|
||||
client, found := helmregistrycache.GetCachedRegistryClientByID(999)
|
||||
is.True(found)
|
||||
is.NotNil(client)
|
||||
|
||||
// Registry ID 0 with nil client should not be found
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(0)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
|
||||
// Registry ID 123 with nil client should not be found
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlushRegistryCache(t *testing.T) {
|
||||
// Initialize cache for testing
|
||||
err := helmregistrycache.Initialize("24h")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize cache: %v", err)
|
||||
}
|
||||
// Clear cache before test
|
||||
helmregistrycache.FlushAll()
|
||||
|
||||
t.Run("should flush specific registry cache", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
// Create mock clients
|
||||
client1 := ®istry.Client{}
|
||||
client2 := ®istry.Client{}
|
||||
|
||||
// Store in cache
|
||||
helmregistrycache.SetCachedRegistryClientByID(123, client1)
|
||||
helmregistrycache.SetCachedRegistryClientByID(456, client2)
|
||||
|
||||
// Verify both are cached
|
||||
client, found := helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.True(found)
|
||||
is.NotNil(client)
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
|
||||
is.True(found)
|
||||
is.NotNil(client)
|
||||
|
||||
// Flush only one
|
||||
helmregistrycache.FlushRegistryByID(123)
|
||||
|
||||
// Verify only one is flushed
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
|
||||
is.True(found)
|
||||
is.NotNil(client)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlushAllRegistryCache(t *testing.T) {
|
||||
// Initialize cache for testing
|
||||
err := helmregistrycache.Initialize("24h")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to initialize cache: %v", err)
|
||||
}
|
||||
|
||||
t.Run("should flush all registry cache", func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
// Create mock clients
|
||||
client1 := ®istry.Client{}
|
||||
client2 := ®istry.Client{}
|
||||
|
||||
// Store in cache
|
||||
helmregistrycache.SetCachedRegistryClientByID(123, client1)
|
||||
helmregistrycache.SetCachedRegistryClientByID(456, client2)
|
||||
|
||||
// Verify both are cached
|
||||
client, found := helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.True(found)
|
||||
is.NotNil(client)
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
|
||||
is.True(found)
|
||||
is.NotNil(client)
|
||||
|
||||
// Flush all
|
||||
helmregistrycache.FlushAll()
|
||||
|
||||
// Verify both are flushed
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(123)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
client, found = helmregistrycache.GetCachedRegistryClientByID(456)
|
||||
is.False(found)
|
||||
is.Nil(client)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRegistryCredentials(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registry *portainer.Registry
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "nil registry should pass validation",
|
||||
registry: nil,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "registry with authentication disabled should pass validation",
|
||||
registry: &portainer.Registry{
|
||||
Authentication: false,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "registry with authentication enabled and valid credentials should pass",
|
||||
registry: &portainer.Registry{
|
||||
Authentication: true,
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "registry with authentication enabled but empty username should fail",
|
||||
registry: &portainer.Registry{
|
||||
Authentication: true,
|
||||
Username: "",
|
||||
Password: "testpass",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "username is required when registry authentication is enabled",
|
||||
},
|
||||
{
|
||||
name: "registry with authentication enabled but whitespace username should fail",
|
||||
registry: &portainer.Registry{
|
||||
Authentication: true,
|
||||
Username: " ",
|
||||
Password: "testpass",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "username is required when registry authentication is enabled",
|
||||
},
|
||||
{
|
||||
name: "registry with authentication enabled but empty password should fail",
|
||||
registry: &portainer.Registry{
|
||||
Authentication: true,
|
||||
Username: "testuser",
|
||||
Password: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "password is required when registry authentication is enabled",
|
||||
},
|
||||
{
|
||||
name: "registry with authentication enabled but whitespace password should fail",
|
||||
registry: &portainer.Registry{
|
||||
Authentication: true,
|
||||
Username: "testuser",
|
||||
Password: " ",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "password is required when registry authentication is enabled",
|
||||
},
|
||||
{
|
||||
name: "GitLab registry with authentication enabled and valid credentials should pass",
|
||||
registry: &portainer.Registry{
|
||||
Type: portainer.GitlabRegistry,
|
||||
Authentication: true,
|
||||
Username: "gitlab-ci-token",
|
||||
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
|
||||
Gitlab: portainer.GitlabRegistryData{
|
||||
ProjectID: 12345,
|
||||
InstanceURL: "https://gitlab.com",
|
||||
ProjectPath: "my-group/my-project",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "GitLab registry with authentication enabled but empty username should fail",
|
||||
registry: &portainer.Registry{
|
||||
Type: portainer.GitlabRegistry,
|
||||
Authentication: true,
|
||||
Username: "",
|
||||
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
|
||||
Gitlab: portainer.GitlabRegistryData{
|
||||
ProjectID: 12345,
|
||||
InstanceURL: "https://gitlab.com",
|
||||
ProjectPath: "my-group/my-project",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "username is required when registry authentication is enabled",
|
||||
},
|
||||
{
|
||||
name: "GitLab registry with authentication enabled but empty password should fail",
|
||||
registry: &portainer.Registry{
|
||||
Type: portainer.GitlabRegistry,
|
||||
Authentication: true,
|
||||
Username: "gitlab-ci-token",
|
||||
Password: "",
|
||||
Gitlab: portainer.GitlabRegistryData{
|
||||
ProjectID: 12345,
|
||||
InstanceURL: "https://gitlab.com",
|
||||
ProjectPath: "my-group/my-project",
|
||||
},
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "password is required when registry authentication is enabled",
|
||||
},
|
||||
{
|
||||
name: "GitLab registry with authentication disabled should pass validation",
|
||||
registry: &portainer.Registry{
|
||||
Type: portainer.GitlabRegistry,
|
||||
Authentication: false,
|
||||
Gitlab: portainer.GitlabRegistryData{
|
||||
ProjectID: 12345,
|
||||
InstanceURL: "https://gitlab.com",
|
||||
ProjectPath: "my-group/my-project",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateRegistryCredentials(tt.registry)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
if err != nil {
|
||||
assert.Equal(t, tt.errorMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Note: buildCacheKey function was removed since we now use registry ID-based caching
|
||||
// instead of endpoint/session-based caching for better rate limiting protection
|
||||
|
||||
func TestShouldFlushCacheOnError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
registryID portainer.RegistryID
|
||||
shouldFlush bool
|
||||
}{
|
||||
{
|
||||
name: "nil error should not flush",
|
||||
err: nil,
|
||||
registryID: 123,
|
||||
shouldFlush: false,
|
||||
},
|
||||
{
|
||||
name: "zero registry ID should not flush",
|
||||
err: errors.New("some error"),
|
||||
registryID: 0,
|
||||
shouldFlush: false,
|
||||
},
|
||||
{
|
||||
name: "unauthorized error should flush",
|
||||
err: errors.New("unauthorized access to registry"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "authentication failed error should flush",
|
||||
err: errors.New("authentication failed"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "login failed error should flush",
|
||||
err: errors.New("login failed for user"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "invalid credentials error should flush",
|
||||
err: errors.New("invalid credentials provided"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "access denied error should flush",
|
||||
err: errors.New("access denied to repository"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "forbidden error should flush",
|
||||
err: errors.New("forbidden: insufficient permissions"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "401 error should flush",
|
||||
err: errors.New("HTTP 401 Unauthorized"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "403 error should flush",
|
||||
err: errors.New("HTTP 403 Forbidden"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "token error should flush",
|
||||
err: errors.New("token expired or invalid"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "auth error should flush",
|
||||
err: errors.New("auth validation failed"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
{
|
||||
name: "chart not found error should not flush",
|
||||
err: errors.New("chart not found in repository"),
|
||||
registryID: 123,
|
||||
shouldFlush: false,
|
||||
},
|
||||
{
|
||||
name: "network error should not flush",
|
||||
err: errors.New("connection timeout"),
|
||||
registryID: 123,
|
||||
shouldFlush: false,
|
||||
},
|
||||
{
|
||||
name: "helm validation error should not flush",
|
||||
err: errors.New("invalid chart values"),
|
||||
registryID: 123,
|
||||
shouldFlush: false,
|
||||
},
|
||||
{
|
||||
name: "kubernetes error should not flush",
|
||||
err: errors.New("namespace not found"),
|
||||
registryID: 123,
|
||||
shouldFlush: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive matching works",
|
||||
err: errors.New("UNAUTHORIZED access denied"),
|
||||
registryID: 123,
|
||||
shouldFlush: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := shouldFlushCacheOnError(tt.err, tt.registryID)
|
||||
is := assert.New(t)
|
||||
is.Equal(tt.shouldFlush, result, "Expected shouldFlushCacheOnError to return %v for error: %v", tt.shouldFlush, tt.err)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,24 +1,38 @@
|
|||
package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||
"github.com/rs/zerolog/log"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/chart"
|
||||
"helm.sh/helm/v3/pkg/chart/loader"
|
||||
"helm.sh/helm/v3/pkg/cli"
|
||||
"helm.sh/helm/v3/pkg/downloader"
|
||||
"helm.sh/helm/v3/pkg/getter"
|
||||
"helm.sh/helm/v3/pkg/repo"
|
||||
)
|
||||
|
||||
// Helm chart reference label constants
|
||||
const (
|
||||
ChartPathAnnotation = "portainer/chart-path"
|
||||
RepoURLAnnotation = "portainer/repo-url"
|
||||
RegistryIDAnnotation = "portainer/registry-id"
|
||||
)
|
||||
|
||||
// loadAndValidateChartWithPathOptions locates and loads the chart, and validates it.
|
||||
// it also checks for chart dependencies and updates them if necessary.
|
||||
// it returns the chart information.
|
||||
func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPathOptions *action.ChartPathOptions, chartName, version string, repoURL string, dependencyUpdate bool, operation string) (*chart.Chart, error) {
|
||||
// Locate and load the chart
|
||||
chartPathOptions.RepoURL = repoURL
|
||||
chartPathOptions.Version = version
|
||||
chartPath, err := chartPathOptions.LocateChart(chartName, hspm.settings)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
|
@ -26,6 +40,11 @@ func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPath
|
|||
Str("chart", chartName).
|
||||
Err(err).
|
||||
Msg("Failed to locate chart for helm " + operation)
|
||||
|
||||
// For OCI charts, chartName already contains the full reference
|
||||
if strings.HasPrefix(chartName, options.OCIProtocolPrefix) {
|
||||
return nil, errors.Wrapf(err, "failed to find the helm chart: %s", chartName)
|
||||
}
|
||||
return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", repoURL, chartName)
|
||||
}
|
||||
|
||||
|
@ -86,3 +105,186 @@ func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPath
|
|||
|
||||
return chartReq, nil
|
||||
}
|
||||
|
||||
// parseRepoURL parses and validates a Helm repository URL using RFC 3986 standards.
|
||||
// Used by search and show operations before downloading index.yaml files.
|
||||
func parseRepoURL(repoURL string) (*url.URL, error) {
|
||||
parsedURL, err := url.ParseRequestURI(repoURL)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
|
||||
}
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
// getRepoNameFromURL generates a unique repository identifier from a URL.
|
||||
// Combines hostname and path for uniqueness (e.g., "charts.helm.sh/stable" → "charts.helm.sh-stable").
|
||||
// Used for Helm's repositories.yaml entries, caching, and chart references.
|
||||
func getRepoNameFromURL(urlStr string) (string, error) {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := parsedURL.Hostname()
|
||||
path := parsedURL.Path
|
||||
path = strings.Trim(path, "/")
|
||||
path = strings.ReplaceAll(path, "/", "-")
|
||||
|
||||
if path == "" {
|
||||
return hostname, nil
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", hostname, path), nil
|
||||
}
|
||||
|
||||
// loadIndexFile loads and parses a Helm repository index.yaml file.
|
||||
// Called after downloading from HTTP repos or generating from OCI registries.
|
||||
// Contains chart metadata used for discovery, version resolution, and caching.
|
||||
func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Str("index_path", indexPath).
|
||||
Msg("Loading index file")
|
||||
|
||||
indexFile, err := repo.LoadIndexFile(indexPath)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("index_path", indexPath).
|
||||
Err(err).
|
||||
Msg("Failed to load index file")
|
||||
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
|
||||
}
|
||||
return indexFile, nil
|
||||
}
|
||||
|
||||
// ensureHelmDirectoriesExist creates required Helm directories and configuration files.
|
||||
// Creates repository cache, config directories, and ensures repositories.yaml exists.
|
||||
// Essential for Helm operations to function properly.
|
||||
func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Msg("Ensuring Helm directories exist")
|
||||
|
||||
// List of directories to ensure exist
|
||||
directories := []string{
|
||||
filepath.Dir(settings.RepositoryConfig), // Repository config directory
|
||||
settings.RepositoryCache, // Repository cache directory
|
||||
filepath.Dir(settings.RegistryConfig), // Registry config directory
|
||||
settings.PluginsDirectory, // Plugins directory
|
||||
}
|
||||
|
||||
// Create each directory if it doesn't exist
|
||||
for _, dir := range directories {
|
||||
if dir == "" {
|
||||
continue // Skip empty paths
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("directory", dir).
|
||||
Err(err).
|
||||
Msg("Failed to create directory")
|
||||
return errors.Wrapf(err, "failed to create directory: %s", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure registry config file exists
|
||||
if settings.RegistryConfig != "" {
|
||||
if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
|
||||
// Create the directory if it doesn't exist
|
||||
dir := filepath.Dir(settings.RegistryConfig)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("directory", dir).
|
||||
Err(err).
|
||||
Msg("Failed to create directory")
|
||||
return errors.Wrapf(err, "failed to create directory: %s", dir)
|
||||
}
|
||||
|
||||
// Create an empty registry config file
|
||||
if _, err := os.Create(settings.RegistryConfig); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("file", settings.RegistryConfig).
|
||||
Err(err).
|
||||
Msg("Failed to create registry config file")
|
||||
return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure repository config file exists
|
||||
if settings.RepositoryConfig != "" {
|
||||
if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
|
||||
// Create an empty repository config file with default yaml structure
|
||||
f := repo.NewFile()
|
||||
if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("file", settings.RepositoryConfig).
|
||||
Err(err).
|
||||
Msg("Failed to create repository config file")
|
||||
return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Msg("Successfully ensured all Helm directories exist")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// appendChartReferenceAnnotations encodes chart reference values for safe storage in Helm labels.
|
||||
// It creates a new map with encoded values for specific chart reference labels.
|
||||
// Preserves existing labels and handles edge cases gracefully.
|
||||
func appendChartReferenceAnnotations(chartPath, repoURL string, registryID int, existingAnnotations map[string]string) map[string]string {
|
||||
// Copy existing annotations
|
||||
annotations := make(map[string]string)
|
||||
maps.Copy(annotations, existingAnnotations)
|
||||
|
||||
// delete the existing portainer specific labels, for a clean overwrite
|
||||
delete(annotations, ChartPathAnnotation)
|
||||
delete(annotations, RepoURLAnnotation)
|
||||
delete(annotations, RegistryIDAnnotation)
|
||||
|
||||
if chartPath != "" {
|
||||
annotations[ChartPathAnnotation] = chartPath
|
||||
}
|
||||
|
||||
if repoURL != "" && registryID == 0 {
|
||||
annotations[RepoURLAnnotation] = repoURL
|
||||
}
|
||||
|
||||
if registryID != 0 {
|
||||
annotations[RegistryIDAnnotation] = strconv.Itoa(registryID)
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// extractChartReferenceAnnotations decodes chart reference labels for display purposes.
|
||||
// It handles existing labels gracefully and only decodes known chart reference labels.
|
||||
// If a chart reference label cannot be decoded, it is omitted entirely from the result.
|
||||
// Returns a ChartReference struct with decoded values.
|
||||
func extractChartReferenceAnnotations(annotations map[string]string) release.ChartReference {
|
||||
if annotations == nil {
|
||||
return release.ChartReference{}
|
||||
}
|
||||
|
||||
registryID, err := strconv.Atoi(annotations[RegistryIDAnnotation])
|
||||
if err != nil {
|
||||
registryID = 0
|
||||
}
|
||||
|
||||
return release.ChartReference{
|
||||
ChartPath: annotations[ChartPathAnnotation],
|
||||
RepoURL: annotations[RepoURLAnnotation],
|
||||
RegistryID: int64(registryID),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,6 +97,7 @@ func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Rel
|
|||
AppVersion: sdkRelease.Chart.Metadata.AppVersion,
|
||||
},
|
||||
},
|
||||
Values: values,
|
||||
Values: values,
|
||||
ChartReference: extractChartReferenceAnnotations(sdkRelease.Chart.Metadata.Annotations),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/pkg/libhelm/cache"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
@ -42,6 +43,12 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
|
||||
}
|
||||
|
||||
// Setup chart source
|
||||
err = authenticateChartSource(actionConfig, installOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to setup chart source for helm release installation")
|
||||
}
|
||||
|
||||
installClient, err := initInstallClient(actionConfig, installOpts)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
|
@ -51,7 +58,7 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||
return nil, errors.Wrap(err, "failed to initialize helm install client for helm release installation")
|
||||
}
|
||||
|
||||
values, err := hspm.GetHelmValuesFromFile(installOpts.ValuesFile)
|
||||
values, err := hspm.getHelmValuesFromFile(installOpts.ValuesFile)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
|
@ -60,15 +67,36 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
|
||||
}
|
||||
|
||||
chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Version, installOpts.Repo, installClient.DependencyUpdate, "release installation")
|
||||
chartRef, repoURL, err := parseChartRef(installOpts.Chart, installOpts.Repo, installOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse chart reference for helm release installation")
|
||||
}
|
||||
chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, chartRef, installOpts.Version, repoURL, installClient.DependencyUpdate, "release installation")
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Err(err).
|
||||
Msg("Failed to load and validate chart for helm release installation")
|
||||
|
||||
// Check if this is an authentication error and flush cache if needed
|
||||
if installOpts.Registry != nil && shouldFlushCacheOnError(err, installOpts.Registry.ID) {
|
||||
cache.FlushRegistryByID(installOpts.Registry.ID)
|
||||
log.Info().
|
||||
Int("registry_id", int(installOpts.Registry.ID)).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Flushed registry cache due to chart loading authentication error during install")
|
||||
}
|
||||
|
||||
return nil, errors.Wrap(err, "failed to load and validate chart for helm release installation")
|
||||
}
|
||||
|
||||
// Add chart references to annotations
|
||||
var registryID int
|
||||
if installOpts.Registry != nil {
|
||||
registryID = int(installOpts.Registry.ID)
|
||||
}
|
||||
chart.Metadata.Annotations = appendChartReferenceAnnotations(installOpts.Chart, installOpts.Repo, registryID, chart.Metadata.Annotations)
|
||||
|
||||
// Run the installation
|
||||
log.Info().
|
||||
Str("context", "HelmClient").
|
||||
|
@ -76,7 +104,6 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||
Str("name", installOpts.Name).
|
||||
Str("namespace", installOpts.Namespace).
|
||||
Msg("Running chart installation for helm release")
|
||||
|
||||
helmRelease, err := installClient.Run(chart, values)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
|
@ -94,9 +121,10 @@ func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (
|
|||
Namespace: helmRelease.Namespace,
|
||||
Chart: release.Chart{
|
||||
Metadata: &release.Metadata{
|
||||
Name: helmRelease.Chart.Metadata.Name,
|
||||
Version: helmRelease.Chart.Metadata.Version,
|
||||
AppVersion: helmRelease.Chart.Metadata.AppVersion,
|
||||
Name: helmRelease.Chart.Metadata.Name,
|
||||
Version: helmRelease.Chart.Metadata.Version,
|
||||
AppVersion: helmRelease.Chart.Metadata.AppVersion,
|
||||
Annotations: helmRelease.Chart.Metadata.Annotations,
|
||||
},
|
||||
},
|
||||
Labels: helmRelease.Labels,
|
||||
|
@ -111,13 +139,17 @@ func initInstallClient(actionConfig *action.Configuration, installOpts options.I
|
|||
installClient := action.NewInstall(actionConfig)
|
||||
installClient.DependencyUpdate = true
|
||||
installClient.ReleaseName = installOpts.Name
|
||||
installClient.ChartPathOptions.RepoURL = installOpts.Repo
|
||||
installClient.Wait = installOpts.Wait
|
||||
installClient.Timeout = installOpts.Timeout
|
||||
installClient.Version = installOpts.Version
|
||||
err := configureChartPathOptions(&installClient.ChartPathOptions, installOpts.Version, installOpts.Repo, installOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to configure chart path options for helm release installation")
|
||||
}
|
||||
|
||||
// Set default values if not specified
|
||||
if installOpts.Timeout == 0 {
|
||||
installClient.Timeout = 5 * time.Minute
|
||||
installClient.Timeout = 15 * time.Minute // set a bigger timeout for large charts
|
||||
} else {
|
||||
installClient.Timeout = installOpts.Timeout
|
||||
}
|
||||
|
|
|
@ -1,24 +1,30 @@
|
|||
package sdk
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/liboras"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"helm.sh/helm/v3/pkg/chart"
|
||||
"helm.sh/helm/v3/pkg/cli"
|
||||
"helm.sh/helm/v3/pkg/getter"
|
||||
"helm.sh/helm/v3/pkg/repo"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
errRequiredSearchOptions = errors.New("repo is required")
|
||||
errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
|
||||
)
|
||||
|
||||
type RepoIndex struct {
|
||||
|
@ -40,7 +46,6 @@ var (
|
|||
|
||||
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
|
||||
func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
|
||||
// Validate input options
|
||||
if err := validateSearchRepoOptions(searchRepoOpts); err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
|
@ -55,33 +60,8 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
|
|||
Str("repo", searchRepoOpts.Repo).
|
||||
Msg("Searching repository")
|
||||
|
||||
// Parse and validate the repository URL
|
||||
repoURL, err := parseRepoURL(searchRepoOpts.Repo)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("repo", searchRepoOpts.Repo).
|
||||
Err(err).
|
||||
Msg("Invalid repository URL")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if searchRepoOpts.UseCache {
|
||||
cacheMutex.RLock()
|
||||
if cached, exists := indexCache[repoURL.String()]; exists {
|
||||
if time.Since(cached.Timestamp) < cacheDuration {
|
||||
cacheMutex.RUnlock()
|
||||
return convertAndMarshalIndex(cached.Index, searchRepoOpts.Chart)
|
||||
}
|
||||
}
|
||||
cacheMutex.RUnlock()
|
||||
}
|
||||
|
||||
// Set up Helm CLI environment
|
||||
repoSettings := cli.New()
|
||||
|
||||
// Ensure all required Helm directories exist
|
||||
if err := ensureHelmDirectoriesExist(repoSettings); err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
|
@ -90,7 +70,88 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
|
|||
return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
|
||||
}
|
||||
|
||||
repoName, err := getRepoNameFromURL(repoURL.String())
|
||||
// Try cache first for HTTP repos
|
||||
if IsHTTPRepository(searchRepoOpts.Registry) && searchRepoOpts.UseCache {
|
||||
if cachedResult := hspm.tryGetFromCache(searchRepoOpts.Repo, searchRepoOpts.Chart); cachedResult != nil {
|
||||
return cachedResult, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Download index based on source type
|
||||
indexFile, err := hspm.downloadRepoIndex(searchRepoOpts, repoSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update cache for HTTP repos
|
||||
if IsHTTPRepository(searchRepoOpts.Registry) {
|
||||
hspm.updateCache(searchRepoOpts.Repo, indexFile)
|
||||
}
|
||||
|
||||
return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
|
||||
}
|
||||
|
||||
// tryGetFromCache attempts to retrieve a cached index file and convert it to the response format
|
||||
func (hspm *HelmSDKPackageManager) tryGetFromCache(repoURL, chartName string) []byte {
|
||||
cacheMutex.RLock()
|
||||
defer cacheMutex.RUnlock()
|
||||
|
||||
if cached, exists := indexCache[repoURL]; exists {
|
||||
if time.Since(cached.Timestamp) < cacheDuration {
|
||||
result, err := convertAndMarshalIndex(cached.Index, chartName)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Str("repo", repoURL).
|
||||
Err(err).
|
||||
Msg("Failed to convert cached index")
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateCache updates the cache with the provided index file and cleans up expired entries
|
||||
func (hspm *HelmSDKPackageManager) updateCache(repoURL string, indexFile *repo.IndexFile) {
|
||||
cacheMutex.Lock()
|
||||
defer cacheMutex.Unlock()
|
||||
|
||||
indexCache[repoURL] = RepoIndexCache{
|
||||
Index: indexFile,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// Clean up expired entries
|
||||
for key, index := range indexCache {
|
||||
if time.Since(index.Timestamp) > cacheDuration {
|
||||
delete(indexCache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// downloadRepoIndex downloads the repository index based on the source type (HTTP or OCI)
|
||||
func (hspm *HelmSDKPackageManager) downloadRepoIndex(opts options.SearchRepoOptions, repoSettings *cli.EnvSettings) (*repo.IndexFile, error) {
|
||||
if IsOCIRegistry(opts.Registry) {
|
||||
return hspm.downloadOCIRepoIndex(opts.Registry, repoSettings, opts.Chart)
|
||||
}
|
||||
return hspm.downloadHTTPRepoIndex(opts.Repo, repoSettings)
|
||||
}
|
||||
|
||||
// downloadHTTPRepoIndex downloads and loads an index file from an HTTP repository
|
||||
func (hspm *HelmSDKPackageManager) downloadHTTPRepoIndex(repoURL string, repoSettings *cli.EnvSettings) (*repo.IndexFile, error) {
|
||||
parsedURL, err := parseRepoURL(repoURL)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("repo", repoURL).
|
||||
Err(err).
|
||||
Msg("Invalid repository URL")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repoName, err := getRepoNameFromURL(parsedURL.String())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
|
@ -99,70 +160,55 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Download the index file and update repository configuration
|
||||
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, repoName)
|
||||
indexPath, err := downloadRepoIndexFromHttpRepo(parsedURL.String(), repoSettings, repoName)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("repo_url", repoURL.String()).
|
||||
Str("repo_url", parsedURL.String()).
|
||||
Err(err).
|
||||
Msg("Failed to download repository index")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load and parse the index file
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Str("index_path", indexPath).
|
||||
Msg("Loading index file")
|
||||
return loadIndexFile(indexPath)
|
||||
}
|
||||
|
||||
indexFile, err := loadIndexFile(indexPath)
|
||||
// downloadOCIRepoIndex downloads and loads an index file from an OCI registry
|
||||
func (hspm *HelmSDKPackageManager) downloadOCIRepoIndex(registry *portainer.Registry, repoSettings *cli.EnvSettings, chartPath string) (*repo.IndexFile, error) {
|
||||
// Validate registry credentials first
|
||||
if err := validateRegistryCredentials(registry); err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("repo", registry.URL).
|
||||
Err(err).
|
||||
Msg("Registry credential validation failed for OCI search")
|
||||
return nil, fmt.Errorf("registry credential validation failed: %w", err)
|
||||
}
|
||||
|
||||
indexPath, err := downloadRepoIndexFromOciRegistry(registry, repoSettings, chartPath)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("index_path", indexPath).
|
||||
Str("repo", registry.URL).
|
||||
Err(err).
|
||||
Msg("Failed to load index file")
|
||||
Msg("Failed to download repository index")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update cache and remove old entries
|
||||
cacheMutex.Lock()
|
||||
indexCache[searchRepoOpts.Repo] = RepoIndexCache{
|
||||
Index: indexFile,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
for key, index := range indexCache {
|
||||
if time.Since(index.Timestamp) > cacheDuration {
|
||||
delete(indexCache, key)
|
||||
}
|
||||
}
|
||||
|
||||
cacheMutex.Unlock()
|
||||
|
||||
return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
|
||||
return loadIndexFile(indexPath)
|
||||
}
|
||||
|
||||
// validateSearchRepoOptions validates the required search repository options.
|
||||
func validateSearchRepoOptions(opts options.SearchRepoOptions) error {
|
||||
if opts.Repo == "" {
|
||||
if opts.Repo == "" && IsHTTPRepository(opts.Registry) {
|
||||
return errRequiredSearchOptions
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseRepoURL parses and validates the repository URL.
|
||||
func parseRepoURL(repoURL string) (*url.URL, error) {
|
||||
parsedURL, err := url.ParseRequestURI(repoURL)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
|
||||
}
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
// downloadRepoIndex downloads the index.yaml file from the repository and updates
|
||||
// downloadRepoIndexFromHttpRepo downloads the index.yaml file from the repository and updates
|
||||
// the repository configuration.
|
||||
func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
|
||||
func downloadRepoIndexFromHttpRepo(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_repo_index").
|
||||
Str("repo_url", repoURLString).
|
||||
|
@ -183,7 +229,7 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
|
|||
Str("repo_url", repoURLString).
|
||||
Err(err).
|
||||
Msg("Failed to create chart repository object")
|
||||
return "", errInvalidRepoURL
|
||||
return "", errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
|
||||
}
|
||||
|
||||
// Load repository configuration file
|
||||
|
@ -239,13 +285,168 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
|
|||
return indexPath, nil
|
||||
}
|
||||
|
||||
// loadIndexFile loads the index file from the given path.
|
||||
func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
|
||||
indexFile, err := repo.LoadIndexFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
|
||||
func downloadRepoIndexFromOciRegistry(registry *portainer.Registry, repoSettings *cli.EnvSettings, chartPath string) (string, error) {
|
||||
if IsHTTPRepository(registry) {
|
||||
return "", errors.New("registry information is required for OCI search")
|
||||
}
|
||||
return indexFile, nil
|
||||
|
||||
if chartPath == "" {
|
||||
return "", errors.New("chart path is required for OCI search")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
registryClient, err := liboras.CreateClient(*registry)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_repo_index_oci").
|
||||
Str("registry_url", registry.URL).
|
||||
Err(err).
|
||||
Msg("Failed to create ORAS registry client")
|
||||
return "", errors.Wrap(err, "failed to create ORAS registry client")
|
||||
}
|
||||
|
||||
// Obtain repository handle for the specific chart path (relative to registry host)
|
||||
repository, err := registryClient.Repository(ctx, chartPath)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_repo_index_oci").
|
||||
Str("repository", chartPath).
|
||||
Err(err).
|
||||
Msg("Failed to obtain repository handle")
|
||||
return "", errors.Wrap(err, "failed to obtain repository handle")
|
||||
}
|
||||
|
||||
// List all tags for this chart repository
|
||||
var tags []string
|
||||
err = repository.Tags(ctx, "", func(t []string) error {
|
||||
tags = append(tags, t...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_repo_index_oci").
|
||||
Str("repository", chartPath).
|
||||
Err(err).
|
||||
Msg("Failed to list tags")
|
||||
return "", errors.Wrap(err, "failed to list tags for repository")
|
||||
}
|
||||
|
||||
if len(tags) == 0 {
|
||||
return "", errors.Errorf("no tags found for repository %s", chartPath)
|
||||
}
|
||||
|
||||
// Build Helm index file in memory
|
||||
indexFile := repo.NewIndexFile()
|
||||
|
||||
const helmConfigMediaType = "application/vnd.cncf.helm.config.v1+json"
|
||||
|
||||
for _, tag := range tags {
|
||||
chartVersion, err := processOCITag(ctx, repository, registry, chartPath, tag, helmConfigMediaType)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_repo_index_oci").
|
||||
Str("repository", chartPath).
|
||||
Str("tag", tag).
|
||||
Err(err).
|
||||
Msg("Failed to process tag; skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
if chartVersion != nil {
|
||||
indexFile.Entries[chartVersion.Name] = append(indexFile.Entries[chartVersion.Name], chartVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if len(indexFile.Entries) == 0 {
|
||||
return "", errors.Errorf("no helm chart versions found for repository %s", chartPath)
|
||||
}
|
||||
|
||||
indexFile.SortEntries()
|
||||
|
||||
fileNameSafe := strings.ReplaceAll(chartPath, "/", "-")
|
||||
destPath := filepath.Join(repoSettings.RepositoryCache, fmt.Sprintf("%s-%d-index.yaml", fileNameSafe, time.Now().UnixNano()))
|
||||
|
||||
if err := indexFile.WriteFile(destPath, 0644); err != nil {
|
||||
return "", errors.Wrap(err, "failed to write OCI index file")
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_repo_index_oci").
|
||||
Str("dest_path", destPath).
|
||||
Int("entries", len(indexFile.Entries)).
|
||||
Msg("Successfully generated OCI index file")
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
// processOCITag processes a single OCI tag and returns a Helm chart version.
|
||||
func processOCITag(ctx context.Context, repository registry.Repository, registry *portainer.Registry, chartPath string, tag string, helmConfigMediaType string) (*repo.ChartVersion, error) {
|
||||
// Resolve tag to get descriptor
|
||||
descriptor, err := repository.Resolve(ctx, tag)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_repo_index_oci").
|
||||
Str("repository", chartPath).
|
||||
Str("tag", tag).
|
||||
Err(err).
|
||||
Msg("Failed to resolve tag; skipping")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fetch manifest to validate media type and obtain config descriptor
|
||||
manifestReader, err := repository.Manifests().Fetch(ctx, descriptor)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_repo_index_oci").
|
||||
Str("repository", chartPath).
|
||||
Str("tag", tag).
|
||||
Err(err).
|
||||
Msg("Failed to fetch manifest; skipping")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
manifestContent, err := io.ReadAll(manifestReader)
|
||||
manifestReader.Close()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(manifestContent, &manifest); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Ensure manifest config is Helm chart metadata
|
||||
if manifest.Config.MediaType != helmConfigMediaType {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Fetch config blob (chart metadata)
|
||||
cfgReader, err := repository.Blobs().Fetch(ctx, manifest.Config)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
cfgBytes, err := io.ReadAll(cfgReader)
|
||||
cfgReader.Close()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var metadata chart.Metadata
|
||||
if err := json.Unmarshal(cfgBytes, &metadata); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build chart version entry
|
||||
chartVersion := &repo.ChartVersion{
|
||||
Metadata: &metadata,
|
||||
URLs: []string{fmt.Sprintf("oci://%s/%s:%s", registry.URL, chartPath, tag)},
|
||||
Created: time.Now(),
|
||||
Digest: descriptor.Digest.String(),
|
||||
}
|
||||
|
||||
return chartVersion, nil
|
||||
}
|
||||
|
||||
// convertIndexToResponse converts the Helm index file to our response format.
|
||||
|
@ -258,7 +459,7 @@ func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIn
|
|||
|
||||
// Convert Helm SDK types to our response types
|
||||
for name, charts := range indexFile.Entries {
|
||||
if chartName == "" || name == chartName {
|
||||
if chartName == "" || strings.Contains(strings.ToLower(chartName), strings.ToLower(name)) {
|
||||
result.Entries[name] = convertChartsToChartInfo(charts)
|
||||
}
|
||||
}
|
||||
|
@ -304,87 +505,6 @@ type ChartInfo struct {
|
|||
Annotations any `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ensureHelmDirectoriesExist checks and creates required Helm directories if they don't exist
|
||||
func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Msg("Ensuring Helm directories exist")
|
||||
|
||||
// List of directories to ensure exist
|
||||
directories := []string{
|
||||
filepath.Dir(settings.RepositoryConfig), // Repository config directory
|
||||
settings.RepositoryCache, // Repository cache directory
|
||||
filepath.Dir(settings.RegistryConfig), // Registry config directory
|
||||
settings.PluginsDirectory, // Plugins directory
|
||||
}
|
||||
|
||||
// Create each directory if it doesn't exist
|
||||
for _, dir := range directories {
|
||||
if dir == "" {
|
||||
continue // Skip empty paths
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("directory", dir).
|
||||
Err(err).
|
||||
Msg("Failed to create directory")
|
||||
return errors.Wrapf(err, "failed to create directory: %s", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure registry config file exists
|
||||
if settings.RegistryConfig != "" {
|
||||
if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
|
||||
// Create the directory if it doesn't exist
|
||||
dir := filepath.Dir(settings.RegistryConfig)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("directory", dir).
|
||||
Err(err).
|
||||
Msg("Failed to create directory")
|
||||
return errors.Wrapf(err, "failed to create directory: %s", dir)
|
||||
}
|
||||
|
||||
// Create an empty registry config file
|
||||
if _, err := os.Create(settings.RegistryConfig); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("file", settings.RegistryConfig).
|
||||
Err(err).
|
||||
Msg("Failed to create registry config file")
|
||||
return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure repository config file exists
|
||||
if settings.RepositoryConfig != "" {
|
||||
if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
|
||||
// Create an empty repository config file with default yaml structure
|
||||
f := repo.NewFile()
|
||||
if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
|
||||
log.Error().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Str("file", settings.RepositoryConfig).
|
||||
Err(err).
|
||||
Msg("Failed to create repository config file")
|
||||
return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("context", "helm_sdk_dirs").
|
||||
Msg("Successfully ensured all Helm directories exist")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) {
|
||||
// Convert the index file to our response format
|
||||
result, err := convertIndexToResponse(indexFile, chartName)
|
||||
|
|
|
@ -2,21 +2,20 @@ package sdk
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/pkg/libhelm/cache"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/rs/zerolog/log"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
)
|
||||
|
||||
var errRequiredShowOptions = errors.New("chart, repo and output format are required")
|
||||
var errRequiredShowOptions = errors.New("chart, output format and either repo or registry are required")
|
||||
|
||||
// Show implements the HelmPackageManager interface by using the Helm SDK to show chart information.
|
||||
// It supports showing chart values, readme, and chart details based on the provided ShowOptions.
|
||||
func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
|
||||
if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
|
||||
if showOpts.Chart == "" || (showOpts.Repo == "" && IsHTTPRepository(showOpts.Registry)) || showOpts.OutputFormat == "" {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("chart", showOpts.Chart).
|
||||
|
@ -33,31 +32,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||
Str("output_format", string(showOpts.OutputFormat)).
|
||||
Msg("Showing chart information")
|
||||
|
||||
repoURL, err := parseRepoURL(showOpts.Repo)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("repo", showOpts.Repo).
|
||||
Err(err).
|
||||
Msg("Invalid repository URL")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repoName, err := getRepoNameFromURL(repoURL.String())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Err(err).
|
||||
Msg("Failed to get hostname from URL")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize action configuration (no namespace or cluster access needed)
|
||||
actionConfig := new(action.Configuration)
|
||||
err = hspm.initActionConfig(actionConfig, "", nil)
|
||||
err := authenticateChartSource(actionConfig, showOpts.Registry)
|
||||
if err != nil {
|
||||
// error is already logged in initActionConfig
|
||||
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
|
||||
return nil, fmt.Errorf("failed to setup chart source: %w", err)
|
||||
}
|
||||
|
||||
// Create showClient action
|
||||
|
@ -70,22 +48,28 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||
return nil, fmt.Errorf("failed to initialize helm show client: %w", err)
|
||||
}
|
||||
|
||||
// Locate and load the chart
|
||||
log.Debug().
|
||||
Str("context", "HelmClient").
|
||||
Str("chart", showOpts.Chart).
|
||||
Str("repo", showOpts.Repo).
|
||||
Msg("Locating chart")
|
||||
|
||||
fullChartPath := fmt.Sprintf("%s/%s", repoName, showOpts.Chart)
|
||||
chartPath, err := showClient.ChartPathOptions.LocateChart(fullChartPath, hspm.settings)
|
||||
chartRef, _, err := parseChartRef(showOpts.Chart, showOpts.Repo, showOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse chart reference: %w", err)
|
||||
}
|
||||
chartPath, err := showClient.ChartPathOptions.LocateChart(chartRef, hspm.settings)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Str("chart", fullChartPath).
|
||||
Str("chart", chartRef).
|
||||
Str("repo", showOpts.Repo).
|
||||
Err(err).
|
||||
Msg("Failed to locate chart")
|
||||
|
||||
// Check if this is an authentication error and flush cache if needed
|
||||
if showOpts.Registry != nil && shouldFlushCacheOnError(err, showOpts.Registry.ID) {
|
||||
cache.FlushRegistryByID(showOpts.Registry.ID)
|
||||
log.Info().
|
||||
Int("registry_id", int(showOpts.Registry.ID)).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Flushed registry cache due to chart registry authentication error")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to locate chart: %w", err)
|
||||
}
|
||||
|
||||
|
@ -98,6 +82,16 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||
Str("output_format", string(showOpts.OutputFormat)).
|
||||
Err(err).
|
||||
Msg("Failed to show chart info")
|
||||
|
||||
// Check if this is an authentication error and flush cache if needed
|
||||
if showOpts.Registry != nil && shouldFlushCacheOnError(err, showOpts.Registry.ID) {
|
||||
cache.FlushRegistryByID(showOpts.Registry.ID)
|
||||
log.Info().
|
||||
Int("registry_id", int(showOpts.Registry.ID)).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Flushed registry cache due to chart show authentication error")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to show chart info: %w", err)
|
||||
}
|
||||
|
||||
|
@ -114,7 +108,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
|
|||
// and return the show client.
|
||||
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
|
||||
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
|
||||
showClient.ChartPathOptions.Version = showOpts.Version
|
||||
err := configureChartPathOptions(&showClient.ChartPathOptions, showOpts.Version, showOpts.Repo, showOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to configure chart path options: %w", err)
|
||||
}
|
||||
|
||||
// Set output type based on ShowOptions
|
||||
switch showOpts.OutputFormat {
|
||||
|
@ -134,26 +131,3 @@ func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOpt
|
|||
|
||||
return showClient, nil
|
||||
}
|
||||
|
||||
// getRepoNameFromURL extracts a unique repository identifier from a URL string.
|
||||
// It combines hostname and path to ensure uniqueness across different repositories on the same host.
|
||||
// Examples:
|
||||
// - https://portainer.github.io/test-public-repo/ -> portainer.github.io-test-public-repo
|
||||
// - https://portainer.github.io/another-repo/ -> portainer.github.io-another-repo
|
||||
// - https://charts.helm.sh/stable -> charts.helm.sh-stable
|
||||
func getRepoNameFromURL(urlStr string) (string, error) {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := parsedURL.Hostname()
|
||||
path := parsedURL.Path
|
||||
path = strings.Trim(path, "/")
|
||||
path = strings.ReplaceAll(path, "/", "-")
|
||||
|
||||
if path == "" {
|
||||
return hostname, nil
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", hostname, path), nil
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ func Test_Show(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
t.Run("show requires chart, repo and output format", func(t *testing.T) {
|
||||
t.Run("show requires chart, output format and repo or registry", func(t *testing.T) {
|
||||
showOpts := options.ShowOptions{
|
||||
Chart: "",
|
||||
Repo: "",
|
||||
|
@ -36,7 +36,7 @@ func Test_Show(t *testing.T) {
|
|||
}
|
||||
_, err := hspm.Show(showOpts)
|
||||
is.Error(err, "should return error when required options are missing")
|
||||
is.Contains(err.Error(), "chart, repo and output format are required", "error message should indicate required options")
|
||||
is.Contains(err.Error(), "chart, output format and either repo or registry are required", "error message should indicate required options")
|
||||
})
|
||||
|
||||
t.Run("show chart values", func(t *testing.T) {
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/pkg/libhelm/cache"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
@ -66,6 +67,12 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
|
|||
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
|
||||
}
|
||||
|
||||
// Setup chart source
|
||||
err = authenticateChartSource(actionConfig, upgradeOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to setup chart source for helm release upgrade")
|
||||
}
|
||||
|
||||
upgradeClient, err := initUpgradeClient(actionConfig, upgradeOpts)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
|
@ -75,7 +82,7 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
|
|||
return nil, errors.Wrap(err, "failed to initialize helm upgrade client for helm release upgrade")
|
||||
}
|
||||
|
||||
values, err := hspm.GetHelmValuesFromFile(upgradeOpts.ValuesFile)
|
||||
values, err := hspm.getHelmValuesFromFile(upgradeOpts.ValuesFile)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
|
@ -84,15 +91,36 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
|
|||
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release upgrade")
|
||||
}
|
||||
|
||||
chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Chart, upgradeOpts.Version, upgradeOpts.Repo, upgradeClient.DependencyUpdate, "release upgrade")
|
||||
chartRef, repoURL, err := parseChartRef(upgradeOpts.Chart, upgradeOpts.Repo, upgradeOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse chart reference for helm release upgrade")
|
||||
}
|
||||
chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, chartRef, upgradeOpts.Version, repoURL, upgradeClient.DependencyUpdate, "release upgrade")
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "HelmClient").
|
||||
Err(err).
|
||||
Msg("Failed to load and validate chart for helm release upgrade")
|
||||
|
||||
// Check if this is an authentication error and flush cache if needed
|
||||
if upgradeOpts.Registry != nil && shouldFlushCacheOnError(err, upgradeOpts.Registry.ID) {
|
||||
cache.FlushRegistryByID(upgradeOpts.Registry.ID)
|
||||
log.Info().
|
||||
Int("registry_id", int(upgradeOpts.Registry.ID)).
|
||||
Str("context", "HelmClient").
|
||||
Msg("Flushed registry cache due to chart loading authentication error during upgrade")
|
||||
}
|
||||
|
||||
return nil, errors.Wrap(err, "failed to load and validate chart for helm release upgrade")
|
||||
}
|
||||
|
||||
// Add chart references to annotations
|
||||
var registryID int
|
||||
if upgradeOpts.Registry != nil {
|
||||
registryID = int(upgradeOpts.Registry.ID)
|
||||
}
|
||||
chart.Metadata.Annotations = appendChartReferenceAnnotations(upgradeOpts.Chart, upgradeOpts.Repo, registryID, chart.Metadata.Annotations)
|
||||
|
||||
log.Info().
|
||||
Str("context", "HelmClient").
|
||||
Str("chart", upgradeOpts.Chart).
|
||||
|
@ -117,9 +145,10 @@ func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
|
|||
Namespace: helmRelease.Namespace,
|
||||
Chart: release.Chart{
|
||||
Metadata: &release.Metadata{
|
||||
Name: helmRelease.Chart.Metadata.Name,
|
||||
Version: helmRelease.Chart.Metadata.Version,
|
||||
AppVersion: helmRelease.Chart.Metadata.AppVersion,
|
||||
Name: helmRelease.Chart.Metadata.Name,
|
||||
Version: helmRelease.Chart.Metadata.Version,
|
||||
AppVersion: helmRelease.Chart.Metadata.AppVersion,
|
||||
Annotations: helmRelease.Chart.Metadata.Annotations,
|
||||
},
|
||||
},
|
||||
Labels: helmRelease.Labels,
|
||||
|
@ -134,12 +163,20 @@ func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.I
|
|||
upgradeClient := action.NewUpgrade(actionConfig)
|
||||
upgradeClient.DependencyUpdate = true
|
||||
upgradeClient.Atomic = upgradeOpts.Atomic
|
||||
upgradeClient.ChartPathOptions.RepoURL = upgradeOpts.Repo
|
||||
upgradeClient.Wait = upgradeOpts.Wait
|
||||
upgradeClient.Version = upgradeOpts.Version
|
||||
err := configureChartPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Version, upgradeOpts.Repo, upgradeOpts.Registry)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to configure chart path options for helm release upgrade")
|
||||
}
|
||||
|
||||
// Set default values if not specified
|
||||
if upgradeOpts.Timeout == 0 {
|
||||
upgradeClient.Timeout = 5 * time.Minute
|
||||
if upgradeClient.Atomic {
|
||||
upgradeClient.Timeout = 30 * time.Minute // the atomic flag significantly increases the upgrade time
|
||||
} else {
|
||||
upgradeClient.Timeout = 15 * time.Minute
|
||||
}
|
||||
} else {
|
||||
upgradeClient.Timeout = upgradeOpts.Timeout
|
||||
}
|
||||
|
|
|
@ -11,9 +11,9 @@ import (
|
|||
"helm.sh/helm/v3/pkg/action"
|
||||
)
|
||||
|
||||
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
|
||||
// getHelmValuesFromFile reads the values file and parses it into a map[string]any
|
||||
// and returns the map.
|
||||
func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map[string]any, error) {
|
||||
func (hspm *HelmSDKPackageManager) getHelmValuesFromFile(valuesFile string) (map[string]any, error) {
|
||||
var vals map[string]any
|
||||
if valuesFile != "" {
|
||||
log.Debug().
|
||||
|
|
47
pkg/liboras/generic_listrepo_client.go
Normal file
47
pkg/liboras/generic_listrepo_client.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package liboras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
)
|
||||
|
||||
// GenericListRepoClient implements RepositoryListClient for standard OCI registries
|
||||
// This client handles repository listing for registries that follow the standard OCI distribution spec
|
||||
type GenericListRepoClient struct {
|
||||
registry *portainer.Registry
|
||||
registryClient *remote.Registry
|
||||
}
|
||||
|
||||
// NewGenericListRepoClient creates a new generic repository listing client
|
||||
func NewGenericListRepoClient(registry *portainer.Registry) *GenericListRepoClient {
|
||||
return &GenericListRepoClient{
|
||||
registry: registry,
|
||||
// registryClient will be set when needed
|
||||
}
|
||||
}
|
||||
|
||||
// SetRegistryClient sets the ORAS registry client for repository listing operations
|
||||
func (c *GenericListRepoClient) SetRegistryClient(registryClient *remote.Registry) {
|
||||
c.registryClient = registryClient
|
||||
}
|
||||
|
||||
// ListRepositories fetches repositories from a standard OCI registry using ORAS
|
||||
func (c *GenericListRepoClient) ListRepositories(ctx context.Context) ([]string, error) {
|
||||
if c.registryClient == nil {
|
||||
return nil, errors.New("registry client not initialized for repository listing")
|
||||
}
|
||||
|
||||
var repositories []string
|
||||
err := c.registryClient.Repositories(ctx, "", func(repos []string) error {
|
||||
repositories = append(repositories, repos...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to list repositories")
|
||||
}
|
||||
|
||||
return repositories, nil
|
||||
}
|
57
pkg/liboras/github_listrepo_client.go
Normal file
57
pkg/liboras/github_listrepo_client.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package liboras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/github"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// GithubListRepoClient implements RepositoryListClient specifically for GitHub registries
|
||||
// This client handles the GitHub Packages API's unique repository listing implementation
|
||||
type GithubListRepoClient struct {
|
||||
registry *portainer.Registry
|
||||
client *github.Client
|
||||
}
|
||||
|
||||
// NewGithubListRepoClient creates a new GitHub repository listing client
|
||||
func NewGithubListRepoClient(registry *portainer.Registry) *GithubListRepoClient {
|
||||
// Prefer the management configuration credentials when available
|
||||
token := registry.Password
|
||||
if registry.ManagementConfiguration != nil && registry.ManagementConfiguration.Password != "" {
|
||||
token = registry.ManagementConfiguration.Password
|
||||
}
|
||||
|
||||
client := github.NewClient(token)
|
||||
|
||||
return &GithubListRepoClient{
|
||||
registry: registry,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// ListRepositories fetches repositories from a GitHub registry using the GitHub Packages API
|
||||
func (c *GithubListRepoClient) ListRepositories(ctx context.Context) ([]string, error) {
|
||||
repositories, err := c.client.GetContainerPackages(
|
||||
ctx,
|
||||
c.registry.Github.UseOrganisation,
|
||||
c.registry.Github.OrganisationName,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("registry_name", c.registry.Name).
|
||||
Err(err).
|
||||
Msg("Failed to list GitHub repositories")
|
||||
return nil, fmt.Errorf("failed to list GitHub repositories: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Bool("use_organisation", c.registry.Github.UseOrganisation).
|
||||
Str("organisation_name", c.registry.Github.OrganisationName).
|
||||
Int("repository_count", len(repositories)).
|
||||
Msg("Successfully listed GitHub repositories")
|
||||
|
||||
return repositories, nil
|
||||
}
|
47
pkg/liboras/gitlab_listrepo_client.go
Normal file
47
pkg/liboras/gitlab_listrepo_client.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package liboras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/gitlab"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// GitlabListRepoClient implements RepositoryListClient specifically for GitLab registries
|
||||
// This client handles the GitLab Container Registry API's unique repository listing implementation
|
||||
type GitlabListRepoClient struct {
|
||||
registry *portainer.Registry
|
||||
client *gitlab.Client
|
||||
}
|
||||
|
||||
// NewGitlabListRepoClient creates a new GitLab repository listing client
|
||||
func NewGitlabListRepoClient(registry *portainer.Registry) *GitlabListRepoClient {
|
||||
client := gitlab.NewClient(registry.Gitlab.InstanceURL, registry.Password)
|
||||
|
||||
return &GitlabListRepoClient{
|
||||
registry: registry,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// ListRepositories fetches repositories from a GitLab registry using the GitLab API
|
||||
func (c *GitlabListRepoClient) ListRepositories(ctx context.Context) ([]string, error) {
|
||||
repositories, err := c.client.GetRegistryRepositoryNames(ctx, c.registry.Gitlab.ProjectID)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("registry_name", c.registry.Name).
|
||||
Err(err).
|
||||
Msg("Failed to list GitLab repositories")
|
||||
return nil, fmt.Errorf("failed to list GitLab repositories: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("gitlab_url", c.registry.Gitlab.InstanceURL).
|
||||
Int("project_id", c.registry.Gitlab.ProjectID).
|
||||
Int("repository_count", len(repositories)).
|
||||
Msg("Successfully listed GitLab repositories")
|
||||
|
||||
return repositories, nil
|
||||
}
|
39
pkg/liboras/listrepo_client.go
Normal file
39
pkg/liboras/listrepo_client.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package liboras
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
)
|
||||
|
||||
// RepositoryListClient provides an interface specifically for listing repositories
|
||||
// This exists because listing repositories isn't a standard OCI operation, and we need to handle
|
||||
// different registry types differently.
|
||||
type RepositoryListClient interface {
|
||||
// ListRepositories returns a list of repository names from the registry
|
||||
ListRepositories(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// RepositoryListClientFactory creates repository listing clients based on registry type
|
||||
type RepositoryListClientFactory struct{}
|
||||
|
||||
// NewRepositoryListClientFactory creates a new factory instance
|
||||
func NewRepositoryListClientFactory() *RepositoryListClientFactory {
|
||||
return &RepositoryListClientFactory{}
|
||||
}
|
||||
|
||||
// CreateListClientWithRegistry creates a repository listing client based on the registry type
|
||||
// and automatically configures it with the provided ORAS registry client for generic registries
|
||||
func (f *RepositoryListClientFactory) CreateListClientWithRegistry(registry *portainer.Registry, registryClient *remote.Registry) (RepositoryListClient, error) {
|
||||
switch registry.Type {
|
||||
case portainer.GitlabRegistry:
|
||||
return NewGitlabListRepoClient(registry), nil
|
||||
case portainer.GithubRegistry:
|
||||
return NewGithubListRepoClient(registry), nil
|
||||
default:
|
||||
genericClient := NewGenericListRepoClient(registry)
|
||||
genericClient.SetRegistryClient(registryClient)
|
||||
return genericClient, nil
|
||||
}
|
||||
}
|
79
pkg/liboras/registry.go
Normal file
79
pkg/liboras/registry.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
package liboras
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/rs/zerolog/log"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
|
||||
registryClient, err := remote.NewRegistry(registry.URL)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("registryUrl", registry.URL).Msg("Failed to create registry client")
|
||||
return nil, err
|
||||
}
|
||||
// By default, oras sends multiple requests to get the full list of repos/tags/referrers.
|
||||
// set a high page size limit for fewer round trips.
|
||||
// e.g. https://github.com/oras-project/oras-go/blob/v2.6.0/registry/remote/registry.go#L129-L142
|
||||
registryClient.RepositoryListPageSize = 1000
|
||||
registryClient.TagListPageSize = 1000
|
||||
registryClient.ReferrerListPageSize = 1000
|
||||
|
||||
// Only apply authentication if explicitly enabled AND credentials are provided
|
||||
if registry.Authentication &&
|
||||
strings.TrimSpace(registry.Username) != "" &&
|
||||
strings.TrimSpace(registry.Password) != "" {
|
||||
|
||||
registryClient.Client = &auth.Client{
|
||||
Client: retry.DefaultClient,
|
||||
Cache: auth.NewCache(),
|
||||
Credential: auth.StaticCredential(registry.URL, auth.Credential{
|
||||
Username: registry.Username,
|
||||
Password: registry.Password,
|
||||
}),
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("registryURL", registry.URL).
|
||||
Str("registryType", getRegistryTypeName(registry.Type)).
|
||||
Bool("authentication", true).
|
||||
Msg("Created ORAS registry client with authentication")
|
||||
} else {
|
||||
// Use default client for anonymous access
|
||||
registryClient.Client = retry.DefaultClient
|
||||
|
||||
log.Debug().
|
||||
Str("registryURL", registry.URL).
|
||||
Str("registryType", getRegistryTypeName(registry.Type)).
|
||||
Bool("authentication", false).
|
||||
Msg("Created ORAS registry client for anonymous access")
|
||||
}
|
||||
|
||||
return registryClient, nil
|
||||
}
|
||||
|
||||
// getRegistryTypeName returns a human-readable name for the registry type
|
||||
func getRegistryTypeName(registryType portainer.RegistryType) string {
|
||||
switch registryType {
|
||||
case portainer.QuayRegistry:
|
||||
return "Quay"
|
||||
case portainer.AzureRegistry:
|
||||
return "Azure"
|
||||
case portainer.CustomRegistry:
|
||||
return "Custom"
|
||||
case portainer.GitlabRegistry:
|
||||
return "GitLab"
|
||||
case portainer.ProGetRegistry:
|
||||
return "ProGet"
|
||||
case portainer.DockerHubRegistry:
|
||||
return "DockerHub"
|
||||
case portainer.EcrRegistry:
|
||||
return "ECR"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
252
pkg/liboras/registry_test.go
Normal file
252
pkg/liboras/registry_test.go
Normal file
|
@ -0,0 +1,252 @@
|
|||
package liboras
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
func TestCreateClient_AuthenticationScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registry portainer.Registry
|
||||
expectAuthenticated bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "authentication disabled should create anonymous client",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: false,
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
},
|
||||
expectAuthenticated: false,
|
||||
description: "Even with credentials present, authentication=false should result in anonymous access",
|
||||
},
|
||||
{
|
||||
name: "authentication enabled with valid credentials should create authenticated client",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: true,
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
},
|
||||
expectAuthenticated: true,
|
||||
description: "Valid credentials with authentication=true should result in authenticated access",
|
||||
},
|
||||
{
|
||||
name: "authentication enabled with empty username should create anonymous client",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: true,
|
||||
Username: "",
|
||||
Password: "testpass",
|
||||
},
|
||||
expectAuthenticated: false,
|
||||
description: "Empty username should fallback to anonymous access",
|
||||
},
|
||||
{
|
||||
name: "authentication enabled with whitespace-only username should create anonymous client",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: true,
|
||||
Username: " ",
|
||||
Password: "testpass",
|
||||
},
|
||||
expectAuthenticated: false,
|
||||
description: "Whitespace-only username should fallback to anonymous access",
|
||||
},
|
||||
{
|
||||
name: "authentication enabled with empty password should create anonymous client",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: true,
|
||||
Username: "testuser",
|
||||
Password: "",
|
||||
},
|
||||
expectAuthenticated: false,
|
||||
description: "Empty password should fallback to anonymous access",
|
||||
},
|
||||
{
|
||||
name: "authentication enabled with whitespace-only password should create anonymous client",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: true,
|
||||
Username: "testuser",
|
||||
Password: " ",
|
||||
},
|
||||
expectAuthenticated: false,
|
||||
description: "Whitespace-only password should fallback to anonymous access",
|
||||
},
|
||||
{
|
||||
name: "authentication enabled with both credentials empty should create anonymous client",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: true,
|
||||
Username: "",
|
||||
Password: "",
|
||||
},
|
||||
expectAuthenticated: false,
|
||||
description: "Both credentials empty should fallback to anonymous access",
|
||||
},
|
||||
{
|
||||
name: "public registry with no authentication should create anonymous client",
|
||||
registry: portainer.Registry{
|
||||
URL: "docker.io",
|
||||
Authentication: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
},
|
||||
expectAuthenticated: false,
|
||||
description: "Public registries without authentication should use anonymous access",
|
||||
},
|
||||
{
|
||||
name: "GitLab registry with valid credentials should create authenticated client",
|
||||
registry: portainer.Registry{
|
||||
Type: portainer.GitlabRegistry,
|
||||
URL: "registry.gitlab.com",
|
||||
Authentication: true,
|
||||
Username: "gitlab-ci-token",
|
||||
Password: "glpat-xxxxxxxxxxxxxxxxxxxx",
|
||||
Gitlab: portainer.GitlabRegistryData{
|
||||
ProjectID: 12345,
|
||||
InstanceURL: "https://gitlab.com",
|
||||
ProjectPath: "my-group/my-project",
|
||||
},
|
||||
},
|
||||
expectAuthenticated: true,
|
||||
description: "GitLab registry with valid credentials should result in authenticated access",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, err := CreateClient(tt.registry)
|
||||
|
||||
assert.NoError(t, err, "CreateClient should not return an error")
|
||||
assert.NotNil(t, client, "Client should not be nil")
|
||||
|
||||
// Check if the client has authentication configured
|
||||
if tt.expectAuthenticated {
|
||||
// Should have auth.Client with credentials
|
||||
authClient, ok := client.Client.(*auth.Client)
|
||||
assert.True(t, ok, "Expected auth.Client for authenticated access")
|
||||
assert.NotNil(t, authClient, "Auth client should not be nil")
|
||||
assert.NotNil(t, authClient.Credential, "Credential function should be set")
|
||||
} else {
|
||||
// Should use retry.DefaultClient (no authentication)
|
||||
assert.Equal(t, retry.DefaultClient, client.Client,
|
||||
"Expected retry.DefaultClient for anonymous access")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClient_RegistryTypes(t *testing.T) {
|
||||
registryTypes := []struct {
|
||||
name string
|
||||
registryType portainer.RegistryType
|
||||
expectedName string
|
||||
}{
|
||||
{"DockerHub", portainer.DockerHubRegistry, "DockerHub"},
|
||||
{"Azure", portainer.AzureRegistry, "Azure"},
|
||||
{"Custom", portainer.CustomRegistry, "Custom"},
|
||||
{"GitLab", portainer.GitlabRegistry, "GitLab"},
|
||||
{"Quay", portainer.QuayRegistry, "Quay"},
|
||||
{"ProGet", portainer.ProGetRegistry, "ProGet"},
|
||||
{"ECR", portainer.EcrRegistry, "ECR"},
|
||||
}
|
||||
|
||||
for _, rt := range registryTypes {
|
||||
t.Run(rt.name, func(t *testing.T) {
|
||||
registry := portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Type: rt.registryType,
|
||||
Authentication: false,
|
||||
}
|
||||
|
||||
client, err := CreateClient(registry)
|
||||
|
||||
assert.NoError(t, err, "CreateClient should not return an error")
|
||||
assert.NotNil(t, client, "Client should not be nil")
|
||||
|
||||
// Verify that getRegistryTypeName returns the expected name
|
||||
typeName := getRegistryTypeName(rt.registryType)
|
||||
assert.Equal(t, rt.expectedName, typeName, "Registry type name mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRegistryTypeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
registryType portainer.RegistryType
|
||||
expectedName string
|
||||
}{
|
||||
{portainer.QuayRegistry, "Quay"},
|
||||
{portainer.AzureRegistry, "Azure"},
|
||||
{portainer.CustomRegistry, "Custom"},
|
||||
{portainer.GitlabRegistry, "GitLab"},
|
||||
{portainer.ProGetRegistry, "ProGet"},
|
||||
{portainer.DockerHubRegistry, "DockerHub"},
|
||||
{portainer.EcrRegistry, "ECR"},
|
||||
{portainer.RegistryType(999), "Unknown"}, // Unknown type
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expectedName, func(t *testing.T) {
|
||||
result := getRegistryTypeName(tt.registryType)
|
||||
assert.Equal(t, tt.expectedName, result, "Registry type name mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateClient_ErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registry portainer.Registry
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid registry URL should not error",
|
||||
registry: portainer.Registry{
|
||||
URL: "registry.example.com",
|
||||
Authentication: false,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty registry URL should error",
|
||||
registry: portainer.Registry{
|
||||
URL: "",
|
||||
Authentication: false,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid registry URL should error",
|
||||
registry: portainer.Registry{
|
||||
URL: "://invalid-url",
|
||||
Authentication: false,
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client, err := CreateClient(tt.registry)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err, "Expected an error but got none")
|
||||
assert.Nil(t, client, "Client should be nil when error occurs")
|
||||
} else {
|
||||
assert.NoError(t, err, "Expected no error but got: %v", err)
|
||||
assert.NotNil(t, client, "Client should not be nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
126
pkg/liboras/repository.go
Normal file
126
pkg/liboras/repository.go
Normal file
|
@ -0,0 +1,126 @@
|
|||
package liboras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/concurrent"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"golang.org/x/mod/semver"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
)
|
||||
|
||||
// ListRepositories retrieves all repositories from a registry using specialized repository listing clients
|
||||
// Each registry type has different repository listing implementations that require specific API calls
|
||||
func ListRepositories(ctx context.Context, registry *portainer.Registry, registryClient *remote.Registry) ([]string, error) {
|
||||
factory := NewRepositoryListClientFactory()
|
||||
listClient, err := factory.CreateListClientWithRegistry(registry, registryClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create repository list client: %w", err)
|
||||
}
|
||||
|
||||
return listClient.ListRepositories(ctx)
|
||||
}
|
||||
|
||||
// FilterRepositoriesByMediaType filters repositories to only include those with the expected media type
|
||||
func FilterRepositoriesByMediaType(ctx context.Context, repositoryNames []string, registryClient *remote.Registry, expectedMediaType string) ([]string, error) {
|
||||
// Run concurrently as this can take 10s+ to complete in serial
|
||||
var tasks []concurrent.Func
|
||||
for _, repoName := range repositoryNames {
|
||||
name := repoName
|
||||
task := func(ctx context.Context) (any, error) {
|
||||
repository, err := registryClient.Repository(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if HasMediaType(ctx, repository, expectedMediaType) {
|
||||
return name, nil
|
||||
}
|
||||
return nil, nil // not a repository with the expected media type
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
// 10 is a reasonable max concurrency limit
|
||||
results, err := concurrent.Run(ctx, 10, tasks...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect repository names
|
||||
var repositories []string
|
||||
for _, result := range results {
|
||||
if result.Result != nil {
|
||||
if repoName, ok := result.Result.(string); ok {
|
||||
repositories = append(repositories, repoName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
// HasMediaType checks if a repository has artifacts with the specified media type
|
||||
func HasMediaType(ctx context.Context, repository registry.Repository, expectedMediaType string) bool {
|
||||
// Check the first available tag
|
||||
// Reasonable limitation - it won't work for repos where the latest tag is missing the expected media type but other tags have it
|
||||
// This tradeoff is worth it for the performance benefits
|
||||
var latestTag string
|
||||
err := repository.Tags(ctx, "", func(tagList []string) error {
|
||||
if len(tagList) > 0 {
|
||||
// Order the taglist by latest semver, then get the latest tag
|
||||
// e.g. ["1.0", "1.1"] -> ["1.1", "1.0"] -> "1.1"
|
||||
sort.Slice(tagList, func(i, j int) bool {
|
||||
return semver.Compare(tagList[i], tagList[j]) > 0
|
||||
})
|
||||
latestTag = tagList[0]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if latestTag == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
descriptor, err := repository.Resolve(ctx, latestTag)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return descriptorHasMediaType(ctx, repository, descriptor, expectedMediaType)
|
||||
}
|
||||
|
||||
// descriptorHasMediaType checks if a descriptor or its manifest contains the expected media type
|
||||
func descriptorHasMediaType(ctx context.Context, repository registry.Repository, descriptor ocispec.Descriptor, expectedMediaType string) bool {
|
||||
// Check if the descriptor indicates the expected media type
|
||||
if descriptor.MediaType == expectedMediaType {
|
||||
return true
|
||||
}
|
||||
|
||||
// Otherwise, look for the expected media type in the entire manifest content
|
||||
manifestReader, err := repository.Manifests().Fetch(ctx, descriptor)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer manifestReader.Close()
|
||||
|
||||
content, err := io.ReadAll(manifestReader)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(content, &manifest); err != nil {
|
||||
return false
|
||||
}
|
||||
return manifest.Config.MediaType == expectedMediaType
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue