Compare commits

...

11 Commits

46 changed files with 1216 additions and 186 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -615,7 +615,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.33.0-rc1",
"KubectlShellImage": "portainer/kubectl-shell:2.33.0-rc2",
"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-rc2\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import { PageHeader } from '@@/PageHeader';
import { useSettings } from '../queries';
import { Settings } from '../types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { ApplicationSettingsPanel } from './ApplicationSettingsPanel';
import { BackupSettingsPanel } from './BackupSettingsView';
@@ -15,7 +14,6 @@ import { HelmCertPanel } from './HelmCertPanel';
import { HiddenContainersPanel } from './HiddenContainersPanel/HiddenContainersPanel';
import { KubeSettingsPanel } from './KubeSettingsPanel';
import { SSLSettingsPanelWrapper } from './SSLSettingsPanel/SSLSettingsPanel';
import { ExperimentalFeatures } from './ExperimentalFeatures';
export function SettingsView() {
const settingsQuery = useSettings();
@@ -50,8 +48,6 @@ export function SettingsView() {
<SSLSettingsPanelWrapper />
{isBE && <ExperimentalFeatures />}
<HiddenContainersPanel />
<BackupSettingsPanel />

View File

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

View File

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

View File

@@ -35,10 +35,10 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libhelm/cache"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/registryhttp"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/registry"
"oras.land/oras-go/v2/registry/remote/retry"
)
// IsOCIRegistry returns true if the registry is an OCI registry (not nil), false if it's an HTTP repository (nil)
@@ -140,14 +140,6 @@ func authenticateChartSource(actionConfig *action.Configuration, registry *porta
return errors.Wrap(err, "registry credential validation failed")
}
// No authentication required
if !registry.Authentication {
log.Debug().
Str("context", "HelmClient").
Msg("No OCI registry authentication required")
return nil
}
// Cache Strategy Decision: Use registry ID as cache key
// This provides optimal rate limiting protection since each registry only gets
// logged into once per Portainer instance, regardless of how many users access it.
@@ -180,14 +172,14 @@ func authenticateChartSource(actionConfig *action.Configuration, registry *porta
Str("context", "HelmClient").
Msg("Cache miss - creating new registry client")
registryClient, err := loginToOCIRegistry(registry)
registryClient, err := createOCIRegistryClient(registry)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", registry.URL).
Err(err).
Msg("Failed to login to registry")
return errors.Wrap(err, "failed to login to registry")
Msg("Failed to create registry client")
return errors.Wrap(err, "failed to create registry client")
}
// Cache the client if login was successful (registry ID-based key)
@@ -230,11 +222,13 @@ func configureOCIChartPathOptions(chartPathOptions *action.ChartPathOptions, reg
}
}
// loginToOCIRegistry performs registry login for OCI-based registries using Helm SDK
// createOCIRegistryClient creates and optionally authenticates a registry client for OCI-based registries
// Handles both authenticated and unauthenticated registries with proper TLS configuration
// Tries to get a cached registry client if available, otherwise creates and caches a new one
func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client, error) {
if IsHTTPRepository(portainerRegistry) || !portainerRegistry.Authentication {
return nil, nil // No authentication needed
func createOCIRegistryClient(portainerRegistry *portainer.Registry) (*registry.Client, error) {
// Handle nil registry (HTTP repository)
if portainerRegistry == nil {
return nil, nil
}
// Check cache first using registry ID-based key
@@ -243,32 +237,70 @@ func loginToOCIRegistry(portainerRegistry *portainer.Registry) (*registry.Client
}
log.Debug().
Str("context", "loginToRegistry").
Str("context", "HelmClient").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Attempting to login to OCI registry")
Bool("authentication", portainerRegistry.Authentication).
Msg("Creating OCI registry client")
registryClient, err := registry.NewClient(registry.ClientOptHTTPClient(retry.DefaultClient))
// Create an HTTP client with proper TLS configuration
httpClient, usePlainHTTP, err := registryhttp.CreateClient(portainerRegistry)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", portainerRegistry.URL).
Err(err).
Msg("Failed to create HTTP client for registry")
return nil, errors.Wrap(err, "failed to create HTTP client for registry")
}
clientOptions := []registry.ClientOption{
registry.ClientOptHTTPClient(httpClient),
}
if usePlainHTTP {
clientOptions = append(clientOptions, registry.ClientOptPlainHTTP())
}
registryClient, err := registry.NewClient(clientOptions...)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", portainerRegistry.URL).
Err(err).
Msg("Failed to create registry client")
return nil, errors.Wrap(err, "failed to create registry client")
}
loginOpts := []registry.LoginOption{
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
// Only perform login if authentication is enabled
if portainerRegistry.Authentication {
loginOpts := []registry.LoginOption{
registry.LoginOptBasicAuth(portainerRegistry.Username, portainerRegistry.Password),
}
err = registryClient.Login(portainerRegistry.URL, loginOpts...)
if err != nil {
log.Error().
Str("context", "HelmClient").
Str("registry_url", portainerRegistry.URL).
Err(err).
Msg("Failed to login to registry")
return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL)
}
log.Debug().
Str("context", "createOCIRegistryClient").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Successfully logged in to OCI registry")
} else {
log.Debug().
Str("context", "createOCIRegistryClient").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Created unauthenticated OCI registry client")
}
err = registryClient.Login(portainerRegistry.URL, loginOpts...)
if err != nil {
return nil, errors.Wrapf(err, "failed to login to registry %s", portainerRegistry.URL)
}
log.Debug().
Str("context", "loginToRegistry").
Int("registry_id", int(portainerRegistry.ID)).
Str("registry_url", portainerRegistry.URL).
Msg("Successfully logged in to OCI registry")
// Cache using registry ID-based key
cache.SetCachedRegistryClientByID(portainerRegistry.ID, registryClient)
return registryClient, nil

View File

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

View File

@@ -4,10 +4,10 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/registryhttp"
"github.com/rs/zerolog/log"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/retry"
)
func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
@@ -16,6 +16,15 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
log.Error().Err(err).Str("registryUrl", registry.URL).Msg("Failed to create registry client")
return nil, err
}
// Configure HTTP client based on registry type using the shared utility
httpClient, usePlainHTTP, err := registryhttp.CreateClient(&registry)
if err != nil {
return nil, err
}
registryClient.PlainHTTP = usePlainHTTP
// By default, oras sends multiple requests to get the full list of repos/tags/referrers.
// set a high page size limit for fewer round trips.
// e.g. https://github.com/oras-project/oras-go/blob/v2.6.0/registry/remote/registry.go#L129-L142
@@ -29,7 +38,7 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
strings.TrimSpace(registry.Password) != "" {
registryClient.Client = &auth.Client{
Client: retry.DefaultClient,
Client: httpClient,
Cache: auth.NewCache(),
Credential: auth.StaticCredential(registry.URL, auth.Credential{
Username: registry.Username,
@@ -43,8 +52,8 @@ func CreateClient(registry portainer.Registry) (*remote.Registry, error) {
Bool("authentication", true).
Msg("Created ORAS registry client with authentication")
} else {
// Use default client for anonymous access
registryClient.Client = retry.DefaultClient
// Use the configured HTTP client for anonymous access
registryClient.Client = httpClient
log.Debug().
Str("registryURL", registry.URL).

View File

@@ -138,9 +138,16 @@ func TestCreateClient_AuthenticationScenarios(t *testing.T) {
assert.NotNil(t, authClient, "Auth client should not be nil")
assert.NotNil(t, authClient.Credential, "Credential function should be set")
} else {
// Should use retry.DefaultClient (no authentication)
assert.Equal(t, retry.DefaultClient, client.Client,
"Expected retry.DefaultClient for anonymous access")
// For anonymous access without custom TLS, all registries should use retry.DefaultClient
// (Only registries with custom TLS configuration use a different retry client)
if tt.registry.ManagementConfiguration == nil || !tt.registry.ManagementConfiguration.TLSConfig.TLS {
assert.Equal(t, retry.DefaultClient, client.Client,
"Expected retry.DefaultClient for anonymous access without custom TLS")
} else {
// Custom TLS configuration means a custom retry client
assert.NotEqual(t, retry.DefaultClient, client.Client,
"Expected custom retry client for registry with custom TLS")
}
}
})
}

View File

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

View File

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

View File

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