Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8802c6d1 | ||
|
|
5d3708ec3e | ||
|
|
9320fd4c50 | ||
|
|
974682bd98 | ||
|
|
631f1deb2e | ||
|
|
4169b045fb | ||
|
|
0a2a786aa3 | ||
|
|
808f87206e | ||
|
|
ed6fa82904 | ||
|
|
9fc301110b | ||
|
|
69101ac89a | ||
|
|
69d33dd432 | ||
|
|
389cbf748c | ||
|
|
d01b31f707 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
55
api/datastore/migrator/migrate_2_33_test.go
Normal file
55
api/datastore/migrator/migrate_2_33_test.go
Normal 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())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
70
api/http/handler/file/handler_test.go
Normal file
70
api/http/handler/file/handler_test.go
Normal 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")
|
||||
}
|
||||
91
api/http/handler/file/permissions_list.go
Normal file
91
api/http/handler/file/permissions_list.go
Normal 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=()",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
89
api/pendingactions/pendingactions_test.go
Normal file
89
api/pendingactions/pendingactions_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
330
app/react/components/form-components/ReactSelect.test.tsx
Normal file
330
app/react/components/form-components/ReactSelect.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
41
go.mod
@@ -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
86
go.sum
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(®istry)
|
||||
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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
48
pkg/registryhttp/client.go
Normal file
48
pkg/registryhttp/client.go
Normal 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
|
||||
}
|
||||
}
|
||||
49
yarn.lock
49
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user