Compare commits

...

14 Commits

Author SHA1 Message Date
Steven Kang
0d8802c6d1 chore: version bump 2.33.0 (#1065) 2025-08-20 11:44:52 +12:00
James Player
5d3708ec3e fix(UI): add experimental features back in [r8s-483] (#1060) 2025-08-19 17:07:27 +12:00
Steven Kang
9320fd4c50 fix: cve-2025-55198 and cve-2025-55199 - release 2.33 [R8S-482] (#1058) 2025-08-19 16:22:54 +12:00
Steven Kang
974682bd98 chore: version bump to 2.33.0-rc2 (#1054) 2025-08-19 11:04:56 +12:00
Ali
631f1deb2e fix(helm): support http and custom tls helm registries, give help when misconfigured [r8s-472] (#1032)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-08-18 12:07:41 +12:00
LP B
4169b045fb fix(api/edge-stacks): avoid overriding updates with old values (#1048) 2025-08-16 03:52:21 +02:00
andres-portainer
0a2a786aa3 fix(migrator): rewrite a migration so it is idempotent BE-12053 (#1043) 2025-08-15 09:18:31 -03:00
James Player
808f87206e fix(ui): Fixed react-select TooManyResultsSelector filter and improved scrolling (#1028) 2025-08-15 15:33:43 +12:00
Cara Ryan
ed6fa82904 fix(pending-actions): Small improvements to pending actions (R8S-350) (#1025) 2025-08-15 10:51:45 +12:00
andres-portainer
9fc301110b fix(crypto): replace fips140 calls with fips calls BE-11979 (#1035) 2025-08-14 19:36:05 -03:00
andres-portainer
69101ac89a feat(openai): remove OpenAI BE-12018 (#1034) 2025-08-14 19:35:43 -03:00
Malcolm Lockyer
69d33dd432 fix(fips): use standard lib pbkdf2 [be-12164] (#1037) 2025-08-15 09:45:49 +12:00
Ali
389cbf748c fix(logs): improve log rendering performance [r8s-437] (#1023)
Merging because the same tests are failing in CE develop https://github.com/portainer/system-tests/actions/runs/16953578581
2025-08-14 13:53:35 +12:00
LP B
d01b31f707 feat(api): Permissions-Policy header deny all (#1022) 2025-08-13 22:07:52 +02:00
48 changed files with 1381 additions and 239 deletions

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"errors"
@@ -15,7 +16,6 @@ import (
"github.com/portainer/portainer/pkg/fips"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/pbkdf2"
"golang.org/x/crypto/scrypt"
)
@@ -248,7 +248,10 @@ func aesEncryptGCMFIPS(input io.Reader, output io.Writer, passphrase []byte) err
return err
}
key := pbkdf2.Key(passphrase, salt, pbkdf2Iterations, 32, sha256.New)
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
if err != nil {
return fmt.Errorf("error deriving key: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
@@ -315,7 +318,10 @@ func aesDecryptGCMFIPS(input io.Reader, passphrase []byte) (io.Reader, error) {
return nil, err
}
key := pbkdf2.Key(passphrase, salt, pbkdf2Iterations, 32, sha256.New)
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
if err != nil {
return nil, fmt.Errorf("error deriving key: %w", err)
}
// Initialize AES cipher block
block, err := aes.NewCipher(key)

View File

@@ -1,18 +1,17 @@
package crypto
import (
"crypto/fips140"
"crypto/tls"
"crypto/x509"
"os"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
)
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
func CreateTLSConfiguration(insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
// TODO: use fips.FIPSMode() instead
return createTLSConfiguration(fips140.Enabled(), insecureSkipVerify)
return createTLSConfiguration(fips.FIPSMode(), insecureSkipVerify)
}
func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
@@ -58,8 +57,7 @@ func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Conf
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
// loaded from memory.
func CreateTLSConfigurationFromBytes(useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
// TODO: use fips.FIPSMode() instead
return createTLSConfigurationFromBytes(fips140.Enabled(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification)
return createTLSConfigurationFromBytes(fips.FIPSMode(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification)
}
func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
@@ -90,8 +88,7 @@ func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
// loaded from disk.
func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
// TODO: use fips.FIPSMode() instead
return createTLSConfigurationFromDisk(fips140.Enabled(), config)
return createTLSConfigurationFromDisk(fips.FIPSMode(), config)
}
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo

View File

@@ -91,9 +91,9 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
})
}
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStack)
})
}

View File

@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/stretchr/testify/require"
@@ -102,3 +103,38 @@ func TestUpdateRelation(t *testing.T) {
require.Equal(t, 0, edgeStacks[edgeStackID1].NumDeployments)
require.Equal(t, 0, edgeStacks[edgeStackID2].NumDeployments)
}
func TestAddEndpointRelationsForEdgeStack(t *testing.T) {
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
service, err := NewService(conn)
require.NoError(t, err)
edgeStackService, err := edgestack.NewService(conn, func(t portainer.Transaction, esi portainer.EdgeStackID) {})
require.NoError(t, err)
service.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFuncTx)
require.NoError(t, edgeStackService.Create(1, &portainer.EdgeStack{}))
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}}))
require.NoError(t, service.AddEndpointRelationsForEdgeStack([]portainer.EndpointID{1}, &portainer.EdgeStack{ID: 1}))
}
func TestEndpointRelations(t *testing.T) {
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
service, err := NewService(conn)
require.NoError(t, err)
require.NoError(t, service.Create(&portainer.EndpointRelation{EndpointID: 1}))
rels, err := service.EndpointRelations()
require.NoError(t, err)
require.Equal(t, 1, len(rels))
}

View File

@@ -76,14 +76,14 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
return nil
}
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
for _, endpointID := range endpointIDs {
rel, err := service.EndpointRelation(endpointID)
if err != nil {
return err
}
rel.EdgeStacks[edgeStackID] = true
rel.EdgeStacks[edgeStack.ID] = true
identifier := service.service.connection.ConvertToKey(int(endpointID))
err = service.tx.UpdateObject(BucketName, identifier, rel)
@@ -97,8 +97,12 @@ func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portaine
service.service.endpointRelationsCache = nil
service.service.mu.Unlock()
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments += len(endpointIDs)
if err := service.service.updateStackFnTx(service.tx, edgeStack.ID, func(es *portainer.EdgeStack) {
es.NumDeployments += len(endpointIDs)
// sync changes in `edgeStack` in case it is re-persisted after `AddEndpointRelationsForEdgeStack` call
// to avoid overriding with the previous values
edgeStack.NumDeployments = es.NumDeployments
}); err != nil {
log.Error().Err(err).Msg("could not update the number of deployments")
}

View File

@@ -126,7 +126,7 @@ type (
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
Create(endpointRelation *portainer.EndpointRelation) error
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
BucketName() string

View File

@@ -11,8 +11,10 @@ func (m *Migrator) migrateEdgeGroupEndpointsToRoars_2_33_0() error {
}
for _, eg := range egs {
eg.EndpointIDs = roar.FromSlice(eg.Endpoints)
eg.Endpoints = nil
if eg.EndpointIDs.Len() == 0 {
eg.EndpointIDs = roar.FromSlice(eg.Endpoints)
eg.Endpoints = nil
}
if err := m.edgeGroupService.Update(eg.ID, &eg); err != nil {
return err

View File

@@ -0,0 +1,55 @@
package migrator
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/stretchr/testify/require"
)
func TestMigrateEdgeGroupEndpointsToRoars_2_33_0Idempotency(t *testing.T) {
var conn portainer.Connection = &boltdb.DbConnection{Path: t.TempDir()}
err := conn.Open()
require.NoError(t, err)
defer conn.Close()
edgeGroupService, err := edgegroup.NewService(conn)
require.NoError(t, err)
edgeGroup := &portainer.EdgeGroup{
ID: 1,
Name: "test-edge-group",
Endpoints: []portainer.EndpointID{1, 2, 3},
}
err = conn.CreateObjectWithId(edgegroup.BucketName, int(edgeGroup.ID), edgeGroup)
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{EdgeGroupService: edgeGroupService})
// Run migration once
err = m.migrateEdgeGroupEndpointsToRoars_2_33_0()
require.NoError(t, err)
migratedEdgeGroup, err := edgeGroupService.Read(edgeGroup.ID)
require.NoError(t, err)
require.Len(t, migratedEdgeGroup.Endpoints, 0)
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
// Run migration again to ensure the results didn't change
err = m.migrateEdgeGroupEndpointsToRoars_2_33_0()
require.NoError(t, err)
migratedEdgeGroup, err = edgeGroupService.Read(edgeGroup.ID)
require.NoError(t, err)
require.Len(t, migratedEdgeGroup.Endpoints, 0)
require.Equal(t, len(edgeGroup.Endpoints), migratedEdgeGroup.EndpointIDs.Len())
}

View File

@@ -256,10 +256,7 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0)
m.addMigrations("2.33.0-rc1", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
//m.addMigrations("2.33.0", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
// when we release 2.33.0 it will also run the rc-1 migration function
m.addMigrations("2.33.0", m.migrateEdgeGroupEndpointsToRoars_2_33_0)
// Add new migrations above...
// One function per migration, each versions migration funcs in the same file.

View File

@@ -2,6 +2,7 @@ package postinit
import (
"context"
"fmt"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
@@ -83,17 +84,27 @@ func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
// try to create a post init migration pending action. If it already exists, do nothing
// this function exists for readability, not reusability
// TODO: This should be moved into pending actions as part of the pending action migration
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
// If there are no pending actions for the given endpoint, create one
err := postInitMigrator.dataStore.PendingActions().Create(&portainer.PendingAction{
action := portainer.PendingAction{
EndpointID: environmentID,
Action: actions.PostInitMigrateEnvironment,
})
if err != nil {
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environmentID)
}
return nil
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
if err != nil {
return fmt.Errorf("failed to retrieve pending actions: %w", err)
}
for _, dba := range pendingActions {
if dba.EndpointID == action.EndpointID && dba.Action == action.Action {
log.Debug().
Str("action", action.Action).
Int("endpoint_id", int(action.EndpointID)).
Msg("pending action already exists for environment, skipping...")
return nil
}
}
return postInitMigrator.dataStore.PendingActions().Create(&action)
}
// MigrateEnvironment runs migrations on a single environment

View File

@@ -8,10 +8,12 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/pendingactions/actions"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -73,3 +75,96 @@ func TestMigrateGPUs(t *testing.T) {
require.False(t, migratedEndpoint.PostInitMigrations.MigrateGPUs)
require.True(t, migratedEndpoint.EnableGPUManagement)
}
func TestPostInitMigrate_PendingActionsCreated(t *testing.T) {
tests := []struct {
name string
existingPendingActions []*portainer.PendingAction
expectedPendingActions int
expectedAction string
}{
{
name: "when existing non-matching action exists, should add migration action",
existingPendingActions: []*portainer.PendingAction{
{
EndpointID: 7,
Action: "some-other-action",
},
},
expectedPendingActions: 2,
expectedAction: actions.PostInitMigrateEnvironment,
},
{
name: "when matching action exists, should not add duplicate",
existingPendingActions: []*portainer.PendingAction{
{
EndpointID: 7,
Action: actions.PostInitMigrateEnvironment,
},
},
expectedPendingActions: 1,
expectedAction: actions.PostInitMigrateEnvironment,
},
{
name: "when no actions exist, should add migration action",
existingPendingActions: []*portainer.PendingAction{},
expectedPendingActions: 1,
expectedAction: actions.PostInitMigrateEnvironment,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
// Create test endpoint
endpoint := &portainer.Endpoint{
ID: 7,
UserTrusted: true,
Type: portainer.EdgeAgentOnDockerEnvironment,
Edge: portainer.EnvironmentEdgeSettings{
AsyncMode: false,
},
EdgeID: "edgeID",
}
err := store.Endpoint().Create(endpoint)
is.NoError(err, "error creating endpoint")
// Create any existing pending actions
for _, action := range tt.existingPendingActions {
err = store.PendingActions().Create(action)
is.NoError(err, "error creating pending action")
}
migrator := NewPostInitMigrator(
nil, // kubeFactory not needed for this test
nil, // dockerFactory not needed for this test
store,
"", // assetsPath not needed for this test
nil, // kubernetesDeployer not needed for this test
)
err = migrator.PostInitMigrate()
is.NoError(err, "PostInitMigrate should not return error")
// Verify the results
pendingActions, err := store.PendingActions().ReadAll()
is.NoError(err, "error reading pending actions")
is.Len(pendingActions, tt.expectedPendingActions, "unexpected number of pending actions")
// If we expect any actions, verify at least one has the expected action type
if tt.expectedPendingActions > 0 {
hasExpectedAction := false
for _, action := range pendingActions {
if action.Action == tt.expectedAction {
hasExpectedAction = true
is.Equal(endpoint.ID, action.EndpointID, "action should reference correct endpoint")
break
}
}
is.True(hasExpectedAction, "should have found action of expected type")
}
})
}
}

View File

@@ -615,7 +615,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.33.0-rc1",
"KubectlShellImage": "portainer/kubectl-shell:2.33.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -944,7 +944,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.33.0-rc1\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.33.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -4,10 +4,14 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestHttpClient(t *testing.T) {
fips.InitFIPS(false)
// Valid TLS configuration
endpoint := &portainer.Endpoint{}
endpoint.TLSConfig = portainer.TLSConfiguration{TLS: true}

View File

@@ -8,6 +8,7 @@ import (
"testing"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert"
)
@@ -234,6 +235,8 @@ func Test_isAzureUrl(t *testing.T) {
}
func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
fips.InitFIPS(false)
type args struct {
options baseOption
}
@@ -308,6 +311,8 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
}
func Test_azureDownloader_latestCommitID(t *testing.T) {
fips.InitFIPS(false)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response := `{
"count": 1,

View File

@@ -4,10 +4,14 @@ import (
"net/http"
"testing"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestNewService(t *testing.T) {
fips.InitFIPS(false)
service := NewService(true)
require.NotNil(t, service)
require.True(t, service.httpsClient.Transport.(*http.Transport).TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo

View File

@@ -6,11 +6,14 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestExecutePingOperationFailure(t *testing.T) {
fips.InitFIPS(false)
host := "http://localhost:1"
config := portainer.TLSConfiguration{
TLS: true,

View File

@@ -99,7 +99,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
groupsIds := stack.EdgeGroups
if payload.EdgeGroups != nil {
newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack.ID, payload.EdgeGroups, relatedEndpointIds, relationConfig)
newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack, payload.EdgeGroups, relatedEndpointIds, relationConfig)
if err != nil {
return nil, httperror.InternalServerError("Unable to handle edge groups change", err)
}
@@ -136,7 +136,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
return stack, nil
}
func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) {
func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) {
newRelatedEnvironmentIDs, err := edge.EdgeStackRelatedEndpoints(newEdgeGroupsIDs, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
if err != nil {
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
@@ -149,13 +149,13 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
if len(relatedEnvironmentsToRemove) > 0 {
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID); err != nil {
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStack.ID); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to remove edge stack relations from the database")
}
}
if len(relatedEnvironmentsToAdd) > 0 {
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID); err != nil {
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStack); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to add edge stack relations to the database")
}
}

View File

@@ -55,6 +55,10 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
if r.RequestURI == "/" || strings.HasSuffix(r.RequestURI, ".html") {
w.Header().Set("Permissions-Policy", strings.Join(permissions, ","))
}
if !isHTML(r.Header["Accept"]) {
w.Header().Set("Cache-Control", "max-age=31536000")
} else {

View File

@@ -0,0 +1,70 @@
package file_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/stretchr/testify/require"
)
func TestNormalServe(t *testing.T) {
handler := file.NewHandler("", false, func() bool { return false })
require.NotNil(t, handler)
request := func(path string) (*http.Request, *httptest.ResponseRecorder) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
handler.ServeHTTP(rr, req)
return req, rr
}
_, rr := request("/timeout.html")
require.Equal(t, http.StatusTemporaryRedirect, rr.Result().StatusCode)
loc, err := rr.Result().Location()
require.NoError(t, err)
require.NotNil(t, loc)
require.Equal(t, "/", loc.Path)
_, rr = request("/")
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
}
func TestPermissionsPolicyHeader(t *testing.T) {
handler := file.NewHandler("", false, func() bool { return false })
require.NotNil(t, handler)
test := func(path string, exist bool) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
handler.ServeHTTP(rr, req)
require.Equal(t, exist, rr.Result().Header.Get("Permissions-Policy") != "")
}
test("/", true)
test("/index.html", true)
test("/api", false)
test("/an/image.png", false)
}
func TestRedirectInstanceDisabled(t *testing.T) {
handler := file.NewHandler("", false, func() bool { return true })
require.NotNil(t, handler)
test := func(path string) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusTemporaryRedirect, rr.Result().StatusCode)
loc, err := rr.Result().Location()
require.NoError(t, err)
require.NotNil(t, loc)
require.Equal(t, "/timeout.html", loc.Path)
}
test("/")
test("/index.html")
}

View File

@@ -0,0 +1,91 @@
package file
var permissions = []string{
"accelerometer=()",
"ambient-light-sensor=()",
"attribution-reporting=()",
"autoplay=()",
"battery=()",
"browsing-topics=()",
"camera=()",
"captured-surface-control=()",
"ch-device-memory=()",
"ch-downlink=()",
"ch-dpr=()",
"ch-ect=()",
"ch-prefers-color-scheme=()",
"ch-prefers-reduced-motion=()",
"ch-prefers-reduced-transparency=()",
"ch-rtt=()",
"ch-save-data=()",
"ch-ua=()",
"ch-ua-arch=()",
"ch-ua-bitness=()",
"ch-ua-form-factors=()",
"ch-ua-full-version=()",
"ch-ua-full-version-list=()",
"ch-ua-mobile=()",
"ch-ua-model=()",
"ch-ua-platform=()",
"ch-ua-platform-version=()",
"ch-ua-wow64=()",
"ch-viewport-height=()",
"ch-viewport-width=()",
"ch-width=()",
"compute-pressure=()",
"conversion-measurement=()",
"cross-origin-isolated=()",
"deferred-fetch=()",
"deferred-fetch-minimal=()",
"display-capture=()",
"document-domain=()",
"encrypted-media=()",
"execution-while-not-rendered=()",
"execution-while-out-of-viewport=()",
"focus-without-user-activation=()",
"fullscreen=()",
"gamepad=()",
"geolocation=()",
"gyroscope=()",
"hid=()",
"identity-credentials-get=()",
"idle-detection=()",
"interest-cohort=()",
"join-ad-interest-group=()",
"keyboard-map=()",
"language-detector=()",
"local-fonts=()",
"magnetometer=()",
"microphone=()",
"midi=()",
"navigation-override=()",
"otp-credentials=()",
"payment=()",
"picture-in-picture=()",
"private-aggregation=()",
"private-state-token-issuance=()",
"private-state-token-redemption=()",
"publickey-credentials-create=()",
"publickey-credentials-get=()",
"rewriter=()",
"run-ad-auction=()",
"screen-wake-lock=()",
"serial=()",
"shared-storage=()",
"shared-storage-select-url=()",
"speaker-selection=()",
"storage-access=()",
"summarizer=()",
"sync-script=()",
"sync-xhr=()",
"translator=()",
"trust-token-redemption=()",
"unload=()",
"usb=()",
"vertical-scroll=()",
"web-share=()",
"window-management=()",
"window-placement=()",
"writer=()",
"xr-spatial-tracking=()",
}

View File

@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.33.0-rc1
// @version 2.33.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)

View File

@@ -7,11 +7,14 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestInitDial(t *testing.T) {
fips.InitFIPS(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer srv.Close()

View File

@@ -3,10 +3,14 @@ package kubernetes
import (
"testing"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestNewLocalTransport(t *testing.T) {
fips.InitFIPS(false)
transport, err := NewLocalTransport(nil, nil, nil, nil, nil)
require.NoError(t, err)
require.True(t, transport.httpTransport.TLSClientConfig.InsecureSkipVerify) //nolint:forbidigo

View File

@@ -111,7 +111,7 @@ func (service *Service) PersistEdgeStack(
}
}
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack); err != nil {
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
}

View File

@@ -230,11 +230,11 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
return nil
}
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStack *portainer.EdgeStack) error {
for _, endpointID := range endpointIDs {
for i, r := range s.relations {
if r.EndpointID == endpointID {
s.relations[i].EdgeStacks[edgeStackID] = true
s.relations[i].EdgeStacks[edgeStack.ID] = true
}
}
}
@@ -460,3 +460,39 @@ func WithStacks(stacks []portainer.Stack) datastoreOption {
d.stack = &stubStacksService{stacks: stacks}
}
}
type stubPendingActionService struct {
actions []portainer.PendingAction
dataservices.PendingActionsService
}
func WithPendingActions(pendingActions []portainer.PendingAction) datastoreOption {
return func(d *testDatastore) {
d.pendingActionsService = &stubPendingActionService{
actions: pendingActions,
}
}
}
func (s *stubPendingActionService) ReadAll(predicates ...func(portainer.PendingAction) bool) ([]portainer.PendingAction, error) {
filtered := s.actions
for _, predicate := range predicates {
filtered = slicesx.Filter(filtered, predicate)
}
return filtered, nil
}
func (s *stubPendingActionService) Delete(ID portainer.PendingActionID) error {
actions := []portainer.PendingAction{}
for _, action := range s.actions {
if action.ID != ID {
actions = append(actions, action)
}
}
s.actions = actions
return nil
}

View File

@@ -7,11 +7,14 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require"
)
func TestCreateConnectionForURL(t *testing.T) {
fips.InitFIPS(false)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer srv.Close()

View File

@@ -71,10 +71,14 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
isKubernetesEndpoint := endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
// EndpointStatusUp is only relevant for non-Kubernetes endpoints
// Sometimes the endpoint is UP but the status is not updated in the database
if !isKubernetesEndpoint {
if endpoint.Status != portainer.EndpointStatusUp {
// Edge environments check the heartbeat
// Other environments check the endpoint status
if endpointutils.IsEdgeEndpoint(endpoint) {
if !endpoint.Heartbeat {
return
}
} else if endpoint.Status != portainer.EndpointStatusUp {
return
}
} else {

View File

@@ -0,0 +1,89 @@
package pendingactions
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
)
func TestExecute(t *testing.T) {
tests := []struct {
name string
endpoint *portainer.Endpoint
pendingActions []portainer.PendingAction
shouldExecute bool
}{
{
name: "Edge endpoint with heartbeat should execute",
// Create test endpoint
endpoint: &portainer.Endpoint{
ID: 1,
Heartbeat: true,
Type: portainer.EdgeAgentOnDockerEnvironment,
EdgeID: "edge-1",
},
pendingActions: []portainer.PendingAction{
{ID: 1, EndpointID: 1, Action: "test-action"},
},
shouldExecute: true,
},
{
name: "Edge endpoint without heartbeat should not execute",
endpoint: &portainer.Endpoint{
ID: 2,
EdgeID: "edge-2",
Heartbeat: false,
Type: portainer.EdgeAgentOnDockerEnvironment,
},
pendingActions: []portainer.PendingAction{
{ID: 2, EndpointID: 2, Action: "test-action"},
},
shouldExecute: false,
},
{
name: "Regular endpoint with status UP should execute",
endpoint: &portainer.Endpoint{
ID: 3,
Status: portainer.EndpointStatusUp,
Type: portainer.AgentOnDockerEnvironment,
},
pendingActions: []portainer.PendingAction{
{ID: 3, EndpointID: 3, Action: "test-action"},
},
shouldExecute: true,
},
{
name: "Regular endpoint with status DOWN should not execute",
endpoint: &portainer.Endpoint{
ID: 4,
Status: portainer.EndpointStatusDown,
Type: portainer.AgentOnDockerEnvironment,
},
pendingActions: []portainer.PendingAction{
{ID: 4, EndpointID: 4, Action: "test-action"},
},
shouldExecute: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Setup services
store := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{*tt.endpoint}), testhelpers.WithPendingActions(tt.pendingActions))
service := NewService(store, nil)
// Execute
service.execute(tt.endpoint.ID)
// Verify expectations
pendingActions, _ := store.PendingActions().ReadAll()
if tt.shouldExecute {
assert.Equal(t, len(tt.pendingActions)-1, len(pendingActions))
} else {
assert.Equal(t, len(tt.pendingActions), len(pendingActions))
}
})
}
}

View File

@@ -1783,7 +1783,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.33.0-rc1"
APIVersion = "2.33.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called

View File

@@ -14,6 +14,7 @@ import (
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/fips"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/stretchr/testify/assert"
@@ -203,6 +204,8 @@ func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
}
func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
fips.InitFIPS(false)
cloneErr := errors.New("failed to clone")
_, store := datastore.MustNewTestStore(t, true, true)

View File

@@ -0,0 +1,330 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import selectEvent from '@/react/test-utils/react-select';
import { Select } from './ReactSelect';
describe('ReactSelect', () => {
const mockOptions = [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
];
const mockGroupedOptions = [
{
label: 'Group 1',
options: [
{ value: 'g1-option1', label: 'Group 1 Option 1' },
{ value: 'g1-option2', label: 'Group 1 Option 2' },
],
},
{
label: 'Group 2',
options: [
{ value: 'g2-option1', label: 'Group 2 Option 1' },
{ value: 'g2-option2', label: 'Group 2 Option 2' },
],
},
];
describe('Select component', () => {
it('should apply the correct size class', () => {
const { container } = render(
<Select
id="test-select"
options={mockOptions}
size="sm"
data-cy="test-select"
/>
);
const selectContainer = container.querySelector(
'.portainer-selector-root'
);
expect(selectContainer).toHaveClass('sm');
});
it('should apply custom className', () => {
const { container } = render(
<Select
id="test-select"
options={mockOptions}
className="custom-class"
data-cy="test-select"
/>
);
const selectContainer = container.querySelector(
'.portainer-selector-root'
);
expect(selectContainer).toHaveClass('custom-class');
});
it('should handle onChange event', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(
<Select
id="test-select"
options={mockOptions}
onChange={handleChange}
data-cy="test-select"
inputId="test-input"
/>
);
const input = screen.getByRole('combobox');
await selectEvent.select(input, 'Option 2', { user });
expect(handleChange).toHaveBeenCalledWith(
mockOptions[1],
expect.objectContaining({ action: 'select-option' })
);
});
it('should handle multi-select', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(
<Select
id="test-select"
options={mockOptions}
onChange={handleChange}
isMulti
data-cy="test-select"
inputId="test-input"
/>
);
const input = screen.getByRole('combobox');
await selectEvent.select(input, 'Option 1', { user });
await selectEvent.select(input, 'Option 2', { user });
expect(handleChange).toHaveBeenCalledTimes(2);
expect(handleChange).toHaveBeenLastCalledWith(
[mockOptions[0], mockOptions[1]],
expect.objectContaining({ action: 'select-option' })
);
});
it('should render with grouped options', async () => {
const user = userEvent.setup();
render(
<Select
id="test-select"
options={mockGroupedOptions}
data-cy="test-select"
inputId="test-input"
/>
);
const input = screen.getByRole('combobox');
await selectEvent.openMenu(input, { user });
expect(screen.getByText('Group 1')).toBeInTheDocument();
expect(screen.getByText('Group 2')).toBeInTheDocument();
expect(screen.getByText('Group 1 Option 1')).toBeInTheDocument();
});
it('should handle disabled state', () => {
const { container } = render(
<Select
id="test-select"
options={mockOptions}
isDisabled
data-cy="test-select"
/>
);
const selectContainer = container.querySelector(
'.portainer-selector-root'
);
expect(selectContainer).toHaveClass('portainer-selector--is-disabled');
});
it('should handle loading state', () => {
const { container } = render(
<Select
id="test-select"
options={mockOptions}
isLoading
data-cy="test-select"
/>
);
const loadingIndicator = container.querySelector(
'.portainer-selector__loading-indicator'
);
expect(loadingIndicator).toBeInTheDocument();
});
it('should clear selection', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(
<Select
id="test-select"
options={mockOptions}
onChange={handleChange}
isClearable
value={mockOptions[0]}
data-cy="test-select"
inputId="test-input"
/>
);
const input = screen.getByRole('combobox');
await selectEvent.clearFirst(input, { user });
expect(handleChange).toHaveBeenCalledWith(
null,
expect.objectContaining({ action: 'clear' })
);
});
it('should handle empty options array', () => {
render(<Select id="test-select" options={[]} data-cy="test-select" />);
const input = screen.getByRole('combobox');
expect(input).toBeInTheDocument();
});
it('should handle undefined options', () => {
render(
<Select id="test-select" options={undefined} data-cy="test-select" />
);
const input = screen.getByRole('combobox');
expect(input).toBeInTheDocument();
});
});
describe('Component integration', () => {
it('should switch between regular and paginated select based on options count', async () => {
const user = userEvent.setup();
// First render with few options - should use regular Select
const { rerender } = render(
<Select
id="test-select"
options={mockOptions}
data-cy="test-select"
inputId="test-input"
/>
);
let input = screen.getByRole('combobox');
await user.click(input);
// Regular select should render all 3 options immediately
expect(screen.getByText('Option 1')).toBeInTheDocument();
expect(screen.getByText('Option 2')).toBeInTheDocument();
expect(screen.getByText('Option 3')).toBeInTheDocument();
// Close menu
await user.keyboard('{Escape}');
// Now rerender with many options - should switch to TooManyResultsSelector
const manyOptions = Array.from({ length: 1001 }, (_, i) => ({
value: `option${i}`,
label: `Option ${i}`,
}));
rerender(
<Select
id="test-select"
options={manyOptions}
data-cy="test-select"
inputId="test-input"
/>
);
input = screen.getByRole('combobox');
await user.click(input);
// Paginated select should only render first page (100 items max)
// Check that first few options are present
await waitFor(() => {
expect(screen.getByText('Option 0')).toBeInTheDocument();
});
// Count total rendered options - should be limited to PAGE_SIZE (100)
// React-select uses divs with class portainer-selector__option for options
const renderedOptions = document.querySelectorAll(
'.portainer-selector__option'
);
expect(renderedOptions.length).toBeLessThanOrEqual(100);
expect(renderedOptions.length).toBeGreaterThan(0);
// Verify that options beyond page size are NOT rendered
expect(screen.queryByText('Option 999')).not.toBeInTheDocument();
});
it('should render creatable mode when isCreatable prop is true', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(
<Select
id="test-select"
options={mockOptions}
isCreatable
onChange={handleChange}
data-cy="test-select"
inputId="test-input"
/>
);
const input = screen.getByRole('combobox');
// Type a new value that doesn't exist in options
await user.type(input, 'Brand New Option');
// Should show create option (may appear in multiple places)
await waitFor(() => {
const createOptions = screen.getAllByText(/Create "Brand New Option"/);
expect(createOptions.length).toBeGreaterThan(0);
});
});
it('should preserve props when switching to TooManyResultsSelector', () => {
const handleChange = vi.fn();
const manyOptions = Array.from({ length: 1001 }, (_, i) => ({
value: `option${i}`,
label: `Option ${i}`,
}));
const { container } = render(
<Select
id="test-select"
options={manyOptions}
onChange={handleChange}
placeholder="Select an option"
isSearchable
isClearable
data-cy="test-select"
inputId="test-input"
/>
);
// Should use TooManyResultsSelector for large datasets
const selectContainer = container.querySelector(
'.portainer-selector-root'
);
expect(selectContainer).toBeInTheDocument();
// Should preserve data-cy attribute
const input = screen.getByRole('combobox');
expect(input).toHaveAttribute('data-cy', 'test-select');
// Should preserve id
expect(input).toHaveAttribute('id', 'test-input');
});
});
});

View File

@@ -1,9 +1,10 @@
import ReactSelectCreatable, {
CreatableProps as ReactSelectCreatableProps,
} from 'react-select/creatable';
import ReactSelectAsync, {
AsyncProps as ReactSelectAsyncProps,
} from 'react-select/async';
import {
AsyncPaginate as ReactSelectAsyncPaginate,
AsyncPaginateProps as ReactSelectAsyncPaginateProps,
} from 'react-select-async-paginate';
import ReactSelect, {
components,
GroupBase,
@@ -18,6 +19,9 @@ import ReactSelectType from 'react-select/dist/declarations/src/Select';
import './ReactSelect.css';
import { AutomationTestingProps } from '@/types';
const PAGE_SIZE = 100;
const MAX_OPTIONS_WITHOUT_PAGINATION = 1000;
interface DefaultOption {
value: string;
label: string;
@@ -86,7 +90,7 @@ export function Select<
Group
>(dataCy, componentsProp);
if ((options?.length || 0) > 1000) {
if ((options?.length || 0) > MAX_OPTIONS_WITHOUT_PAGINATION) {
return (
<TooManyResultsSelector
size={size}
@@ -143,7 +147,7 @@ export function Async<
className,
size,
...props
}: ReactSelectAsyncProps<Option, IsMulti, Group> & {
}: ReactSelectAsyncPaginateProps<Option, Group, unknown, IsMulti> & {
size?: 'sm' | 'md';
} & AutomationTestingProps) {
const { 'data-cy': dataCy, components: componentsProp, ...rest } = props;
@@ -155,7 +159,7 @@ export function Async<
>(dataCy, componentsProp);
return (
<ReactSelectAsync
<ReactSelectAsyncPaginate
className={clsx(className, 'portainer-selector-root', size)}
classNamePrefix="portainer-selector"
components={memoizedComponents}
@@ -173,22 +177,29 @@ export function TooManyResultsSelector<
options,
isLoading,
getOptionValue,
getOptionLabel,
isItemVisible = (item, search) =>
!!getOptionValue?.(item).toLowerCase().includes(search.toLowerCase()),
search.trim() === '' ||
!!getOptionLabel?.(item).toLowerCase().includes(search.toLowerCase()),
...props
}: RegularProps<Option, IsMulti, Group> & {
isItemVisible?: (item: Option, search: string) => boolean;
}) {
const defaultOptions = useMemo(() => options?.slice(0, 100), [options]);
return (
<Async
isLoading={isLoading}
getOptionValue={getOptionValue}
loadOptions={(search: string) =>
filterOptions<Option, Group>(options, isItemVisible, search)
loadOptions={(
search: string,
loadedOptions: OptionsOrGroups<Option, Group> | undefined
) =>
filterOptions<Option, Group>(
options,
isItemVisible,
search,
loadedOptions
)
}
defaultOptions={defaultOptions}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
@@ -201,17 +212,21 @@ function filterOptions<
>(
options: OptionsOrGroups<Option, Group> | undefined,
isItemVisible: (item: Option, search: string) => boolean,
search: string
): Promise<OptionsOrGroups<Option, Group> | undefined> {
return Promise.resolve<OptionsOrGroups<Option, Group> | undefined>(
options
?.filter((item) =>
isGroup(item)
? item.options.some((ni) => isItemVisible(ni, search))
: isItemVisible(item, search)
)
.slice(0, 100)
search: string,
loadedOptions?: OptionsOrGroups<Option, Group>
) {
const filteredOptions = options?.filter((item) =>
isGroup(item)
? item.options.some((ni) => isItemVisible(ni, search))
: isItemVisible(item, search)
);
const offset = loadedOptions?.length ?? 0;
return {
options: filteredOptions?.slice(offset, offset + PAGE_SIZE) ?? [],
hasMore: (filteredOptions?.length ?? 0) > offset + PAGE_SIZE,
};
}
function isGroup<

View File

@@ -1,11 +1,13 @@
import { CellContext, Row } from '@tanstack/react-table';
import { useRef } from 'react';
import { isoDate, truncate } from '@/portainer/filters/filters';
import { isoDate } from '@/portainer/filters/filters';
import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
import { Link } from '@@/Link';
import { SystemBadge } from '@@/Badge/SystemBadge';
import { filterHOC } from '@@/datatables/Filter';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { Application } from './types';
import { helper } from './columns.helper';
@@ -40,15 +42,45 @@ function NamespaceCell({ row, getValue }: CellContext<Application, string>) {
export const image = helper.accessor('Image', {
header: 'Image',
cell: ({ row: { original: item } }) => (
<>
{truncate(item.Image, 64)}
{item.Containers && item.Containers?.length > 1 && (
<>+ {item.Containers.length - 1}</>
)}
</>
<ImageCell image={item.Image} imageCount={item.Containers?.length || 0} />
),
});
function ImageCell({
image,
imageCount,
}: {
image: string;
imageCount: number;
}) {
const contentRef = useRef<HTMLDivElement>(null);
const isTruncated = isWidthTruncated();
const imageElement = (
<div className="inline-block max-w-xs truncate" ref={contentRef}>
{image}
</div>
);
if (isTruncated) {
return (
<TooltipWithChildren message={image}>{imageElement}</TooltipWithChildren>
);
}
return (
<div>
{imageElement}
{imageCount > 1 && <> + {imageCount - 1}</>}
</div>
);
function isWidthTruncated() {
const el = contentRef.current;
return el && el.scrollWidth > el.clientWidth;
}
}
export const appType = helper.accessor('ApplicationType', {
header: 'Application type',
meta: {

View File

@@ -1,32 +0,0 @@
import { useField } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { Switch } from '@@/form-components/SwitchField/Switch';
const fieldKey = 'OpenAIIntegration';
export function EnableOpenAIIntegrationSwitch() {
const [inputProps, meta, helpers] = useField<boolean>(fieldKey);
return (
<FormControl
inputId="experimental_openAI"
label="Enable OpenAI integration"
size="medium"
errors={meta.error}
>
<Switch
id="experimental_openAI"
data-cy="enable-openai-integration-switch"
name={fieldKey}
className="space-right"
checked={inputProps.value}
onChange={handleChange}
/>
</FormControl>
);
function handleChange(enable: boolean) {
helpers.setValue(enable);
}
}

View File

@@ -10,14 +10,9 @@ import { useUpdateExperimentalSettingsMutation } from '@/react/portainer/setting
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { EnableOpenAIIntegrationSwitch } from './EnableOpenAIIntegrationSwitch';
interface FormValues {}
interface FormValues {
OpenAIIntegration: boolean;
}
const validation = yup.object({
OpenAIIntegration: yup.boolean(),
});
const validation = yup.object({});
interface Props {
settings: ExperimentalFeatures;
@@ -30,24 +25,19 @@ export function ExperimentalFeaturesSettingsForm({ settings }: Props) {
const { mutate: updateSettings } = mutation;
const handleSubmit = useCallback(
(variables: FormValues) => {
updateSettings(
{
OpenAIIntegration: variables.OpenAIIntegration,
const handleSubmit = useCallback(() => {
updateSettings(
{},
{
onSuccess() {
notifySuccess(
'Success',
'Successfully updated experimental features settings'
);
},
{
onSuccess() {
notifySuccess(
'Success',
'Successfully updated experimental features settings'
);
},
}
);
},
[updateSettings]
);
}
);
}, [updateSettings]);
return (
<Formik<FormValues>
@@ -76,8 +66,6 @@ export function ExperimentalFeaturesSettingsForm({ settings }: Props) {
experimental feature will prevent access to it.
</div>
<EnableOpenAIIntegrationSwitch />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton

View File

@@ -0,0 +1,95 @@
import { render } from '@testing-library/react';
import { HttpResponse, http } from 'msw';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { server } from '@/setup-tests/server';
import { SettingsView } from './SettingsView';
describe('SettingsView', () => {
function setupMocks() {
// Mock the settings API endpoints
server.use(
http.get('/api/settings', () =>
HttpResponse.json({
LogoURL: '',
SnapshotInterval: '5m',
EnableTelemetry: false,
})
)
);
server.use(
http.get('/api/settings/experimental', () =>
HttpResponse.json({
experimentalFeatures: {},
})
)
);
// Mock public settings for feature flags
server.use(
http.get('/api/settings/public', () =>
HttpResponse.json({
Features: {
'auto-patch': false,
'disable-roles-sync': false,
},
})
)
);
// Mock SSL settings
server.use(
http.get('/api/ssl', () =>
HttpResponse.json({
HTTPSOnly: false,
SelfSigned: false,
})
)
);
// Mock debug settings
server.use(
http.get('/api/support/debug_log', () =>
HttpResponse.json({
LogLevel: 'INFO',
EnableProfiling: false,
})
)
);
// Mock backup S3 settings
server.use(
http.get('/api/backup/s3/settings', () =>
HttpResponse.json({
Enabled: false,
AccessKey: '',
SecretKey: '',
Region: '',
Bucket: '',
})
)
);
}
function renderComponent() {
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(SettingsView))
);
return render(<Wrapped />);
}
describe('Experimental Features', () => {
test('should NOT render ExperimentalFeatures component in CE edition', async () => {
setupMocks();
const { queryByText } = renderComponent();
// Check that the ExperimentalFeatures component is NOT rendered
const experimentalFeaturesTitle = queryByText('Experimental features');
expect(experimentalFeaturesTitle).not.toBeInTheDocument();
});
});
});

View File

@@ -99,9 +99,7 @@ export interface DefaultRegistry {
Hide: boolean;
}
export interface ExperimentalFeatures {
OpenAIIntegration: boolean;
}
export interface ExperimentalFeatures {}
export interface Settings {
LogoURL: string;

41
go.mod
View File

@@ -49,22 +49,22 @@ require (
github.com/urfave/negroni v1.0.0
github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.4.0
golang.org/x/crypto v0.39.0
golang.org/x/crypto v0.40.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/mod v0.25.0
golang.org/x/oauth2 v0.29.0
golang.org/x/sync v0.15.0
golang.org/x/sync v0.16.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v3 v3.18.4
k8s.io/api v0.33.2
k8s.io/apimachinery v0.33.2
k8s.io/cli-runtime v0.33.2
k8s.io/client-go v0.33.2
k8s.io/kubectl v0.33.2
helm.sh/helm/v3 v3.18.5
k8s.io/api v0.33.3
k8s.io/apimachinery v0.33.3
k8s.io/cli-runtime v0.33.3
k8s.io/client-go v0.33.3
k8s.io/kubectl v0.33.3
k8s.io/kubelet v0.33.2
k8s.io/metrics v0.33.2
k8s.io/metrics v0.33.3
oras.land/oras-go/v2 v2.6.0
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
)
@@ -243,6 +243,7 @@ require (
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rubenv/sql-migrate v1.8.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
@@ -253,7 +254,7 @@ require (
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
@@ -291,10 +292,12 @@ require (
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/mock v0.5.2 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.33.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
@@ -304,10 +307,10 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
k8s.io/apiextensions-apiserver v0.33.2 // indirect
k8s.io/apiserver v0.33.2 // indirect
k8s.io/component-base v0.33.2 // indirect
k8s.io/component-helpers v0.33.2 // indirect
k8s.io/apiextensions-apiserver v0.33.3 // indirect
k8s.io/apiserver v0.33.3 // indirect
k8s.io/component-base v0.33.3 // indirect
k8s.io/component-helpers v0.33.3 // indirect
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
@@ -316,6 +319,6 @@ require (
sigs.k8s.io/kustomize/kyaml v0.19.0 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
sigs.k8s.io/yaml v1.5.0 // indirect
tags.cncf.io/container-device-interface v1.0.1 // indirect
)

86
go.sum
View File

@@ -206,6 +206,8 @@ github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN
github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/buildx v0.24.0 h1:qiD+xktY+Fs3R79oz8M+7pbhip78qGLx6LBuVmyb+64=
github.com/docker/buildx v0.24.0/go.mod h1:vYkdBUBjFo/i5vUE0mkajGlk03gE0T/HaGXXhgIxo8E=
github.com/docker/cli v28.2.1+incompatible h1:AYyTcuwvhl9dXdyCiXlOGXiIqSNYzTmaDNpxIISPGsM=
@@ -662,6 +664,8 @@ github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2N
github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA=
github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
@@ -698,8 +702,9 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431 h1:XTHrT015sxHyJ5FnQ0AeemSspZWaDq7DoTRW0EVsDCE=
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c h1:2EejZtjFjKJGk71ANb+wtFK5EjUzUkEM3R0xnp559xg=
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -838,6 +843,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -856,8 +865,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -889,8 +898,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
@@ -907,8 +916,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -944,8 +953,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -957,8 +966,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -973,8 +982,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -985,8 +994,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1042,34 +1051,34 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
helm.sh/helm/v3 v3.18.4 h1:pNhnHM3nAmDrxz6/UC+hfjDY4yeDATQCka2/87hkZXQ=
helm.sh/helm/v3 v3.18.4/go.mod h1:WVnwKARAw01iEdjpEkP7Ii1tT1pTPYfM1HsakFKM3LI=
k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY=
k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs=
k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8=
k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8=
k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY=
k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apiserver v0.33.2 h1:KGTRbxn2wJagJowo29kKBp4TchpO1DRO3g+dB/KOJN4=
k8s.io/apiserver v0.33.2/go.mod h1:9qday04wEAMLPWWo9AwqCZSiIn3OYSZacDyu/AcoM/M=
k8s.io/cli-runtime v0.33.2 h1:koNYQKSDdq5AExa/RDudXMhhtFasEg48KLS2KSAU74Y=
k8s.io/cli-runtime v0.33.2/go.mod h1:gnhsAWpovqf1Zj5YRRBBU7PFsRc6NkEkwYNQE+mXL88=
k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E=
k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo=
k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0=
k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k=
k8s.io/component-helpers v0.33.2 h1:AjCtYzst11NV8ensxV/2LEEXRwctqS7Bs44bje9Qcnw=
k8s.io/component-helpers v0.33.2/go.mod h1:PsPpiCk74n8pGWp1d6kjK/iSKBTyQfIacv02BNkMenU=
helm.sh/helm/v3 v3.18.5 h1:Cc3Z5vd6kDrZq9wO9KxKLNEickiTho6/H/dBNRVSos4=
helm.sh/helm/v3 v3.18.5/go.mod h1:L/dXDR2r539oPlFP1PJqKAC1CUgqHJDLkxKpDGrWnyg=
k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8=
k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE=
k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs=
k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8=
k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA=
k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4=
k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E=
k8s.io/cli-runtime v0.33.3 h1:Dgy4vPjNIu8LMJBSvs8W0LcdV0PX/8aGG1DA1W8lklA=
k8s.io/cli-runtime v0.33.3/go.mod h1:yklhLklD4vLS8HNGgC9wGiuHWze4g7x6XQZ+8edsKEo=
k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA=
k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg=
k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA=
k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4=
k8s.io/component-helpers v0.33.3 h1:fjWVORSQfI0WKzPeIFSju/gMD9sybwXBJ7oPbqQu6eM=
k8s.io/component-helpers v0.33.3/go.mod h1:7iwv+Y9Guw6X4RrnNQOyQlXcvJrVjPveHVqUA5dm31c=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/kubectl v0.33.2 h1:7XKZ6DYCklu5MZQzJe+CkCjoGZwD1wWl7t/FxzhMz7Y=
k8s.io/kubectl v0.33.2/go.mod h1:8rC67FB8tVTYraovAGNi/idWIK90z2CHFNMmGJZJ3KI=
k8s.io/kubectl v0.33.3 h1:r/phHvH1iU7gO/l7tTjQk2K01ER7/OAJi8uFHHyWSac=
k8s.io/kubectl v0.33.3/go.mod h1:euj2bG56L6kUGOE/ckZbCoudPwuj4Kud7BR0GzyNiT0=
k8s.io/kubelet v0.33.2 h1:wxEau5/563oJb3j3KfrCKlNWWx35YlSgDLOYUBCQ0pg=
k8s.io/kubelet v0.33.2/go.mod h1:way8VCDTUMiX1HTOvJv7M3xS/xNysJI6qh7TOqMe5KM=
k8s.io/metrics v0.33.2 h1:gNCBmtnUMDMCRg9Ly5ehxP3OdKISMsOnh1vzk01iCgE=
k8s.io/metrics v0.33.2/go.mod h1:yxoAosKGRsZisv3BGekC5W6T1J8XSV+PoUEevACRv7c=
k8s.io/metrics v0.33.3 h1:9CcqBz15JZfISqwca33gdHS8I6XfsK1vA8WUdEnG70g=
k8s.io/metrics v0.33.3/go.mod h1:Aw+cdg4AYHw0HvUY+lCyq40FOO84awrqvJRTw0cmXDs=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
@@ -1085,8 +1094,9 @@ sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 h1:SqYE5+A2qvRhErbsXFfUEUmpWEKxxRSMgGLkvRAFOV4=
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78/go.mod h1:B7Wf0Ya4DHF9Yw+qfZuJijQYkWicqDa+79Ytmmq3Kjg=
tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc=

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.33.0-rc1",
"version": "2.33.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
@@ -125,6 +125,7 @@
"react-is": "^17.0.2",
"react-json-view-lite": "^1.2.1",
"react-select": "^5.2.1",
"react-select-async-paginate": "^0.7.11",
"sanitize-html": "^2.8.1",
"spinkit": "^2.0.1",
"strip-ansi": "^6.0.0",

View File

@@ -35,10 +35,10 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/registryhttp"
"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)
@@ -140,14 +140,6 @@ func authenticateChartSource(actionConfig *action.Configuration, registry *porta
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.
@@ -180,14 +172,14 @@ func authenticateChartSource(actionConfig *action.Configuration, registry *porta
Str("context", "HelmClient").
Msg("Cache miss - creating new registry client")
registryClient, err := loginToOCIRegistry(registry)
registryClient, err := createOCIRegistryClient(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")
Msg("Failed to create registry client")
return errors.Wrap(err, "failed to create registry client")
}
// Cache the client if login was successful (registry ID-based key)
@@ -230,11 +222,13 @@ func configureOCIChartPathOptions(chartPathOptions *action.ChartPathOptions, reg
}
}
// loginToOCIRegistry performs registry login for OCI-based registries using Helm SDK
// createOCIRegistryClient creates and optionally authenticates a registry client for OCI-based registries
// Handles both authenticated and unauthenticated registries with proper TLS configuration
// 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
func createOCIRegistryClient(portainerRegistry *portainer.Registry) (*registry.Client, error) {
// Handle nil registry (HTTP repository)
if portainerRegistry == nil {
return nil, nil
}
// Check cache first using registry ID-based key
@@ -243,32 +237,70 @@ func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client
}
log.Debug().
Str("context", "loginToRegistry").
Str("context", "HelmClient").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Attempting to login to OCI registry")
Bool("authentication", portainerRegistry.Authentication).
Msg("Creating OCI registry client")
registryClient, err := registry.NewClient(registry.ClientOptHTTPClient(retry.DefaultClient))
// Create an HTTP client with proper TLS configuration
httpClient, usePlainHTTP, err := registryhttp.CreateClient(portainerRegistry)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", portainerRegistry.URL).
Err(err).
Msg("Failed to create HTTP client for registry")
return nil, errors.Wrap(err, "failed to create HTTP client for registry")
}
clientOptions := []registry.ClientOption{
registry.ClientOptHTTPClient(httpClient),
}
if usePlainHTTP {
clientOptions = append(clientOptions, registry.ClientOptPlainHTTP())
}
registryClient, err := registry.NewClient(clientOptions...)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", portainerRegistry.URL).
Err(err).
Msg("Failed to create registry client")
return nil, errors.Wrap(err, "failed to create registry client")
}
loginOpts := []registry.LoginOption{
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
// Only perform login if authentication is enabled
if portainerRegistry.Authentication {
loginOpts := []registry.LoginOption{
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
}
err = registryClient.Login(portainerRegistry.URL, loginOpts...)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", portainerRegistry.URL).
Err(err).
Msg("Failed to login to registry")
return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL)
}
log.Debug().
Str("context", "createOCIRegistryClient").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Successfully logged in to OCI registry")
} else {
log.Debug().
Str("context", "createOCIRegistryClient").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Created unauthenticated OCI registry client")
}
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

View File

@@ -6,12 +6,17 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
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 init() {
fips.InitFIPS(false)
}
func TestIsOCIRegistry(t *testing.T) {
t.Run("should return false for nil registry (HTTP repo)", func(t *testing.T) {
assert.False(t, IsOCIRegistry(nil))
@@ -188,19 +193,40 @@ 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)
client, err := createOCIRegistryClient(nil)
is.NoError(err)
is.Nil(client)
})
t.Run("should return nil for registry with auth disabled", func(t *testing.T) {
t.Run("should return client for registry with auth disabled (for TLS support)", func(t *testing.T) {
registry := &portainer.Registry{
URL: "my-registry.io",
Authentication: false,
}
client, err := loginToOCIRegistry(registry)
client, err := createOCIRegistryClient(registry)
is.NoError(err)
is.Nil(client)
is.NotNil(client) // Now returns a client even without auth for potential TLS configuration
})
t.Run("should handle custom TLS configuration without authentication", func(t *testing.T) {
registry := &portainer.Registry{
URL: "my-registry.io",
Authentication: false,
ManagementConfiguration: &portainer.RegistryManagementConfiguration{
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: false,
// In a real scenario, these would point to actual cert files
TLSCACertPath: "",
TLSCertPath: "",
TLSKeyPath: "",
},
},
}
client, err := createOCIRegistryClient(registry)
// Should succeed even without cert files when they're empty strings
is.NoError(err)
is.NotNil(client) // Should get a client configured for TLS
})
t.Run("should return error for invalid credentials", func(t *testing.T) {
@@ -209,7 +235,7 @@ func TestLoginToOCIRegistry(t *testing.T) {
Authentication: true,
Username: " ",
}
client, err := loginToOCIRegistry(registry)
client, err := createOCIRegistryClient(registry)
is.Error(err)
is.Nil(client)
// The error might be a validation error or a login error, both are acceptable
@@ -227,7 +253,7 @@ func TestLoginToOCIRegistry(t *testing.T) {
}
// 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)
client, err := createOCIRegistryClient(registry)
is.Error(err)
is.Nil(client)
is.Contains(err.Error(), "failed to login to registry")
@@ -249,7 +275,7 @@ func TestLoginToOCIRegistry(t *testing.T) {
}
// 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)
client, err := createOCIRegistryClient(registry)
is.Error(err)
is.Nil(client)
is.Contains(err.Error(), "failed to login to registry")

View File

@@ -4,10 +4,10 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/registryhttp"
"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) {
@@ -16,6 +16,15 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
log.Error().Err(err).Str("registryUrl", registry.URL).Msg("Failed to create registry client")
return nil, err
}
// Configure HTTP client based on registry type using the shared utility
httpClient, usePlainHTTP, err := registryhttp.CreateClient(&registry)
if err != nil {
return nil, err
}
registryClient.PlainHTTP = usePlainHTTP
// 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
@@ -29,7 +38,7 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
strings.TrimSpace(registry.Password) != "" {
registryClient.Client = &auth.Client{
Client: retry.DefaultClient,
Client: httpClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(registry.URL, auth.Credential{
Username: registry.Username,
@@ -43,8 +52,8 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
Bool("authentication", true).
Msg("Created ORAS registry client with authentication")
} else {
// Use default client for anonymous access
registryClient.Client = retry.DefaultClient
// Use the configured HTTP client for anonymous access
registryClient.Client = httpClient
log.Debug().
Str("registryURL", registry.URL).

View File

@@ -138,9 +138,16 @@ func TestCreateClient_AuthenticationScenarios(t *testing.T) {
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")
// For anonymous access without custom TLS, all registries should use retry.DefaultClient
// (Only registries with custom TLS configuration use a different retry client)
if tt.registry.ManagementConfiguration == nil || !tt.registry.ManagementConfiguration.TLSConfig.TLS {
assert.Equal(t, retry.DefaultClient, client.Client,
"Expected retry.DefaultClient for anonymous access without custom TLS")
} else {
// Custom TLS configuration means a custom retry client
assert.NotEqual(t, retry.DefaultClient, client.Client,
"Expected custom retry client for registry with custom TLS")
}
}
})
}

View File

@@ -5,6 +5,8 @@ import (
"strings"
"testing"
"time"
"github.com/portainer/portainer/pkg/fips"
)
// Response structs for each function
@@ -110,6 +112,8 @@ func TestProbeTelnetConnection(t *testing.T) {
}
func TestDetectProxy(t *testing.T) {
fips.InitFIPS(false)
tests := []struct {
name string
url string

View File

@@ -0,0 +1,48 @@
package registryhttp
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/rs/zerolog/log"
"oras.land/oras-go/v2/registry/remote/retry"
)
// CreateClient creates an HTTP client with appropriate TLS configuration based on registry type.
// All registries use retry clients for better resilience.
// Returns the HTTP client, whether to use plainHTTP, and any error.
func CreateClient(registry *portainer.Registry) (*http.Client, bool, error) {
switch registry.Type {
case portainer.AzureRegistry, portainer.EcrRegistry, portainer.GithubRegistry, portainer.GitlabRegistry:
// Cloud registries use the default retry client with built-in TLS
return retry.DefaultClient, false, nil
default:
// For all other registry types, check if custom TLS is needed
if registry.ManagementConfiguration != nil && registry.ManagementConfiguration.TLSConfig.TLS {
// Need custom TLS configuration - create a retry client with custom transport
baseTransport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
}
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(
registry.ManagementConfiguration.TLSConfig,
)
if err != nil {
log.Error().Err(err).Msg("Failed to create TLS configuration")
return nil, false, err
}
baseTransport.TLSClientConfig = tlsConfig
// Create a retry transport wrapping our custom base transport
retryTransport := retry.NewTransport(baseTransport)
httpClient := &http.Client{
Transport: retryTransport,
}
return httpClient, false, nil
}
// Default to HTTP for non-cloud registries without TLS configuration
return retry.DefaultClient, true, nil
}
}

View File

@@ -4808,6 +4808,11 @@
"@sagold/json-pointer" "^5.1.2"
ebnf "^1.9.1"
"@seznam/compose-react-refs@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@seznam/compose-react-refs/-/compose-react-refs-1.0.6.tgz#6ec4e70bdd6e32f8e70b4100f27267cf306bd8df"
integrity sha512-izzOXQfeQLonzrIQb8u6LQ8dk+ymz3WXTIXjvOlTXHq6sbzROg3NWU+9TTAOpEoK9Bth24/6F/XrfHJ5yR5n6Q==
"@shikijs/core@1.29.2":
version "1.29.2"
resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-1.29.2.tgz#9c051d3ac99dd06ae46bd96536380c916e552bf3"
@@ -6992,6 +6997,11 @@
loupe "^3.1.2"
tinyrainbow "^1.2.0"
"@vtaits/use-lazy-ref@^0.1.4":
version "0.1.4"
resolved "https://registry.yarnpkg.com/@vtaits/use-lazy-ref/-/use-lazy-ref-0.1.4.tgz#6befc141f4b29f97022259b00c4a5b6c482fe953"
integrity sha512-pdHe8k2WLIm8ccVfNw3HzeTCkifKKjVQ3hpiM7/rMynCp8nev715wrY2RCYnbeowNvekWqpGdHtrWKfCDocC6g==
"@webassemblyjs/ast@1.11.5", "@webassemblyjs/ast@^1.11.5":
version "1.11.5"
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.5.tgz#6e818036b94548c1fb53b754b5cae3c9b208281c"
@@ -12868,6 +12878,11 @@ klona@^2.0.6:
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
krustykrab@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/krustykrab/-/krustykrab-1.1.0.tgz#2b77faf06da9a43fe740799ac73fc2e8b6b515b0"
integrity sha512-xpX9MPbw+nJseewe6who9Oq46RQwrBfps+dO/N4fSjJhsf2+y4XWC2kz46oBGX8yzMHyYJj35ug0X5s5yxB6tA==
kubernetes-types@^1.30.0:
version "1.30.0"
resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116"
@@ -15514,6 +15529,18 @@ react-remove-scroll@^2.6.3:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-select-async-paginate@^0.7.11:
version "0.7.11"
resolved "https://registry.yarnpkg.com/react-select-async-paginate/-/react-select-async-paginate-0.7.11.tgz#737b3fef1beb23dab82c7d2b90059c7b823aae1d"
integrity sha512-AjtCLPMk5DLNgygwQprEPC0gfVIjkou+QYvXM+2gm/LeRpY1Gv5KNT79EYB37H1uMCrwA+HL9BY7OtlaNWtYNg==
dependencies:
"@seznam/compose-react-refs" "^1.0.6"
"@vtaits/use-lazy-ref" "^0.1.4"
krustykrab "^1.1.0"
sleep-promise "^9.1.0"
use-is-mounted-ref "^1.5.0"
use-latest "^1.3.0"
react-select@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.2.1.tgz#416c25c6b79b94687702374e019c4f2ed9d159d6"
@@ -16554,6 +16581,11 @@ slash@^4.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
sleep-promise@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/sleep-promise/-/sleep-promise-9.1.0.tgz#101ebe65700bcd184709da95d960967b02b79d03"
integrity sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==
slice-ansi@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a"
@@ -17908,6 +17940,23 @@ use-callback-ref@^1.3.3:
dependencies:
tslib "^2.0.0"
use-is-mounted-ref@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/use-is-mounted-ref/-/use-is-mounted-ref-1.5.0.tgz#d737e7b30f1bbbaca594f21cdd2621dc52ae8180"
integrity sha512-p5FksHf/ospZUr5KU9ese6u3jp9fzvZ3wuSb50i0y6fdONaHWgmOqQtxR/PUcwi6hnhQDbNxWSg3eTK3N6m+dg==
use-isomorphic-layout-effect@^1.1.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz#2f11a525628f56424521c748feabc2ffcc962fce"
integrity sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==
use-latest@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.3.0.tgz#549b9b0d4c1761862072f0899c6f096eb379137a"
integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-resize-observer@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-9.1.0.tgz#14735235cf3268569c1ea468f8a90c5789fc5c6c"