diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4b58737c3..b83e3792a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -94,20 +94,11 @@ body: description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed. multiple: false options: - - '2.31.3' - - '2.31.2' - - '2.31.1' - - '2.31.0' - - '2.30.1' - - '2.30.0' - '2.29.2' - '2.29.1' - '2.29.0' - '2.28.1' - '2.28.0' - - '2.27.9' - - '2.27.8' - - '2.27.7' - '2.27.6' - '2.27.5' - '2.27.4' diff --git a/api/cli/cli.go b/api/cli/cli.go index 0722c0b2e..f6035f298 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -61,8 +61,6 @@ func CLIFlags() *portainer.CLIFlags { LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"), KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(), PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(), - TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(), - CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(), } } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 30493a3da..6261efbd9 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -52,7 +52,6 @@ import ( "github.com/portainer/portainer/pkg/libhelm" libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" "github.com/portainer/portainer/pkg/libstack/compose" - "github.com/portainer/portainer/pkg/validate" "github.com/gofrs/uuid" "github.com/rs/zerolog/log" @@ -331,18 +330,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags) } - trustedOrigins := []string{} - if *flags.TrustedOrigins != "" { - // validate if the trusted origins are valid urls - for _, origin := range strings.Split(*flags.TrustedOrigins, ",") { - if !validate.IsTrustedOrigin(origin) { - log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.") - } - - trustedOrigins = append(trustedOrigins, origin) - } - } - fileService := initFileService(*flags.Data) encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName) if encryptionKey == nil { @@ -383,8 +370,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { gitService := git.NewService(shutdownCtx) - // Setting insecureSkipVerify to true to preserve the old behaviour. - openAMTService := openamt.NewService(true) + openAMTService := openamt.NewService() cryptoService := &crypto.Service{} @@ -559,7 +545,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { Status: applicationStatus, BindAddress: *flags.Addr, BindAddressHTTPS: *flags.AddrHTTPS, - CSP: *flags.CSP, HTTPEnabled: sslDBSettings.HTTPEnabled, AssetsPath: *flags.Assets, DataStore: dataStore, @@ -593,7 +578,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { PendingActionsService: pendingActionsService, PlatformService: platformService, PullLimitCheckDisabled: *flags.PullLimitCheckDisabled, - TrustedOrigins: trustedOrigins, } } diff --git a/api/database/boltdb/db.go b/api/database/boltdb/db.go index 32a1b55c3..a0db7f4e0 100644 --- a/api/database/boltdb/db.go +++ b/api/database/boltdb/db.go @@ -138,8 +138,6 @@ func (connection *DbConnection) Open() error { db, err := bolt.Open(databasePath, 0600, &bolt.Options{ Timeout: 1 * time.Second, InitialMmapSize: connection.InitialMmapSize, - FreelistType: bolt.FreelistMapType, - NoFreelistSync: true, }) if err != nil { return err diff --git a/api/dataservices/base.go b/api/dataservices/base.go index 18839b60f..04af70b02 100644 --- a/api/dataservices/base.go +++ b/api/dataservices/base.go @@ -10,7 +10,7 @@ type BaseCRUD[T any, I constraints.Integer] interface { Create(element *T) error Read(ID I) (*T, error) Exists(ID I) (bool, error) - ReadAll(predicates ...func(T) bool) ([]T, error) + ReadAll() ([]T, error) Update(ID I, element *T) error Delete(ID I) error } @@ -56,13 +56,12 @@ func (service BaseDataService[T, I]) Exists(ID I) (bool, error) { return exists, err } -// ReadAll retrieves all the elements that satisfy all the provided predicates. -func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { +func (service BaseDataService[T, I]) ReadAll() ([]T, error) { var collection = make([]T, 0) return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error { var err error - collection, err = service.Tx(tx).ReadAll(predicates...) + collection, err = service.Tx(tx).ReadAll() return err }) diff --git a/api/dataservices/base_test.go b/api/dataservices/base_test.go deleted file mode 100644 index e97a09963..000000000 --- a/api/dataservices/base_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package dataservices - -import ( - "strconv" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/slicesx" - - "github.com/stretchr/testify/require" -) - -type testObject struct { - ID int - Value int -} - -type mockConnection struct { - store map[int]testObject - - portainer.Connection -} - -func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error { - obj := value.(*testObject) - - m.store[obj.ID] = *obj - - return nil -} - -func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error { - for _, v := range m.store { - if _, err := appendFn(&v); err != nil { - return err - } - } - - return nil -} - -func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error { - return fn(m) -} - -func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error { - return fn(m) -} - -func (m mockConnection) ConvertToKey(v int) []byte { - return []byte(strconv.Itoa(v)) -} - -func TestReadAll(t *testing.T) { - service := BaseDataService[testObject, int]{ - Bucket: "testBucket", - Connection: mockConnection{store: make(map[int]testObject)}, - } - - data := []testObject{ - {ID: 1, Value: 1}, - {ID: 2, Value: 2}, - {ID: 3, Value: 3}, - {ID: 4, Value: 4}, - {ID: 5, Value: 5}, - } - - for _, item := range data { - err := service.Update(item.ID, &item) - require.NoError(t, err) - } - - // ReadAll without predicates - result, err := service.ReadAll() - require.NoError(t, err) - - expected := append([]testObject{}, data...) - - require.ElementsMatch(t, expected, result) - - // ReadAll with predicates - hasLowID := func(obj testObject) bool { return obj.ID < 3 } - isEven := func(obj testObject) bool { return obj.Value%2 == 0 } - - result, err = service.ReadAll(hasLowID, isEven) - require.NoError(t, err) - - expected = slicesx.Filter(expected, hasLowID) - expected = slicesx.Filter(expected, isEven) - - require.ElementsMatch(t, expected, result) -} diff --git a/api/dataservices/base_tx.go b/api/dataservices/base_tx.go index 5d7e7eee0..d9915b64c 100644 --- a/api/dataservices/base_tx.go +++ b/api/dataservices/base_tx.go @@ -34,32 +34,13 @@ func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) { return service.Tx.KeyExists(service.Bucket, identifier) } -// ReadAll retrieves all the elements that satisfy all the provided predicates. -func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) { +func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) { var collection = make([]T, 0) - if len(predicates) == 0 { - return collection, service.Tx.GetAll( - service.Bucket, - new(T), - AppendFn(&collection), - ) - } - - filterFn := func(element T) bool { - for _, p := range predicates { - if !p(element) { - return false - } - } - - return true - } - return collection, service.Tx.GetAll( service.Bucket, new(T), - FilterFn(&collection, filterFn), + AppendFn(&collection), ) } diff --git a/api/dataservices/edgegroup/tx.go b/api/dataservices/edgegroup/tx.go index 2fba688a6..19f37e011 100644 --- a/api/dataservices/edgegroup/tx.go +++ b/api/dataservices/edgegroup/tx.go @@ -17,29 +17,11 @@ func (service ServiceTx) UpdateEdgeGroupFunc(ID portainer.EdgeGroupID, updateFun } func (service ServiceTx) Create(group *portainer.EdgeGroup) error { - es := group.Endpoints - group.Endpoints = nil // Clear deprecated field - - err := service.Tx.CreateObject( + return service.Tx.CreateObject( BucketName, func(id uint64) (int, any) { group.ID = portainer.EdgeGroupID(id) return int(group.ID), group }, ) - - group.Endpoints = es // Restore endpoints after create - - return err -} - -func (service ServiceTx) Update(ID portainer.EdgeGroupID, group *portainer.EdgeGroup) error { - es := group.Endpoints - group.Endpoints = nil // Clear deprecated field - - err := service.BaseDataServiceTx.Update(ID, group) - - group.Endpoints = es // Restore endpoints after update - - return err } diff --git a/api/dataservices/edgestackstatus/edgestackstatus.go b/api/dataservices/edgestackstatus/edgestackstatus.go deleted file mode 100644 index 7d063ba49..000000000 --- a/api/dataservices/edgestackstatus/edgestackstatus.go +++ /dev/null @@ -1,89 +0,0 @@ -package edgestackstatus - -import ( - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" -) - -var _ dataservices.EdgeStackStatusService = &Service{} - -const BucketName = "edge_stack_status" - -type Service struct { - conn portainer.Connection -} - -func (service *Service) BucketName() string { - return BucketName -} - -func NewService(connection portainer.Connection) (*Service, error) { - if err := connection.SetServiceName(BucketName); err != nil { - return nil, err - } - - return &Service{conn: connection}, nil -} - -func (s *Service) Tx(tx portainer.Transaction) ServiceTx { - return ServiceTx{ - service: s, - tx: tx, - } -} - -func (s *Service) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { - return s.conn.UpdateTx(func(tx portainer.Transaction) error { - return s.Tx(tx).Create(edgeStackID, endpointID, status) - }) -} - -func (s *Service) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) { - var element *portainer.EdgeStackStatusForEnv - - return element, s.conn.ViewTx(func(tx portainer.Transaction) error { - var err error - element, err = s.Tx(tx).Read(edgeStackID, endpointID) - - return err - }) -} - -func (s *Service) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) { - var collection = make([]portainer.EdgeStackStatusForEnv, 0) - - return collection, s.conn.ViewTx(func(tx portainer.Transaction) error { - var err error - collection, err = s.Tx(tx).ReadAll(edgeStackID) - - return err - }) -} - -func (s *Service) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { - return s.conn.UpdateTx(func(tx portainer.Transaction) error { - return s.Tx(tx).Update(edgeStackID, endpointID, status) - }) -} - -func (s *Service) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error { - return s.conn.UpdateTx(func(tx portainer.Transaction) error { - return s.Tx(tx).Delete(edgeStackID, endpointID) - }) -} - -func (s *Service) DeleteAll(edgeStackID portainer.EdgeStackID) error { - return s.conn.UpdateTx(func(tx portainer.Transaction) error { - return s.Tx(tx).DeleteAll(edgeStackID) - }) -} - -func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error { - return s.conn.UpdateTx(func(tx portainer.Transaction) error { - return s.Tx(tx).Clear(edgeStackID, relatedEnvironmentsIDs) - }) -} - -func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte { - return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...) -} diff --git a/api/dataservices/edgestackstatus/tx.go b/api/dataservices/edgestackstatus/tx.go deleted file mode 100644 index b0dc14856..000000000 --- a/api/dataservices/edgestackstatus/tx.go +++ /dev/null @@ -1,95 +0,0 @@ -package edgestackstatus - -import ( - "fmt" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" -) - -var _ dataservices.EdgeStackStatusService = &Service{} - -type ServiceTx struct { - service *Service - tx portainer.Transaction -} - -func (service ServiceTx) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { - identifier := service.service.key(edgeStackID, endpointID) - return service.tx.CreateObjectWithStringId(BucketName, identifier, status) -} - -func (s ServiceTx) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) { - var status portainer.EdgeStackStatusForEnv - identifier := s.service.key(edgeStackID, endpointID) - - if err := s.tx.GetObject(BucketName, identifier, &status); err != nil { - return nil, err - } - - return &status, nil -} - -func (s ServiceTx) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) { - keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID)) - - statuses := make([]portainer.EdgeStackStatusForEnv, 0) - - if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil { - return nil, fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err) - } - - return statuses, nil -} - -func (s ServiceTx) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error { - identifier := s.service.key(edgeStackID, endpointID) - return s.tx.UpdateObject(BucketName, identifier, status) -} - -func (s ServiceTx) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error { - identifier := s.service.key(edgeStackID, endpointID) - return s.tx.DeleteObject(BucketName, identifier) -} - -func (s ServiceTx) DeleteAll(edgeStackID portainer.EdgeStackID) error { - keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID)) - - statuses := make([]portainer.EdgeStackStatusForEnv, 0) - - if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil { - return fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err) - } - - for _, status := range statuses { - if err := s.tx.DeleteObject(BucketName, s.service.key(edgeStackID, status.EndpointID)); err != nil { - return fmt.Errorf("unable to delete EdgeStackStatus for EdgeStack %d and Endpoint %d: %w", edgeStackID, status.EndpointID, err) - } - } - - return nil -} - -func (s ServiceTx) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error { - for _, envID := range relatedEnvironmentsIDs { - existingStatus, err := s.Read(edgeStackID, envID) - if err != nil && !dataservices.IsErrObjectNotFound(err) { - return fmt.Errorf("unable to retrieve status for environment %d: %w", envID, err) - } - - var deploymentInfo portainer.StackDeploymentInfo - if existingStatus != nil { - deploymentInfo = existingStatus.DeploymentInfo - } - - if err := s.Update(edgeStackID, envID, &portainer.EdgeStackStatusForEnv{ - EndpointID: envID, - Status: []portainer.EdgeStackDeploymentStatus{}, - DeploymentInfo: deploymentInfo, - }); err != nil { - return err - } - } - - return nil -} diff --git a/api/dataservices/endpointrelation/endpointrelation.go b/api/dataservices/endpointrelation/endpointrelation.go index 556a046bb..a81c258b9 100644 --- a/api/dataservices/endpointrelation/endpointrelation.go +++ b/api/dataservices/endpointrelation/endpointrelation.go @@ -112,13 +112,13 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID, } func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { - return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.connection.ViewTx(func(tx portainer.Transaction) error { return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) }) } func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error { - return service.connection.UpdateTx(func(tx portainer.Transaction) error { + return service.connection.ViewTx(func(tx portainer.Transaction) error { return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID) }) } diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index d330d4959..8ba55531c 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -12,7 +12,6 @@ type ( EdgeGroup() EdgeGroupService EdgeJob() EdgeJobService EdgeStack() EdgeStackService - EdgeStackStatus() EdgeStackStatusService Endpoint() EndpointService EndpointGroup() EndpointGroupService EndpointRelation() EndpointRelationService @@ -40,8 +39,8 @@ type ( Open() (newStore bool, err error) Init() error Close() error - UpdateTx(func(tx DataStoreTx) error) error - ViewTx(func(tx DataStoreTx) error) error + UpdateTx(func(DataStoreTx) error) error + ViewTx(func(DataStoreTx) error) error MigrateData() error Rollback(force bool) error CheckCurrentEdition() error @@ -90,16 +89,6 @@ type ( BucketName() string } - EdgeStackStatusService interface { - Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error - Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) - ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) - Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error - Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error - DeleteAll(edgeStackID portainer.EdgeStackID) error - Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error - } - // EndpointService represents a service for managing environment(endpoint) data EndpointService interface { Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) diff --git a/api/dataservices/snapshot/snapshot.go b/api/dataservices/snapshot/snapshot.go index c0066317d..155077677 100644 --- a/api/dataservices/snapshot/snapshot.go +++ b/api/dataservices/snapshot/snapshot.go @@ -51,20 +51,3 @@ func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portai return snapshot, err } - -func (service *Service) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) { - var snapshot *portainer.SnapshotRawMessage - - err := service.Connection.ViewTx(func(tx portainer.Transaction) error { - var err error - snapshot, err = service.Tx(tx).ReadRawMessage(ID) - - return err - }) - - return snapshot, err -} - -func (service *Service) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error { - return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot) -} diff --git a/api/dataservices/snapshot/tx.go b/api/dataservices/snapshot/tx.go index 45d1df9fc..8a8dcc1c2 100644 --- a/api/dataservices/snapshot/tx.go +++ b/api/dataservices/snapshot/tx.go @@ -35,19 +35,3 @@ func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*porta return &snapshot.Snapshot, nil } - -func (service ServiceTx) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) { - var snapshot = portainer.SnapshotRawMessage{} - - identifier := service.Connection.ConvertToKey(int(ID)) - - if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil { - return nil, err - } - - return &snapshot, nil -} - -func (service ServiceTx) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error { - return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot) -} diff --git a/api/datastore/migrate_data.go b/api/datastore/migrate_data.go index 2b53bbb9c..8047274d1 100644 --- a/api/datastore/migrate_data.go +++ b/api/datastore/migrate_data.go @@ -40,11 +40,13 @@ func (store *Store) MigrateData() error { } // before we alter anything in the DB, create a backup - if _, err := store.Backup(""); err != nil { + _, err = store.Backup("") + if err != nil { return errors.Wrap(err, "while backing up database") } - if err := store.FailSafeMigrate(migrator, version); err != nil { + err = store.FailSafeMigrate(migrator, version) + if err != nil { err = errors.Wrap(err, "failed to migrate database") log.Warn().Err(err).Msg("migration failed, restoring database to previous version") @@ -83,9 +85,7 @@ func (store *Store) newMigratorParameters(version *models.Version, flags *portai DockerhubService: store.DockerHubService, AuthorizationService: authorization.NewService(store), EdgeStackService: store.EdgeStackService, - EdgeStackStatusService: store.EdgeStackStatusService, EdgeJobService: store.EdgeJobService, - EdgeGroupService: store.EdgeGroupService, TunnelServerService: store.TunnelServerService, PendingActionsService: store.PendingActionsService, } @@ -140,7 +140,8 @@ func (store *Store) connectionRollback(force bool) error { } } - if err := store.Restore(); err != nil { + err := store.Restore() + if err != nil { return err } diff --git a/api/datastore/migrator/migrate_2_31_0.go b/api/datastore/migrator/migrate_2_31_0.go deleted file mode 100644 index 7afea9802..000000000 --- a/api/datastore/migrator/migrate_2_31_0.go +++ /dev/null @@ -1,31 +0,0 @@ -package migrator - -import portainer "github.com/portainer/portainer/api" - -func (m *Migrator) migrateEdgeStacksStatuses_2_31_0() error { - edgeStacks, err := m.edgeStackService.EdgeStacks() - if err != nil { - return err - } - - for _, edgeStack := range edgeStacks { - for envID, status := range edgeStack.Status { - if err := m.edgeStackStatusService.Create(edgeStack.ID, envID, &portainer.EdgeStackStatusForEnv{ - EndpointID: envID, - Status: status.Status, - DeploymentInfo: status.DeploymentInfo, - ReadyRePullImage: status.ReadyRePullImage, - }); err != nil { - return err - } - } - - edgeStack.Status = nil - - if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil { - return err - } - } - - return nil -} diff --git a/api/datastore/migrator/migrate_2_32_0.go b/api/datastore/migrator/migrate_2_32_0.go deleted file mode 100644 index c32a63cad..000000000 --- a/api/datastore/migrator/migrate_2_32_0.go +++ /dev/null @@ -1,33 +0,0 @@ -package migrator - -import ( - "github.com/pkg/errors" - portainer "github.com/portainer/portainer/api" - perrors "github.com/portainer/portainer/api/dataservices/errors" - "github.com/portainer/portainer/api/internal/endpointutils" -) - -func (m *Migrator) addEndpointRelationForEdgeAgents_2_32_0() error { - endpoints, err := m.endpointService.Endpoints() - if err != nil { - return err - } - - for _, endpoint := range endpoints { - if endpointutils.IsEdgeEndpoint(&endpoint) { - _, err := m.endpointRelationService.EndpointRelation(endpoint.ID) - if err != nil && errors.Is(err, perrors.ErrObjectNotFound) { - relation := &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: make(map[portainer.EdgeStackID]bool), - } - - if err := m.endpointRelationService.Create(relation); err != nil { - return err - } - } - } - } - - return nil -} diff --git a/api/datastore/migrator/migrate_2_33_0.go b/api/datastore/migrator/migrate_2_33_0.go deleted file mode 100644 index f000a780a..000000000 --- a/api/datastore/migrator/migrate_2_33_0.go +++ /dev/null @@ -1,23 +0,0 @@ -package migrator - -import ( - "github.com/portainer/portainer/api/roar" -) - -func (m *Migrator) migrateEdgeGroupEndpointsToRoars_2_33_0() error { - egs, err := m.edgeGroupService.ReadAll() - if err != nil { - return err - } - - for _, eg := range egs { - eg.EndpointIDs = roar.FromSlice(eg.Endpoints) - eg.Endpoints = nil - - if err := m.edgeGroupService.Update(eg.ID, &eg); err != nil { - return err - } - } - - return nil -} diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go index df27cc0cd..dc92006ad 100644 --- a/api/datastore/migrator/migrator.go +++ b/api/datastore/migrator/migrator.go @@ -3,13 +3,12 @@ package migrator import ( "errors" + "github.com/Masterminds/semver" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/database/models" "github.com/portainer/portainer/api/dataservices/dockerhub" - "github.com/portainer/portainer/api/dataservices/edgegroup" "github.com/portainer/portainer/api/dataservices/edgejob" "github.com/portainer/portainer/api/dataservices/edgestack" - "github.com/portainer/portainer/api/dataservices/edgestackstatus" "github.com/portainer/portainer/api/dataservices/endpoint" "github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointrelation" @@ -28,8 +27,6 @@ import ( "github.com/portainer/portainer/api/dataservices/user" "github.com/portainer/portainer/api/dataservices/version" "github.com/portainer/portainer/api/internal/authorization" - - "github.com/Masterminds/semver" "github.com/rs/zerolog/log" ) @@ -59,9 +56,7 @@ type ( authorizationService *authorization.Service dockerhubService *dockerhub.Service edgeStackService *edgestack.Service - edgeStackStatusService *edgestackstatus.Service edgeJobService *edgejob.Service - edgeGroupService *edgegroup.Service TunnelServerService *tunnelserver.Service pendingActionsService *pendingactions.Service } @@ -89,9 +84,7 @@ type ( AuthorizationService *authorization.Service DockerhubService *dockerhub.Service EdgeStackService *edgestack.Service - EdgeStackStatusService *edgestackstatus.Service EdgeJobService *edgejob.Service - EdgeGroupService *edgegroup.Service TunnelServerService *tunnelserver.Service PendingActionsService *pendingactions.Service } @@ -121,15 +114,12 @@ func NewMigrator(parameters *MigratorParameters) *Migrator { authorizationService: parameters.AuthorizationService, dockerhubService: parameters.DockerhubService, edgeStackService: parameters.EdgeStackService, - edgeStackStatusService: parameters.EdgeStackStatusService, edgeJobService: parameters.EdgeJobService, - edgeGroupService: parameters.EdgeGroupService, TunnelServerService: parameters.TunnelServerService, pendingActionsService: parameters.PendingActionsService, } migrator.initMigrations() - return migrator } @@ -252,12 +242,6 @@ func (m *Migrator) initMigrations() { m.migratePendingActionsDataForDB130, ) - m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0) - - m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0) - - 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. } diff --git a/api/datastore/services.go b/api/datastore/services.go index b0570fa67..b5363afe9 100644 --- a/api/datastore/services.go +++ b/api/datastore/services.go @@ -13,7 +13,6 @@ import ( "github.com/portainer/portainer/api/dataservices/edgegroup" "github.com/portainer/portainer/api/dataservices/edgejob" "github.com/portainer/portainer/api/dataservices/edgestack" - "github.com/portainer/portainer/api/dataservices/edgestackstatus" "github.com/portainer/portainer/api/dataservices/endpoint" "github.com/portainer/portainer/api/dataservices/endpointgroup" "github.com/portainer/portainer/api/dataservices/endpointrelation" @@ -40,8 +39,6 @@ import ( "github.com/segmentio/encoding/json" ) -var _ dataservices.DataStore = &Store{} - // Store defines the implementation of portainer.DataStore using // BoltDB as the storage system. type Store struct { @@ -54,7 +51,6 @@ type Store struct { EdgeGroupService *edgegroup.Service EdgeJobService *edgejob.Service EdgeStackService *edgestack.Service - EdgeStackStatusService *edgestackstatus.Service EndpointGroupService *endpointgroup.Service EndpointService *endpoint.Service EndpointRelationService *endpointrelation.Service @@ -113,12 +109,6 @@ func (store *Store) initServices() error { store.EdgeStackService = edgeStackService endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc, edgeStackService.UpdateEdgeStackFuncTx) - edgeStackStatusService, err := edgestackstatus.NewService(store.connection) - if err != nil { - return err - } - store.EdgeStackStatusService = edgeStackStatusService - edgeGroupService, err := edgegroup.NewService(store.connection) if err != nil { return err @@ -279,10 +269,6 @@ func (store *Store) EdgeStack() dataservices.EdgeStackService { return store.EdgeStackService } -func (store *Store) EdgeStackStatus() dataservices.EdgeStackStatusService { - return store.EdgeStackStatusService -} - // Environment(Endpoint) gives access to the Environment(Endpoint) data management layer func (store *Store) Endpoint() dataservices.EndpointService { return store.EndpointService diff --git a/api/datastore/services_tx.go b/api/datastore/services_tx.go index cf9f868f4..ddedf20cc 100644 --- a/api/datastore/services_tx.go +++ b/api/datastore/services_tx.go @@ -32,10 +32,6 @@ func (tx *StoreTx) EdgeStack() dataservices.EdgeStackService { return tx.store.EdgeStackService.Tx(tx.tx) } -func (tx *StoreTx) EdgeStackStatus() dataservices.EdgeStackStatusService { - return tx.store.EdgeStackStatusService.Tx(tx.tx) -} - func (tx *StoreTx) Endpoint() dataservices.EndpointService { return tx.store.EndpointService.Tx(tx.tx) } diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 5e8b0eefa..b0078c326 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -8,7 +8,6 @@ } ], "edge_stack": null, - "edge_stack_status": null, "edgegroups": null, "edgejobs": null, "endpoint_groups": [ @@ -121,10 +120,6 @@ "Ecr": { "Region": "" }, - "Github": { - "OrganisationName": "", - "UseOrganisation": false - }, "Gitlab": { "InstanceURL": "", "ProjectId": 0, @@ -615,7 +610,7 @@ "RequiredPasswordLength": 12 }, "KubeconfigExpiry": "0", - "KubectlShellImage": "portainer/kubectl-shell:2.32.0", + "KubectlShellImage": "portainer/kubectl-shell:2.30.0", "LDAPSettings": { "AnonymousMode": true, "AutoCreateUsers": true, @@ -683,11 +678,14 @@ "Images": null, "Info": { "Architecture": "", + "BridgeNfIp6tables": false, + "BridgeNfIptables": false, "CDISpecDirs": null, "CPUSet": false, "CPUShares": false, "CgroupDriver": "", "ContainerdCommit": { + "Expected": "", "ID": "" }, "Containers": 0, @@ -711,6 +709,7 @@ "IndexServerAddress": "", "InitBinary": "", "InitCommit": { + "Expected": "", "ID": "" }, "Isolation": "", @@ -739,6 +738,7 @@ }, "RegistryConfig": null, "RuncCommit": { + "Expected": "", "ID": "" }, "Runtimes": null, @@ -780,7 +780,6 @@ "ImageCount": 9, "IsPodman": false, "NodeCount": 0, - "PerformanceMetrics": null, "RunningContainerCount": 5, "ServiceCount": 0, "StackCount": 2, @@ -944,7 +943,7 @@ } ], "version": { - "VERSION": "{\"SchemaVersion\":\"2.32.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" + "VERSION": "{\"SchemaVersion\":\"2.30.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" }, "webhooks": null } \ No newline at end of file diff --git a/api/git/azure_integration_test.go b/api/git/azure_integration_test.go index 5de18b303..3e297a129 100644 --- a/api/git/azure_integration_test.go +++ b/api/git/azure_integration_test.go @@ -58,15 +58,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) { t.Run(tt.name, func(t *testing.T) { dst := t.TempDir() repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password) - err := service.CloneRepository( - dst, - repositoryUrl, - tt.args.referenceName, - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - ) + err := service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "", false) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) }) @@ -81,15 +73,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) { dst := t.TempDir() - err := service.CloneRepository( - dst, - privateAzureRepoURL, - "refs/heads/main", - "", - pat, - gittypes.GitCredentialAuthType_Basic, - false, - ) + err := service.CloneRepository(dst, privateAzureRepoURL, "refs/heads/main", "", pat, false) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) } @@ -100,14 +84,7 @@ func TestService_LatestCommitID_Azure(t *testing.T) { pat := getRequiredValue(t, "AZURE_DEVOPS_PAT") service := NewService(context.TODO()) - id, err := service.LatestCommitID( - privateAzureRepoURL, - "refs/heads/main", - "", - pat, - gittypes.GitCredentialAuthType_Basic, - false, - ) + id, err := service.LatestCommitID(privateAzureRepoURL, "refs/heads/main", "", pat, false) assert.NoError(t, err) assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") } @@ -119,14 +96,7 @@ func TestService_ListRefs_Azure(t *testing.T) { username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") service := NewService(context.TODO()) - refs, err := service.ListRefs( - privateAzureRepoURL, - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - ) + refs, err := service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) } @@ -138,8 +108,8 @@ func TestService_ListRefs_Azure_Concurrently(t *testing.T) { username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) - go service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListRefs(privateAzureRepoURL, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + go service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) + service.ListRefs(privateAzureRepoURL, username, accessToken, false, false) time.Sleep(2 * time.Second) } @@ -277,17 +247,7 @@ func TestService_ListFiles_Azure(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - paths, err := service.ListFiles( - tt.args.repositoryUrl, - tt.args.referenceName, - tt.args.username, - tt.args.password, - gittypes.GitCredentialAuthType_Basic, - false, - false, - tt.extensions, - false, - ) + paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false) if tt.expect.shouldFail { assert.Error(t, err) if tt.expect.err != nil { @@ -310,28 +270,8 @@ func TestService_ListFiles_Azure_Concurrently(t *testing.T) { username := getRequiredValue(t, "AZURE_DEVOPS_USERNAME") service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) - go service.ListFiles( - privateAzureRepoURL, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) - service.ListFiles( - privateAzureRepoURL, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + go service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false) + service.ListFiles(privateAzureRepoURL, "refs/heads/main", username, accessToken, false, false, []string{}, false) time.Sleep(2 * time.Second) } diff --git a/api/git/backup.go b/api/git/backup.go index 6928f521a..286b51876 100644 --- a/api/git/backup.go +++ b/api/git/backup.go @@ -19,7 +19,6 @@ type CloneOptions struct { ReferenceName string Username string Password string - AuthType gittypes.GitCredentialAuthType // TLSSkipVerify skips SSL verification when cloning the Git repository TLSSkipVerify bool `example:"false"` } @@ -43,15 +42,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File cleanUp = true - if err := gitService.CloneRepository( - options.ProjectPath, - options.URL, - options.ReferenceName, - options.Username, - options.Password, - options.AuthType, - options.TLSSkipVerify, - ); err != nil { + if err := gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify); err != nil { cleanUp = false if err := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false); err != nil { log.Warn().Err(err).Msg("failed restoring backup folder") diff --git a/api/git/git.go b/api/git/git.go index cf0c9f478..6c2835815 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -7,14 +7,12 @@ import ( "strings" gittypes "github.com/portainer/portainer/api/git/types" - "github.com/rs/zerolog/log" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" "github.com/pkg/errors" @@ -35,7 +33,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e URL: opt.repositoryUrl, Depth: opt.depth, InsecureSkipTLS: opt.tlsSkipVerify, - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), Tags: git.NoTags, } @@ -53,10 +51,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e } if !c.preserveGitDirectory { - err := os.RemoveAll(filepath.Join(dst, ".git")) - if err != nil { - log.Error().Err(err).Msg("failed to remove .git directory") - } + os.RemoveAll(filepath.Join(dst, ".git")) } return nil @@ -69,7 +64,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string }) listOptions := &git.ListOptions{ - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), InsecureSkipTLS: opt.tlsSkipVerify, } @@ -99,23 +94,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName) } -func getAuth(authType gittypes.GitCredentialAuthType, username, password string) transport.AuthMethod { - if password == "" { - return nil - } - - switch authType { - case gittypes.GitCredentialAuthType_Basic: - return getBasicAuth(username, password) - case gittypes.GitCredentialAuthType_Token: - return getTokenAuth(password) - default: - log.Warn().Msg("unknown git credentials authorization type, defaulting to None") - return nil - } -} - -func getBasicAuth(username, password string) *githttp.BasicAuth { +func getAuth(username, password string) *githttp.BasicAuth { if password != "" { if username == "" { username = "token" @@ -129,15 +108,6 @@ func getBasicAuth(username, password string) *githttp.BasicAuth { return nil } -func getTokenAuth(token string) *githttp.TokenAuth { - if token != "" { - return &githttp.TokenAuth{ - Token: token, - } - } - return nil -} - func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, error) { rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ Name: "origin", @@ -145,7 +115,7 @@ func (c *gitClient) listRefs(ctx context.Context, opt baseOption) ([]string, err }) listOptions := &git.ListOptions{ - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), InsecureSkipTLS: opt.tlsSkipVerify, } @@ -173,7 +143,7 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e Depth: 1, SingleBranch: true, ReferenceName: plumbing.ReferenceName(opt.referenceName), - Auth: getAuth(opt.authType, opt.username, opt.password), + Auth: getAuth(opt.username, opt.password), InsecureSkipTLS: opt.tlsSkipVerify, Tags: git.NoTags, } diff --git a/api/git/git_integration_test.go b/api/git/git_integration_test.go index 6cb10253a..add10afd6 100644 --- a/api/git/git_integration_test.go +++ b/api/git/git_integration_test.go @@ -2,8 +2,6 @@ package git import ( "context" - "net/http" - "net/http/httptest" "path/filepath" "testing" "time" @@ -26,15 +24,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) { dst := t.TempDir() repositoryUrl := privateGitRepoURL - err := service.CloneRepository( - dst, - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - ) + err := service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken, false) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) } @@ -47,14 +37,7 @@ func TestService_LatestCommitID_GitHub(t *testing.T) { service := newService(context.TODO(), 0, 0) repositoryUrl := privateGitRepoURL - id, err := service.LatestCommitID( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - ) + id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken, false) assert.NoError(t, err) assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") } @@ -67,7 +50,7 @@ func TestService_ListRefs_GitHub(t *testing.T) { service := newService(context.TODO(), 0, 0) repositoryUrl := privateGitRepoURL - refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) } @@ -80,8 +63,8 @@ func TestService_ListRefs_Github_Concurrently(t *testing.T) { service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) repositoryUrl := privateGitRepoURL - go service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + go service.ListRefs(repositoryUrl, username, accessToken, false, false) + service.ListRefs(repositoryUrl, username, accessToken, false, false) time.Sleep(2 * time.Second) } @@ -219,17 +202,7 @@ func TestService_ListFiles_GitHub(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - paths, err := service.ListFiles( - tt.args.repositoryUrl, - tt.args.referenceName, - tt.args.username, - tt.args.password, - gittypes.GitCredentialAuthType_Basic, - false, - false, - tt.extensions, - false, - ) + paths, err := service.ListFiles(tt.args.repositoryUrl, tt.args.referenceName, tt.args.username, tt.args.password, false, false, tt.extensions, false) if tt.expect.shouldFail { assert.Error(t, err) if tt.expect.err != nil { @@ -253,28 +226,8 @@ func TestService_ListFiles_Github_Concurrently(t *testing.T) { username := getRequiredValue(t, "GITHUB_USERNAME") service := newService(context.TODO(), repositoryCacheSize, 200*time.Millisecond) - go service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) - service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + go service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) + service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) time.Sleep(2 * time.Second) } @@ -287,18 +240,8 @@ func TestService_purgeCache_Github(t *testing.T) { username := getRequiredValue(t, "GITHUB_USERNAME") service := NewService(context.TODO()) - service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + service.ListRefs(repositoryUrl, username, accessToken, false, false) + service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len()) @@ -318,18 +261,8 @@ func TestService_purgeCacheByTTL_Github(t *testing.T) { // 40*timeout is designed for giving enough time for ListRefs and ListFiles to cache the result service := newService(context.TODO(), 2, 40*timeout) - service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) - service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + service.ListRefs(repositoryUrl, username, accessToken, false, false) + service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.Equal(t, 1, service.repoRefCache.Len()) assert.Equal(t, 1, service.repoFileCache.Len()) @@ -360,12 +293,12 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) { service := newService(context.TODO(), 2, 0) repositoryUrl := privateGitRepoURL - refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) assert.Equal(t, 1, service.repoRefCache.Len()) - _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false) + _, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false) assert.Error(t, err) assert.Equal(t, 1, service.repoRefCache.Len()) } @@ -378,46 +311,26 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) { service := newService(context.TODO(), 2, 0) repositoryUrl := privateGitRepoURL - refs, err := service.ListRefs(repositoryUrl, username, accessToken, gittypes.GitCredentialAuthType_Basic, false, false) + refs, err := service.ListRefs(repositoryUrl, username, accessToken, false, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(refs), 1) assert.Equal(t, 1, service.repoRefCache.Len()) - files, err := service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(files), 1) assert.Equal(t, 1, service.repoFileCache.Len()) - files, err = service.ListFiles( - repositoryUrl, - "refs/heads/test", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + files, err = service.ListFiles(repositoryUrl, "refs/heads/test", username, accessToken, false, false, []string{}, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(files), 1) assert.Equal(t, 2, service.repoFileCache.Len()) - _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, false, false) + _, err = service.ListRefs(repositoryUrl, username, "fake-token", false, false) assert.Error(t, err) assert.Equal(t, 1, service.repoRefCache.Len()) - _, err = service.ListRefs(repositoryUrl, username, "fake-token", gittypes.GitCredentialAuthType_Basic, true, false) + _, err = service.ListRefs(repositoryUrl, username, "fake-token", true, false) assert.Error(t, err) assert.Equal(t, 1, service.repoRefCache.Len()) // The relevant file caches should be removed too @@ -431,72 +344,12 @@ func TestService_HardRefresh_ListFiles_GitHub(t *testing.T) { accessToken := getRequiredValue(t, "GITHUB_PAT") username := getRequiredValue(t, "GITHUB_USERNAME") repositoryUrl := privateGitRepoURL - files, err := service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{}, - false, - ) + files, err := service.ListFiles(repositoryUrl, "refs/heads/main", username, accessToken, false, false, []string{}, false) assert.NoError(t, err) assert.GreaterOrEqual(t, len(files), 1) assert.Equal(t, 1, service.repoFileCache.Len()) - _, err = service.ListFiles( - repositoryUrl, - "refs/heads/main", - username, - "fake-token", - gittypes.GitCredentialAuthType_Basic, - false, - true, - []string{}, - false, - ) + _, err = service.ListFiles(repositoryUrl, "refs/heads/main", username, "fake-token", false, true, []string{}, false) assert.Error(t, err) assert.Equal(t, 0, service.repoFileCache.Len()) } - -func TestService_CloneRepository_TokenAuth(t *testing.T) { - ensureIntegrationTest(t) - - service := newService(context.TODO(), 2, 0) - var requests []*http.Request - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests = append(requests, r) - })) - accessToken := "test_access_token" - username := "test_username" - repositoryUrl := testServer.URL - - // Since we aren't hitting a real git server we ignore the error - _ = service.CloneRepository( - "test_dir", - repositoryUrl, - "refs/heads/main", - username, - accessToken, - gittypes.GitCredentialAuthType_Token, - false, - ) - - testServer.Close() - - if len(requests) != 1 { - t.Fatalf("expected 1 request sent but got %d", len(requests)) - } - - gotAuthHeader := requests[0].Header.Get("Authorization") - if gotAuthHeader == "" { - t.Fatal("no Authorization header in git request") - } - - expectedAuthHeader := "Bearer test_access_token" - if gotAuthHeader != expectedAuthHeader { - t.Fatalf("expected Authorization header %q but got %q", expectedAuthHeader, gotAuthHeader) - } -} diff --git a/api/git/git_test.go b/api/git/git_test.go index fc0db196d..81efa2688 100644 --- a/api/git/git_test.go +++ b/api/git/git_test.go @@ -38,7 +38,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) { dir := t.TempDir() t.Logf("Cloning into %s", dir) - err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false) + err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false) assert.NoError(t, err) assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth") } @@ -50,7 +50,7 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) { dir := t.TempDir() t.Logf("Cloning into %s", dir) - err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false) + err := service.CloneRepository(dir, repositoryURL, referenceName, "", "", false) assert.NoError(t, err) assert.NoDirExists(t, filepath.Join(dir, ".git")) } @@ -84,7 +84,7 @@ func Test_latestCommitID(t *testing.T) { repositoryURL := setup(t) referenceName := "refs/heads/main" - id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", gittypes.GitCredentialAuthType_Basic, false) + id, err := service.LatestCommitID(repositoryURL, referenceName, "", "", false) assert.NoError(t, err) assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id) @@ -95,7 +95,7 @@ func Test_ListRefs(t *testing.T) { repositoryURL := setup(t) - fs, err := service.ListRefs(repositoryURL, "", "", gittypes.GitCredentialAuthType_Basic, false, false) + fs, err := service.ListRefs(repositoryURL, "", "", false, false) assert.NoError(t, err) assert.Equal(t, []string{"refs/heads/main"}, fs) @@ -107,17 +107,7 @@ func Test_ListFiles(t *testing.T) { repositoryURL := setup(t) referenceName := "refs/heads/main" - fs, err := service.ListFiles( - repositoryURL, - referenceName, - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - false, - []string{".yml"}, - false, - ) + fs, err := service.ListFiles(repositoryURL, referenceName, "", "", false, false, []string{".yml"}, false) assert.NoError(t, err) assert.Equal(t, []string{"docker-compose.yml"}, fs) @@ -265,7 +255,7 @@ func Test_listFilesPrivateRepository(t *testing.T) { name: "list tree with real repository and head ref but no credential", args: fetchOption{ baseOption: baseOption{ - repositoryUrl: privateGitRepoURL, + repositoryUrl: privateGitRepoURL + "fake", username: "", password: "", }, diff --git a/api/git/service.go b/api/git/service.go index 834e0c827..3e995eccd 100644 --- a/api/git/service.go +++ b/api/git/service.go @@ -8,7 +8,6 @@ import ( "time" lru "github.com/hashicorp/golang-lru" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/rs/zerolog/log" "golang.org/x/sync/singleflight" ) @@ -23,7 +22,6 @@ type baseOption struct { repositoryUrl string username string password string - authType gittypes.GitCredentialAuthType tlsSkipVerify bool } @@ -125,22 +123,13 @@ func (service *Service) timerHasStopped() bool { // CloneRepository clones a git repository using the specified URL in the specified // destination folder. -func (service *Service) CloneRepository( - destination, - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error { options := cloneOption{ fetchOption: fetchOption{ baseOption: baseOption{ repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, }, referenceName: referenceName, @@ -166,20 +155,12 @@ func (service *Service) cloneRepository(destination string, options cloneOption) } // LatestCommitID returns SHA1 of the latest commit of the specified reference -func (service *Service) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { options := fetchOption{ baseOption: baseOption{ repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, }, referenceName: referenceName, @@ -189,14 +170,7 @@ func (service *Service) LatestCommitID( } // ListRefs will list target repository's references without cloning the repository -func (service *Service) ListRefs( - repositoryURL, - username, - password string, - authType gittypes.GitCredentialAuthType, - hardRefresh bool, - tlsSkipVerify bool, -) ([]string, error) { +func (service *Service) ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { refCacheKey := generateCacheKey(repositoryURL, username, password, strconv.FormatBool(tlsSkipVerify)) if service.cacheEnabled && hardRefresh { // Should remove the cache explicitly, so that the following normal list can show the correct result @@ -222,7 +196,6 @@ func (service *Service) ListRefs( repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, } @@ -242,62 +215,18 @@ var singleflightGroup = &singleflight.Group{} // ListFiles will list all the files of the target repository with specific extensions. // If extension is not provided, it will list all the files under the target repository -func (service *Service) ListFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - includedExts []string, - tlsSkipVerify bool, -) ([]string, error) { - repoKey := generateCacheKey( - repositoryURL, - referenceName, - username, - password, - strconv.FormatBool(tlsSkipVerify), - strconv.Itoa(int(authType)), - strconv.FormatBool(dirOnly), - ) +func (service *Service) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) { + repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly)) fs, err, _ := singleflightGroup.Do(repoKey, func() (any, error) { - return service.listFiles( - repositoryURL, - referenceName, - username, - password, - authType, - dirOnly, - hardRefresh, - tlsSkipVerify, - ) + return service.listFiles(repositoryURL, referenceName, username, password, dirOnly, hardRefresh, tlsSkipVerify) }) return filterFiles(fs.([]string), includedExts), err } -func (service *Service) listFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - tlsSkipVerify bool, -) ([]string, error) { - repoKey := generateCacheKey( - repositoryURL, - referenceName, - username, - password, - strconv.FormatBool(tlsSkipVerify), - strconv.Itoa(int(authType)), - strconv.FormatBool(dirOnly), - ) +func (service *Service) listFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { + repoKey := generateCacheKey(repositoryURL, referenceName, username, password, strconv.FormatBool(tlsSkipVerify), strconv.FormatBool(dirOnly)) if service.cacheEnabled && hardRefresh { // Should remove the cache explicitly, so that the following normal list can show the correct result @@ -318,7 +247,6 @@ func (service *Service) listFiles( repositoryUrl: repositoryURL, username: username, password: password, - authType: authType, tlsSkipVerify: tlsSkipVerify, }, referenceName: referenceName, diff --git a/api/git/types/types.go b/api/git/types/types.go index cb9d7cf03..12d95e093 100644 --- a/api/git/types/types.go +++ b/api/git/types/types.go @@ -1,21 +1,12 @@ package gittypes -import ( - "errors" -) +import "errors" var ( ErrIncorrectRepositoryURL = errors.New("git repository could not be found, please ensure that the URL is correct") ErrAuthenticationFailure = errors.New("authentication failed, please ensure that the git credentials are correct") ) -type GitCredentialAuthType int - -const ( - GitCredentialAuthType_Basic GitCredentialAuthType = iota - GitCredentialAuthType_Token -) - // RepoConfig represents a configuration for a repo type RepoConfig struct { // The repo url @@ -33,11 +24,10 @@ type RepoConfig struct { } type GitAuthentication struct { - Username string - Password string - AuthorizationType GitCredentialAuthType + Username string + Password string // Git credentials identifier when the value is not 0 - // When the value is 0, Username, Password, and Authtype are set without using saved credential + // When the value is 0, Username and Password are set without using saved credential // This is introduced since 2.15.0 GitCredentialID int `example:"0"` } diff --git a/api/git/update/update.go b/api/git/update/update.go index 780d6e046..203e361dd 100644 --- a/api/git/update/update.go +++ b/api/git/update/update.go @@ -29,14 +29,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g return false, "", errors.WithMessagef(err, "failed to get credentials for %v", objId) } - newHash, err := gitService.LatestCommitID( - gitConfig.URL, - gitConfig.ReferenceName, - username, - password, - gittypes.GitCredentialAuthType_Basic, - gitConfig.TLSSkipVerify, - ) + newHash, err := gitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, username, password, gitConfig.TLSSkipVerify) if err != nil { return false, "", errors.WithMessagef(err, "failed to fetch latest commit id of %v", objId) } @@ -69,7 +62,6 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g cloneParams.auth = &gitAuth{ username: username, password: password, - authType: gitConfig.Authentication.AuthorizationType, } } @@ -97,31 +89,14 @@ type cloneRepositoryParameters struct { } type gitAuth struct { - authType gittypes.GitCredentialAuthType username string password string } func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error { if cloneParams.auth != nil { - return gitService.CloneRepository( - cloneParams.toDir, - cloneParams.url, - cloneParams.ref, - cloneParams.auth.username, - cloneParams.auth.password, - cloneParams.auth.authType, - cloneParams.tlsSkipVerify, - ) + return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password, cloneParams.tlsSkipVerify) } - return gitService.CloneRepository( - cloneParams.toDir, - cloneParams.url, - cloneParams.ref, - "", - "", - gittypes.GitCredentialAuthType_Basic, - cloneParams.tlsSkipVerify, - ) + return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "", cloneParams.tlsSkipVerify) } diff --git a/api/hostmanagement/openamt/openamt.go b/api/hostmanagement/openamt/openamt.go index 5843c1bdb..b27b78878 100644 --- a/api/hostmanagement/openamt/openamt.go +++ b/api/hostmanagement/openamt/openamt.go @@ -32,9 +32,9 @@ type Service struct { } // NewService initializes a new service. -func NewService(insecureSkipVerify bool) *Service { +func NewService() *Service { tlsConfig := crypto.CreateTLSConfiguration() - tlsConfig.InsecureSkipVerify = insecureSkipVerify + tlsConfig.InsecureSkipVerify = true return &Service{ httpsClient: &http.Client{ diff --git a/api/http/csrf/csrf.go b/api/http/csrf/csrf.go index 6205c9290..857d72c8b 100644 --- a/api/http/csrf/csrf.go +++ b/api/http/csrf/csrf.go @@ -2,7 +2,6 @@ package csrf import ( "crypto/rand" - "errors" "fmt" "net/http" "os" @@ -10,8 +9,7 @@ import ( "github.com/portainer/portainer/api/http/security" httperror "github.com/portainer/portainer/pkg/libhttp/error" - gcsrf "github.com/gorilla/csrf" - "github.com/rs/zerolog/log" + gorillacsrf "github.com/gorilla/csrf" "github.com/urfave/negroni" ) @@ -21,7 +19,7 @@ func SkipCSRFToken(w http.ResponseWriter) { w.Header().Set(csrfSkipHeader, "1") } -func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) { +func WithProtect(handler http.Handler) (http.Handler, error) { // IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck) // DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml isDockerDesktopExtension := false @@ -36,12 +34,10 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e return nil, fmt.Errorf("failed to generate CSRF token: %w", err) } - handler = gcsrf.Protect( + handler = gorillacsrf.Protect( token, - gcsrf.Path("/"), - gcsrf.Secure(false), - gcsrf.TrustedOrigins(trustedOrigins), - gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)), + gorillacsrf.Path("/"), + gorillacsrf.Secure(false), )(handler) return withSkipCSRF(handler, isDockerDesktopExtension), nil @@ -59,7 +55,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler { } if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 { - sw.Header().Set("X-CSRF-Token", gcsrf.Token(r)) + sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r)) } }) @@ -77,33 +73,9 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand } if skip { - r = gcsrf.UnsafeSkipCheck(r) + r = gorillacsrf.UnsafeSkipCheck(r) } handler.ServeHTTP(w, r) }) } - -func withErrorHandler(trustedOrigins []string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := gcsrf.FailureReason(r) - - if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) { - log.Error().Err(err). - Str("request_url", r.URL.String()). - Str("host", r.Host). - Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")). - Str("forwarded", r.Header.Get("Forwarded")). - Str("origin", r.Header.Get("Origin")). - Str("referer", r.Header.Get("Referer")). - Strs("trusted_origins", trustedOrigins). - Msg("Failed to validate Origin or Referer") - } - - http.Error( - w, - http.StatusText(http.StatusForbidden)+" - "+err.Error(), - http.StatusForbidden, - ) - }) -} diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 4df31c92c..989949daa 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -2,7 +2,6 @@ package auth import ( "net/http" - "strconv" "strings" portainer "github.com/portainer/portainer/api" @@ -83,11 +82,6 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h } } - // Clear any existing user caches - if user != nil { - handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(user.ID))) - } - if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal { return handler.authenticateInternal(rw, user, payload.Password) } diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 035ceabf8..3b7210fbf 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -8,7 +8,6 @@ import ( "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/gorilla/mux" @@ -24,18 +23,16 @@ type Handler struct { OAuthService portainer.OAuthService ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager - KubernetesClientFactory *cli.ClientFactory passwordStrengthChecker security.PasswordStrengthChecker bouncer security.BouncerService } // NewHandler creates a handler to manage authentication operations. -func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *Handler { +func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler { h := &Handler{ Router: mux.NewRouter(), passwordStrengthChecker: passwordStrengthChecker, bouncer: bouncer, - KubernetesClientFactory: kubernetesClientFactory, } h.Handle("/auth/oauth/validate", diff --git a/api/http/handler/auth/logout.go b/api/http/handler/auth/logout.go index 73288565d..977fafa69 100644 --- a/api/http/handler/auth/logout.go +++ b/api/http/handler/auth/logout.go @@ -2,7 +2,6 @@ package auth import ( "net/http" - "strconv" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/logoutcontext" @@ -24,7 +23,6 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro if tokenData != nil { handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) - handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID))) logoutcontext.Cancel(tokenData.Token) } diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go index b63db356d..60ed1666f 100644 --- a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go +++ b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go @@ -33,28 +33,13 @@ type TestGitService struct { targetFilePath string } -func (g *TestGitService) CloneRepository( - destination string, - repositoryURL, - referenceName string, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (g *TestGitService) CloneRepository(destination string, repositoryURL, referenceName string, username, password string, tlsSkipVerify bool) error { time.Sleep(100 * time.Millisecond) return createTestFile(g.targetFilePath) } -func (g *TestGitService) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (g *TestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { return "", nil } @@ -71,26 +56,11 @@ type InvalidTestGitService struct { targetFilePath string } -func (g *InvalidTestGitService) CloneRepository( - dest, - repoUrl, - refName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (g *InvalidTestGitService) CloneRepository(dest, repoUrl, refName, username, password string, tlsSkipVerify bool) error { return errors.New("simulate network error") } -func (g *InvalidTestGitService) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (g *InvalidTestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { return "", nil } diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index c96d61523..581b219ae 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques customTemplates = filterByType(customTemplates, templateTypes) if edge != nil { - customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool { + customTemplates = slicesx.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool { return customTemplate.EdgeTemplate == *edge }) } diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index f12eeb2e1..f14d228f3 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -37,16 +37,14 @@ type customTemplateUpdatePayload struct { RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"` // Reference name of a Git repository hosting the Stack file RepositoryReferenceName string `example:"refs/heads/master"` - // Use authentication to clone the Git repository + // Use basic authentication to clone the Git repository RepositoryAuthentication bool `example:"true"` // Username used in basic authentication. Required when RepositoryAuthentication is true - // and RepositoryGitCredentialID is 0. Ignored if RepositoryAuthType is token + // and RepositoryGitCredentialID is 0 RepositoryUsername string `example:"myGitUsername"` - // Password used in basic authentication or token used in token authentication. - // Required when RepositoryAuthentication is true and RepositoryGitCredentialID is 0 + // Password used in basic authentication. Required when RepositoryAuthentication is true + // and RepositoryGitCredentialID is 0 RepositoryPassword string `example:"myGitPassword"` - // RepositoryAuthorizationType is the authorization type to use - RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"` // GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication // is true and RepositoryUsername/RepositoryPassword are not provided RepositoryGitCredentialID int `example:"0"` @@ -184,15 +182,12 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ repositoryUsername := "" repositoryPassword := "" - repositoryAuthType := gittypes.GitCredentialAuthType_Basic if payload.RepositoryAuthentication { repositoryUsername = payload.RepositoryUsername repositoryPassword = payload.RepositoryPassword - repositoryAuthType = payload.RepositoryAuthorizationType gitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, } } @@ -202,7 +197,6 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ ReferenceName: gitConfig.ReferenceName, Username: repositoryUsername, Password: repositoryPassword, - AuthType: repositoryAuthType, TLSSkipVerify: gitConfig.TLSSkipVerify, }) if err != nil { @@ -211,14 +205,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ defer cleanBackup() - commitHash, err := handler.GitService.LatestCommitID( - gitConfig.URL, - gitConfig.ReferenceName, - repositoryUsername, - repositoryPassword, - repositoryAuthType, - gitConfig.TLSSkipVerify, - ) + commitHash, err := handler.GitService.LatestCommitID(gitConfig.URL, gitConfig.ReferenceName, repositoryUsername, repositoryPassword, gitConfig.TLSSkipVerify) if err != nil { return httperror.InternalServerError("Unable get latest commit id", fmt.Errorf("failed to fetch latest commit id of the template %v: %w", customTemplate.ID, err)) } diff --git a/api/http/handler/docker/dashboard.go b/api/http/handler/docker/dashboard.go index 97d40e069..ad0399569 100644 --- a/api/http/handler/docker/dashboard.go +++ b/api/http/handler/docker/dashboard.go @@ -6,7 +6,6 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" - "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/volume" portainer "github.com/portainer/portainer/api" @@ -117,12 +116,12 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H return err } - networks, err := cli.NetworkList(r.Context(), network.ListOptions{}) + networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{}) if err != nil { return httperror.InternalServerError("Unable to retrieve Docker networks", err) } - networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c network.Summary) string { + networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string { return c.Name }) if err != nil { diff --git a/api/http/handler/edgegroups/associated_endpoints.go b/api/http/handler/edgegroups/associated_endpoints.go index b26e94d0c..d03618c56 100644 --- a/api/http/handler/edgegroups/associated_endpoints.go +++ b/api/http/handler/edgegroups/associated_endpoints.go @@ -4,7 +4,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/roar" ) type endpointSetType map[portainer.EndpointID]bool @@ -50,29 +49,22 @@ func GetEndpointsByTags(tx dataservices.DataStoreTx, tagIDs []portainer.TagID, p return results, nil } -func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs roar.Roar[portainer.EndpointID]) ([]portainer.EndpointID, error) { - var innerErr error - +func getTrustedEndpoints(tx dataservices.DataStoreTx, endpointIDs []portainer.EndpointID) ([]portainer.EndpointID, error) { results := []portainer.EndpointID{} - - endpointIDs.Iterate(func(endpointID portainer.EndpointID) bool { + for _, endpointID := range endpointIDs { endpoint, err := tx.Endpoint().Endpoint(endpointID) if err != nil { - innerErr = err - - return false + return nil, err } if !endpoint.UserTrusted { - return true + continue } results = append(results, endpoint.ID) + } - return true - }) - - return results, innerErr + return results, nil } func mapEndpointGroupToEndpoints(endpoints []portainer.Endpoint) map[portainer.EndpointGroupID]endpointSetType { diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index c074bffde..3988160f0 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -7,7 +7,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/roar" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" ) @@ -53,7 +52,6 @@ func calculateEndpointsOrTags(tx dataservices.DataStoreTx, edgeGroup *portainer. } edgeGroup.Endpoints = endpointIDs - edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs) return nil } @@ -96,7 +94,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) Dynamic: payload.Dynamic, TagIDs: []portainer.TagID{}, Endpoints: []portainer.EndpointID{}, - EndpointIDs: roar.Roar[portainer.EndpointID]{}, PartialMatch: payload.PartialMatch, } @@ -111,5 +108,5 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) return nil }) - return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err) + return txResponse(w, edgeGroup, err) } diff --git a/api/http/handler/edgegroups/edgegroup_create_test.go b/api/http/handler/edgegroups/edgegroup_create_test.go deleted file mode 100644 index e7710432f..000000000 --- a/api/http/handler/edgegroups/edgegroup_create_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package edgegroups - -import ( - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/internal/testhelpers" - - "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" -) - -func TestEdgeGroupCreateHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodPost, - "/edge_groups", - strings.NewReader(`{"Name": "New Edge Group", "Endpoints": [1, 2, 3]}`), - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go index 76780ec1d..c17ac6b7c 100644 --- a/api/http/handler/edgegroups/edgegroup_inspect.go +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -5,7 +5,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - "github.com/portainer/portainer/api/roar" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" ) @@ -34,9 +33,7 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) return err }) - edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice() - - return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err) + return txResponse(w, edgeGroup, err) } func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*portainer.EdgeGroup, error) { @@ -53,7 +50,7 @@ func getEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) (*porta return nil, httperror.InternalServerError("Unable to retrieve environments and environment groups for Edge group", err) } - edgeGroup.EndpointIDs = roar.FromSlice(endpoints) + edgeGroup.Endpoints = endpoints } return edgeGroup, err diff --git a/api/http/handler/edgegroups/edgegroup_inspect_test.go b/api/http/handler/edgegroups/edgegroup_inspect_test.go deleted file mode 100644 index d7966cf97..000000000 --- a/api/http/handler/edgegroups/edgegroup_inspect_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package edgegroups - -import ( - "net/http" - "net/http/httptest" - "strconv" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEdgeGroupInspectHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}), - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodGet, - "/edge_groups/1", - nil, - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - assert.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} - -func TestDynamicEdgeGroupInspectHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - err = store.Tag().Create(&portainer.Tag{ - ID: 1, - Name: "Test Tag", - Endpoints: map[portainer.EndpointID]bool{ - 1: true, - 2: true, - 3: true, - }, - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - TagIDs: []portainer.TagID{1}, - UserTrusted: true, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - Dynamic: true, - TagIDs: []portainer.TagID{1}, - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodGet, - "/edge_groups/1", - nil, - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go index 87de867eb..bc67176fd 100644 --- a/api/http/handler/edgegroups/edgegroup_list.go +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -7,17 +7,11 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - "github.com/portainer/portainer/api/roar" httperror "github.com/portainer/portainer/pkg/libhttp/error" ) -type shadowedEdgeGroup struct { - portainer.EdgeGroup - EndpointIds int `json:"EndpointIds,omitempty"` // Shadow to avoid exposing in the API -} - type decoratedEdgeGroup struct { - shadowedEdgeGroup + portainer.EdgeGroup HasEdgeStack bool `json:"HasEdgeStack"` HasEdgeJob bool `json:"HasEdgeJob"` EndpointTypes []portainer.EndpointType @@ -82,8 +76,8 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) } edgeGroup := decoratedEdgeGroup{ - shadowedEdgeGroup: shadowedEdgeGroup{EdgeGroup: orgEdgeGroup}, - EndpointTypes: []portainer.EndpointType{}, + EdgeGroup: orgEdgeGroup, + EndpointTypes: []portainer.EndpointType{}, } if edgeGroup.Dynamic { endpointIDs, err := GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch) @@ -94,16 +88,15 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) edgeGroup.Endpoints = endpointIDs edgeGroup.TrustedEndpoints = endpointIDs } else { - trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.EndpointIDs) + trustedEndpoints, err := getTrustedEndpoints(tx, edgeGroup.Endpoints) if err != nil { return nil, httperror.InternalServerError("Unable to retrieve environments for Edge group", err) } - edgeGroup.Endpoints = edgeGroup.EndpointIDs.ToSlice() edgeGroup.TrustedEndpoints = trustedEndpoints } - endpointTypes, err := getEndpointTypes(tx, edgeGroup.EndpointIDs) + endpointTypes, err := getEndpointTypes(tx, edgeGroup.Endpoints) if err != nil { return nil, httperror.InternalServerError("Unable to retrieve environment types for Edge group", err) } @@ -118,26 +111,15 @@ func getEdgeGroupList(tx dataservices.DataStoreTx) ([]decoratedEdgeGroup, error) return decoratedEdgeGroups, nil } -func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds roar.Roar[portainer.EndpointID]) ([]portainer.EndpointType, error) { - var innerErr error - +func getEndpointTypes(tx dataservices.DataStoreTx, endpointIds []portainer.EndpointID) ([]portainer.EndpointType, error) { typeSet := map[portainer.EndpointType]bool{} - - endpointIds.Iterate(func(endpointID portainer.EndpointID) bool { + for _, endpointID := range endpointIds { endpoint, err := tx.Endpoint().Endpoint(endpointID) if err != nil { - innerErr = fmt.Errorf("failed fetching environment: %w", err) - - return false + return nil, fmt.Errorf("failed fetching environment: %w", err) } typeSet[endpoint.Type] = true - - return true - }) - - if innerErr != nil { - return nil, innerErr } endpointTypes := make([]portainer.EndpointType, 0, len(typeSet)) diff --git a/api/http/handler/edgegroups/edgegroup_list_test.go b/api/http/handler/edgegroups/edgegroup_list_test.go index bf084c377..b77b2966e 100644 --- a/api/http/handler/edgegroups/edgegroup_list_test.go +++ b/api/http/handler/edgegroups/edgegroup_list_test.go @@ -1,19 +1,11 @@ package edgegroups import ( - "net/http" - "net/http/httptest" - "strconv" "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/segmentio/encoding/json" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_getEndpointTypes(t *testing.T) { @@ -46,7 +38,7 @@ func Test_getEndpointTypes(t *testing.T) { } for _, test := range tests { - ans, err := getEndpointTypes(datastore, roar.FromSlice(test.endpointIds)) + ans, err := getEndpointTypes(datastore, test.endpointIds) assert.NoError(t, err, "getEndpointTypes shouldn't fail") assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) @@ -56,61 +48,6 @@ func Test_getEndpointTypes(t *testing.T) { func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) { datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) - _, err := getEndpointTypes(datastore, roar.FromSlice([]portainer.EndpointID{1})) + _, err := getEndpointTypes(datastore, []portainer.EndpointID{1}) assert.Error(t, err, "getEndpointTypes should fail") } - -func TestEdgeGroupListHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1, 2, 3}), - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodGet, - "/edge_groups", - nil, - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroups []decoratedEdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroups) - require.NoError(t, err) - - require.Len(t, responseGroups, 1) - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroups[0].Endpoints) - require.Len(t, responseGroups[0].TrustedEndpoints, 0) -} diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index 270bd10df..7831b634e 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -158,12 +158,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) return nil }) - return txResponse(w, shadowedEdgeGroup{EdgeGroup: *edgeGroup}, err) + return txResponse(w, edgeGroup, err) } func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil { + if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { return err } @@ -179,6 +179,12 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi edgeStackSet[edgeStackID] = true } + if relation == nil { + relation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + } relation.EdgeStacks = edgeStackSet return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) diff --git a/api/http/handler/edgegroups/edgegroup_update_test.go b/api/http/handler/edgegroups/edgegroup_update_test.go deleted file mode 100644 index dbecbdfcf..000000000 --- a/api/http/handler/edgegroups/edgegroup_update_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package edgegroups - -import ( - "net/http" - "net/http/httptest" - "strconv" - "strings" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" -) - -func TestEdgeGroupUpdateHandler(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - err := store.EndpointGroup().Create(&portainer.EndpointGroup{ - ID: 1, - Name: "Test Group", - }) - require.NoError(t, err) - - for i := range 3 { - err = store.Endpoint().Create(&portainer.Endpoint{ - ID: portainer.EndpointID(i + 1), - Name: "Test Endpoint " + strconv.Itoa(i+1), - Type: portainer.EdgeAgentOnDockerEnvironment, - GroupID: 1, - }) - require.NoError(t, err) - - err = store.EndpointRelation().Create(&portainer.EndpointRelation{ - EndpointID: portainer.EndpointID(i + 1), - EdgeStacks: map[portainer.EdgeStackID]bool{}, - }) - require.NoError(t, err) - } - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Test Edge Group", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(t, err) - - rr := httptest.NewRecorder() - - req := httptest.NewRequest( - http.MethodPut, - "/edge_groups/1", - strings.NewReader(`{"Endpoints": [1, 2, 3]}`), - ) - - handler.ServeHTTP(rr, req) - require.Equal(t, http.StatusOK, rr.Result().StatusCode) - - var responseGroup portainer.EdgeGroup - err = json.NewDecoder(rr.Body).Decode(&responseGroup) - require.NoError(t, err) - - require.ElementsMatch(t, []portainer.EndpointID{1, 2, 3}, responseGroup.Endpoints) -} diff --git a/api/http/handler/edgestacks/edgestack_create_file.go b/api/http/handler/edgestacks/edgestack_create_file.go index a0bc2995f..555418835 100644 --- a/api/http/handler/edgestacks/edgestack_create_file.go +++ b/api/http/handler/edgestacks/edgestack_create_file.go @@ -101,7 +101,8 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error { // @router /edge_stacks/create/file [post] func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) { payload := &edgeStackFromFileUploadPayload{} - if err := payload.Validate(r); err != nil { + err := payload.Validate(r) + if err != nil { return nil, err } diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go index a8775495d..2da816481 100644 --- a/api/http/handler/edgestacks/edgestack_create_git.go +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -33,8 +33,6 @@ type edgeStackFromGitRepositoryPayload struct { RepositoryUsername string `example:"myGitUsername"` // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` - // RepositoryAuthorizationType is the authorization type to use - RepositoryAuthorizationType gittypes.GitCredentialAuthType `example:"0"` // Path to the Stack file inside the Git repository FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` // List of identifiers of EdgeGroups @@ -105,7 +103,8 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro // @router /edge_stacks/create/repository [post] func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) { var payload edgeStackFromGitRepositoryPayload - if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { return nil, err } @@ -127,9 +126,8 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat if payload.RepositoryAuthentication { repoConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, } } @@ -139,31 +137,24 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat } func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStoreTx, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) { - if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil { + hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType) + if err != nil { return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err) - } else if hasWrongType { + } + if hasWrongType { return "", "", "", errors.New("edge stack with config do not match the environment type") } projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder) repositoryUsername := "" repositoryPassword := "" - repositoryAuthType := gittypes.GitCredentialAuthType_Basic if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" { repositoryUsername = repositoryConfig.Authentication.Username repositoryPassword = repositoryConfig.Authentication.Password - repositoryAuthType = repositoryConfig.Authentication.AuthorizationType } - if err := handler.GitService.CloneRepository( - projectPath, - repositoryConfig.URL, - repositoryConfig.ReferenceName, - repositoryUsername, - repositoryPassword, - repositoryAuthType, - repositoryConfig.TLSSkipVerify, - ); err != nil { + err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify) + if err != nil { return "", "", "", err } diff --git a/api/http/handler/edgestacks/edgestack_create_string.go b/api/http/handler/edgestacks/edgestack_create_string.go index 5e3fb57b8..556633fae 100644 --- a/api/http/handler/edgestacks/edgestack_create_string.go +++ b/api/http/handler/edgestacks/edgestack_create_string.go @@ -76,7 +76,8 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error { // @router /edge_stacks/create/string [post] func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) { var payload edgeStackFromStringPayload - if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { return nil, err } @@ -95,9 +96,11 @@ func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx datas } func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) { - if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil { + hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType) + if err != nil { return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err) - } else if hasWrongType { + } + if hasWrongType { return "", "", "", errors.New("edge stack with config do not match the environment type") } @@ -121,6 +124,7 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde } return "", manifestPath, projectPath, nil + } errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType) diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go index 70252c25d..32158d300 100644 --- a/api/http/handler/edgestacks/edgestack_create_test.go +++ b/api/http/handler/edgestacks/edgestack_create_test.go @@ -8,10 +8,8 @@ import ( "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/roar" "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" ) // Create @@ -25,12 +23,14 @@ func TestCreateAndInspect(t *testing.T) { Name: "EdgeGroup 1", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint.ID}), + Endpoints: []portainer.EndpointID{endpoint.ID}, PartialMatch: false, } err := handler.DataStore.EdgeGroup().Create(&edgeGroup) - require.NoError(t, err) + if err != nil { + t.Fatal(err) + } endpointRelation := portainer.EndpointRelation{ EndpointID: endpoint.ID, @@ -38,7 +38,9 @@ func TestCreateAndInspect(t *testing.T) { } err = handler.DataStore.EndpointRelation().Create(&endpointRelation) - require.NoError(t, err) + if err != nil { + t.Fatal(err) + } payload := edgeStackFromStringPayload{ Name: "test-stack", @@ -48,14 +50,16 @@ func TestCreateAndInspect(t *testing.T) { } jsonPayload, err := json.Marshal(payload) - require.NoError(t, err) - + if err != nil { + t.Fatal("JSON marshal error:", err) + } r := bytes.NewBuffer(jsonPayload) // Create EdgeStack req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r) - require.NoError(t, err) - + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -66,11 +70,15 @@ func TestCreateAndInspect(t *testing.T) { data := portainer.EdgeStack{} err = json.NewDecoder(rec.Body).Decode(&data) - require.NoError(t, err) + if err != nil { + t.Fatal("error decoding response:", err) + } // Inspect req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec = httptest.NewRecorder() @@ -82,7 +90,9 @@ func TestCreateAndInspect(t *testing.T) { data = portainer.EdgeStack{} err = json.NewDecoder(rec.Body).Decode(&data) - require.NoError(t, err) + if err != nil { + t.Fatal("error decoding response:", err) + } if payload.Name != data.Name { t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name) diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go index 0e6307684..3d71f2bce 100644 --- a/api/http/handler/edgestacks/edgestack_delete.go +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -30,9 +30,10 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid edge stack identifier route variable", err) } - if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID)) - }); err != nil { + }) + if err != nil { var httpErr *httperror.HandlerError if errors.As(err, &httpErr) { return httpErr diff --git a/api/http/handler/edgestacks/edgestack_delete_test.go b/api/http/handler/edgestacks/edgestack_delete_test.go index ca334c7ce..ef25ae45c 100644 --- a/api/http/handler/edgestacks/edgestack_delete_test.go +++ b/api/http/handler/edgestacks/edgestack_delete_test.go @@ -8,10 +8,9 @@ import ( "testing" portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // Delete @@ -24,7 +23,9 @@ func TestDeleteAndInspect(t *testing.T) { // Inspect req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec := httptest.NewRecorder() @@ -36,7 +37,9 @@ func TestDeleteAndInspect(t *testing.T) { data := portainer.EdgeStack{} err = json.NewDecoder(rec.Body).Decode(&data) - require.NoError(t, err) + if err != nil { + t.Fatal("error decoding response:", err) + } if data.ID != edgeStack.ID { t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID) @@ -44,7 +47,9 @@ func TestDeleteAndInspect(t *testing.T) { // Delete req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec = httptest.NewRecorder() @@ -56,7 +61,9 @@ func TestDeleteAndInspect(t *testing.T) { // Inspect req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec = httptest.NewRecorder() @@ -110,12 +117,15 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) { } var buf bytes.Buffer - err := json.NewEncoder(&buf).Encode(payload) - require.NoError(t, err) + if err := json.NewEncoder(&buf).Encode(payload); err != nil { + t.Fatal("error encoding payload:", err) + } // Create req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec := httptest.NewRecorder() @@ -128,8 +138,9 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) { assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1")) // Delete - req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil) - require.NoError(t, err) + if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec = httptest.NewRecorder() diff --git a/api/http/handler/edgestacks/edgestack_inspect.go b/api/http/handler/edgestacks/edgestack_inspect.go index 2936f320e..06c118835 100644 --- a/api/http/handler/edgestacks/edgestack_inspect.go +++ b/api/http/handler/edgestacks/edgestack_inspect.go @@ -4,7 +4,6 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -34,35 +33,5 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database") } - if err := fillEdgeStackStatus(handler.DataStore, edgeStack); err != nil { - return handlerDBErr(err, "Unable to retrieve edge stack status from the database") - } - return response.JSON(w, edgeStack) } - -func fillEdgeStackStatus(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack) error { - status, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) - if err != nil { - return err - } - - edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus, len(status)) - - emptyStatus := make([]portainer.EdgeStackDeploymentStatus, 0) - - for _, s := range status { - if s.Status == nil { - s.Status = emptyStatus - } - - edgeStack.Status[s.EndpointID] = portainer.EdgeStackStatus{ - Status: s.Status, - EndpointID: s.EndpointID, - DeploymentInfo: s.DeploymentInfo, - ReadyRePullImage: s.ReadyRePullImage, - } - } - - return nil -} diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go index 1ea991c4b..26fd7da05 100644 --- a/api/http/handler/edgestacks/edgestack_list.go +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -3,39 +3,10 @@ package edgestacks import ( "net/http" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" - "github.com/portainer/portainer/api/slicesx" httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" ) -type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int - -type SummarizedStatus string - -const ( - sumStatusUnavailable SummarizedStatus = "Unavailable" - sumStatusDeploying SummarizedStatus = "Deploying" - sumStatusFailed SummarizedStatus = "Failed" - sumStatusPaused SummarizedStatus = "Paused" - sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning" - sumStatusCompleted SummarizedStatus = "Completed" - sumStatusRunning SummarizedStatus = "Running" -) - -type edgeStackStatusSummary struct { - AggregatedStatus aggregatedStatusesMap - Status SummarizedStatus - Reason string -} - -type edgeStackListResponseItem struct { - portainer.EdgeStack - StatusSummary edgeStackStatusSummary -} - // @id EdgeStackList // @summary Fetches the list of EdgeStacks // @description **Access policy**: administrator @@ -43,122 +14,16 @@ type edgeStackListResponseItem struct { // @security ApiKeyAuth // @security jwt // @produce json -// @param summarizeStatuses query boolean false "will summarize the statuses" // @success 200 {array} portainer.EdgeStack // @failure 500 // @failure 400 // @failure 503 "Edge compute features are disabled" // @router /edge_stacks [get] func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true) - edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) } - res := make([]edgeStackListResponseItem, len(edgeStacks)) - - for i := range edgeStacks { - res[i].EdgeStack = edgeStacks[i] - - if summarizeStatuses { - if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil { - return handlerDBErr(err, "Unable to retrieve edge stack status from the database") - } - } else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil { - return handlerDBErr(err, "Unable to retrieve edge stack status from the database") - } - } - - return response.JSON(w, res) -} - -func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error { - statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) - if err != nil { - return err - } - - aggregated := make(aggregatedStatusesMap) - - for _, envStatus := range statuses { - for _, status := range envStatus.Status { - aggregated[status.Type]++ - } - } - - status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments) - - edgeStack.StatusSummary = edgeStackStatusSummary{ - AggregatedStatus: aggregated, - Status: status, - Reason: reason, - } - - edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} - - return nil -} - -func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) { - if numDeployments == 0 { - return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group" - } - - allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus { - return x.Status - }) - - lastStatuses := slicesx.Map( - slicesx.Filter( - statuses, - func(s portainer.EdgeStackStatusForEnv) bool { - return len(s.Status) > 0 - }, - ), - func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus { - return x.Status[len(x.Status)-1] - }, - ) - - if len(lastStatuses) == 0 { - return sumStatusDeploying, "" - } - - if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { - return s.Type == portainer.EdgeStackStatusError - }); allFailed { - return sumStatusFailed, "" - } - - if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { - return s.Type == portainer.EdgeStackStatusPausedDeploying - }); hasPaused { - return sumStatusPaused, "" - } - - if len(lastStatuses) < numDeployments { - return sumStatusDeploying, "" - } - - hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying }) - hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning }) - hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError }) - - if hasRunning && hasFailed && !hasDeploying { - return sumStatusPartiallyRunning, "" - } - - if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted { - return sumStatusCompleted, "" - } - - if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { - return s.Type == portainer.EdgeStackStatusRunning - }); allRunning { - return sumStatusRunning, "" - } - - return sumStatusDeploying, "" + return response.JSON(w, edgeStacks) } diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go index 0ff6a9eff..fef5a6927 100644 --- a/api/http/handler/edgestacks/edgestack_status_update.go +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -9,10 +9,11 @@ import ( "time" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" ) type updateStatusPayload struct { @@ -77,25 +78,12 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name)) } - var stack *portainer.EdgeStack + updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) { + return handler.updateEdgeStackStatus(stack, stack.ID, payload) + } - if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { - var err error - stack, err = tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID)) - if err != nil { - if dataservices.IsErrObjectNotFound(err) { - return nil - } - - return httperror.InternalServerError("Unable to retrieve Edge stack from the database", err) - } - - if err := handler.updateEdgeStackStatus(tx, stack, stack.ID, payload); err != nil { - return httperror.InternalServerError("Unable to update Edge stack status", err) - } - - return nil - }); err != nil { + stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn) + if err != nil { var httpErr *httperror.HandlerError if errors.As(err, &httpErr) { return httpErr @@ -108,36 +96,43 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req return nil } - if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil { - return handlerDBErr(err, "Unable to retrieve edge stack status from the database") - } - return response.JSON(w, stack) } -func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) error { +func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) { if payload.Version > 0 && payload.Version < stack.Version { - return nil + return stack, nil } status := *payload.Status + log.Debug(). + Int("stackID", int(stackID)). + Int("status", int(status)). + Msg("Updating stack status") + deploymentStatus := portainer.EdgeStackDeploymentStatus{ Type: status, Error: payload.Error, Time: payload.Time, } + updateEnvStatus(payload.EndpointID, stack, deploymentStatus) + + return stack, nil +} + +func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) { if deploymentStatus.Type == portainer.EdgeStackStatusRemoved { - return tx.EdgeStackStatus().Delete(stackID, payload.EndpointID) + delete(stack.Status, environmentId) + + return } - environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID) - if err != nil && !tx.IsErrObjectNotFound(err) { - return err - } else if tx.IsErrObjectNotFound(err) { - environmentStatus = &portainer.EdgeStackStatusForEnv{ - EndpointID: payload.EndpointID, + environmentStatus, ok := stack.Status[environmentId] + if !ok { + environmentStatus = portainer.EdgeStackStatus{ + EndpointID: environmentId, Status: []portainer.EdgeStackDeploymentStatus{}, } } @@ -148,5 +143,5 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack environmentStatus.Status = append(environmentStatus.Status, deploymentStatus) } - return tx.EdgeStackStatus().Update(stackID, payload.EndpointID, environmentStatus) + stack.Status[environmentId] = environmentStatus } diff --git a/api/http/handler/edgestacks/edgestack_status_update_coordinator.go b/api/http/handler/edgestacks/edgestack_status_update_coordinator.go new file mode 100644 index 000000000..885b4c6da --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_status_update_coordinator.go @@ -0,0 +1,155 @@ +package edgestacks + +import ( + "errors" + "fmt" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + + "github.com/rs/zerolog/log" +) + +type statusRequest struct { + respCh chan statusResponse + stackID portainer.EdgeStackID + updateFn statusUpdateFn +} + +type statusResponse struct { + Stack *portainer.EdgeStack + Error error +} + +type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error) + +type EdgeStackStatusUpdateCoordinator struct { + updateCh chan statusRequest + dataStore dataservices.DataStore +} + +var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress") + +func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator { + return &EdgeStackStatusUpdateCoordinator{ + updateCh: make(chan statusRequest), + dataStore: dataStore, + } +} + +func (c *EdgeStackStatusUpdateCoordinator) Start() { + for { + c.loop() + } +} + +func (c *EdgeStackStatusUpdateCoordinator) loop() { + u := <-c.updateCh + + respChs := []chan statusResponse{u.respCh} + + var stack *portainer.EdgeStack + + err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + // 1. Load the edge stack + var err error + + stack, err = loadEdgeStack(tx, u.stackID) + if err != nil { + return err + } + + // Return early when the agent tries to update the status on a deleted stack + if stack == nil { + return nil + } + + // 2. Mutate the edge stack opportunistically until there are no more pending updates + for { + stack, err = u.updateFn(stack) + if err != nil { + return err + } + + if m, ok := c.getNextUpdate(stack.ID); ok { + u = m + } else { + break + } + + respChs = append(respChs, u.respCh) + } + + // 3. Save the changes back to the database + if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil { + return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database") + } + + return nil + }) + + // 4. Send back the responses + for _, ch := range respChs { + ch <- statusResponse{Stack: stack, Error: err} + } +} + +func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) { + stack, err := tx.EdgeStack().EdgeStack(stackID) + if err != nil { + if dataservices.IsErrObjectNotFound(err) { + // Skip the error when the agent tries to update the status on a deleted stack + log.Debug(). + Err(err). + Int("stackID", int(stackID)). + Msg("Unable to find a stack inside the database, skipping error") + + return nil, nil + } + + return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err) + } + + return stack, nil +} + +func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) { + for { + select { + case u := <-c.updateCh: + // Discard the update and let the agent retry + if u.stackID != stackID { + u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress} + + continue + } + + return u, true + + default: + return statusRequest{}, false + } + } +} + +func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) { + respCh := make(chan statusResponse) + defer close(respCh) + + msg := statusRequest{ + respCh: respCh, + stackID: stackID, + updateFn: updateFn, + } + + select { + case c.updateCh <- msg: + r := <-respCh + + return r.Stack, r.Error + + case <-r.Context().Done(): + return nil, r.Context().Err() + } +} diff --git a/api/http/handler/edgestacks/edgestack_status_update_test.go b/api/http/handler/edgestacks/edgestack_status_update_test.go index 4d94368fe..50a0863d4 100644 --- a/api/http/handler/edgestacks/edgestack_status_update_test.go +++ b/api/http/handler/edgestacks/edgestack_status_update_test.go @@ -10,7 +10,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" ) // Update Status @@ -29,11 +28,15 @@ func TestUpdateStatusAndInspect(t *testing.T) { } jsonPayload, err := json.Marshal(payload) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } r := bytes.NewBuffer(jsonPayload) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) rec := httptest.NewRecorder() @@ -45,7 +48,9 @@ func TestUpdateStatusAndInspect(t *testing.T) { // Get updated edge stack req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec = httptest.NewRecorder() @@ -57,10 +62,14 @@ func TestUpdateStatusAndInspect(t *testing.T) { updatedStack := portainer.EdgeStack{} err = json.NewDecoder(rec.Body).Decode(&updatedStack) - require.NoError(t, err) + if err != nil { + t.Fatal("error decoding response:", err) + } endpointStatus, ok := updatedStack.Status[payload.EndpointID] - require.True(t, ok) + if !ok { + t.Fatal("Missing status") + } lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1] @@ -75,8 +84,8 @@ func TestUpdateStatusAndInspect(t *testing.T) { if endpointStatus.EndpointID != payload.EndpointID { t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID) } -} +} func TestUpdateStatusWithInvalidPayload(t *testing.T) { handler, _ := setupHandler(t) @@ -127,11 +136,15 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) { for _, tc := range cases { t.Run(tc.Name, func(t *testing.T) { jsonPayload, err := json.Marshal(tc.Payload) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } r := bytes.NewBuffer(jsonPayload) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) rec := httptest.NewRecorder() diff --git a/api/http/handler/edgestacks/edgestack_test.go b/api/http/handler/edgestacks/edgestack_test.go index 38fd4be55..ce1e9b659 100644 --- a/api/http/handler/edgestacks/edgestack_test.go +++ b/api/http/handler/edgestacks/edgestack_test.go @@ -15,10 +15,8 @@ import ( "github.com/portainer/portainer/api/internal/edge/edgestacks" "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" - "github.com/portainer/portainer/api/roar" "github.com/pkg/errors" - "github.com/stretchr/testify/require" ) // Helpers @@ -53,21 +51,27 @@ func setupHandler(t *testing.T) (*Handler, string) { t.Fatal(err) } + coord := NewEdgeStackStatusUpdateCoordinator(store) + go coord.Start() + handler := NewHandler( security.NewRequestBouncer(store, jwtService, apiKeyService), store, edgestacks.NewService(store), + coord, ) handler.FileService = fs settings, err := handler.DataStore.Settings().Settings() - require.NoError(t, err) - + if err != nil { + t.Fatal(err) + } settings.EnableEdgeComputeFeatures = true - err = handler.DataStore.Settings().UpdateSettings(settings) - require.NoError(t, err) + if err := handler.DataStore.Settings().UpdateSettings(settings); err != nil { + t.Fatal(err) + } handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id") @@ -86,8 +90,9 @@ func createEndpointWithId(t *testing.T, store dataservices.DataStore, endpointID LastCheckInDate: time.Now().Unix(), } - err := store.Endpoint().Create(&endpoint) - require.NoError(t, err) + if err := store.Endpoint().Create(&endpoint); err != nil { + t.Fatal(err) + } return endpoint } @@ -104,17 +109,19 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port Name: "EdgeGroup 1", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointID}), + Endpoints: []portainer.EndpointID{endpointID}, PartialMatch: false, } - err := store.EdgeGroup().Create(&edgeGroup) - require.NoError(t, err) + if err := store.EdgeGroup().Create(&edgeGroup); err != nil { + t.Fatal(err) + } edgeStackID := portainer.EdgeStackID(14) edgeStack := portainer.EdgeStack{ ID: edgeStackID, Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)), + Status: map[portainer.EndpointID]portainer.EdgeStackStatus{}, CreationDate: time.Now().Unix(), EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID}, ProjectPath: "/project/path", @@ -131,11 +138,13 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port }, } - err = store.EdgeStack().Create(edgeStack.ID, &edgeStack) - require.NoError(t, err) + if err := store.EdgeStack().Create(edgeStack.ID, &edgeStack); err != nil { + t.Fatal(err) + } - err = store.EndpointRelation().Create(&endpointRelation) - require.NoError(t, err) + if err := store.EndpointRelation().Create(&endpointRelation); err != nil { + t.Fatal(err) + } return edgeStack } @@ -146,8 +155,8 @@ func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeG Name: "EdgeGroup 1", } - err := store.EdgeGroup().Create(&edgeGroup) - require.NoError(t, err) - + if err := store.EdgeGroup().Create(&edgeGroup); err != nil { + t.Fatal(err) + } return edgeGroup } diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index db896d0eb..a3d59abb8 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -74,10 +74,6 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) return httperror.InternalServerError("Unexpected error", err) } - if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil { - return handlerDBErr(err, "Unable to retrieve edge stack status from the database") - } - return response.JSON(w, stack) } @@ -124,7 +120,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por stack.EdgeGroups = groupsIds if payload.UpdateVersion { - if err := handler.updateStackVersion(tx, stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil { + if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil { return nil, httperror.InternalServerError("Unable to update stack version", err) } } diff --git a/api/http/handler/edgestacks/edgestack_update_test.go b/api/http/handler/edgestacks/edgestack_update_test.go index 8040af329..7e4a9b23c 100644 --- a/api/http/handler/edgestacks/edgestack_update_test.go +++ b/api/http/handler/edgestacks/edgestack_update_test.go @@ -9,10 +9,9 @@ import ( "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/roar" + "github.com/stretchr/testify/require" "github.com/segmentio/encoding/json" - "github.com/stretchr/testify/require" ) // Update @@ -26,8 +25,9 @@ func TestUpdateAndInspect(t *testing.T) { endpointID := portainer.EndpointID(6) newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID) - err := handler.DataStore.Endpoint().Create(&newEndpoint) - require.NoError(t, err) + if err := handler.DataStore.Endpoint().Create(&newEndpoint); err != nil { + t.Fatal(err) + } endpointRelation := portainer.EndpointRelation{ EndpointID: endpointID, @@ -36,20 +36,22 @@ func TestUpdateAndInspect(t *testing.T) { }, } - err = handler.DataStore.EndpointRelation().Create(&endpointRelation) - require.NoError(t, err) + if err := handler.DataStore.EndpointRelation().Create(&endpointRelation); err != nil { + t.Fatal(err) + } newEdgeGroup := portainer.EdgeGroup{ ID: 2, Name: "EdgeGroup 2", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{newEndpoint.ID}), + Endpoints: []portainer.EndpointID{newEndpoint.ID}, PartialMatch: false, } - err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup) - require.NoError(t, err) + if err := handler.DataStore.EdgeGroup().Create(&newEdgeGroup); err != nil { + t.Fatal(err) + } payload := updateEdgeStackPayload{ StackFileContent: "update-test", @@ -59,11 +61,15 @@ func TestUpdateAndInspect(t *testing.T) { } jsonPayload, err := json.Marshal(payload) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } r := bytes.NewBuffer(jsonPayload) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec := httptest.NewRecorder() @@ -75,7 +81,9 @@ func TestUpdateAndInspect(t *testing.T) { // Get updated edge stack req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec = httptest.NewRecorder() @@ -86,8 +94,9 @@ func TestUpdateAndInspect(t *testing.T) { } updatedStack := portainer.EdgeStack{} - err = json.NewDecoder(rec.Body).Decode(&updatedStack) - require.NoError(t, err) + if err := json.NewDecoder(rec.Body).Decode(&updatedStack); err != nil { + t.Fatal("error decoding response:", err) + } if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 { t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1) @@ -113,7 +122,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) { Name: "EdgeGroup 2", Dynamic: false, TagIDs: nil, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{8889}), + Endpoints: []portainer.EndpointID{8889}, PartialMatch: false, } @@ -217,11 +226,15 @@ func TestUpdateWithInvalidPayload(t *testing.T) { for _, tc := range cases { t.Run(tc.Name, func(t *testing.T) { jsonPayload, err := json.Marshal(tc.Payload) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } r := bytes.NewBuffer(jsonPayload) req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) - require.NoError(t, err) + if err != nil { + t.Fatal("request error:", err) + } req.Header.Add("x-api-key", rawAPIKey) rec := httptest.NewRecorder() diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go index 78df853a6..9fa90776f 100644 --- a/api/http/handler/edgestacks/handler.go +++ b/api/http/handler/edgestacks/handler.go @@ -22,15 +22,17 @@ type Handler struct { GitService portainer.GitService edgeStacksService *edgestackservice.Service KubernetesDeployer portainer.KubernetesDeployer + stackCoordinator *EdgeStackStatusUpdateCoordinator } // NewHandler creates a handler to manage environment(endpoint) group operations. -func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler { +func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler { h := &Handler{ Router: mux.NewRouter(), requestBouncer: bouncer, DataStore: dataStore, edgeStacksService: edgeStacksService, + stackCoordinator: stackCoordinator, } h.Handle("/edge_stacks/create/{method}", diff --git a/api/http/handler/edgestacks/utils_update_stack_version.go b/api/http/handler/edgestacks/utils_update_stack_version.go index 78ac5002f..2502a88f6 100644 --- a/api/http/handler/edgestacks/utils_update_stack_version.go +++ b/api/http/handler/edgestacks/utils_update_stack_version.go @@ -5,18 +5,15 @@ import ( "strconv" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/filesystem" + edgestackutils "github.com/portainer/portainer/api/internal/edge/edgestacks" "github.com/rs/zerolog/log" ) -func (handler *Handler) updateStackVersion(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error { - stack.Version++ - - if err := tx.EdgeStackStatus().Clear(stack.ID, relatedEnvironmentsIDs); err != nil { - return err - } +func (handler *Handler) updateStackVersion(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error { + stack.Version = stack.Version + 1 + stack.Status = edgestackutils.NewStatus(stack.Status, relatedEnvironmentsIDs) return handler.storeStackFile(stack, deploymentType, config) } diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect.go b/api/http/handler/endpointedge/endpointedge_status_inspect.go index 9bd341561..4d6368493 100644 --- a/api/http/handler/endpointedge/endpointedge_status_inspect.go +++ b/api/http/handler/endpointedge/endpointedge_status_inspect.go @@ -264,6 +264,9 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) { relation, err := tx.EndpointRelation().EndpointRelation(endpointID) if err != nil { + if tx.IsErrObjectNotFound(err) { + return nil, nil + } return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err) } diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go index 526fc58de..8bfaa9814 100644 --- a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go +++ b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go @@ -16,7 +16,6 @@ import ( "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/jwt" - "github.com/portainer/portainer/api/roar" "github.com/segmentio/encoding/json" "github.com/stretchr/testify/assert" @@ -288,8 +287,11 @@ func TestEdgeStackStatus(t *testing.T) { edgeStackID := portainer.EdgeStackID(17) edgeStack := portainer.EdgeStack{ - ID: edgeStackID, - Name: "test-edge-stack-17", + ID: edgeStackID, + Name: "test-edge-stack-17", + Status: map[portainer.EndpointID]portainer.EdgeStackStatus{ + endpointID: {}, + }, CreationDate: time.Now().Unix(), EdgeGroups: []portainer.EdgeGroupID{1, 2}, ProjectPath: "/project/path", @@ -367,8 +369,8 @@ func TestEdgeJobsResponse(t *testing.T) { unrelatedEndpoint := localCreateEndpoint(80, nil) staticEdgeGroup := portainer.EdgeGroup{ - ID: 1, - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointFromStaticEdgeGroup.ID}), + ID: 1, + Endpoints: []portainer.EndpointID{endpointFromStaticEdgeGroup.ID}, } err := handler.DataStore.EdgeGroup().Create(&staticEdgeGroup) require.NoError(t, err) diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go index 8b420f2a6..b34032d9e 100644 --- a/api/http/handler/endpointgroups/endpoints.go +++ b/api/http/handler/endpointgroups/endpoints.go @@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end } endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil { + if err != nil && !tx.IsErrObjectNotFound(err) { return err } + if endpointRelation == nil { + endpointRelation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + } + edgeGroups, err := tx.EdgeGroup().ReadAll() if err != nil { return err diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 3cfe934bc..1c6415023 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -563,10 +563,6 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.Data return err } - if err := endpointutils.InitializeEdgeEndpointRelation(endpoint, tx); err != nil { - return err - } - for _, tagID := range endpoint.TagIDs { if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) { tag.Endpoints[endpoint.ID] = true diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index a9b4ae5dc..4752364ec 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -3,6 +3,7 @@ package endpoints import ( "errors" "net/http" + "slices" "strconv" portainer "github.com/portainer/portainer/api" @@ -199,7 +200,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p } for _, edgeGroup := range edgeGroups { - edgeGroup.EndpointIDs.Remove(endpoint.ID) + edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(e portainer.EndpointID) bool { + return e == endpoint.ID + }) if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil { log.Warn().Err(err).Msg("Unable to update edge group") @@ -211,9 +214,14 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database") } - for _, edgeStack := range edgeStacks { - if err := tx.EdgeStackStatus().Delete(edgeStack.ID, endpoint.ID); err != nil { - log.Warn().Err(err).Msg("Unable to delete edge stack status") + for idx := range edgeStacks { + edgeStack := &edgeStacks[idx] + if _, ok := edgeStack.Status[endpoint.ID]; ok { + delete(edgeStack.Status, endpoint.ID) + + if err := tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack); err != nil { + log.Warn().Err(err).Msg("Unable to update edge stack") + } } } diff --git a/api/http/handler/endpoints/endpoint_delete_test.go b/api/http/handler/endpoints/endpoint_delete_test.go index 559c1b680..309b45ffe 100644 --- a/api/http/handler/endpoints/endpoint_delete_test.go +++ b/api/http/handler/endpoints/endpoint_delete_test.go @@ -11,7 +11,6 @@ import ( "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" ) func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { @@ -43,9 +42,9 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { } if err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "edgegroup-1", - EndpointIDs: roar.FromSlice(endpointIDs), + ID: 1, + Name: "edgegroup-1", + Endpoints: endpointIDs, }); err != nil { t.Fatal("could not create edge group:", err) } @@ -79,7 +78,7 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) { t.Fatal("could not retrieve the edge group:", err) } - if edgeGroup.EndpointIDs.Len() > 0 { + if len(edgeGroup.Endpoints) > 0 { t.Fatal("the edge group is not consistent") } } diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 43b14ad6a..86f1b1d3c 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -95,11 +95,12 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht return httperror.BadRequest("Invalid query parameters", err) } - filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, securityContext) + filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) + + filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, edgeGroups, settings) if err != nil { return httperror.InternalServerError("Unable to filter endpoints", err) } - filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext) sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc") diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go index 5bc4a930d..e81bc34a9 100644 --- a/api/http/handler/endpoints/endpoint_registries_list.go +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err) } - registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships) + registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships) if handleError != nil { return nil, handleError } @@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ return registries, err } -func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { if !endpointutils.IsKubernetesEndpoint(endpoint) { return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil } - return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships) + return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships) } -func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) { namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true) isAdmin, err := security.IsAdmin(r) if err != nil { @@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataS return registries, nil } - return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user) + return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user) } func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) { @@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc return false } -func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { +func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) { err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if errors.Is(err, security.ErrAuthorizationRequired) { return nil, httperror.Forbidden("User is not authorized", err) @@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) } - userNamespaces, err := handler.userNamespaces(tx, endpoint, user) + userNamespaces, err := handler.userNamespaces(endpoint, user) if err != nil { return nil, httperror.InternalServerError("unable to retrieve user namespaces", err) } @@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil } -func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { +func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) { kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { return nil, err @@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *po return nil, err } - userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID) + userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) if err != nil { return nil, err } diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go index 961cad147..6dc41b0bd 100644 --- a/api/http/handler/endpoints/filter.go +++ b/api/http/handler/endpoints/filter.go @@ -11,10 +11,9 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/handler/edgegroups" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/roar" + "github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/pkg/errors" @@ -141,14 +140,11 @@ func (handler *Handler) filterEndpointsByQuery( groups []portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup, settings *portainer.Settings, - context *security.RestrictedRequestContext, ) ([]portainer.Endpoint, int, error) { totalAvailableEndpoints := len(filteredEndpoints) if len(query.endpointIds) > 0 { - endpointIDs := roar.FromSlice(query.endpointIds) - - filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs) + filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds) } if len(query.excludeIds) > 0 { @@ -185,16 +181,11 @@ func (handler *Handler) filterEndpointsByQuery( } // filter edge environments by trusted/untrusted - // only portainer admins are allowed to see untrusted environments filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool { if !endpointutils.IsEdgeEndpoint(&endpoint) { return true } - if query.edgeDeviceUntrusted { - return !endpoint.UserTrusted && context.IsAdmin - } - return endpoint.UserTrusted == !query.edgeDeviceUntrusted }) @@ -256,17 +247,19 @@ func (handler *Handler) filterEndpointsByQuery( return filteredEndpoints, totalAvailableEndpoints, nil } -func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool { +func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool { + status, ok := edgeStackStatus[envId] + // consider that if the env has no status in the stack it is in Pending state if statusFilter == portainer.EdgeStackStatusPending { - return stackStatus == nil || len(stackStatus.Status) == 0 + return !ok || len(status.Status) == 0 } - if stackStatus == nil { + if !ok { return false } - return slices.ContainsFunc(stackStatus.Status, func(s portainer.EdgeStackDeploymentStatus) bool { + return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == statusFilter }) } @@ -277,7 +270,7 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database") } - envIds := roar.Roar[portainer.EndpointID]{} + envIds := make([]portainer.EndpointID, 0) for _, edgeGroupdId := range stack.EdgeGroups { edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId) if err != nil { @@ -289,37 +282,25 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port if err != nil { return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group") } - edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs) + edgeGroup.Endpoints = endpointIDs } - envIds.Union(edgeGroup.EndpointIDs) + envIds = append(envIds, edgeGroup.Endpoints...) } if statusFilter != nil { - var innerErr error - - envIds.Iterate(func(envId portainer.EndpointID) bool { - edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId) - if dataservices.IsErrObjectNotFound(err) { - return true - } else if err != nil { - innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId) - return false + n := 0 + for _, envId := range envIds { + if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) { + envIds[n] = envId + n++ } - - if !endpointStatusInStackMatchesFilter(edgeStackStatus, portainer.EndpointID(envId), *statusFilter) { - envIds.Remove(envId) - } - - return true - }) - - if innerErr != nil { - return nil, innerErr } + envIds = envIds[:n] } - filteredEndpoints := filteredEndpointsByIds(endpoints, envIds) + uniqueIds := slicesx.Unique(envIds) + filteredEndpoints := filteredEndpointsByIds(endpoints, uniqueIds) return filteredEndpoints, nil } @@ -351,14 +332,16 @@ func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups [] } edgeGroups = edgeGroups[:n] - endpointIDSet := roar.Roar[portainer.EndpointID]{} + endpointIDSet := make(map[portainer.EndpointID]struct{}) for _, edgeGroup := range edgeGroups { - endpointIDSet.Union(edgeGroup.EndpointIDs) + for _, endpointID := range edgeGroup.Endpoints { + endpointIDSet[endpointID] = struct{}{} + } } n = 0 for _, endpoint := range endpoints { - if endpointIDSet.Contains(endpoint.ID) { + if _, exists := endpointIDSet[endpoint.ID]; exists { endpoints[n] = endpoint n++ } @@ -374,11 +357,12 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr } n := 0 - excludeEndpointIDSet := roar.Roar[portainer.EndpointID]{} - + excludeEndpointIDSet := make(map[portainer.EndpointID]struct{}) for _, edgeGroup := range edgeGroups { if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok { - excludeEndpointIDSet.Union(edgeGroup.EndpointIDs) + for _, endpointID := range edgeGroup.Endpoints { + excludeEndpointIDSet[endpointID] = struct{}{} + } } else { edgeGroups[n] = edgeGroup n++ @@ -388,7 +372,7 @@ func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGr n = 0 for _, endpoint := range endpoints { - if !excludeEndpointIDSet.Contains(endpoint.ID) { + if _, ok := excludeEndpointIDSet[endpoint.ID]; !ok { endpoints[n] = endpoint n++ } @@ -613,10 +597,15 @@ func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer. return len(missingTags) == 0 } -func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids roar.Roar[portainer.EndpointID]) []portainer.Endpoint { +func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint { + idsSet := make(map[portainer.EndpointID]bool, len(ids)) + for _, id := range ids { + idsSet[id] = true + } + n := 0 for _, endpoint := range endpoints { - if ids.Contains(endpoint.ID) { + if idsSet[endpoint.ID] { endpoints[n] = endpoint n++ } diff --git a/api/http/handler/endpoints/filter_test.go b/api/http/handler/endpoints/filter_test.go index 642448b86..f19d0a276 100644 --- a/api/http/handler/endpoints/filter_test.go +++ b/api/http/handler/endpoints/filter_test.go @@ -6,13 +6,10 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" "github.com/portainer/portainer/api/slicesx" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) type filterTest struct { @@ -177,7 +174,7 @@ func BenchmarkFilterEndpointsBySearchCriteria_PartialMatch(b *testing.B) { edgeGroups = append(edgeGroups, portainer.EdgeGroup{ ID: portainer.EdgeGroupID(i + 1), Name: "edge-group-" + strconv.Itoa(i+1), - EndpointIDs: roar.FromSlice(endpointIDs), + Endpoints: append([]portainer.EndpointID{}, endpointIDs...), Dynamic: true, TagIDs: []portainer.TagID{1, 2, 3}, PartialMatch: true, @@ -224,11 +221,11 @@ func BenchmarkFilterEndpointsBySearchCriteria_FullMatch(b *testing.B) { edgeGroups := []portainer.EdgeGroup{} for i := range 1000 { edgeGroups = append(edgeGroups, portainer.EdgeGroup{ - ID: portainer.EdgeGroupID(i + 1), - Name: "edge-group-" + strconv.Itoa(i+1), - EndpointIDs: roar.FromSlice(endpointIDs), - Dynamic: true, - TagIDs: []portainer.TagID{1}, + ID: portainer.EdgeGroupID(i + 1), + Name: "edge-group-" + strconv.Itoa(i+1), + Endpoints: append([]portainer.EndpointID{}, endpointIDs...), + Dynamic: true, + TagIDs: []portainer.TagID{1}, }) } @@ -266,7 +263,6 @@ func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portai []portainer.EndpointGroup{}, []portainer.EdgeGroup{}, &portainer.Settings{}, - &security.RestrictedRequestContext{IsAdmin: true}, ) is.NoError(err) @@ -302,127 +298,3 @@ func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) *Handler { return handler } - -func TestFilterEndpointsByEdgeStack(t *testing.T) { - _, store := datastore.MustNewTestStore(t, false, false) - - endpoints := []portainer.Endpoint{ - {ID: 1, Name: "Endpoint 1"}, - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - } - - edgeStackId := portainer.EdgeStackID(1) - - err := store.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{ - ID: edgeStackId, - Name: "Test Edge Stack", - EdgeGroups: []portainer.EdgeGroupID{1, 2}, - }) - require.NoError(t, err) - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Edge Group 1", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(t, err) - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 2, - Name: "Edge Group 2", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}), - }) - require.NoError(t, err) - - es, err := filterEndpointsByEdgeStack(endpoints, edgeStackId, nil, store) - require.NoError(t, err) - require.Len(t, es, 3) - require.Contains(t, es, endpoints[0]) // Endpoint 1 - require.Contains(t, es, endpoints[1]) // Endpoint 2 - require.Contains(t, es, endpoints[2]) // Endpoint 3 - require.NotContains(t, es, endpoints[3]) // Endpoint 4 -} - -func TestFilterEndpointsByEdgeGroup(t *testing.T) { - _, store := datastore.MustNewTestStore(t, false, false) - - endpoints := []portainer.Endpoint{ - {ID: 1, Name: "Endpoint 1"}, - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - } - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Edge Group 1", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(t, err) - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 2, - Name: "Edge Group 2", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}), - }) - require.NoError(t, err) - - edgeGroups, err := store.EdgeGroup().ReadAll() - require.NoError(t, err) - - es, egs := filterEndpointsByEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1, 2}) - require.NoError(t, err) - - require.Len(t, es, 3) - require.Contains(t, es, endpoints[0]) // Endpoint 1 - require.Contains(t, es, endpoints[1]) // Endpoint 2 - require.Contains(t, es, endpoints[2]) // Endpoint 3 - require.NotContains(t, es, endpoints[3]) // Endpoint 4 - - require.Len(t, egs, 2) - require.Equal(t, egs[0].ID, portainer.EdgeGroupID(1)) - require.Equal(t, egs[1].ID, portainer.EdgeGroupID(2)) -} - -func TestFilterEndpointsByExcludeEdgeGroupIDs(t *testing.T) { - _, store := datastore.MustNewTestStore(t, false, false) - - endpoints := []portainer.Endpoint{ - {ID: 1, Name: "Endpoint 1"}, - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - } - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 1, - Name: "Edge Group 1", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}), - }) - require.NoError(t, err) - - err = store.EdgeGroup().Create(&portainer.EdgeGroup{ - ID: 2, - Name: "Edge Group 2", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}), - }) - require.NoError(t, err) - - edgeGroups, err := store.EdgeGroup().ReadAll() - require.NoError(t, err) - - es, egs := filterEndpointsByExcludeEdgeGroupIDs(endpoints, edgeGroups, []portainer.EdgeGroupID{1}) - require.NoError(t, err) - - require.Len(t, es, 3) - require.Equal(t, es, []portainer.Endpoint{ - {ID: 2, Name: "Endpoint 2"}, - {ID: 3, Name: "Endpoint 3"}, - {ID: 4, Name: "Endpoint 4"}, - }) - - require.Len(t, egs, 1) - require.Equal(t, egs[0].ID, portainer.EdgeGroupID(2)) -} diff --git a/api/http/handler/endpoints/update_edge_relations.go b/api/http/handler/endpoints/update_edge_relations.go index c487519f0..1390c9fd4 100644 --- a/api/http/handler/endpoints/update_edge_relations.go +++ b/api/http/handler/endpoints/update_edge_relations.go @@ -17,7 +17,17 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { - return errors.WithMessage(err, "Unable to retrieve environment relation inside the database") + if !tx.IsErrObjectNotFound(err) { + return errors.WithMessage(err, "Unable to retrieve environment relation inside the database") + } + + relation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + if err := tx.EndpointRelation().Create(relation); err != nil { + return errors.WithMessage(err, "Unable to create environment relation inside the database") + } } endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) diff --git a/api/http/handler/endpoints/utils_update_edge_groups.go b/api/http/handler/endpoints/utils_update_edge_groups.go index 6207acbc5..bd9c413d7 100644 --- a/api/http/handler/endpoints/utils_update_edge_groups.go +++ b/api/http/handler/endpoints/utils_update_edge_groups.go @@ -1,11 +1,12 @@ package endpoints import ( + "slices" + + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/set" - - "github.com/pkg/errors" ) func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []portainer.EdgeGroupID, environmentID portainer.EndpointID) (bool, error) { @@ -18,8 +19,10 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po environmentEdgeGroupsSet := set.Set[portainer.EdgeGroupID]{} for _, edgeGroup := range edgeGroups { - if edgeGroup.EndpointIDs.Contains(environmentID) { - environmentEdgeGroupsSet[edgeGroup.ID] = true + for _, eID := range edgeGroup.Endpoints { + if eID == environmentID { + environmentEdgeGroupsSet[edgeGroup.ID] = true + } } } @@ -49,16 +52,20 @@ func updateEnvironmentEdgeGroups(tx dataservices.DataStoreTx, newEdgeGroups []po } removeEdgeGroups := environmentEdgeGroupsSet.Difference(newEdgeGroupsSet) - if err := updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { - edgeGroup.EndpointIDs.Remove(environmentID) - }); err != nil { + err = updateSet(removeEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { + edgeGroup.Endpoints = slices.DeleteFunc(edgeGroup.Endpoints, func(eID portainer.EndpointID) bool { + return eID == environmentID + }) + }) + if err != nil { return false, err } addToEdgeGroups := newEdgeGroupsSet.Difference(environmentEdgeGroupsSet) - if err := updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { - edgeGroup.EndpointIDs.Add(environmentID) - }); err != nil { + err = updateSet(addToEdgeGroups, func(edgeGroup *portainer.EdgeGroup) { + edgeGroup.Endpoints = append(edgeGroup.Endpoints, environmentID) + }) + if err != nil { return false, err } diff --git a/api/http/handler/endpoints/utils_update_edge_groups_test.go b/api/http/handler/endpoints/utils_update_edge_groups_test.go index a57651fae..e89d501fb 100644 --- a/api/http/handler/endpoints/utils_update_edge_groups_test.go +++ b/api/http/handler/endpoints/utils_update_edge_groups_test.go @@ -6,7 +6,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/datastore" - "github.com/stretchr/testify/assert" ) @@ -15,9 +14,10 @@ func Test_updateEdgeGroups(t *testing.T) { groups := make([]portainer.EdgeGroup, len(names)) for index, name := range names { group := &portainer.EdgeGroup{ - Name: name, - Dynamic: false, - TagIDs: make([]portainer.TagID, 0), + Name: name, + Dynamic: false, + TagIDs: make([]portainer.TagID, 0), + Endpoints: make([]portainer.EndpointID, 0), } if err := store.EdgeGroup().Create(group); err != nil { @@ -35,8 +35,13 @@ func Test_updateEdgeGroups(t *testing.T) { group, err := store.EdgeGroup().Read(groupID) is.NoError(err) - is.True(group.EndpointIDs.Contains(endpointID), - "expected endpoint to be in group") + for _, endpoint := range group.Endpoints { + if endpoint == endpointID { + return + } + } + + is.Fail("expected endpoint to be in group") } } @@ -76,7 +81,7 @@ func Test_updateEdgeGroups(t *testing.T) { endpointGroups := groupsByName(groups, testCase.endpointGroupNames) for _, group := range endpointGroups { - group.EndpointIDs.Add(testCase.endpoint.ID) + group.Endpoints = append(group.Endpoints, testCase.endpoint.ID) err = store.EdgeGroup().Update(group.ID, &group) is.NoError(err) diff --git a/api/http/handler/endpoints/utils_update_tags_test.go b/api/http/handler/endpoints/utils_update_tags_test.go index 527f963a4..ee42e4e10 100644 --- a/api/http/handler/endpoints/utils_update_tags_test.go +++ b/api/http/handler/endpoints/utils_update_tags_test.go @@ -10,6 +10,7 @@ import ( ) func Test_updateTags(t *testing.T) { + createTags := func(store *datastore.Store, tagNames []string) ([]portainer.Tag, error) { tags := make([]portainer.Tag, len(tagNames)) for index, tagName := range tagNames { diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go index 9e57478c8..66f81b64a 100644 --- a/api/http/handler/file/handler.go +++ b/api/http/handler/file/handler.go @@ -17,12 +17,12 @@ type Handler struct { } // NewHandler creates a handler to serve static files. -func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler { +func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler { h := &Handler{ Handler: security.MWSecureHeaders( gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))), featureflags.IsEnabled("hsts"), - csp, + featureflags.IsEnabled("csp"), ), wasInstanceDisabled: wasInstanceDisabled, } @@ -36,7 +36,6 @@ func isHTML(acceptContent []string) bool { return true } } - return false } @@ -44,13 +43,11 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if handler.wasInstanceDisabled() { if r.RequestURI == "/" || r.RequestURI == "/index.html" { http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect) - return } } else { if strings.HasPrefix(r.RequestURI, "/timeout.html") { http.Redirect(w, r, "/", http.StatusTemporaryRedirect) - return } } diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go index 43c08c870..1eaa52716 100644 --- a/api/http/handler/gitops/git_repo_file_preview.go +++ b/api/http/handler/gitops/git_repo_file_preview.go @@ -17,11 +17,10 @@ type fileResponse struct { } type repositoryFilePreviewPayload struct { - Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"` - Reference string `json:"reference" example:"refs/heads/master"` - Username string `json:"username" example:"myGitUsername"` - Password string `json:"password" example:"myGitPassword"` - AuthorizationType gittypes.GitCredentialAuthType `json:"authorizationType"` + Repository string `json:"repository" example:"https://github.com/openfaas/faas" validate:"required"` + Reference string `json:"reference" example:"refs/heads/master"` + Username string `json:"username" example:"myGitUsername"` + Password string `json:"password" example:"myGitPassword"` // Path to file whose content will be read TargetFile string `json:"targetFile" example:"docker-compose.yml"` // TLSSkipVerify skips SSL verification when cloning the Git repository @@ -69,15 +68,7 @@ func (handler *Handler) gitOperationRepoFilePreview(w http.ResponseWriter, r *ht return httperror.InternalServerError("Unable to create temporary folder", err) } - err = handler.gitService.CloneRepository( - projectPath, - payload.Repository, - payload.Reference, - payload.Username, - payload.Password, - payload.AuthorizationType, - payload.TLSSkipVerify, - ) + err = handler.gitService.CloneRepository(projectPath, payload.Repository, payload.Reference, payload.Username, payload.Password, payload.TLSSkipVerify) if err != nil { if errors.Is(err, gittypes.ErrAuthenticationFailure) { return httperror.BadRequest("Invalid git credential", err) diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 1704eb316..72c7f7669 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -81,7 +81,7 @@ type Handler struct { } // @title PortainerCE API -// @version 2.32.0 +// @version 2.30.0 // @description.markdown api-description.md // @termsOfService diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go index 33b0d82cd..83ae0db51 100644 --- a/api/http/handler/helm/helm_install.go +++ b/api/http/handler/helm/helm_install.go @@ -46,24 +46,18 @@ var errChartNameInvalid = errors.New("invalid chart name. " + // @produce json // @param id path int true "Environment(Endpoint) identifier" // @param payload body installChartPayload true "Chart details" -// @param dryRun query bool false "Dry run" // @success 201 {object} release.Release "Created" // @failure 401 "Unauthorized" // @failure 404 "Environment(Endpoint) or ServiceAccount not found" // @failure 500 "Server error" // @router /endpoints/{id}/kubernetes/helm [post] func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - dryRun, err := request.RetrieveBooleanQueryParameter(r, "dryRun", true) - if err != nil { - return httperror.BadRequest("Invalid dryRun query parameter", err) - } - var payload installChartPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { return httperror.BadRequest("Invalid Helm install payload", err) } - release, err := handler.installChart(r, payload, dryRun) + release, err := handler.installChart(r, payload) if err != nil { return httperror.InternalServerError("Unable to install a chart", err) } @@ -100,7 +94,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error { return nil } -func (handler *Handler) installChart(r *http.Request, p installChartPayload, dryRun bool) (*release.Release, error) { +func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) { clusterAccess, httperr := handler.getHelmClusterAccess(r) if httperr != nil { return nil, httperr.Err @@ -113,7 +107,6 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry Namespace: p.Namespace, Repo: p.Repo, Atomic: p.Atomic, - DryRun: dryRun, KubernetesClusterAccess: clusterAccess, } @@ -141,14 +134,13 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload, dry return nil, err } - if !installOpts.DryRun { - manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest) - if err != nil { - return nil, err - } - if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil { - return nil, err - } + manifest, err := handler.applyPortainerLabelsToHelmAppManifest(r, installOpts, release.Manifest) + if err != nil { + return nil, err + } + + if err := handler.updateHelmAppManifest(r, manifest, installOpts.Namespace); err != nil { + return nil, err } return release, nil diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go index c29423fa9..aab9c523d 100644 --- a/api/http/handler/helm/helm_repo_search.go +++ b/api/http/handler/helm/helm_repo_search.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/portainer/pkg/libhelm/options" httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/portainer/portainer/pkg/libhttp/request" "github.com/pkg/errors" ) @@ -18,8 +17,6 @@ import ( // @description **Access policy**: authenticated // @tags helm // @param repo query string true "Helm repository URL" -// @param chart query string false "Helm chart name" -// @param useCache query string false "If true will use cache to search" // @security ApiKeyAuth // @security jwt // @produce json @@ -35,19 +32,13 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) * return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter")) } - chart, _ := request.RetrieveQueryParameter(r, "chart", false) - // If true will useCache to search, will always add to cache after - useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false) - _, err := url.ParseRequestURI(repo) if err != nil { return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))) } searchOpts := options.SearchRepoOptions{ - Repo: repo, - Chart: chart, - UseCache: useCache, + Repo: repo, } result, err := handler.helmPackageManager.SearchRepo(searchOpts) diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go index f139827b8..591c57922 100644 --- a/api/http/handler/helm/helm_show.go +++ b/api/http/handler/helm/helm_show.go @@ -20,7 +20,6 @@ import ( // @tags helm // @param repo query string true "Helm repository URL" // @param chart query string true "Chart name" -// @param version query string true "Chart version" // @param command path string true "chart/values/readme" // @security ApiKeyAuth // @security jwt @@ -46,11 +45,6 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter")) } - version, err := request.RetrieveQueryParameter(r, "version", true) - if err != nil { - return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version))) - } - cmd, err := request.RetrieveRouteVariableValue(r, "command") if err != nil { cmd = "all" @@ -61,7 +55,6 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper OutputFormat: options.ShowOutputFormat(cmd), Chart: chart, Repo: repo, - Version: version, } result, err := handler.helmPackageManager.Show(showOptions) if err != nil { diff --git a/api/http/handler/kubernetes/client.go b/api/http/handler/kubernetes/client.go index a7f2485e3..a85f2cff9 100644 --- a/api/http/handler/kubernetes/client.go +++ b/api/http/handler/kubernetes/client.go @@ -2,10 +2,8 @@ package kubernetes import ( "net/http" - "strconv" "github.com/portainer/portainer/api/http/middlewares" - "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/kubernetes/cli" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/rs/zerolog/log" @@ -27,19 +25,13 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err) } - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to retrieve token data associated to the request.") - return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err) - } - - pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID))) + pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint) if err != nil { log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.") return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err) } - pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin()) - pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces()) + pcli.IsKubeAdmin = cli.IsKubeAdmin + pcli.NonAdminNamespaces = cli.NonAdminNamespaces return pcli, nil } diff --git a/api/http/handler/kubernetes/cluster_role_bindings.go b/api/http/handler/kubernetes/cluster_role_bindings.go index 83621a900..a5050c947 100644 --- a/api/http/handler/kubernetes/cluster_role_bindings.go +++ b/api/http/handler/kubernetes/cluster_role_bindings.go @@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr) } - if !cli.GetIsKubeAdmin() { + if !cli.IsKubeAdmin { log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.") return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil) } diff --git a/api/http/handler/kubernetes/cluster_roles.go b/api/http/handler/kubernetes/cluster_roles.go index 6d5d028be..3fd2ca8aa 100644 --- a/api/http/handler/kubernetes/cluster_roles.go +++ b/api/http/handler/kubernetes/cluster_roles.go @@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr) } - if !cli.GetIsKubeAdmin() { + if !cli.IsKubeAdmin { log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.") return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil) } diff --git a/api/http/handler/kubernetes/event.go b/api/http/handler/kubernetes/event.go deleted file mode 100644 index 25f024303..000000000 --- a/api/http/handler/kubernetes/event.go +++ /dev/null @@ -1,102 +0,0 @@ -package kubernetes - -import ( - "net/http" - - httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/portainer/portainer/pkg/libhttp/request" - "github.com/portainer/portainer/pkg/libhttp/response" - "github.com/rs/zerolog/log" - k8serrors "k8s.io/apimachinery/pkg/api/errors" -) - -// @id getKubernetesEventsForNamespace -// @summary Gets kubernetes events for namespace -// @description Get events by optional query param resourceId for a given namespace. -// @description **Access policy**: Authenticated user. -// @tags kubernetes -// @security ApiKeyAuth || jwt -// @produce json -// @param id path int true "Environment identifier" -// @param namespace path string true "The namespace name the events are associated to" -// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" -// @success 200 {object} []kubernetes.K8sEvent "Success" -// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." -// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." -// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." -// @failure 500 "Server error occurred while attempting to retrieve the events within the specified namespace." -// @router /kubernetes/{id}/namespaces/{namespace}/events [get] -func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - namespace, err := request.RetrieveRouteVariableValue(r, "namespace") - if err != nil { - log.Error().Err(err).Str("context", "getKubernetesEvents").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable") - return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err) - } - - resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true) - if err != nil { - log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter") - return httperror.BadRequest("Unable to retrieve resourceId query parameter", err) - } - - cli, httpErr := handler.getProxyKubeClient(r) - if httpErr != nil { - log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user") - return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) - } - - events, err := cli.GetEvents(namespace, resourceId) - if err != nil { - if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { - log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API") - return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) - } - - log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events") - return httperror.InternalServerError("Unable to retrieve events", err) - } - - return response.JSON(w, events) -} - -// @id getAllKubernetesEvents -// @summary Gets kubernetes events -// @description Get events by query param resourceId -// @description **Access policy**: Authenticated user. -// @tags kubernetes -// @security ApiKeyAuth || jwt -// @produce json -// @param id path int true "Environment identifier" -// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" -// @success 200 {object} []kubernetes.K8sEvent "Success" -// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." -// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." -// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." -// @failure 500 "Server error occurred while attempting to retrieve the events." -// @router /kubernetes/{id}/events [get] -func (handler *Handler) getAllKubernetesEvents(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true) - if err != nil { - log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter") - return httperror.BadRequest("Unable to retrieve resourceId query parameter", err) - } - - cli, httpErr := handler.getProxyKubeClient(r) - if httpErr != nil { - log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user") - return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr) - } - - events, err := cli.GetEvents("", resourceId) - if err != nil { - if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) { - log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API") - return httperror.Forbidden("Unauthorized access to the Kubernetes API", err) - } - - log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events") - return httperror.InternalServerError("Unable to retrieve events", err) - } - - return response.JSON(w, events) -} diff --git a/api/http/handler/kubernetes/event_test.go b/api/http/handler/kubernetes/event_test.go deleted file mode 100644 index 77f38c511..000000000 --- a/api/http/handler/kubernetes/event_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package kubernetes - -import ( - "net/http" - "net/http/httptest" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/authorization" - "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/jwt" - "github.com/portainer/portainer/api/kubernetes" - kubeClient "github.com/portainer/portainer/api/kubernetes/cli" - "github.com/stretchr/testify/assert" -) - -// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order -// test the logic in event.go -func TestGetKubernetesEvents(t *testing.T) { - is := assert.New(t) - - _, store := datastore.MustNewTestStore(t, true, true) - - err := store.Endpoint().Create(&portainer.Endpoint{ - ID: 1, - Type: portainer.AgentOnKubernetesEnvironment, - }, - ) - is.NoError(err, "error creating environment") - - err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) - is.NoError(err, "error creating a user") - - jwtService, err := jwt.NewService("1h", store) - is.NoError(err, "Error initiating jwt service") - - tk, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole}) - - kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "") - - cli := testhelpers.NewKubernetesClient() - factory, _ := kubeClient.NewClientFactory(nil, nil, store, "", "", "") - - authorizationService := authorization.NewService(store) - handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService, - factory, cli) - is.NotNil(handler, "Handler should not fail") - - req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil) - ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) - req = req.WithContext(ctx) - testhelpers.AddTestSecurityCookie(req, tk) - - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - is.Equal(http.StatusOK, rr.Code, "Status should be 200") -} diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index a8a5898c8..cc068e4a4 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -58,7 +58,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet) endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet) endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost) - endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet) endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet) endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost) endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet) @@ -111,7 +110,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza // to keep it simple, we've decided to leave it like this. namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter() namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet) - namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet) namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet) namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut) @@ -135,7 +133,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza // getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient // from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using // admin permissions. If you're unsure which one to use, use this. -func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) { +func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err) @@ -146,7 +144,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *ht return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err) } - cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) + cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) if !ok { return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil) } @@ -179,7 +177,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { } // Check if we have a kubeclient against this auth token already, otherwise generate a new one - _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID))) + _, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token) if ok { next.ServeHTTP(w, r) return @@ -255,7 +253,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } serverURL.Scheme = "https" - serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS() + serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS config.Clusters[0].Cluster.Server = serverURL.String() yaml, err := cli.GenerateYAML(config) @@ -269,7 +267,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler { return } - handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli) + handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli) next.ServeHTTP(w, r) }) } diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go index 75dae9e69..2efde3b85 100644 --- a/api/http/handler/kubernetes/namespaces.go +++ b/api/http/handler/kubernetes/namespaces.go @@ -22,7 +22,6 @@ import ( // @produce json // @param id path int true "Environment identifier" // @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" -// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false" // @success 200 {array} portainer.K8sNamespaceInfo "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." @@ -37,12 +36,6 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err) } - withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true) - if err != nil { - log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents") - return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err) - } - cli, httpErr := handler.prepareKubeClient(r) if httpErr != nil { log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") @@ -55,14 +48,6 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err) } - if withUnhealthyEvents { - namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces) - if err != nil { - log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events") - return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err) - } - } - if withResourceQuota { return cli.CombineNamespacesWithResourceQuotas(namespaces, w) } diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index 4117de830..dd2112c16 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -7,9 +7,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/pkg/libcrypto" - libclient "github.com/portainer/portainer/pkg/libhttp/client" "github.com/portainer/portainer/pkg/libhttp/response" - "github.com/rs/zerolog/log" "github.com/segmentio/encoding/json" ) @@ -39,12 +37,6 @@ type motdData struct { // @success 200 {object} motdResponse // @router /motd [get] func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { - if err := libclient.ExternalRequestDisabled(portainer.MessageOfTheDayURL); err != nil { - log.Debug().Err(err).Msg("External request disabled: MOTD") - response.JSON(w, &motdResponse{Message: ""}) - return - } - motd, err := client.Get(portainer.MessageOfTheDayURL, 0) if err != nil { response.JSON(w, &motdResponse{Message: ""}) diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 026039833..dee14885e 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -5,10 +5,10 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/registryutils/access" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/pendingactions" httperror "github.com/portainer/portainer/pkg/libhttp/error" @@ -17,7 +17,6 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" - "github.com/rs/zerolog/log" ) func hideFields(registry *portainer.Registry, hideAccesses bool) { @@ -57,20 +56,17 @@ func newHandler(bouncer security.BouncerService) *Handler { func (handler *Handler) initRouter(bouncer accessGuard) { adminRouter := handler.NewRoute().Subrouter() adminRouter.Use(bouncer.AdminAccess) + + authenticatedRouter := handler.NewRoute().Subrouter() + authenticatedRouter.Use(bouncer.AuthenticatedAccess) + adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet) adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut) adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost) adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete) - // Use registry-specific access bouncer for inspect and repositories endpoints - registryAccessRouter := handler.NewRoute().Subrouter() - registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess) - registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) - - // Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access - authenticatedRouter := handler.NewRoute().Subrouter() - authenticatedRouter.Use(bouncer.AuthenticatedAccess) + authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet) authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry)) } @@ -92,7 +88,9 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re } // this function validates that +// // 1. user has the appropriate authorizations to perform the request +// // 2. user has a direct or indirect access to the registry func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) { securityContext, err := security.RetrieveRestrictedRequestContext(r) @@ -100,6 +98,11 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain return false, false, err } + user, err := handler.DataStore.User().Read(securityContext.UserID) + if err != nil { + return false, false, err + } + // Portainer admins always have access to everything if securityContext.IsAdmin { return true, true, nil @@ -125,68 +128,47 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain return false, false, err } - // Use the enhanced registry access utility function that includes namespace validation - _, err = access.GetAccessibleRegistry( - handler.DataStore, - handler.K8sClientFactory, - securityContext.UserID, - endpointId, - registry.ID, - ) + memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID) if err != nil { - return false, false, nil // No access + return false, false, nil } - return true, false, nil -} - -// RegistryAccess defines a security check for registry-specific API endpoints. -// Authentication is required to access these endpoints. -// The user must have direct or indirect access to the specific registry being requested. -// This bouncer validates registry access using the userHasRegistryAccess logic. -func (handler *Handler) RegistryAccess(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // First ensure the user is authenticated - tokenData, err := security.RetrieveTokenData(r) - if err != nil { - httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized) - return - } - - // Extract registry ID from the route - registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") - if err != nil { - httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err) - return - } - - // Get the registry from the database - registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) - if handler.DataStore.IsErrObjectNotFound(err) { - httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err) - return - } else if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err) - return - } - - // Check if user has access to this registry - hasAccess, _, err := handler.userHasRegistryAccess(r, registry) - if err != nil { - httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err) - return - } - if !hasAccess { - log.Debug(). - Int("registry_id", registryID). - Str("registry_name", registry.Name). - Int("user_id", int(tokenData.ID)). - Str("context", "RegistryAccessBouncer"). - Msg("User access denied to registry") - httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied) - return - } - - next.ServeHTTP(w, r) - }) + // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) + if endpointutils.IsKubernetesEndpoint(endpoint) { + kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint) + if err != nil { + return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access") + } + accessPolicies, err := kcl.GetNamespaceAccessPolicies() + if err != nil { + return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access") + } + + authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces + + for _, namespace := range authorizedNamespaces { + // when the default namespace is authorized to use a registry, all users have the ability to use it + // unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations + if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { + return true, false, nil + } + + namespacePolicy := accessPolicies[namespace] + if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { + return true, false, nil + } + } + return false, false, nil + } + + // validate access for docker environments + // leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) + // and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) + if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) { + return true, false, nil + } + + // when user has no access via their role, direct grant or indirect grant + // then they don't have access to the registry + return false, false, nil } diff --git a/api/http/handler/registries/registry_access_test.go b/api/http/handler/registries/registry_access_test.go deleted file mode 100644 index 8231f4d66..000000000 --- a/api/http/handler/registries/registry_access_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package registries - -import ( - "net/http" - "net/http/httptest" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/testhelpers" - - "github.com/gorilla/mux" - "github.com/stretchr/testify/assert" -) - -func Test_RegistryAccess_RequiresAuthentication(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - registry := &portainer.Registry{ - ID: 1, - Name: "test-registry", - URL: "https://registry.test.com", - } - err := store.Registry().Create(registry) - assert.NoError(t, err) - handler := &Handler{ - DataStore: store, - } - req := httptest.NewRequest(http.MethodGet, "/registries/1", nil) - req = mux.SetURLVars(req, map[string]string{"id": "1"}) - rr := httptest.NewRecorder() - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - bouncer := handler.RegistryAccess(testHandler) - bouncer.ServeHTTP(rr, req) - assert.Equal(t, http.StatusUnauthorized, rr.Code) -} - -func Test_RegistryAccess_InvalidRegistryID(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} - err := store.User().Create(user) - assert.NoError(t, err) - - handler := &Handler{ - DataStore: store, - } - req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil) - req = mux.SetURLVars(req, map[string]string{"id": "invalid"}) - tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} - req = req.WithContext(security.StoreTokenData(req, tokenData)) - - rr := httptest.NewRecorder() - - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - bouncer := handler.RegistryAccess(testHandler) - bouncer.ServeHTTP(rr, req) - assert.Equal(t, http.StatusBadRequest, rr.Code) -} - -func Test_RegistryAccess_RegistryNotFound(t *testing.T) { - _, store := datastore.MustNewTestStore(t, true, true) - user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole} - err := store.User().Create(user) - assert.NoError(t, err) - - handler := &Handler{ - DataStore: store, - requestBouncer: testhelpers.NewTestRequestBouncer(), - } - req := httptest.NewRequest(http.MethodGet, "/registries/999", nil) - req = mux.SetURLVars(req, map[string]string{"id": "999"}) - tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole} - req = req.WithContext(security.StoreTokenData(req, tokenData)) - - rr := httptest.NewRecorder() - - testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - bouncer := handler.RegistryAccess(testHandler) - bouncer.ServeHTTP(rr, req) - assert.Equal(t, http.StatusNotFound, rr.Code) -} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index f606a953e..a1f0bd9c5 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -4,12 +4,10 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/http/security" + httperrors "github.com/portainer/portainer/api/http/errors" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" - - "github.com/rs/zerolog/log" ) // @id RegistryInspect @@ -33,11 +31,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid registry identifier route variable", err) } - log.Debug(). - Int("registry_id", registryID). - Str("context", "RegistryInspectHandler"). - Msg("Starting registry inspection") - registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID)) if handler.DataStore.IsErrObjectNotFound(err) { return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err) @@ -45,12 +38,14 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err) } - // Check if user is admin to determine if we should hide sensitive fields - securityContext, err := security.RetrieveRestrictedRequestContext(r) + hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry) if err != nil { return httperror.InternalServerError("Unable to retrieve info from request context", err) } + if !hasAccess { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } - hideFields(registry, !securityContext.IsAdmin) + hideFields(registry, !isAdmin) return response.JSON(w, registry) } diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index 2bdf2b71f..8d0687694 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -19,15 +19,14 @@ import ( ) type stackGitUpdatePayload struct { - AutoUpdate *portainer.AutoUpdateSettings - Env []portainer.Pair - Prune bool - RepositoryReferenceName string - RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - RepositoryAuthorizationType gittypes.GitCredentialAuthType - TLSSkipVerify bool + AutoUpdate *portainer.AutoUpdateSettings + Env []portainer.Pair + Prune bool + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + TLSSkipVerify bool } func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { @@ -152,19 +151,11 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * } stack.GitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: password, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: password, } - if _, err := handler.GitService.LatestCommitID( - stack.GitConfig.URL, - stack.GitConfig.ReferenceName, - stack.GitConfig.Authentication.Username, - stack.GitConfig.Authentication.Password, - stack.GitConfig.Authentication.AuthorizationType, - stack.GitConfig.TLSSkipVerify, - ); err != nil { + if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil { return httperror.InternalServerError("Unable to fetch git repository", err) } } else { diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index c595808aa..e65e1e70c 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -6,7 +6,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/git" - gittypes "github.com/portainer/portainer/api/git/types" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" k "github.com/portainer/portainer/api/kubernetes" @@ -20,13 +19,12 @@ import ( ) type stackGitRedployPayload struct { - RepositoryReferenceName string - RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - RepositoryAuthorizationType gittypes.GitCredentialAuthType - Env []portainer.Pair - Prune bool + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + Env []portainer.Pair + Prune bool // Force a pulling to current image with the original tag though the image is already the latest PullImage bool `example:"false"` @@ -137,16 +135,13 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) repositoryUsername := "" repositoryPassword := "" - repositoryAuthType := gittypes.GitCredentialAuthType_Basic if payload.RepositoryAuthentication { repositoryPassword = payload.RepositoryPassword - repositoryAuthType = payload.RepositoryAuthorizationType // When the existing stack is using the custom username/password and the password is not updated, // the stack should keep using the saved username/password if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { repositoryPassword = stack.GitConfig.Authentication.Password - repositoryAuthType = stack.GitConfig.Authentication.AuthorizationType } repositoryUsername = payload.RepositoryUsername } @@ -157,7 +152,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) ReferenceName: stack.GitConfig.ReferenceName, Username: repositoryUsername, Password: repositoryPassword, - AuthType: repositoryAuthType, TLSSkipVerify: stack.GitConfig.TLSSkipVerify, } @@ -172,7 +166,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) return err } - newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryAuthType, stack.GitConfig.TLSSkipVerify) + newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword, stack.GitConfig.TLSSkipVerify) if err != nil { return httperror.InternalServerError("Unable get latest commit id", errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)) } diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index 42ecbaa04..95195bb10 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -27,13 +27,12 @@ type kubernetesFileStackUpdatePayload struct { } type kubernetesGitStackUpdatePayload struct { - RepositoryReferenceName string - RepositoryAuthentication bool - RepositoryUsername string - RepositoryPassword string - RepositoryAuthorizationType gittypes.GitCredentialAuthType - AutoUpdate *portainer.AutoUpdateSettings - TLSSkipVerify bool + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + AutoUpdate *portainer.AutoUpdateSettings + TLSSkipVerify bool } func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { @@ -77,19 +76,11 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer. } stack.GitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: password, - AuthorizationType: payload.RepositoryAuthorizationType, + Username: payload.RepositoryUsername, + Password: password, } - if _, err := handler.GitService.LatestCommitID( - stack.GitConfig.URL, - stack.GitConfig.ReferenceName, - stack.GitConfig.Authentication.Username, - stack.GitConfig.Authentication.Password, - stack.GitConfig.Authentication.AuthorizationType, - stack.GitConfig.TLSSkipVerify, - ); err != nil { + if _, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password, stack.GitConfig.TLSSkipVerify); err != nil { return httperror.InternalServerError("Unable to fetch git repository", err) } } diff --git a/api/http/handler/system/version.go b/api/http/handler/system/version.go index 52af5879c..9d80b88a9 100644 --- a/api/http/handler/system/version.go +++ b/api/http/handler/system/version.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/pkg/build" - libclient "github.com/portainer/portainer/pkg/libhttp/client" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/response" @@ -70,14 +69,10 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperr } func GetLatestVersion() string { - if err := libclient.ExternalRequestDisabled(portainer.VersionCheckURL); err != nil { - log.Debug().Err(err).Msg("External request disabled: Version check") - return "" - } - motd, err := client.Get(portainer.VersionCheckURL, 5) if err != nil { log.Debug().Err(err).Msg("couldn't fetch latest Portainer release version") + return "" } diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index f8f1b7786..4f8554faf 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -8,7 +8,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/edge" - "github.com/portainer/portainer/api/internal/endpointutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -59,9 +58,6 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { for endpointID := range tag.Endpoints { endpoint, err := tx.Endpoint().Endpoint(endpointID) - if tx.IsErrObjectNotFound(err) { - continue - } if err != nil { return httperror.InternalServerError("Unable to retrieve environment from the database", err) } @@ -107,10 +103,15 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) } - edgeJobs, err := tx.EdgeJob().ReadAll() - if err != nil { - return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err) + for _, endpoint := range endpoints { + if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) { + err = updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks) + if err != nil { + return httperror.InternalServerError("Unable to update environment relations in the database", err) + } + } } + for _, edgeGroup := range edgeGroups { edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool { return t == tagID @@ -122,16 +123,6 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { } } - for _, endpoint := range endpoints { - if (!tag.Endpoints[endpoint.ID] && !tag.EndpointGroups[endpoint.GroupID]) || !endpointutils.IsEdgeEndpoint(&endpoint) { - continue - } - - if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks, edgeJobs); err != nil { - return httperror.InternalServerError("Unable to update environment relations in the database", err) - } - } - err = tx.Tag().Delete(tagID) if err != nil { return httperror.InternalServerError("Unable to remove the tag from the database", err) @@ -140,12 +131,19 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error { return nil } -func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error { +func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error { endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID) - if err != nil { + if err != nil && !tx.IsErrObjectNotFound(err) { return err } + if endpointRelation == nil { + endpointRelation = &portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: make(map[portainer.EdgeStackID]bool), + } + } + endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID) if err != nil { return err @@ -159,25 +157,5 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End endpointRelation.EdgeStacks = stacksSet - if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil { - return err - } - - for _, edgeJob := range edgeJobs { - endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx) - if err != nil { - return err - } - if slices.Contains(endpoints, endpoint.ID) { - continue - } - - delete(edgeJob.GroupLogsCollection, endpoint.ID) - - if err := tx.EdgeJob().Update(edgeJob.ID, &edgeJob); err != nil { - return err - } - } - - return nil + return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation) } diff --git a/api/http/handler/tags/tag_delete_test.go b/api/http/handler/tags/tag_delete_test.go index c933610c5..cabf20963 100644 --- a/api/http/handler/tags/tag_delete_test.go +++ b/api/http/handler/tags/tag_delete_test.go @@ -8,20 +8,23 @@ import ( "testing" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" - portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/portainer/portainer/api/roar" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { const tagsCount = 100 - handler, store := setUpHandler(t) + _, store := datastore.MustNewTestStore(t, true, false) + + user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} + if err := store.User().Create(user); err != nil { + t.Fatal("could not create admin user:", err) + } + + handler := NewHandler(testhelpers.NewTestRequestBouncer()) + handler.DataStore = store + // Create all the tags and add them to the same edge group var tagIDs []portainer.TagID @@ -81,128 +84,3 @@ func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) { t.Fatal("the edge group is not consistent") } } - -func TestHandler_tagDelete(t *testing.T) { - t.Run("should delete tag and update related endpoints and edge groups", func(t *testing.T) { - handler, store := setUpHandler(t) - - tag := &portainer.Tag{ - ID: 1, - Name: "tag-1", - Endpoints: make(map[portainer.EndpointID]bool), - EndpointGroups: make(map[portainer.EndpointGroupID]bool), - } - require.NoError(t, store.Tag().Create(tag)) - - endpointGroup := &portainer.EndpointGroup{ - ID: 2, - Name: "endpoint-group-1", - TagIDs: []portainer.TagID{tag.ID}, - } - require.NoError(t, store.EndpointGroup().Create(endpointGroup)) - - endpoint1 := &portainer.Endpoint{ - ID: 1, - Name: "endpoint-1", - GroupID: endpointGroup.ID, - } - require.NoError(t, store.Endpoint().Create(endpoint1)) - - endpoint2 := &portainer.Endpoint{ - ID: 2, - Name: "endpoint-2", - TagIDs: []portainer.TagID{tag.ID}, - } - require.NoError(t, store.Endpoint().Create(endpoint2)) - - tag.Endpoints[endpoint2.ID] = true - tag.EndpointGroups[endpointGroup.ID] = true - require.NoError(t, store.Tag().Update(tag.ID, tag)) - - dynamicEdgeGroup := &portainer.EdgeGroup{ - ID: 1, - Name: "edgegroup-1", - TagIDs: []portainer.TagID{tag.ID}, - Dynamic: true, - } - require.NoError(t, store.EdgeGroup().Create(dynamicEdgeGroup)) - - staticEdgeGroup := &portainer.EdgeGroup{ - ID: 2, - Name: "edgegroup-2", - EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint2.ID}), - } - require.NoError(t, store.EdgeGroup().Create(staticEdgeGroup)) - - req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(tag.ID)), nil) - if err != nil { - t.Fail() - - return - } - - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - require.Equal(t, http.StatusNoContent, rec.Code) - - // Check that the tag is deleted - _, err = store.Tag().Read(tag.ID) - require.ErrorIs(t, err, portainerDsErrors.ErrObjectNotFound) - - // Check that the endpoints are updated - endpoint1, err = store.Endpoint().Endpoint(endpoint1.ID) - require.NoError(t, err) - assert.Len(t, endpoint1.TagIDs, 0, "endpoint-1 should not have any tags") - assert.Equal(t, endpoint1.GroupID, endpointGroup.ID, "endpoint-1 should still belong to the endpoint group") - - endpoint2, err = store.Endpoint().Endpoint(endpoint2.ID) - require.NoError(t, err) - assert.Len(t, endpoint2.TagIDs, 0, "endpoint-2 should not have any tags") - - // Check that the dynamic edge group is updated - dynamicEdgeGroup, err = store.EdgeGroup().Read(dynamicEdgeGroup.ID) - require.NoError(t, err) - assert.Len(t, dynamicEdgeGroup.TagIDs, 0, "dynamic edge group should not have any tags") - assert.Equal(t, 0, dynamicEdgeGroup.EndpointIDs.Len(), "dynamic edge group should not have any endpoints") - - // Check that the static edge group is not updated - staticEdgeGroup, err = store.EdgeGroup().Read(staticEdgeGroup.ID) - require.NoError(t, err) - assert.Len(t, staticEdgeGroup.TagIDs, 0, "static edge group should not have any tags") - assert.Equal(t, 1, staticEdgeGroup.EndpointIDs.Len(), "static edge group should have one endpoint") - assert.True(t, staticEdgeGroup.EndpointIDs.Contains(endpoint2.ID), "static edge group should have the endpoint-2") - }) - - // Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup - t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) { - _, store := setUpHandler(t) - // Create a tag with a reference to a non-existent endpoint - tag := &portainer.Tag{ - ID: 1, - Name: "test-tag", - Endpoints: map[portainer.EndpointID]bool{999: true}, // Non-existent endpoint - EndpointGroups: make(map[portainer.EndpointGroupID]bool), - } - - err := store.Tag().Create(tag) - require.NoError(t, err) - - err = deleteTag(store, 1) - require.NoError(t, err) - }) -} - -func setUpHandler(t *testing.T) (*Handler, dataservices.DataStore) { - _, store := datastore.MustNewTestStore(t, true, false) - - user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole} - if err := store.User().Create(user); err != nil { - t.Fatal("could not create admin user:", err) - } - - handler := NewHandler(testhelpers.NewTestRequestBouncer()) - handler.DataStore = store - - return handler, store -} diff --git a/api/http/handler/templates/template_file.go b/api/http/handler/templates/template_file.go index f9ec0135c..b834eeed9 100644 --- a/api/http/handler/templates/template_file.go +++ b/api/http/handler/templates/template_file.go @@ -5,7 +5,6 @@ import ( "slices" portainer "github.com/portainer/portainer/api" - gittypes "github.com/portainer/portainer/api/git/types" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" @@ -72,15 +71,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht defer handler.cleanUp(projectPath) - if err := handler.GitService.CloneRepository( - projectPath, - template.Repository.URL, - "", - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - ); err != nil { + if err := handler.GitService.CloneRepository(projectPath, template.Repository.URL, "", "", "", false); err != nil { return httperror.InternalServerError("Unable to clone git repository", err) } diff --git a/api/http/handler/templates/utils_fetch_templates.go b/api/http/handler/templates/utils_fetch_templates.go index fc5c97125..6feb9edeb 100644 --- a/api/http/handler/templates/utils_fetch_templates.go +++ b/api/http/handler/templates/utils_fetch_templates.go @@ -4,9 +4,7 @@ import ( "net/http" portainer "github.com/portainer/portainer/api" - libclient "github.com/portainer/portainer/pkg/libhttp/client" httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/rs/zerolog/log" "github.com/segmentio/encoding/json" ) @@ -26,27 +24,18 @@ func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError templatesURL = portainer.DefaultTemplatesURL } - var body *listResponse - if err := libclient.ExternalRequestDisabled(templatesURL); err != nil { - if templatesURL == portainer.DefaultTemplatesURL { - log.Debug().Err(err).Msg("External request disabled: Default templates") - return body, nil - } - } - resp, err := http.Get(templatesURL) if err != nil { return nil, httperror.InternalServerError("Unable to retrieve templates via the network", err) } defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + var body *listResponse + err = json.NewDecoder(resp.Body).Decode(&body) + if err != nil { return nil, httperror.InternalServerError("Unable to parse template file", err) } - for i := range body.Templates { - body.Templates[i].ID = portainer.TemplateID(i + 1) - } return body, nil } diff --git a/api/http/handler/webhooks/webhook_create.go b/api/http/handler/webhooks/webhook_create.go index d7edde333..b69e93db3 100644 --- a/api/http/handler/webhooks/webhook_create.go +++ b/api/http/handler/webhooks/webhook_create.go @@ -80,7 +80,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h return httperror.InternalServerError("Unable to retrieve user authentication token", err) } - _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID) + _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID) if err != nil { return httperror.Forbidden("Permission deny to access registry", err) } diff --git a/api/http/handler/webhooks/webhook_update.go b/api/http/handler/webhooks/webhook_update.go index 94133c49a..7a026fcd7 100644 --- a/api/http/handler/webhooks/webhook_update.go +++ b/api/http/handler/webhooks/webhook_update.go @@ -69,7 +69,7 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h return httperror.InternalServerError("Unable to retrieve user authentication token", err) } - _, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID) + _, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID) if err != nil { return httperror.Forbidden("Permission deny to access registry", err) } diff --git a/api/http/middlewares/endpoint.go b/api/http/middlewares/endpoint.go index 0050e4300..c88731dd3 100644 --- a/api/http/middlewares/endpoint.go +++ b/api/http/middlewares/endpoint.go @@ -25,12 +25,12 @@ type key int const contextEndpoint key = 0 func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc { - if endpointIDParam == "" { - endpointIDParam = "id" - } - return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + if endpointIDParam == "" { + endpointIDParam = "id" + } + endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam) if err != nil { httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err) @@ -51,6 +51,7 @@ func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam ctx := context.WithValue(request.Context(), contextEndpoint, endpoint) next.ServeHTTP(rw, request.WithContext(ctx)) + }) } } diff --git a/api/http/middlewares/plaintext_http_request.go b/api/http/middlewares/plaintext_http_request.go index e746fd819..668346098 100644 --- a/api/http/middlewares/plaintext_http_request.go +++ b/api/http/middlewares/plaintext_http_request.go @@ -3,7 +3,6 @@ package middlewares import ( "net/http" "slices" - "strings" "github.com/gorilla/csrf" ) @@ -17,45 +16,6 @@ type plainTextHTTPRequestHandler struct { next http.Handler } -// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol. -// The Forwarded header format supports: -// - Single proxy: Forwarded: by=;for=;host=;proto= -// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17 -// We take the first (leftmost) entry as it represents the original client -func parseForwardedHeaderProto(forwarded string) string { - if forwarded == "" { - return "" - } - - // Parse the first part (leftmost proxy, closest to original client) - firstPart, _, _ := strings.Cut(forwarded, ",") - firstPart = strings.TrimSpace(firstPart) - - // Split by semicolon to get key-value pairs within this proxy entry - // Format: key=value;key=value;key=value - pairs := strings.Split(firstPart, ";") - for _, pair := range pairs { - // Split by equals sign to separate key and value - key, value, found := strings.Cut(pair, "=") - if !found { - continue - } - - if strings.EqualFold(strings.TrimSpace(key), "proto") { - return strings.Trim(strings.TrimSpace(value), `"'`) - } - } - - return "" -} - -// isHTTPSRequest checks if the original request was made over HTTPS -// by examining both X-Forwarded-Proto and Forwarded headers -func isHTTPSRequest(r *http.Request) bool { - return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") || - strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https") -} - func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if slices.Contains(safeMethods, r.Method) { h.next.ServeHTTP(w, r) @@ -64,7 +24,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R req := r // If original request was HTTPS (via proxy), keep CSRF checks. - if !isHTTPSRequest(r) { + if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" { req = csrf.PlaintextHTTPRequest(r) } diff --git a/api/http/middlewares/plaintext_http_request_test.go b/api/http/middlewares/plaintext_http_request_test.go deleted file mode 100644 index 33912be80..000000000 --- a/api/http/middlewares/plaintext_http_request_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package middlewares - -import ( - "testing" -) - -var tests = []struct { - name string - forwarded string - expected string -}{ - { - name: "empty header", - forwarded: "", - expected: "", - }, - { - name: "single proxy with proto=https", - forwarded: "proto=https", - expected: "https", - }, - { - name: "single proxy with proto=http", - forwarded: "proto=http", - expected: "http", - }, - { - name: "single proxy with multiple directives", - forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43", - expected: "https", - }, - { - name: "single proxy with proto in middle", - forwarded: "for=192.0.2.60;proto=https;host=example.com", - expected: "https", - }, - { - name: "single proxy with proto at end", - forwarded: "for=192.0.2.60;host=example.com;proto=https", - expected: "https", - }, - { - name: "multiple proxies - takes first", - forwarded: "proto=https, proto=http", - expected: "https", - }, - { - name: "multiple proxies with complex format", - forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http", - expected: "https", - }, - { - name: "multiple proxies with for directive only", - forwarded: "for=192.0.2.43, for=198.51.100.17", - expected: "", - }, - { - name: "multiple proxies with proto only in second", - forwarded: "for=192.0.2.43, proto=https", - expected: "", - }, - { - name: "multiple proxies with proto only in first", - forwarded: "proto=https, for=198.51.100.17", - expected: "https", - }, - { - name: "quoted protocol value", - forwarded: "proto=\"https\"", - expected: "https", - }, - { - name: "single quoted protocol value", - forwarded: "proto='https'", - expected: "https", - }, - { - name: "mixed case protocol", - forwarded: "proto=HTTPS", - expected: "HTTPS", - }, - { - name: "no proto directive", - forwarded: "for=192.0.2.60;by=203.0.113.43", - expected: "", - }, - { - name: "empty proto value", - forwarded: "proto=", - expected: "", - }, - { - name: "whitespace around values", - forwarded: " proto = https ", - expected: "https", - }, - { - name: "whitespace around semicolons", - forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43", - expected: "https", - }, - { - name: "whitespace around commas", - forwarded: "proto=https , proto=http", - expected: "https", - }, - { - name: "IPv6 address in for directive", - forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https", - expected: "https", - }, - { - name: "complex multiple proxies with IPv6", - forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http", - expected: "https", - }, - { - name: "obfuscated identifiers", - forwarded: "for=_mdn;proto=https", - expected: "https", - }, - { - name: "unknown identifier", - forwarded: "for=unknown;proto=https", - expected: "https", - }, - { - name: "malformed key-value pair", - forwarded: "proto", - expected: "", - }, - { - name: "malformed key-value pair with equals", - forwarded: "proto=", - expected: "", - }, - { - name: "multiple equals signs", - forwarded: "proto=https=extra", - expected: "https=extra", - }, - { - name: "mixed case directive name", - forwarded: "PROTO=https", - expected: "https", - }, - { - name: "mixed case directive name with spaces", - forwarded: " Proto = https ", - expected: "https", - }, -} - -func TestParseForwardedHeaderProto(t *testing.T) { - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseForwardedHeaderProto(tt.forwarded) - if result != tt.expected { - t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected) - } - }) - } -} - -func FuzzParseForwardedHeaderProto(f *testing.F) { - for _, t := range tests { - f.Add(t.forwarded) - } - - f.Fuzz(func(t *testing.T, forwarded string) { - parseForwardedHeaderProto(forwarded) - }) -} diff --git a/api/http/models/kubernetes/application.go b/api/http/models/kubernetes/application.go index 4759d9214..fcb49b23d 100644 --- a/api/http/models/kubernetes/application.go +++ b/api/http/models/kubernetes/application.go @@ -38,30 +38,14 @@ type K8sApplication struct { Labels map[string]string `json:"Labels,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"` HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` - CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"` } type Metadata struct { Labels map[string]string `json:"labels"` } -type CustomResourceMetadata struct { - Kind string `json:"kind"` - APIVersion string `json:"apiVersion"` - Plural string `json:"plural"` -} - type Pod struct { - Name string `json:"Name"` - ContainerName string `json:"ContainerName"` - Image string `json:"Image"` - ImagePullPolicy string `json:"ImagePullPolicy"` - Status string `json:"Status"` - NodeName string `json:"NodeName"` - PodIP string `json:"PodIP"` - UID string `json:"Uid"` - Resource K8sApplicationResource `json:"Resource,omitempty"` - CreationDate time.Time `json:"CreationDate"` + Status string `json:"Status"` } type Configuration struct { @@ -88,8 +72,8 @@ type TLSInfo struct { // Existing types type K8sApplicationResource struct { - CPURequest float64 `json:"CpuRequest,omitempty"` - CPULimit float64 `json:"CpuLimit,omitempty"` - MemoryRequest int64 `json:"MemoryRequest,omitempty"` - MemoryLimit int64 `json:"MemoryLimit,omitempty"` + CPURequest float64 `json:"CpuRequest"` + CPULimit float64 `json:"CpuLimit"` + MemoryRequest int64 `json:"MemoryRequest"` + MemoryLimit int64 `json:"MemoryLimit"` } diff --git a/api/http/models/kubernetes/event.go b/api/http/models/kubernetes/event.go deleted file mode 100644 index be447b554..000000000 --- a/api/http/models/kubernetes/event.go +++ /dev/null @@ -1,25 +0,0 @@ -package kubernetes - -import "time" - -type K8sEvent struct { - Type string `json:"type"` - Name string `json:"name"` - Reason string `json:"reason"` - Message string `json:"message"` - Namespace string `json:"namespace"` - EventTime time.Time `json:"eventTime"` - Kind string `json:"kind,omitempty"` - Count int32 `json:"count"` - FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"` - LastTimestamp *time.Time `json:"lastTimestamp,omitempty"` - UID string `json:"uid"` - InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"` -} - -type K8sEventInvolvedObject struct { - Kind string `json:"kind,omitempty"` - UID string `json:"uid"` - Name string `json:"name"` - Namespace string `json:"namespace"` -} diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index ac25a7b7a..e945d38da 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -35,7 +35,7 @@ type ( func getUniqueElements(items string) []string { xs := strings.Split(items, ",") xs = slicesx.Map(xs, strings.TrimSpace) - xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 }) + xs = slicesx.Filter(xs, func(x string) bool { return len(x) > 0 }) return slicesx.Unique(xs) } diff --git a/api/http/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go index cd94478d2..95c96df81 100644 --- a/api/http/proxy/factory/docker/networks.go +++ b/api/http/proxy/factory/docker/networks.go @@ -6,7 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" - "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types" "github.com/docker/docker/client" @@ -20,7 +20,7 @@ const ( ) func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { - network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{}) + network, err := dockerClient.NetworkInspect(context.Background(), networkID, types.NetworkInspectOptions{}) if err != nil { return nil, err } diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go index 7036853c7..ecf7935f1 100644 --- a/api/http/proxy/factory/docker/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -55,13 +55,12 @@ func createRegistryAuthenticationHeader( return } - if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil { + if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil { return } authenticationHeader.Serveraddress = matchingRegistry.URL - authenticationHeader.Username = matchingRegistry.Username - authenticationHeader.Password = matchingRegistry.Password + authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry) return } diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index dae72ecc1..49f1cd501 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -15,7 +15,6 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" @@ -419,14 +418,7 @@ func (transport *Transport) updateDefaultGitBranch(request *http.Request) error } repositoryURL := remote[:len(remote)-4] - latestCommitID, err := transport.gitService.LatestCommitID( - repositoryURL, - "", - "", - "", - gittypes.GitCredentialAuthType_Basic, - false, - ) + latestCommitID, err := transport.gitService.LatestCommitID(repositoryURL, "", "", "", false) if err != nil { return err } diff --git a/api/http/proxy/factory/github/client.go b/api/http/proxy/factory/github/client.go deleted file mode 100644 index 74dcfb994..000000000 --- a/api/http/proxy/factory/github/client.go +++ /dev/null @@ -1,108 +0,0 @@ -package github - -import ( - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/segmentio/encoding/json" - "oras.land/oras-go/v2/registry/remote/retry" -) - -const GitHubAPIHost = "https://api.github.com" - -// Package represents a GitHub container package -type Package struct { - Name string `json:"name"` - Owner struct { - Login string `json:"login"` - } `json:"owner"` -} - -// Client represents a GitHub API client -type Client struct { - httpClient *http.Client - baseURL string -} - -// NewClient creates a new GitHub API client -func NewClient(token string) *Client { - return &Client{ - httpClient: NewHTTPClient(token), - baseURL: GitHubAPIHost, - } -} - -// GetContainerPackages fetches container packages for the configured namespace -// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras -func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) { - // Determine the namespace (user or organisation) for the request - namespace := "user" - if useOrganisation { - namespace = "orgs/" + organisationName - } - - // Build the full URL for listing container packages - url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var packages []Package - if err := json.Unmarshal(body, &packages); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Extract repository names in the form "owner/name" - repositories := make([]string, len(packages)) - for i, pkg := range packages { - repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name)) - } - - return repositories, nil -} - -// NewHTTPClient creates a new HTTP client configured for GitHub API requests -func NewHTTPClient(token string) *http.Client { - return &http.Client{ - Transport: &tokenTransport{ - token: token, - transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling - }, - Timeout: 1 * time.Minute, - } -} - -// tokenTransport automatically adds the Bearer token header to requests -type tokenTransport struct { - token string - transport http.RoundTripper -} - -func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if t.token != "" { - req.Header.Set("Authorization", "Bearer "+t.token) - req.Header.Set("Accept", "application/vnd.github+json") - } - return t.transport.RoundTrip(req) -} diff --git a/api/http/proxy/factory/gitlab/client.go b/api/http/proxy/factory/gitlab/client.go deleted file mode 100644 index 13d07e18b..000000000 --- a/api/http/proxy/factory/gitlab/client.go +++ /dev/null @@ -1,130 +0,0 @@ -package gitlab - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "time" - - "github.com/segmentio/encoding/json" - "oras.land/oras-go/v2/registry/remote/retry" -) - -// Repository represents a GitLab registry repository -type Repository struct { - ID int `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - ProjectID int `json:"project_id"` - Location string `json:"location"` - CreatedAt string `json:"created_at"` - Status string `json:"status"` -} - -// Client represents a GitLab API client -type Client struct { - httpClient *http.Client - baseURL string -} - -// NewClient creates a new GitLab API client -// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands). -// if we need to support other commands, consider using the gitlab client library. -func NewClient(baseURL, token string) *Client { - return &Client{ - httpClient: NewHTTPClient(token), - baseURL: baseURL, - } -} - -// GetRegistryRepositoryNames fetches registry repository names for a given project. -// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras -func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) { - url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID) - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var repositories []Repository - if err := json.Unmarshal(body, &repositories); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - // Extract repository names - names := make([]string, len(repositories)) - for i, repo := range repositories { - // the full path is required for further repo operations - names[i] = repo.Path - } - - return names, nil -} - -type Transport struct { - httpTransport *http.Transport -} - -// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport -// interface for proxying requests to the Gitlab API. -func NewTransport() *Transport { - return &Transport{ - httpTransport: &http.Transport{}, - } -} - -// RoundTrip is the implementation of the http.RoundTripper interface -func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { - token := request.Header.Get("Private-Token") - if token == "" { - return nil, errors.New("no gitlab token provided") - } - - r, err := http.NewRequest(request.Method, request.URL.String(), request.Body) - if err != nil { - return nil, err - } - - r.Header.Set("Private-Token", token) - return transport.httpTransport.RoundTrip(r) -} - -// NewHTTPClient creates a new HTTP client configured for GitLab API requests -func NewHTTPClient(token string) *http.Client { - return &http.Client{ - Transport: &tokenTransport{ - token: token, - transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling - }, - Timeout: 1 * time.Minute, - } -} - -// tokenTransport automatically adds the Private-Token header to requests -type tokenTransport struct { - token string - transport http.RoundTripper -} - -func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("Private-Token", t.token) - return t.transport.RoundTrip(req) -} diff --git a/api/http/proxy/factory/gitlab/transport.go b/api/http/proxy/factory/gitlab/transport.go new file mode 100644 index 000000000..7e1804c45 --- /dev/null +++ b/api/http/proxy/factory/gitlab/transport.go @@ -0,0 +1,34 @@ +package gitlab + +import ( + "errors" + "net/http" +) + +type Transport struct { + httpTransport *http.Transport +} + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Gitlab API. +func NewTransport() *Transport { + return &Transport{ + httpTransport: &http.Transport{}, + } +} + +// RoundTrip is the implementation of the http.RoundTripper interface +func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + token := request.Header.Get("Private-Token") + if token == "" { + return nil, errors.New("no gitlab token provided") + } + + r, err := http.NewRequest(request.Method, request.URL.String(), request.Body) + if err != nil { + return nil, err + } + + r.Header.Set("Private-Token", token) + return transport.httpTransport.RoundTrip(r) +} diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index ddab7a096..76e9daa68 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -58,7 +58,6 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (* switch { case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"): - transport.k8sClientFactory.ClearClientCache() defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID)) return transport.executeKubernetesRequest(request) case strings.EqualFold(requestPath, "/namespaces"): diff --git a/api/http/proxy/factory/reverse_proxy.go b/api/http/proxy/factory/reverse_proxy.go index c40e6c485..d583d75fe 100644 --- a/api/http/proxy/factory/reverse_proxy.go +++ b/api/http/proxy/factory/reverse_proxy.go @@ -7,21 +7,6 @@ import ( "strings" ) -// Note that we discard any non-canonical headers by design -var allowedHeaders = map[string]struct{}{ - "Accept": {}, - "Accept-Encoding": {}, - "Accept-Language": {}, - "Cache-Control": {}, - "Content-Length": {}, - "Content-Type": {}, - "Private-Token": {}, - "User-Agent": {}, - "X-Portaineragent-Target": {}, - "X-Portainer-Volumename": {}, - "X-Registry-Auth": {}, -} - // newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy // from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host // HTTP header, which NewSingleHostReverseProxy deliberately preserves. @@ -30,6 +15,7 @@ func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP } func createDirector(target *url.URL) func(*http.Request) { + sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"} targetQuery := target.RawQuery return func(req *http.Request) { req.URL.Scheme = target.Scheme @@ -46,11 +32,8 @@ func createDirector(target *url.URL) func(*http.Request) { req.Header.Set("User-Agent", "") } - for k := range req.Header { - if _, ok := allowedHeaders[k]; !ok { - // We use delete here instead of req.Header.Del because we want to delete non canonical headers. - delete(req.Header, k) - } + for _, header := range sensitiveHeaders { + delete(req.Header, header) } } } diff --git a/api/http/proxy/factory/reverse_proxy_test.go b/api/http/proxy/factory/reverse_proxy_test.go index 6f23d75ec..1a3d88ba0 100644 --- a/api/http/proxy/factory/reverse_proxy_test.go +++ b/api/http/proxy/factory/reverse_proxy_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - portainer "github.com/portainer/portainer/api" ) func Test_createDirector(t *testing.T) { @@ -24,14 +23,12 @@ func Test_createDirector(t *testing.T) { "GET", "https://agent-portainer.io/test?c=7", map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, - true, ), expectedReq: createRequest( t, "GET", "https://portainer.io/api/docker/test?a=5&b=6&c=7", map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, - true, ), }, { @@ -42,14 +39,12 @@ func Test_createDirector(t *testing.T) { "GET", "https://agent-portainer.io/test?c=7", map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"}, - true, ), expectedReq: createRequest( t, "GET", "https://portainer.io/api/docker/test?a=5&b=6&c=7", map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""}, - true, ), }, { @@ -60,83 +55,18 @@ func Test_createDirector(t *testing.T) { "GET", "https://agent-portainer.io/test?c=7", map[string]string{ - "Authorization": "secret", - "Proxy-Authorization": "secret", - "Cookie": "secret", - "X-Csrf-Token": "secret", - "X-Api-Key": "secret", - "Accept": "application/json", - "Accept-Encoding": "gzip", - "Accept-Language": "en-GB", - "Cache-Control": "None", - "Content-Length": "100", - "Content-Type": "application/json", - "Private-Token": "test-private-token", - "User-Agent": "test-user-agent", - "X-Portaineragent-Target": "test-agent-1", - "X-Portainer-Volumename": "test-volume-1", - "X-Registry-Auth": "test-registry-auth", - }, - true, - ), - expectedReq: createRequest( - t, - "GET", - "https://portainer.io/api/docker/test?a=5&b=6&c=7", - map[string]string{ - "Accept": "application/json", - "Accept-Encoding": "gzip", - "Accept-Language": "en-GB", - "Cache-Control": "None", - "Content-Length": "100", - "Content-Type": "application/json", - "Private-Token": "test-private-token", - "User-Agent": "test-user-agent", - "X-Portaineragent-Target": "test-agent-1", - "X-Portainer-Volumename": "test-volume-1", - "X-Registry-Auth": "test-registry-auth", - }, - true, - ), - }, - { - name: "Non canonical Headers", - target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"), - req: createRequest( - t, - "GET", - "https://agent-portainer.io/test?c=7", - map[string]string{ - "Accept": "application/json", - "Accept-Encoding": "gzip", - "Accept-Language": "en-GB", - "Cache-Control": "None", - "Content-Length": "100", - "Content-Type": "application/json", - "Private-Token": "test-private-token", - "User-Agent": "test-user-agent", - portainer.PortainerAgentTargetHeader: "test-agent-1", - "X-Portainer-VolumeName": "test-volume-1", - "X-Registry-Auth": "test-registry-auth", - }, - false, - ), - expectedReq: createRequest( - t, - "GET", - "https://portainer.io/api/docker/test?a=5&b=6&c=7", - map[string]string{ - "Accept": "application/json", "Accept-Encoding": "gzip", - "Accept-Language": "en-GB", - "Cache-Control": "None", - "Content-Length": "100", - "Content-Type": "application/json", - "Private-Token": "test-private-token", - "User-Agent": "test-user-agent", - "X-Registry-Auth": "test-registry-auth", + "Accept": "application/json", + "User-Agent": "something", + "Cookie": "junk", + "X-Csrf-Token": "junk", }, - true, + ), + expectedReq: createRequest( + t, + "GET", + "https://portainer.io/api/docker/test?a=5&b=6&c=7", + map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"}, ), }, } @@ -162,17 +92,13 @@ func createURL(t *testing.T, urlString string) *url.URL { return parsedURL } -func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request { +func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request { req, err := http.NewRequest(method, url, nil) if err != nil { t.Fatalf("Failed to create http request: %s", err) } else { for k, v := range headers { - if canonicalHeaders { - req.Header.Add(k, v) - } else { - req.Header[k] = []string{v} - } + req.Header.Add(k, v) } } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 55b7faecc..eb240692d 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -35,7 +35,6 @@ type ( JWTAuthLookup(*http.Request) (*portainer.TokenData, error) TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error RevokeJWT(string) - DisableCSP() } // RequestBouncer represents an entity that manages API request accesses @@ -73,7 +72,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW jwtService: jwtService, apiKeyService: apiKeyService, hsts: featureflags.IsEnabled("hsts"), - csp: true, + csp: featureflags.IsEnabled("csp"), } go b.cleanUpExpiredJWT() @@ -81,11 +80,6 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW return b } -// DisableCSP disables Content Security Policy -func (bouncer *RequestBouncer) DisableCSP() { - bouncer.csp = false -} - // PublicAccess defines a security check for public API endpoints. // No authentication is required to access these endpoints. func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { @@ -534,7 +528,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler { } if csp { - w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net; frame-ancestors 'none';") + w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud") } w.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/api/http/security/bouncer_test.go b/api/http/security/bouncer_test.go index 3dd42fdc5..4d84dcfee 100644 --- a/api/http/security/bouncer_test.go +++ b/api/http/security/bouncer_test.go @@ -530,34 +530,3 @@ func TestJWTRevocation(t *testing.T) { require.Equal(t, 1, revokeLen()) } - -func TestCSPHeaderDefault(t *testing.T) { - b := NewRequestBouncer(nil, nil, nil) - - srv := httptest.NewServer( - b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), - ) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/") - require.NoError(t, err) - defer resp.Body.Close() - - require.Contains(t, resp.Header, "Content-Security-Policy") -} - -func TestCSPHeaderDisabled(t *testing.T) { - b := NewRequestBouncer(nil, nil, nil) - b.DisableCSP() - - srv := httptest.NewServer( - b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})), - ) - defer srv.Close() - - resp, err := http.Get(srv.URL + "/") - require.NoError(t, err) - defer resp.Body.Close() - - require.NotContains(t, resp.Header, "Content-Security-Policy") -} diff --git a/api/http/server.go b/api/http/server.go index 8f073ce58..183a78c04 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -77,7 +77,6 @@ type Server struct { AuthorizationService *authorization.Service BindAddress string BindAddressHTTPS string - CSP bool HTTPEnabled bool AssetsPath string Status *portainer.Status @@ -114,7 +113,6 @@ type Server struct { PendingActionsService *pendingactions.PendingActionsService PlatformService platform.Service PullLimitCheckDisabled bool - TrustedOrigins []string } // Start starts the HTTP server @@ -122,16 +120,13 @@ func (server *Server) Start() error { kubernetesTokenCacheManager := server.KubernetesTokenCacheManager requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService) - if !server.CSP { - requestBouncer.DisableCSP() - } rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) offlineGate := offlinegate.NewOfflineGate() passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings()) - var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker, server.KubernetesClientFactory) + var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker) authHandler.DataStore = server.DataStore authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService @@ -166,7 +161,10 @@ func (server *Server) Start() error { edgeJobsHandler.FileService = server.FileService edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService - var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService) + edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore) + go edgeStackCoordinator.Start() + + var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator) edgeStacksHandler.FileService = server.FileService edgeStacksHandler.GitService = server.GitService edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer @@ -204,7 +202,7 @@ func (server *Server) Start() error { var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService) - var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled) + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled) var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService) @@ -341,7 +339,7 @@ func (server *Server) Start() error { handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler)) - handler, err := csrf.WithProtect(handler, server.TrustedOrigins) + handler, err := csrf.WithProtect(handler) if err != nil { return errors.Wrap(err, "failed to create CSRF middleware") } diff --git a/api/internal/edge/edgegroup.go b/api/internal/edge/edgegroup.go index eae4fedce..64aa296a5 100644 --- a/api/internal/edge/edgegroup.go +++ b/api/internal/edge/edgegroup.go @@ -1,6 +1,8 @@ package edge import ( + "slices" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" @@ -10,7 +12,7 @@ import ( // EdgeGroupRelatedEndpoints returns a list of environments(endpoints) related to this Edge group func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup) []portainer.EndpointID { if !edgeGroup.Dynamic { - return edgeGroup.EndpointIDs.ToSlice() + return edgeGroup.Endpoints } endpointGroupsMap := map[portainer.EndpointGroupID]*portainer.EndpointGroup{} @@ -70,7 +72,7 @@ func GetEndpointsFromEdgeGroups(edgeGroupIDs []portainer.EdgeGroupID, datastore // edgeGroupRelatedToEndpoint returns true if edgeGroup is associated with environment(endpoint) func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool { if !edgeGroup.Dynamic { - return edgeGroup.EndpointIDs.Contains(endpoint.ID) + return slices.Contains(edgeGroup.Endpoints, endpoint.ID) } endpointTags := tag.Set(endpoint.TagIDs) diff --git a/api/internal/edge/edgegroup_benchmark_test.go b/api/internal/edge/edgegroup_benchmark_test.go deleted file mode 100644 index 861db09fc..000000000 --- a/api/internal/edge/edgegroup_benchmark_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package edge - -import ( - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/datastore" - "github.com/portainer/portainer/api/roar" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/require" -) - -const n = 1_000_000 - -func BenchmarkWriteEdgeGroupOld(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var endpointIDs []portainer.EndpointID - - for i := range n { - endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) - } - - for b.Loop() { - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - Endpoints: endpointIDs, - }) - require.NoError(b, err) - } -} - -func BenchmarkWriteEdgeGroupNew(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var ts []portainer.EndpointID - - for i := range n { - ts = append(ts, portainer.EndpointID(i+1)) - } - - endpointIDs := roar.FromSlice(ts) - - for b.Loop() { - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - EndpointIDs: endpointIDs, - }) - require.NoError(b, err) - } -} - -func BenchmarkReadEdgeGroupOld(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var endpointIDs []portainer.EndpointID - - for i := range n { - endpointIDs = append(endpointIDs, portainer.EndpointID(i+1)) - } - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - Endpoints: endpointIDs, - }) - require.NoError(b, err) - - for b.Loop() { - _, err := store.EdgeGroup().ReadAll() - require.NoError(b, err) - } -} - -func BenchmarkReadEdgeGroupNew(b *testing.B) { - zerolog.SetGlobalLevel(zerolog.ErrorLevel) - - _, store := datastore.MustNewTestStore(b, false, false) - - var ts []portainer.EndpointID - - for i := range n { - ts = append(ts, portainer.EndpointID(i+1)) - } - - endpointIDs := roar.FromSlice(ts) - - err := store.EdgeGroup().Create(&portainer.EdgeGroup{ - Name: "Test Edge Group", - EndpointIDs: endpointIDs, - }) - require.NoError(b, err) - - for b.Loop() { - _, err := store.EdgeGroup().ReadAll() - require.NoError(b, err) - } -} diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go index c0ecb5caf..6986a6917 100644 --- a/api/internal/edge/edgestacks/service.go +++ b/api/internal/edge/edgestacks/service.go @@ -49,6 +49,7 @@ func (service *Service) BuildEdgeStack( DeploymentType: deploymentType, CreationDate: time.Now().Unix(), EdgeGroups: edgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus, 0), Version: 1, UseManifestNamespaces: useManifestNamespaces, }, nil @@ -103,14 +104,6 @@ func (service *Service) PersistEdgeStack( return nil, err } - for _, endpointID := range relatedEndpointIds { - status := &portainer.EdgeStackStatusForEnv{EndpointID: endpointID} - - if err := tx.EdgeStackStatus().Create(stack.ID, endpointID, status); err != nil { - return nil, err - } - } - if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil { return nil, fmt.Errorf("unable to add endpoint relations: %w", err) } @@ -129,6 +122,9 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg for _, endpointID := range relatedEndpointIds { relation, err := endpointRelationService.EndpointRelation(endpointID) if err != nil { + if tx.IsErrObjectNotFound(err) { + continue + } return fmt.Errorf("unable to find endpoint relation in database: %w", err) } @@ -162,9 +158,5 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID return errors.WithMessage(err, "Unable to remove the edge stack from the database") } - if err := tx.EdgeStackStatus().DeleteAll(edgeStackID); err != nil { - return errors.WithMessage(err, "unable to remove edge stack statuses from the database") - } - return nil } diff --git a/api/internal/edge/edgestacks/status.go b/api/internal/edge/edgestacks/status.go new file mode 100644 index 000000000..25629d958 --- /dev/null +++ b/api/internal/edge/edgestacks/status.go @@ -0,0 +1,26 @@ +package edgestacks + +import ( + portainer "github.com/portainer/portainer/api" +) + +// NewStatus returns a new status object for an Edge stack +func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIDs []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus { + status := map[portainer.EndpointID]portainer.EdgeStackStatus{} + + for _, environmentID := range relatedEnvironmentIDs { + newEnvStatus := portainer.EdgeStackStatus{ + Status: []portainer.EdgeStackDeploymentStatus{}, + EndpointID: environmentID, + } + + oldEnvStatus, ok := oldStatus[environmentID] + if ok { + newEnvStatus.DeploymentInfo = oldEnvStatus.DeploymentInfo + } + + status[environmentID] = newEnvStatus + } + + return status +} diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index f596ae0d5..6b7eb1c2d 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -249,19 +249,3 @@ func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portaine return defaultInterval } - -func InitializeEdgeEndpointRelation(endpoint *portainer.Endpoint, tx dataservices.DataStoreTx) error { - if !IsEdgeEndpoint(endpoint) { - return nil - } - - relation := &portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: make(map[portainer.EdgeStackID]bool), - } - - if err := tx.EndpointRelation().Create(relation); err != nil { - return err - } - return nil -} diff --git a/api/internal/registryutils/access/access.go b/api/internal/registryutils/access/access.go index bfa5181c0..0d14cba39 100644 --- a/api/internal/registryutils/access/access.go +++ b/api/internal/registryutils/access/access.go @@ -2,82 +2,40 @@ package access import ( "errors" - "fmt" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/endpointutils" - "github.com/portainer/portainer/api/kubernetes" - "github.com/portainer/portainer/api/kubernetes/cli" ) func hasPermission( dataStore dataservices.DataStore, - k8sClientFactory *cli.ClientFactory, userID portainer.UserID, endpointID portainer.EndpointID, registry *portainer.Registry, ) (hasPermission bool, err error) { user, err := dataStore.User().Read(userID) if err != nil { - return false, err + return } if user.Role == portainer.AdministratorRole { - return true, nil - } - - endpoint, err := dataStore.Endpoint().Endpoint(endpointID) - if err != nil { - return false, err + return true, err } teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID) if err != nil { - return false, err + return } - // validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces) - if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil { - kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint) - if err != nil { - return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err) - } - accessPolicies, err := kcl.GetNamespaceAccessPolicies() - if err != nil { - return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err) - } - - authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces - - for _, namespace := range authorizedNamespaces { - // when the default namespace is authorized to use a registry, all users have the ability to use it - // unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations - if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace { - return true, nil - } - - namespacePolicy := accessPolicies[namespace] - if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) { - return true, nil - } - } - return false, nil - } - - // validate access for docker environments - // leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access) - // and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams) hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID) - return hasPermission, nil + return } // GetAccessibleRegistry get the registry if the user has permission func GetAccessibleRegistry( dataStore dataservices.DataStore, - k8sClientFactory *cli.ClientFactory, userID portainer.UserID, endpointID portainer.EndpointID, registryID portainer.RegistryID, @@ -88,7 +46,7 @@ func GetAccessibleRegistry( return } - hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry) + hasPermission, err := hasPermission(dataStore, userID, endpointID, registry) if err != nil { return } diff --git a/api/internal/registryutils/ecr_reg_token.go b/api/internal/registryutils/ecr_reg_token.go index 6e9a754bf..cbcceb982 100644 --- a/api/internal/registryutils/ecr_reg_token.go +++ b/api/internal/registryutils/ecr_reg_token.go @@ -62,26 +62,3 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password return } - -// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token -// and setting effective credentials on the registry when authentication is enabled. -// This function modifies the registry in-place by setting Username and Password to the effective values. -func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error { - if !registry.Authentication { - return nil - } - - if err := EnsureRegTokenValid(tx, registry); err != nil { - return err - } - - username, password, err := GetRegEffectiveCredential(registry) - if err != nil { - return err - } - - registry.Username = username - registry.Password = password - - return nil -} diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 19254f540..d4a29ae09 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -7,7 +7,6 @@ import ( "github.com/portainer/portainer/api/database" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices/errors" - "github.com/portainer/portainer/api/slicesx" ) var _ dataservices.DataStore = &testDatastore{} @@ -17,7 +16,6 @@ type testDatastore struct { edgeGroup dataservices.EdgeGroupService edgeJob dataservices.EdgeJobService edgeStack dataservices.EdgeStackService - edgeStackStatus dataservices.EdgeStackStatusService endpoint dataservices.EndpointService endpointGroup dataservices.EndpointGroupService endpointRelation dataservices.EndpointRelationService @@ -55,11 +53,8 @@ func (d *testDatastore) CustomTemplate() dataservices.CustomTemplateService { re func (d *testDatastore) EdgeGroup() dataservices.EdgeGroupService { return d.edgeGroup } func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { return d.edgeJob } func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack } -func (d *testDatastore) EdgeStackStatus() dataservices.EdgeStackStatusService { - return d.edgeStackStatus -} -func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint } -func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } +func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint } +func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup } func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService { return d.endpointRelation @@ -153,17 +148,8 @@ type stubUserService struct { users []portainer.User } -func (s *stubUserService) BucketName() string { return "users" } -func (s *stubUserService) ReadAll(predicates ...func(portainer.User) bool) ([]portainer.User, error) { - filtered := s.users - - for _, p := range predicates { - filtered = slicesx.Filter(filtered, p) - } - - return filtered, nil -} - +func (s *stubUserService) BucketName() string { return "users" } +func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil } func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) { return s.users, nil } @@ -181,16 +167,8 @@ type stubEdgeJobService struct { jobs []portainer.EdgeJob } -func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } -func (s *stubEdgeJobService) ReadAll(predicates ...func(portainer.EdgeJob) bool) ([]portainer.EdgeJob, error) { - filtered := s.jobs - - for _, p := range predicates { - filtered = slicesx.Filter(filtered, p) - } - - return filtered, nil -} +func (s *stubEdgeJobService) BucketName() string { return "edgejobs" } +func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil } // WithEdgeJobs option will instruct testDatastore to return provided jobs func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { @@ -380,14 +358,8 @@ func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) return nil, errors.ErrObjectNotFound } -func (s *stubStacksService) ReadAll(predicates ...func(portainer.Stack) bool) ([]portainer.Stack, error) { - filtered := s.stacks - - for _, p := range predicates { - filtered = slicesx.Filter(filtered, p) - } - - return filtered, nil +func (s *stubStacksService) ReadAll() ([]portainer.Stack, error) { + return s.stacks, nil } func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) { diff --git a/api/internal/testhelpers/git_service.go b/api/internal/testhelpers/git_service.go index 6b1a352ee..6af1b6459 100644 --- a/api/internal/testhelpers/git_service.go +++ b/api/internal/testhelpers/git_service.go @@ -1,9 +1,6 @@ package testhelpers -import ( - portainer "github.com/portainer/portainer/api" - gittypes "github.com/portainer/portainer/api/git/types" -) +import portainer "github.com/portainer/portainer/api" type gitService struct { cloneErr error @@ -18,50 +15,18 @@ func NewGitService(cloneErr error, id string) portainer.GitService { } } -func (g *gitService) CloneRepository( - destination, - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) error { +func (g *gitService) CloneRepository(destination, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error { return g.cloneErr } -func (g *gitService) LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, -) (string, error) { +func (g *gitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) { return g.id, nil } -func (g *gitService) ListRefs( - repositoryURL, - username, - password string, - authType gittypes.GitCredentialAuthType, - hardRefresh bool, - tlsSkipVerify bool, -) ([]string, error) { +func (g *gitService) ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) { return nil, nil } -func (g *gitService) ListFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - includedExts []string, - tlsSkipVerify bool, -) ([]string, error) { +func (g *gitService) ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includedExts []string, tlsSkipVerify bool) ([]string, error) { return nil, nil } diff --git a/api/internal/testhelpers/kube_client.go b/api/internal/testhelpers/kube_client.go deleted file mode 100644 index 550e7ce92..000000000 --- a/api/internal/testhelpers/kube_client.go +++ /dev/null @@ -1,19 +0,0 @@ -package testhelpers - -import ( - portainer "github.com/portainer/portainer/api" - models "github.com/portainer/portainer/api/http/models/kubernetes" -) - -type testKubeClient struct { - portainer.KubeClient -} - -func NewKubernetesClient() portainer.KubeClient { - return &testKubeClient{} -} - -// Event -func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { - return nil, nil -} diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go index 0586dffef..b89154549 100644 --- a/api/internal/testhelpers/request_bouncer.go +++ b/api/internal/testhelpers/request_bouncer.go @@ -60,8 +60,6 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, func (testRequestBouncer) RevokeJWT(jti string) {} -func (testRequestBouncer) DisableCSP() {} - // AddTestSecurityCookie adds a security cookie to the request func AddTestSecurityCookie(r *http.Request, jwt string) { r.AddCookie(&http.Cookie{ diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index 6f254c296..73f8d50af 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -143,23 +143,3 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri return nonAdminNamespaces, nil } - -// GetIsKubeAdmin retrieves true if client is admin -func (client *KubeClient) GetIsKubeAdmin() bool { - return client.IsKubeAdmin -} - -// UpdateIsKubeAdmin sets whether the kube client is admin -func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) { - client.IsKubeAdmin = isKubeAdmin -} - -// GetClientNonAdminNamespaces retrieves non-admin namespaces -func (client *KubeClient) GetClientNonAdminNamespaces() []string { - return client.NonAdminNamespaces -} - -// UpdateClientNonAdminNamespaces sets the client non admin namespace list -func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) { - client.NonAdminNamespaces = nonAdminNamespaces -} diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 550ade1d3..6d2cc437c 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -77,30 +77,9 @@ func (factory *ClientFactory) ClearClientCache() { factory.endpointProxyClients.Flush() } -// ClearClientCache removes all cached kube clients for a userId -func (factory *ClientFactory) ClearUserClientCache(userID string) { - for key := range factory.endpointProxyClients.Items() { - if strings.HasSuffix(key, "."+userID) { - factory.endpointProxyClients.Delete(key) - } - } -} - // Remove the cached kube client so a new one can be created func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID))) - - endpointPrefix := strconv.Itoa(int(endpointID)) + "." - - for key := range factory.endpointProxyClients.Items() { - if strings.HasPrefix(key, endpointPrefix) { - factory.endpointProxyClients.Delete(key) - } - } -} - -func (factory *ClientFactory) GetAddrHTTPS() string { - return factory.AddrHTTPS } // GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. @@ -121,24 +100,6 @@ func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoi return kcl, nil } -// GetPrivilegedUserKubeClient checks if an existing admin client is already registered for the environment(endpoint) and user and returns it if one is found. -// If no client is registered, it will create a new client, register it, and returns it. -func (factory *ClientFactory) GetPrivilegedUserKubeClient(endpoint *portainer.Endpoint, userID string) (*KubeClient, error) { - key := strconv.Itoa(int(endpoint.ID)) + ".admin." + userID - pcl, ok := factory.endpointProxyClients.Get(key) - if ok { - return pcl.(*KubeClient), nil - } - - kcl, err := factory.createCachedPrivilegedKubeClient(endpoint) - if err != nil { - return nil, err - } - - factory.endpointProxyClients.Set(key, kcl, cache.DefaultExpiration) - return kcl, nil -} - // GetProxyKubeClient retrieves a KubeClient from the cache. You should be // calling SetProxyKubeClient before first. It is normally, called the // kubernetes middleware. @@ -191,9 +152,8 @@ func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portain } return &KubeClient{ - cli: cli, - instanceID: factory.instanceID, - IsKubeAdmin: true, + cli: cli, + instanceID: factory.instanceID, }, nil } diff --git a/api/kubernetes/cli/client_test.go b/api/kubernetes/cli/client_test.go deleted file mode 100644 index 993a966e3..000000000 --- a/api/kubernetes/cli/client_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package cli - -import ( - "testing" -) - -func TestClearUserClientCache(t *testing.T) { - factory, _ := NewClientFactory(nil, nil, nil, "", "", "") - kcl := &KubeClient{} - factory.endpointProxyClients.Set("12.1", kcl, 0) - factory.endpointProxyClients.Set("12.12", kcl, 0) - factory.endpointProxyClients.Set("12", kcl, 0) - - factory.ClearUserClientCache("12") - - if len(factory.endpointProxyClients.Items()) != 2 { - t.Errorf("Incorrect clients cached after clearUserClientCache;\ngot=\n%d\nwant=\n%d", len(factory.endpointProxyClients.Items()), 2) - } - if _, ok := factory.GetProxyKubeClient("12", "12"); ok { - t.Errorf("Expected not to find client cache for user after clear") - } -} diff --git a/api/kubernetes/cli/event.go b/api/kubernetes/cli/event.go deleted file mode 100644 index 03472fca6..000000000 --- a/api/kubernetes/cli/event.go +++ /dev/null @@ -1,93 +0,0 @@ -package cli - -import ( - "context" - - models "github.com/portainer/portainer/api/http/models/kubernetes" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// GetEvents gets all the Events for a given namespace and resource -// If the user is a kube admin, it returns all events in the namespace -// Otherwise, it returns only the events in the non-admin namespaces -func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { - if kcl.IsKubeAdmin { - return kcl.fetchAllEvents(namespace, resourceId) - } - - return kcl.fetchEventsForNonAdmin(namespace, resourceId) -} - -// fetchEventsForNonAdmin returns all events in the given namespace and resource -// It returns only the events in the non-admin namespaces -func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) { - if len(kcl.NonAdminNamespaces) == 0 { - return nil, nil - } - - events, err := kcl.fetchAllEvents(namespace, resourceId) - if err != nil { - return nil, err - } - - nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap() - results := make([]models.K8sEvent, 0) - for _, event := range events { - if _, ok := nonAdminNamespaceSet[event.Namespace]; ok { - results = append(results, event) - } - } - - return results, nil -} - -// fetchEventsForNonAdmin returns all events in the given namespace and resource -// It returns all events in the namespace and resource -func (kcl *KubeClient) fetchAllEvents(namespace string, resourceId string) ([]models.K8sEvent, error) { - options := metav1.ListOptions{} - if resourceId != "" { - options.FieldSelector = "involvedObject.uid=" + resourceId - } - - list, err := kcl.cli.CoreV1().Events(namespace).List(context.TODO(), options) - if err != nil { - return nil, err - } - - results := make([]models.K8sEvent, 0) - for _, event := range list.Items { - results = append(results, parseEvent(&event)) - } - - return results, nil -} - -func parseEvent(event *corev1.Event) models.K8sEvent { - result := models.K8sEvent{ - Type: event.Type, - Name: event.Name, - Message: event.Message, - Reason: event.Reason, - Namespace: event.Namespace, - EventTime: event.EventTime.UTC(), - Kind: event.Kind, - Count: event.Count, - UID: string(event.ObjectMeta.GetUID()), - InvolvedObjectKind: models.K8sEventInvolvedObject{ - Kind: event.InvolvedObject.Kind, - UID: string(event.InvolvedObject.UID), - Name: event.InvolvedObject.Name, - Namespace: event.InvolvedObject.Namespace, - }, - } - - if !event.LastTimestamp.Time.IsZero() { - result.LastTimestamp = &event.LastTimestamp.Time - } - if !event.FirstTimestamp.Time.IsZero() { - result.FirstTimestamp = &event.FirstTimestamp.Time - } - - return result -} diff --git a/api/kubernetes/cli/event_test.go b/api/kubernetes/cli/event_test.go deleted file mode 100644 index 926928317..000000000 --- a/api/kubernetes/cli/event_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package cli - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - kfake "k8s.io/client-go/kubernetes/fake" -) - -// TestGetEvents tests the GetEvents method -// It creates a fake Kubernetes client and passes it to the GetEvents method -// It then logs the fetched events and validated the data returned -func TestGetEvents(t *testing.T) { - t.Run("can get events for resource id when admin", func(t *testing.T) { - kcl := &KubeClient{ - cli: kfake.NewSimpleClientset(), - instanceID: "instance", - IsKubeAdmin: true, - } - event := corev1.Event{ - InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, - Action: "something", - ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "myEvent"}, - EventTime: metav1.NowMicro(), - Type: "warning", - Message: "This event has a very serious warning", - } - _, err := kcl.cli.CoreV1().Events("default").Create(context.TODO(), &event, metav1.CreateOptions{}) - if err != nil { - t.Fatalf("Failed to create Event: %v", err) - } - - events, err := kcl.GetEvents("default", "resourceId") - - if err != nil { - t.Fatalf("Failed to fetch Cron Jobs: %v", err) - } - t.Logf("Fetched Events: %v", events) - require.Equal(t, 1, len(events), "Expected to return 1 event") - assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created") - assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created") - assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created") - }) - t.Run("can get kubernetes events for non admin namespace when non admin", func(t *testing.T) { - kcl := &KubeClient{ - cli: kfake.NewSimpleClientset(), - instanceID: "instance", - IsKubeAdmin: false, - NonAdminNamespaces: []string{"nonAdmin"}, - } - event := corev1.Event{ - InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, - Action: "something", - ObjectMeta: metav1.ObjectMeta{Namespace: "nonAdmin", Name: "myEvent"}, - EventTime: metav1.NowMicro(), - Type: "warning", - Message: "This event has a very serious warning", - } - _, err := kcl.cli.CoreV1().Events("nonAdmin").Create(context.TODO(), &event, metav1.CreateOptions{}) - if err != nil { - t.Fatalf("Failed to create Event: %v", err) - } - - events, err := kcl.GetEvents("nonAdmin", "resourceId") - - if err != nil { - t.Fatalf("Failed to fetch Cron Jobs: %v", err) - } - t.Logf("Fetched Events: %v", events) - require.Equal(t, 1, len(events), "Expected to return 1 event") - assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created") - assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created") - assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created") - }) - - t.Run("cannot get kubernetes events for admin namespace when non admin", func(t *testing.T) { - kcl := &KubeClient{ - cli: kfake.NewSimpleClientset(), - instanceID: "instance", - IsKubeAdmin: false, - NonAdminNamespaces: []string{"nonAdmin"}, - } - event := corev1.Event{ - InvolvedObject: corev1.ObjectReference{UID: "resourceId"}, - Action: "something", - ObjectMeta: metav1.ObjectMeta{Namespace: "admin", Name: "myEvent"}, - EventTime: metav1.NowMicro(), - Type: "warning", - Message: "This event has a very serious warning", - } - _, err := kcl.cli.CoreV1().Events("admin").Create(context.TODO(), &event, metav1.CreateOptions{}) - if err != nil { - t.Fatalf("Failed to create Event: %v", err) - } - - events, err := kcl.GetEvents("admin", "resourceId") - - if err != nil { - t.Fatalf("Failed to fetch Cron Jobs: %v", err) - } - t.Logf("Fetched Events: %v", events) - assert.Equal(t, 0, len(events), "Expected to return 0 events") - }) -} diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 560b91e75..11307d651 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "sort" "strconv" "time" @@ -352,34 +351,6 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, return namespace, nil } -// CombineNamespacesWithUnhealthyEvents combines namespaces with unhealthy events across all namespaces -func (kcl *KubeClient) CombineNamespacesWithUnhealthyEvents(namespaces map[string]portainer.K8sNamespaceInfo) (map[string]portainer.K8sNamespaceInfo, error) { - allEvents, err := kcl.GetEvents("", "") - if err != nil && !k8serrors.IsNotFound(err) { - log.Error(). - Str("context", "CombineNamespacesWithUnhealthyEvents"). - Err(err). - Msg("unable to retrieve unhealthy events from the Kubernetes for an admin user") - return nil, err - } - - unhealthyEventCounts := make(map[string]int) - for _, event := range allEvents { - if event.Type == "Warning" { - unhealthyEventCounts[event.Namespace]++ - } - } - - for namespaceName, namespace := range namespaces { - if count, exists := unhealthyEventCounts[namespaceName]; exists { - namespace.UnhealthyEventCount = count - namespaces[namespaceName] = namespace - } - } - - return namespaces, nil -} - // CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { resourceQuotas, err := kcl.GetResourceQuotas("") @@ -438,10 +409,5 @@ func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portaine namespaceSlice = append(namespaceSlice, namespace) } - // Sort namespaces by name - sort.Slice(namespaceSlice, func(i, j int) bool { - return namespaceSlice[i].Name < namespaceSlice[j].Name - }) - return namespaceSlice } diff --git a/api/portainer.go b/api/portainer.go index 3ccda4107..935def1f1 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -4,21 +4,16 @@ import ( "context" "fmt" "io" - "net/http" "time" - gittypes "github.com/portainer/portainer/api/git/types" - models "github.com/portainer/portainer/api/http/models/kubernetes" - "github.com/portainer/portainer/api/roar" - "github.com/portainer/portainer/pkg/featureflags" - httperror "github.com/portainer/portainer/pkg/libhttp/error" - "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/image" - "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/system" "github.com/docker/docker/api/types/volume" - "github.com/segmentio/encoding/json" + gittypes "github.com/portainer/portainer/api/git/types" + models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/portainer/portainer/pkg/featureflags" + "golang.org/x/oauth2" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/version" @@ -111,7 +106,6 @@ type ( AdminPassword *string AdminPasswordFile *string Assets *string - CSP *bool Data *string FeatureFlags *[]string EnableEdgeComputeFeatures *bool @@ -141,7 +135,6 @@ type ( LogMode *string KubectlShellImage *string PullLimitCheckDisabled *bool - TrustedOrigins *string } // CustomTemplateVariableDefinition @@ -216,34 +209,26 @@ type ( // DockerSnapshot represents a snapshot of a specific Docker environment(endpoint) at a specific time DockerSnapshot struct { - Time int64 `json:"Time"` - DockerVersion string `json:"DockerVersion"` - Swarm bool `json:"Swarm"` - TotalCPU int `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - ContainerCount int `json:"ContainerCount"` - RunningContainerCount int `json:"RunningContainerCount"` - StoppedContainerCount int `json:"StoppedContainerCount"` - HealthyContainerCount int `json:"HealthyContainerCount"` - UnhealthyContainerCount int `json:"UnhealthyContainerCount"` - VolumeCount int `json:"VolumeCount"` - ImageCount int `json:"ImageCount"` - ServiceCount int `json:"ServiceCount"` - StackCount int `json:"StackCount"` - SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` - NodeCount int `json:"NodeCount"` - GpuUseAll bool `json:"GpuUseAll"` - GpuUseList []string `json:"GpuUseList"` - IsPodman bool `json:"IsPodman"` - DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` - PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"` - } - - // PerformanceMetrics represents the performance metrics of a Docker, Swarm, Podman, and Kubernetes environments - PerformanceMetrics struct { - CPUUsage float64 `json:"CPUUsage,omitempty"` - MemoryUsage float64 `json:"MemoryUsage,omitempty"` - NetworkUsage float64 `json:"NetworkUsage,omitempty"` + Time int64 `json:"Time"` + DockerVersion string `json:"DockerVersion"` + Swarm bool `json:"Swarm"` + TotalCPU int `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + ContainerCount int `json:"ContainerCount"` + RunningContainerCount int `json:"RunningContainerCount"` + StoppedContainerCount int `json:"StoppedContainerCount"` + HealthyContainerCount int `json:"HealthyContainerCount"` + UnhealthyContainerCount int `json:"UnhealthyContainerCount"` + VolumeCount int `json:"VolumeCount"` + ImageCount int `json:"ImageCount"` + ServiceCount int `json:"ServiceCount"` + StackCount int `json:"StackCount"` + SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"` + NodeCount int `json:"NodeCount"` + GpuUseAll bool `json:"GpuUseAll"` + GpuUseList []string `json:"GpuUseList"` + IsPodman bool `json:"IsPodman"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` } // DockerContainerSnapshot is an extent of Docker's Container struct @@ -257,7 +242,7 @@ type ( DockerSnapshotRaw struct { Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"` Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"` - Networks []network.Summary `json:"Networks" swaggerignore:"true"` + Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"` Images []image.Summary `json:"Images" swaggerignore:"true"` Info system.Info `json:"Info" swaggerignore:"true"` Version types.Version `json:"Version" swaggerignore:"true"` @@ -266,15 +251,12 @@ type ( // EdgeGroup represents an Edge group EdgeGroup struct { // EdgeGroup Identifier - ID EdgeGroupID `json:"Id" example:"1"` - Name string `json:"Name"` - Dynamic bool `json:"Dynamic"` - TagIDs []TagID `json:"TagIds"` - EndpointIDs roar.Roar[EndpointID] `json:"EndpointIds"` - PartialMatch bool `json:"PartialMatch"` - - // Deprecated: only used for API responses - Endpoints []EndpointID `json:"Endpoints"` + ID EdgeGroupID `json:"Id" example:"1"` + Name string `json:"Name"` + Dynamic bool `json:"Dynamic"` + TagIDs []TagID `json:"TagIds"` + Endpoints []EndpointID `json:"Endpoints"` + PartialMatch bool `json:"PartialMatch"` } // EdgeGroupID represents an Edge group identifier @@ -350,15 +332,6 @@ type ( UseManifestNamespaces bool } - EdgeStackStatusForEnv struct { - EndpointID EndpointID - Status []EdgeStackDeploymentStatus - // EE only feature - DeploymentInfo StackDeploymentInfo - // ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image - ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"` - } - EdgeStackDeploymentType int // EdgeStackID represents an edge stack id @@ -607,12 +580,6 @@ type ( ProjectPath string `json:"ProjectPath"` } - // GithubRegistryData represents data required for Github registry to work - GithubRegistryData struct { - UseOrganisation bool `json:"UseOrganisation"` - OrganisationName string `json:"OrganisationName"` - } - HelmUserRepositoryID int // HelmUserRepositories stores a Helm repository URL for the given user @@ -640,16 +607,15 @@ type ( JobType int K8sNamespaceInfo struct { - Id string `json:"Id"` - Name string `json:"Name"` - Status corev1.NamespaceStatus `json:"Status"` - Annotations map[string]string `json:"Annotations"` - CreationDate string `json:"CreationDate"` - UnhealthyEventCount int `json:"UnhealthyEventCount"` - NamespaceOwner string `json:"NamespaceOwner"` - IsSystem bool `json:"IsSystem"` - IsDefault bool `json:"IsDefault"` - ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` + Id string `json:"Id"` + Name string `json:"Name"` + Status corev1.NamespaceStatus `json:"Status"` + Annotations map[string]string `json:"Annotations"` + CreationDate string `json:"CreationDate"` + NamespaceOwner string `json:"NamespaceOwner"` + IsSystem bool `json:"IsSystem"` + IsDefault bool `json:"IsDefault"` + ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` } K8sNodeLimits struct { @@ -681,13 +647,12 @@ type ( // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time KubernetesSnapshot struct { - Time int64 `json:"Time"` - KubernetesVersion string `json:"KubernetesVersion"` - NodeCount int `json:"NodeCount"` - TotalCPU int64 `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` - PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"` + Time int64 `json:"Time"` + KubernetesVersion string `json:"KubernetesVersion"` + NodeCount int `json:"NodeCount"` + TotalCPU int64 `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"` } // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) @@ -833,7 +798,6 @@ type ( Password string `json:"Password,omitempty" example:"registry_password"` ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` Gitlab GitlabRegistryData `json:"Gitlab"` - Github GithubRegistryData `json:"Github"` Quay QuayRegistryData `json:"Quay"` Ecr EcrData `json:"Ecr"` RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` @@ -1410,12 +1374,6 @@ type ( Kubernetes *KubernetesSnapshot `json:"Kubernetes"` } - SnapshotRawMessage struct { - EndpointID EndpointID `json:"EndpointId"` - Docker json.RawMessage `json:"Docker"` - Kubernetes json.RawMessage `json:"Kubernetes"` - } - // CLIService represents a service for managing CLI CLIService interface { ParseFlags(version string) (*CLIFlags, error) @@ -1542,42 +1500,10 @@ type ( // GitService represents a service for managing Git GitService interface { - CloneRepository( - destination string, - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, - ) error - LatestCommitID( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - tlsSkipVerify bool, - ) (string, error) - ListRefs( - repositoryURL, - username, - password string, - authType gittypes.GitCredentialAuthType, - hardRefresh bool, - tlsSkipVerify bool, - ) ([]string, error) - ListFiles( - repositoryURL, - referenceName, - username, - password string, - authType gittypes.GitCredentialAuthType, - dirOnly, - hardRefresh bool, - includeExts []string, - tlsSkipVerify bool, - ) ([]string, error) + CloneRepository(destination string, repositoryURL, referenceName, username, password string, tlsSkipVerify bool) error + LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) + ListRefs(repositoryURL, username, password string, hardRefresh bool, tlsSkipVerify bool) ([]string, error) + ListFiles(repositoryURL, referenceName, username, password string, dirOnly, hardRefresh bool, includeExts []string, tlsSkipVerify bool) ([]string, error) } // OpenAMTService represents a service for managing OpenAMT @@ -1598,127 +1524,56 @@ type ( // KubeClient represents a service used to query a Kubernetes environment(endpoint) KubeClient interface { - // Access - GetIsKubeAdmin() bool - SetIsKubeAdmin(isKubeAdmin bool) - GetClientNonAdminNamespaces() []string - SetClientNonAdminNamespaces([]string) - NamespaceAccessPoliciesDeleteNamespace(ns string) error - UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error - GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) - GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error) + ServerVersion() (*version.Info, error) - // Applications - GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) - GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error) - - // ClusterRole - GetClusterRoles() ([]models.K8sClusterRole, error) - DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error - - // ConfigMap - GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error) - CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error) - - // CronJob - GetCronJobs(namespace string) ([]models.K8sCronJob, error) - DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error - - // Event - GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) - - // Exec + SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error + IsRBACEnabled() (bool, error) + GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) + GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) + DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error + GetServiceAccountBearerToken(userID int) (string, error) + CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) - // ClusterRoleBinding - GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) - DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error - - // Dashboard - GetDashboard() (models.K8sDashboard, error) - - // Deployment HasStackName(namespace string, stackName string) (bool, error) - - // Ingress - GetIngressControllers() (models.K8sIngressControllers, error) - GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error) - GetIngresses(namespace string) ([]models.K8sIngressInfo, error) - CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error - DeleteIngresses(reqs models.K8sIngressDeleteRequests) error - UpdateIngress(namespace string, info models.K8sIngressInfo) error - CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error) - CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error) - - // Job - GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) - DeleteJobs(payload models.K8sJobDeleteRequests) error - - // Metrics - GetMetrics() (models.K8sMetrics, error) - - // Namespace - ToggleSystemState(namespaceName string, isSystem bool) error - UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) - GetNamespace(name string) (K8sNamespaceInfo, error) + NamespaceAccessPoliciesDeleteNamespace(namespace string) error CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) + UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) GetNamespaces() (map[string]K8sNamespaceInfo, error) - CombineNamespaceWithResourceQuota(namespace K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError - DeleteNamespace(namespaceName string) (*corev1.Namespace, error) - CombineNamespacesWithResourceQuotas(namespaces map[string]K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError - ConvertNamespaceMapToSlice(namespaces map[string]K8sNamespaceInfo) []K8sNamespaceInfo - - // NodeLimits + GetNamespace(string) (K8sNamespaceInfo, error) + DeleteNamespace(namespace string) (*corev1.Namespace, error) + GetConfigMaps(namespace string) ([]models.K8sConfigMap, error) + GetSecrets(namespace string) ([]models.K8sSecret, error) + GetIngressControllers() (models.K8sIngressControllers, error) + GetApplications(namespace, nodename string) ([]models.K8sApplication, error) + GetMetrics() (models.K8sMetrics, error) + GetStorage() ([]KubernetesStorageClassConfig, error) + CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error + UpdateIngress(namespace string, info models.K8sIngressInfo) error + GetIngresses(namespace string) ([]models.K8sIngressInfo, error) + DeleteIngresses(reqs models.K8sIngressDeleteRequests) error + CreateService(namespace string, service models.K8sServiceInfo) error + UpdateService(namespace string, service models.K8sServiceInfo) error + GetServices(namespace string) ([]models.K8sServiceInfo, error) + DeleteServices(reqs models.K8sServiceDeleteRequests) error GetNodesLimits() (K8sNodesLimits, error) - GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) - - // Pod - CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error) - - // RBAC - IsRBACEnabled() (bool, error) - - // Registries + GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) + GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) + UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error DeleteRegistrySecret(registry RegistryID, namespace string) error CreateRegistrySecret(registry *Registry, namespace string) error IsRegistrySecret(namespace, secretName string) (bool, error) + ToggleSystemState(namespace string, isSystem bool) error - // RoleBinding + GetClusterRoles() ([]models.K8sClusterRole, error) + DeleteClusterRoles(models.K8sClusterRoleDeleteRequests) error + GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) + DeleteClusterRoleBindings(models.K8sClusterRoleBindingDeleteRequests) error + + GetRoles(namespace string) ([]models.K8sRole, error) + DeleteRoles(models.K8sRoleDeleteRequests) error GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) - DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error - - // Role - DeleteRoles(reqs models.K8sRoleDeleteRequests) error - - // Secret - GetSecrets(namespace string) ([]models.K8sSecret, error) - GetSecret(namespace string, secretName string) (models.K8sSecret, error) - CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error) - - // ServiceAccount - GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) - DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error - SetupUserServiceAccount(int, []int, bool) error - GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error) - GetServiceAccountBearerToken(userID int) (string, error) - - // Service - GetServices(namespace string) ([]models.K8sServiceInfo, error) - CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) - CreateService(namespace string, info models.K8sServiceInfo) error - DeleteServices(reqs models.K8sServiceDeleteRequests) error - UpdateService(namespace string, info models.K8sServiceInfo) error - - // ServerVersion - ServerVersion() (*version.Info, error) - - // Storage - GetStorage() ([]KubernetesStorageClassConfig, error) - - // Volumes - GetVolumes(namespace string) ([]models.K8sVolumeInfo, error) - GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error) - CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error) + DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) error } // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint) @@ -1783,7 +1638,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.32.0" + APIVersion = "2.30.0" // Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support) APIVersionSupport = "STS" // Edition is what this edition of Portainer is called @@ -1837,15 +1692,6 @@ const ( KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE" // PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED" - // LicenseServerBaseURL represents the base URL of the API used to validate - // an extension license. - LicenseServerBaseURL = "https://api.portainer.io" - // URL to validate licenses along with system metadata. - LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin" - // TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection - TrustedOriginsEnvVar = "TRUSTED_ORIGINS" - // CSPEnvVar is the environment variable used to enable/disable the Content Security Policy - CSPEnvVar = "CSP" ) // List of supported features @@ -2015,8 +1861,6 @@ const ( DockerHubRegistry // EcrRegistry represents an ECR registry EcrRegistry - // Github container registry - GithubRegistry ) const ( diff --git a/api/roar/roar.go b/api/roar/roar.go deleted file mode 100644 index c32843ee4..000000000 --- a/api/roar/roar.go +++ /dev/null @@ -1,145 +0,0 @@ -package roar - -import ( - "fmt" - - "github.com/RoaringBitmap/roaring/v2" -) - -type Roar[T ~int] struct { - rb *roaring.Bitmap -} - -// Iterate iterates over the bitmap, calling the given callback with each value in the bitmap. If the callback returns -// false, the iteration is halted. -// The iteration results are undefined if the bitmap is modified (e.g., with Add or Remove). -// There is no guarantee as to what order the values will be iterated. -func (r *Roar[T]) Iterate(f func(T) bool) { - if r.rb == nil { - return - } - - r.rb.Iterate(func(e uint32) bool { - return f(T(e)) - }) -} - -// Len returns the number of elements contained in the bitmap -func (r *Roar[T]) Len() int { - if r.rb == nil { - return 0 - } - - return int(r.rb.GetCardinality()) -} - -// Remove removes the given element from the bitmap -func (r *Roar[T]) Remove(e T) { - if r.rb == nil { - return - } - - r.rb.Remove(uint32(e)) -} - -// Add adds the given element to the bitmap -func (r *Roar[T]) Add(e T) { - if r.rb == nil { - r.rb = roaring.New() - } - - r.rb.AddInt(int(e)) -} - -// Contains returns whether the bitmap contains the given element or not -func (r *Roar[T]) Contains(e T) bool { - if r.rb == nil { - return false - } - - return r.rb.ContainsInt(int(e)) -} - -// Union combines the elements of the given bitmap with this bitmap -func (r *Roar[T]) Union(other Roar[T]) { - if other.rb == nil { - return - } else if r.rb == nil { - r.rb = roaring.New() - } - - r.rb.Or(other.rb) -} - -// Intersection modifies this bitmap to only contain elements that are also in the other bitmap -func (r *Roar[T]) Intersection(other Roar[T]) { - if other.rb == nil { - if r.rb != nil { - r.rb.Clear() - } - - return - } - - if r.rb == nil { - r.rb = roaring.New() - } - - r.rb.And(other.rb) -} - -// ToSlice converts the bitmap to a slice of elements -func (r *Roar[T]) ToSlice() []T { - if r.rb == nil { - return nil - } - - slice := make([]T, 0, r.rb.GetCardinality()) - r.rb.Iterate(func(e uint32) bool { - slice = append(slice, T(e)) - - return true - }) - - return slice -} - -func (r *Roar[T]) MarshalJSON() ([]byte, error) { - if r.rb == nil { - return []byte("null"), nil - } - - r.rb.RunOptimize() - - buf, err := r.rb.ToBase64() - if err != nil { - return nil, fmt.Errorf("failed to encode roaring bitmap: %w", err) - } - - return fmt.Appendf(nil, `"%s"`, buf), nil -} - -func (r *Roar[T]) UnmarshalJSON(data []byte) error { - if len(data) == 0 || string(data) == "null" { - return nil - } - - r.rb = roaring.New() - - _, err := r.rb.FromBase64(string(data[1 : len(data)-1])) - - return err -} - -// FromSlice creates a Roar by adding all elements from the provided slices -func FromSlice[T ~int](ess ...[]T) Roar[T] { - var r Roar[T] - - for _, es := range ess { - for _, e := range es { - r.Add(e) - } - } - - return r -} diff --git a/api/roar/roar_test.go b/api/roar/roar_test.go deleted file mode 100644 index ed5103ad5..000000000 --- a/api/roar/roar_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package roar - -import ( - "slices" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRoar(t *testing.T) { - r := Roar[int]{} - require.Equal(t, 0, r.Len()) - - r.Add(1) - require.Equal(t, 1, r.Len()) - require.True(t, r.Contains(1)) - require.False(t, r.Contains(2)) - - r.Add(2) - require.Equal(t, 2, r.Len()) - require.True(t, r.Contains(2)) - - r.Remove(1) - require.Equal(t, 1, r.Len()) - require.False(t, r.Contains(1)) - - s := FromSlice([]int{3, 4, 5}) - require.Equal(t, 3, s.Len()) - require.True(t, s.Contains(3)) - require.True(t, s.Contains(4)) - require.True(t, s.Contains(5)) - - r.Union(s) - require.Equal(t, 4, r.Len()) - require.True(t, r.Contains(2)) - require.True(t, r.Contains(3)) - require.True(t, r.Contains(4)) - require.True(t, r.Contains(5)) - - r.Iterate(func(id int) bool { - require.True(t, slices.Contains([]int{2, 3, 4, 5}, id)) - - return true - }) - - rSlice := r.ToSlice() - require.EqualValues(t, []int{2, 3, 4, 5}, rSlice) - - r.Intersection(FromSlice([]int{4})) - require.Equal(t, 1, r.Len()) - require.True(t, r.Contains(4)) - require.False(t, r.Contains(2)) - require.False(t, r.Contains(3)) - require.False(t, r.Contains(5)) - - b, err := r.MarshalJSON() - require.NoError(t, err) - require.NotEqual(t, "null", string(b)) - require.True(t, strings.HasPrefix(string(b), `"`)) - require.True(t, strings.HasSuffix(string(b), `"`)) -} - -func TestNilSafety(t *testing.T) { - var r, s, u Roar[int] - - r.Iterate(func(id int) bool { - require.Fail(t, "should not iterate over nil Roar") - - return true - }) - - b, err := r.MarshalJSON() - require.NoError(t, err) - require.Equal(t, "null", string(b)) - - err = r.UnmarshalJSON([]byte("null")) - require.NoError(t, err) - require.Equal(t, 0, r.Len()) - - r.Contains(1) - r.Remove(1) - - require.Equal(t, 0, r.Len()) - require.Empty(t, r.ToSlice()) - - r.Add(1) - require.Equal(t, 1, r.Len()) - require.False(t, r.Contains(2)) - - s.Union(r) - require.Equal(t, 1, s.Len()) - require.True(t, s.Contains(1)) - - r.Union(u) - require.Equal(t, 1, r.Len()) - require.True(t, r.Contains(1)) - - s.Intersection(u) - require.Equal(t, 0, s.Len()) - - u.Intersection(r) - require.Equal(t, 0, u.Len()) -} - -func TestJSON(t *testing.T) { - var r, u Roar[int] - - r.Add(1) - r.Add(2) - r.Add(3) - - b, err := r.MarshalJSON() - require.NoError(t, err) - require.NotEqual(t, "null", string(b)) - - err = u.UnmarshalJSON(b) - require.NoError(t, err) - require.Equal(t, 3, u.Len()) - require.True(t, u.Contains(1)) - require.True(t, u.Contains(2)) - require.True(t, u.Contains(3)) -} diff --git a/api/slicesx/filter.go b/api/slicesx/filter.go deleted file mode 100644 index 13dc12105..000000000 --- a/api/slicesx/filter.go +++ /dev/null @@ -1,28 +0,0 @@ -package slicesx - -// Iterates over elements of collection, returning an array of all elements predicate returns truthy for. -// -// Note: Unlike `FilterInPlace`, this method returns a new array. -func Filter[T any](input []T, predicate func(T) bool) []T { - result := make([]T, 0) - for i := range input { - if predicate(input[i]) { - result = append(result, input[i]) - } - } - return result -} - -// Filter in place all elements from input that predicate returns truthy for and returns an array of the removed elements. -// -// Note: Unlike `Filter`, this method mutates input. -func FilterInPlace[T any](input []T, predicate func(T) bool) []T { - n := 0 - for _, v := range input { - if predicate(v) { - input[n] = v - n++ - } - } - return input[:n] -} diff --git a/api/slicesx/filter_test.go b/api/slicesx/filter_test.go deleted file mode 100644 index 36f97fa10..000000000 --- a/api/slicesx/filter_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" -) - -func Test_Filter(t *testing.T) { - test(t, slicesx.Filter, "Filter even numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{2, 4, 6, 8}, - func(x int) bool { return x%2 == 0 }, - ) - test(t, slicesx.Filter, "Filter odd numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{1, 3, 5, 7, 9}, - func(x int) bool { return x%2 == 1 }, - ) - test(t, slicesx.Filter, "Filter strings starting with 'A'", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Apple", "Avocado", "Apricot"}, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Filter, "Filter strings longer than 5 chars", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Banana", "Avocado", "Grapes", "Apricot"}, - func(s string) bool { return len(s) > 5 }, - ) -} - -func Test_Retain(t *testing.T) { - test(t, slicesx.FilterInPlace, "Filter even numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{2, 4, 6, 8}, - func(x int) bool { return x%2 == 0 }, - ) - test(t, slicesx.FilterInPlace, "Filter odd numbers", - []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - []int{1, 3, 5, 7, 9}, - func(x int) bool { return x%2 == 1 }, - ) - test(t, slicesx.FilterInPlace, "Filter strings starting with 'A'", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Apple", "Avocado", "Apricot"}, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.FilterInPlace, "Filter strings longer than 5 chars", - []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - []string{"Banana", "Avocado", "Grapes", "Apricot"}, - func(s string) bool { return len(s) > 5 }, - ) -} - -func Benchmark_Filter(b *testing.B) { - n := 100000 - - source := make([]int, n) - for i := range source { - source[i] = i - } - - b.ResetTimer() - for range b.N { - e := slicesx.Filter(source, func(x int) bool { return x%2 == 0 }) - if len(e) != n/2 { - b.FailNow() - } - } -} - -func Benchmark_FilterInPlace(b *testing.B) { - n := 100000 - - source := make([]int, n) - for i := range source { - source[i] = i - } - - // Preallocate all copies before timing - // because FilterInPlace mutates the original slice - copies := make([][]int, b.N) - for i := range b.N { - buf := make([]int, len(source)) - copy(buf, source) - copies[i] = buf - } - - b.ResetTimer() - for i := range b.N { - e := slicesx.FilterInPlace(copies[i], func(x int) bool { return x%2 == 0 }) - if len(e) != n/2 { - b.FailNow() - } - } -} diff --git a/api/slicesx/flatten.go b/api/slicesx/flatten.go deleted file mode 100644 index 56a77f3e9..000000000 --- a/api/slicesx/flatten.go +++ /dev/null @@ -1,7 +0,0 @@ -package slicesx - -import "slices" - -func Flatten[T any](input [][]T) []T { - return slices.Concat(input...) -} diff --git a/api/slicesx/flatten_test.go b/api/slicesx/flatten_test.go deleted file mode 100644 index 6875c4e6b..000000000 --- a/api/slicesx/flatten_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" - "github.com/stretchr/testify/assert" -) - -func Test_Flatten(t *testing.T) { - t.Run("Flatten an array of arrays", func(t *testing.T) { - is := assert.New(t) - - source := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}} - expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} - is.ElementsMatch(slicesx.Flatten(source), expected) - - }) -} diff --git a/api/slicesx/includes.go b/api/slicesx/includes.go deleted file mode 100644 index 377a54215..000000000 --- a/api/slicesx/includes.go +++ /dev/null @@ -1,17 +0,0 @@ -package slicesx - -import "slices" - -// Checks if predicate returns truthy for any element of input. Iteration is stopped once predicate returns truthy. -func Some[T any](input []T, predicate func(T) bool) bool { - return slices.ContainsFunc(input, predicate) -} - -// Checks if predicate returns truthy for all elements of input. Iteration is stopped once predicate returns falsey. -// -// Note: This method returns true for empty collections because everything is true of elements of empty collections. -// https://en.wikipedia.org/wiki/Vacuous_truth -func Every[T any](input []T, predicate func(T) bool) bool { - // if the slice doesn't contain an inverted predicate then all items follow the predicate - return !slices.ContainsFunc(input, func(t T) bool { return !predicate(t) }) -} diff --git a/api/slicesx/includes_test.go b/api/slicesx/includes_test.go deleted file mode 100644 index a3f074c1c..000000000 --- a/api/slicesx/includes_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" -) - -func Test_Every(t *testing.T) { - test(t, slicesx.Every, "All start with an A (ok)", - []string{"Apple", "Avocado", "Apricot"}, - true, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Every, "All start with an A (ko = some don't start with A)", - []string{"Apple", "Avocado", "Banana"}, - false, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Every, "All are under 5 (ok)", - []int{1, 2, 3}, - true, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Every, "All are under 5 (ko = some above 10)", - []int{1, 2, 10}, - false, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Every, "All are true (ok)", - []struct{ x bool }{{x: true}, {x: true}, {x: true}}, - true, - func(s struct{ x bool }) bool { return s.x }) - test(t, slicesx.Every, "All are true (ko = some are false)", - []struct{ x bool }{{x: true}, {x: true}, {x: false}}, - false, - func(s struct{ x bool }) bool { return s.x }) - test(t, slicesx.Every, "Must be true on empty slice", - []int{}, - true, - func(i int) bool { return i%2 == 0 }, - ) -} - -func Test_Some(t *testing.T) { - test(t, slicesx.Some, "Some start with an A (ok)", - []string{"Apple", "Avocado", "Banana"}, - true, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Some, "Some start with an A (ko = all don't start with A)", - []string{"Banana", "Cherry", "Peach"}, - false, - func(s string) bool { return s[0] == 'A' }, - ) - test(t, slicesx.Some, "Some are under 5 (ok)", - []int{1, 2, 30}, - true, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Some, "Some are under 5 (ko = all above 5)", - []int{10, 11, 12}, - false, - func(i int) bool { return i < 5 }, - ) - test(t, slicesx.Some, "Some are true (ok)", - []struct{ x bool }{{x: true}, {x: true}, {x: false}}, - true, - func(s struct{ x bool }) bool { return s.x }, - ) - test(t, slicesx.Some, "Some are true (ko = all are false)", - []struct{ x bool }{{x: false}, {x: false}, {x: false}}, - false, - func(s struct{ x bool }) bool { return s.x }, - ) -} diff --git a/api/slicesx/map.go b/api/slicesx/map.go deleted file mode 100644 index 7e24bdd0d..000000000 --- a/api/slicesx/map.go +++ /dev/null @@ -1,15 +0,0 @@ -package slicesx - -// Map applies the given function to each element of the slice and returns a new slice with the results -func Map[T, U any](s []T, f func(T) U) []U { - result := make([]U, len(s)) - for i, v := range s { - result[i] = f(v) - } - return result -} - -// FlatMap applies the given function to each element of the slice and returns a new slice with the flattened results -func FlatMap[T, U any](s []T, f func(T) []U) []U { - return Flatten(Map(s, f)) -} diff --git a/api/slicesx/map_test.go b/api/slicesx/map_test.go deleted file mode 100644 index a2cd2256d..000000000 --- a/api/slicesx/map_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package slicesx_test - -import ( - "strconv" - "testing" - - "github.com/portainer/portainer/api/slicesx" -) - -func Test_Map(t *testing.T) { - test(t, slicesx.Map, "Map integers to strings", - []int{1, 2, 3, 4, 5}, - []string{"1", "2", "3", "4", "5"}, - strconv.Itoa, - ) - test(t, slicesx.Map, "Map strings to integers", - []string{"1", "2", "3", "4", "5"}, - []int{1, 2, 3, 4, 5}, - func(s string) int { - n, _ := strconv.Atoi(s) - return n - }, - ) -} - -func Test_FlatMap(t *testing.T) { - test(t, slicesx.FlatMap, "Map integers to strings and flatten", - []int{1, 2, 3, 4, 5}, - []string{"1", "1", "2", "2", "3", "3", "4", "4", "5", "5"}, - func(i int) []string { - x := strconv.Itoa(i) - return []string{x, x} - }, - ) - test(t, slicesx.FlatMap, "Map strings to integers and flatten", - []string{"1", "2", "3", "4", "5"}, - []int{1, 1, 2, 2, 3, 3, 4, 4, 5, 5}, - func(s string) []int { - n, _ := strconv.Atoi(s) - return []int{n, n} - }, - ) -} diff --git a/api/slicesx/unique.go b/api/slicesx/slices.go similarity index 51% rename from api/slicesx/unique.go rename to api/slicesx/slices.go index 8659b0778..b7e0aa0ef 100644 --- a/api/slicesx/unique.go +++ b/api/slicesx/slices.go @@ -1,5 +1,27 @@ package slicesx +// Map applies the given function to each element of the slice and returns a new slice with the results +func Map[T, U any](s []T, f func(T) U) []U { + result := make([]U, len(s)) + for i, v := range s { + result[i] = f(v) + } + return result +} + +// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true +func Filter[T any](s []T, predicate func(T) bool) []T { + n := 0 + for _, v := range s { + if predicate(v) { + s[n] = v + n++ + } + } + + return s[:n] +} + func Unique[T comparable](items []T) []T { return UniqueBy(items, func(item T) T { return item diff --git a/api/slicesx/slices_test.go b/api/slicesx/slices_test.go new file mode 100644 index 000000000..d75f9b559 --- /dev/null +++ b/api/slicesx/slices_test.go @@ -0,0 +1,127 @@ +package slicesx + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +type filterTestCase[T any] struct { + name string + input []T + expected []T + predicate func(T) bool +} + +func TestFilter(t *testing.T) { + intTestCases := []filterTestCase[int]{ + { + name: "Filter even numbers", + input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + expected: []int{2, 4, 6, 8}, + + predicate: func(n int) bool { + return n%2 == 0 + }, + }, + { + name: "Filter odd numbers", + input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + expected: []int{1, 3, 5, 7, 9}, + + predicate: func(n int) bool { + return n%2 != 0 + }, + }, + } + + runTestCases(t, intTestCases) + + stringTestCases := []filterTestCase[string]{ + { + name: "Filter strings starting with 'A'", + input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + expected: []string{"Apple", "Avocado", "Apricot"}, + predicate: func(s string) bool { + return s[0] == 'A' + }, + }, + { + name: "Filter strings longer than 5 characters", + input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + expected: []string{"Banana", "Avocado", "Grapes", "Apricot"}, + predicate: func(s string) bool { + return len(s) > 5 + }, + }, + } + + runTestCases(t, stringTestCases) +} + +func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + is := assert.New(t) + result := Filter(testCase.input, testCase.predicate) + + is.Equal(len(testCase.expected), len(result)) + is.ElementsMatch(testCase.expected, result) + }) + } +} + +func TestMap(t *testing.T) { + intTestCases := []struct { + name string + input []int + expected []string + mapper func(int) string + }{ + { + name: "Map integers to strings", + input: []int{1, 2, 3, 4, 5}, + expected: []string{"1", "2", "3", "4", "5"}, + mapper: strconv.Itoa, + }, + } + + runMapTestCases(t, intTestCases) + + stringTestCases := []struct { + name string + input []string + expected []int + mapper func(string) int + }{ + { + name: "Map strings to integers", + input: []string{"1", "2", "3", "4", "5"}, + expected: []int{1, 2, 3, 4, 5}, + mapper: func(s string) int { + n, _ := strconv.Atoi(s) + return n + }, + }, + } + + runMapTestCases(t, stringTestCases) +} + +func runMapTestCases[T, U any](t *testing.T, testCases []struct { + name string + input []T + expected []U + mapper func(T) U +}) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + is := assert.New(t) + result := Map(testCase.input, testCase.mapper) + + is.Equal(len(testCase.expected), len(result)) + is.ElementsMatch(testCase.expected, result) + }) + } +} diff --git a/api/slicesx/slicesx_test.go b/api/slicesx/slicesx_test.go deleted file mode 100644 index 1bb8a76fe..000000000 --- a/api/slicesx/slicesx_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package slicesx_test - -import ( - "reflect" - "testing" - - "github.com/stretchr/testify/assert" -) - -type libFunc[T, U, V any] func([]T, func(T) U) V -type predicateFunc[T, U any] func(T) U - -func test[T, U, V any](t *testing.T, libFn libFunc[T, U, V], name string, input []T, expected V, predicate predicateFunc[T, U]) { - t.Helper() - - t.Run(name, func(t *testing.T) { - is := assert.New(t) - - result := libFn(input, predicate) - - switch reflect.TypeOf(result).Kind() { - case reflect.Slice, reflect.Array: - is.Equal(expected, result) - is.ElementsMatch(expected, result) - default: - is.Equal(expected, result) - } - }) -} diff --git a/api/slicesx/unique_test.go b/api/slicesx/unique_test.go deleted file mode 100644 index 8ff967ca6..000000000 --- a/api/slicesx/unique_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package slicesx_test - -import ( - "testing" - - "github.com/portainer/portainer/api/slicesx" - "github.com/stretchr/testify/assert" -) - -func Test_Unique(t *testing.T) { - is := assert.New(t) - t.Run("Should extract unique numbers", func(t *testing.T) { - - source := []int{1, 1, 2, 3, 4, 4, 5, 4, 6, 7, 8, 9, 1} - result := slicesx.Unique(source) - expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} - - is.ElementsMatch(result, expected) - }) - - t.Run("Should return empty array", func(t *testing.T) { - source := []int{} - result := slicesx.Unique(source) - expected := []int{} - is.ElementsMatch(result, expected) - }) -} - -func Test_UniqueBy(t *testing.T) { - is := assert.New(t) - t.Run("Should extract unique numbers by property", func(t *testing.T) { - - source := []struct{ int }{{1}, {1}, {2}, {3}, {4}, {4}, {5}, {4}, {6}, {7}, {8}, {9}, {1}} - result := slicesx.UniqueBy(source, func(item struct{ int }) int { return item.int }) - expected := []struct{ int }{{1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}} - - is.ElementsMatch(result, expected) - }) - - t.Run("Should return empty array", func(t *testing.T) { - source := []int{} - result := slicesx.UniqueBy(source, func(x int) int { return x }) - expected := []int{} - is.ElementsMatch(result, expected) - }) -} diff --git a/api/stacks/stackutils/gitops.go b/api/stacks/stackutils/gitops.go index 566a2a2e2..d035b783d 100644 --- a/api/stacks/stackutils/gitops.go +++ b/api/stacks/stackutils/gitops.go @@ -19,23 +19,13 @@ var ( func DownloadGitRepository(config gittypes.RepoConfig, gitService portainer.GitService, getProjectPath func() string) (string, error) { username := "" password := "" - authType := gittypes.GitCredentialAuthType_Basic if config.Authentication != nil { username = config.Authentication.Username password = config.Authentication.Password - authType = config.Authentication.AuthorizationType } projectPath := getProjectPath() - err := gitService.CloneRepository( - projectPath, - config.URL, - config.ReferenceName, - username, - password, - authType, - config.TLSSkipVerify, - ) + err := gitService.CloneRepository(projectPath, config.URL, config.ReferenceName, username, password, config.TLSSkipVerify) if err != nil { if errors.Is(err, gittypes.ErrAuthenticationFailure) { newErr := git.ErrInvalidGitCredential @@ -46,14 +36,7 @@ func DownloadGitRepository(config gittypes.RepoConfig, gitService portainer.GitS return "", newErr } - commitID, err := gitService.LatestCommitID( - config.URL, - config.ReferenceName, - username, - password, - authType, - config.TLSSkipVerify, - ) + commitID, err := gitService.LatestCommitID(config.URL, config.ReferenceName, username, password, config.TLSSkipVerify) if err != nil { newErr := fmt.Errorf("unable to fetch git repository id: %w", err) return "", newErr diff --git a/app/assets/css/button.css b/app/assets/css/button.css index 547d9fdc0..dc3f82460 100644 --- a/app/assets/css/button.css +++ b/app/assets/css/button.css @@ -24,84 +24,44 @@ fieldset[disabled] .btn { box-shadow: none; } -.btn-icon { - @apply !border-none !bg-transparent p-0; -} - .btn.btn-primary { - @apply border-graphite-700 bg-graphite-700 text-mist-100; - @apply hover:border-graphite-700/90 hover:bg-graphite-700/90 hover:text-mist-100; - @apply focus:border-blue-5 focus:shadow-graphite-700/80 focus:text-mist-100; - - @apply th-dark:border-mist-100 th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-dark:hover:border-mist-100/90 th-dark:hover:bg-mist-100/90 th-dark:hover:text-graphite-700; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-graphite-700; - - @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; - @apply th-highcontrast:hover:border-mist-100/90 th-highcontrast:hover:bg-mist-100/90 th-highcontrast:hover:text-graphite-700; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-graphite-700; -} - -/* Sidebar background is always dark, so we need to override the primary button styles */ -.btn.btn-primary.sidebar { - @apply border-mist-100 bg-mist-100 text-graphite-700; - @apply hover:border-mist-100/90 hover:bg-mist-100/90 hover:text-graphite-700; - @apply focus:border-blue-5 focus:shadow-white/80 focus:text-graphite-700; + @apply border-blue-8 bg-blue-8 text-white; + @apply hover:border-blue-9 hover:bg-blue-9 hover:text-white; + @apply th-dark:hover:border-blue-7 th-dark:hover:bg-blue-7; } .btn.btn-primary:active, .btn.btn-primary.active, .open > .dropdown-toggle.btn-primary { - @apply border-graphite-700/80 bg-graphite-700 text-mist-100; - @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply border-blue-5 bg-blue-9; } .nav-pills > li.active > a, .nav-pills > li.active > a:hover, .nav-pills > li.active > a:focus { - @apply bg-graphite-700 text-mist-100; - @apply th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply bg-blue-8; } /* Button Secondary */ .btn.btn-secondary { @apply border border-solid; - @apply border-graphite-700 bg-mist-100 text-graphite-700; - @apply hover:border-graphite-700 hover:bg-graphite-700/10 hover:text-graphite-700; - @apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-graphite-700; + @apply border-blue-8 bg-blue-2 text-blue-9; + @apply hover:bg-blue-3; - @apply th-dark:border-mist-100 th-dark:bg-graphite-700 th-dark:text-mist-100; - @apply th-dark:hover:border-mist-100 th-dark:hover:bg-mist-100/20 th-dark:hover:text-mist-100; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-mist-100; - - @apply th-highcontrast:border-mist-100 th-highcontrast:bg-graphite-700 th-highcontrast:text-mist-100; - @apply th-highcontrast:hover:border-mist-100 th-highcontrast:hover:bg-mist-100/20 th-highcontrast:hover:text-mist-100; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-mist-100; -} - -.btn.btn-secondary:active, -.btn.btn-secondary.active, -.open > .dropdown-toggle.btn-secondary { - @apply border-graphite-700 bg-graphite-700/10 text-graphite-700; - @apply th-dark:border-mist-100 th-dark:bg-mist-100/20 th-dark:text-mist-100; - @apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100/20 th-highcontrast:text-mist-100; + @apply th-dark:border-blue-7 th-dark:bg-gray-10 th-dark:text-blue-3; + @apply th-dark:hover:bg-blue-11; } .btn.btn-danger { @apply border-error-8 bg-error-8; @apply hover:border-error-7 hover:bg-error-7 hover:text-white; - @apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-white; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white; } .btn.btn-danger:active, .btn.btn-danger.active, .open > .dropdown-toggle.btn-danger { - @apply border-error-5 bg-error-8 text-white; + @apply border-blue-5 bg-error-8 text-white; } .btn.btn-dangerlight { @@ -110,13 +70,6 @@ fieldset[disabled] .btn { @apply hover:bg-error-2 th-dark:hover:bg-error-11; @apply border-error-5 th-highcontrast:border-error-7 th-dark:border-error-7; @apply border border-solid; - - @apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-error-9; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80; -} -.btn.btn-icon.btn-dangerlight { - @apply hover:text-error-11 th-dark:hover:text-error-7; } .btn.btn-success { @@ -130,18 +83,15 @@ fieldset[disabled] .btn { /* secondary-grey */ .btn.btn-default, .btn.btn-light { - @apply border-gray-5 bg-white text-gray-8; + @apply border-gray-5 bg-white text-gray-9; @apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10; - @apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-gray-8; /* dark mode */ @apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4; @apply th-dark:hover:border-gray-6 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-gray-warm-4; - @apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-gray-warm-4; @apply th-highcontrast:border-gray-2 th-highcontrast:bg-black th-highcontrast:text-white; @apply th-highcontrast:hover:border-gray-6 th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:text-gray-warm-4; - @apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white; } .btn.btn-light:active, @@ -162,17 +112,38 @@ fieldset[disabled] .btn { .input-group-btn .btn.active, .btn-group .btn.active { - @apply border-graphite-700/80 bg-graphite-700 text-mist-100; - @apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700; - @apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700; + @apply border-blue-5 bg-blue-2 text-blue-10; + @apply th-dark:border-blue-9 th-dark:bg-blue-11 th-dark:text-blue-2; } -.btn.btn-icon:focus { - box-shadow: none !important; +/* focus */ + +.btn.btn-primary:focus, +.btn.btn-secondary:focus, +.btn.btn-light:focus { + @apply border-blue-5; } -.btn:focus { - box-shadow: 0px 0px 0px 2px var(--tw-shadow-color); +.btn.btn-danger:focus, +.btn.btn-dangerlight:focus { + @apply border-blue-6; +} + +.btn.btn-primary:focus, +.btn.btn-secondary:focus, +.btn.btn-light:focus, +.btn.btn-danger:focus, +.btn.btn-dangerlight:focus { + --btn-focus-color: var(--ui-blue-3); + box-shadow: 0px 0px 0px 4px var(--btn-focus-color); +} + +[theme='dark'] .btn.btn-primary:focus, +[theme='dark'] .btn.btn-secondary:focus, +[theme='dark'] .btn.btn-light:focus, +[theme='dark'] .btn.btn-danger:focus, +[theme='dark'] .btn.btn-dangerlight:focus { + --btn-focus-color: var(--ui-blue-11); } a.no-link, diff --git a/app/assets/css/colors.json b/app/assets/css/colors.json index 94d3c2015..55f2922e5 100644 --- a/app/assets/css/colors.json +++ b/app/assets/css/colors.json @@ -1,31 +1,6 @@ { "black": "#000000", "white": "#ffffff", - "graphite": { - "10": "#f5f5f6", - "50": "#e5e6e8", - "100": "#ced0d3", - "200": "#abafb5", - "300": "#7b8089", - "400": "#5c6066", - "500": "#484a4e", - "600": "#3a3b3f", - "700": "#2e2f33", - "800": "#222326", - "900": "#161719" - }, - "mist": { - "50": "#fcfbfa", - "100": "#f7f6f3", - "200": "#f0f0ec", - "300": "#e8e7e2", - "400": "#e2e1db", - "500": "#d9d8d2", - "600": "#ceccc4", - "700": "#bebcb4", - "800": "#a7a6a0", - "900": "#8b8983" - }, "gray": { "1": "#fcfcfd", "2": "#f9fafb", diff --git a/app/assets/css/react-datetime-picker-override.css b/app/assets/css/react-datetime-picker-override.css index dbbea4766..acd26fb58 100644 --- a/app/assets/css/react-datetime-picker-override.css +++ b/app/assets/css/react-datetime-picker-override.css @@ -12,40 +12,35 @@ /* Extending Calendar.css from react-daterange-picker__calendar */ -.react-calendar { +.react-daterange-picker__calendar .react-calendar { background: var(--bg-calendar-color); color: var(--text-main-color); - @apply th-dark:bg-gray-iron-10; } /* calendar nav buttons */ -.react-calendar__navigation button:disabled { +.react-daterange-picker__calendar .react-calendar__navigation button:disabled { background: var(--bg-calendar-color); @apply opacity-60; @apply brightness-95 th-dark:brightness-110; - @apply th-dark:bg-gray-iron-7; } -.react-calendar__navigation button:enabled:hover, -.react-calendar__navigation button:enabled:focus { +.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover, +.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus { background: var(--bg-daterangepicker-color); - @apply th-dark:bg-gray-iron-7; } /* date tile */ -.react-calendar__tile:disabled { +.react-daterange-picker__calendar .react-calendar__tile:disabled { + background: var(--bg-calendar-color); @apply opacity-60; @apply brightness-95 th-dark:brightness-110; - @apply th-dark:bg-gray-iron-7; } - -.react-calendar__tile:enabled:hover, -.react-calendar__tile:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile:enabled:focus { background: var(--bg-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } /* today's date tile */ -.react-calendar__tile--now { +.react-daterange-picker__calendar .react-calendar__tile--now { @apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)]; border-radius: 0.25rem !important; } @@ -53,27 +48,23 @@ .react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } /* probably date tile in range */ -.react-calendar__tile--hasActive { +.react-daterange-picker__calendar .react-calendar__tile--hasActive { background: var(--bg-daterangepicker-end-date); color: var(--text-daterangepicker-end-date); - @apply th-dark:bg-gray-iron-7; } -.react-calendar__tile--hasActive:enabled:hover, -.react-calendar__tile--hasActive:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } -.react-calendar__tile--active:enabled:hover, -.react-calendar__tile--active:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); - @apply th-dark:bg-gray-iron-7; } .react-daterange-picker__calendar @@ -84,10 +75,9 @@ } /* on range select hover */ -.react-calendar--selectRange .react-calendar__tile--hover { +.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover { background: var(--bg-daterangepicker-in-range); color: var(--text-daterangepicker-in-range); - @apply th-dark:bg-gray-iron-7; } /* @@ -121,5 +111,4 @@ .react-calendar__tile--active.react-calendar__month-view__days__day--weekend { color: var(--text-daterangepicker-active); - @apply th-dark:bg-gray-iron-7; } diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index 318e0d9e4..eb2d36882 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -3,16 +3,6 @@ --black-color: var(--ui-black); --white-color: var(--ui-white); - --graphite-600: #3a3b3f; - --graphite-700: #2e2f33; - --graphite-800: #222326; - --graphite-900: #161719; - - --mist-50: #fcfbfa; - --mist-100: #f7f6f3; - --mist-200: #f0f0ec; - --mist-300: #e8e7e2; - --grey-1: #212121; --grey-2: #181818; --grey-3: #383838; @@ -68,8 +58,6 @@ --grey-58: #ebf4f8; --grey-59: #e6e6e6; --grey-61: rgb(231, 231, 231); - --grey-62: #fdfdfd; - --grey-63: #121212; --blue-1: #219; --blue-2: #337ab7; @@ -111,16 +99,17 @@ /* Default Theme */ --bg-card-color: var(--white-color); --bg-main-color: var(--white-color); - --bg-body-color: var(--grey-62); + --bg-body-color: var(--grey-9); --bg-checkbox-border-color: var(--grey-49); - --bg-sidebar-color: var(--graphite-700); - --bg-sidebar-nav-color: var(--graphite-600); + --bg-sidebar-color: var(--ui-blue-10); + --bg-sidebar-nav-color: var(--ui-blue-11); --bg-widget-color: var(--white-color); --bg-widget-header-color: var(--grey-10); --bg-widget-table-color: var(--ui-gray-3); --bg-header-color: var(--white-color); --bg-hover-table-color: var(--grey-14); --bg-input-group-addon-color: var(--ui-gray-3); + --bg-btn-default-color: var(--ui-blue-10); --bg-blocklist-hover-color: var(--ui-blue-2); --bg-table-color: var(--white-color); --bg-md-checkbox-color: var(--grey-12); @@ -139,8 +128,7 @@ --border-pagination-color: var(--ui-white); --bg-pagination-span-color: var(--white-color); --bg-pagination-hover-color: var(--ui-blue-3); - --bg-motd-body-color: var(--mist-50); - --bg-motd-btn-color: var(--graphite-700); + --bg-motd-body-color: var(--grey-20); --bg-item-highlighted-color: var(--grey-21); --bg-item-highlighted-null-color: var(--grey-14); --bg-panel-body-color: var(--white-color); @@ -156,6 +144,8 @@ --bg-daterangepicker-in-range: var(--grey-58); --bg-daterangepicker-active: var(--blue-14); --bg-input-autofill-color: var(--bg-inputbox); + --bg-btn-default-hover-color: var(--ui-blue-9); + --bg-btn-focus: var(--grey-59); --bg-small-select-color: var(--white-color); --bg-stepper-item-active: var(--white-color); --bg-stepper-item-counter: var(--grey-61); @@ -187,6 +177,7 @@ --text-navtabs-color: var(--grey-7); --text-navtabs-hover-color: var(--grey-6); --text-nav-tab-active-color: var(--grey-25); + --text-dropdown-menu-color: var(--grey-6); --text-log-viewer-color: var(--black-color); --text-json-tree-color: var(--blue-3); @@ -198,8 +189,6 @@ --text-pagination-color: var(--grey-26); --text-pagination-span-color: var(--grey-3); --text-pagination-span-hover-color: var(--grey-3); - --text-motd-body-color: var(--black-color); - --text-motd-btn-color: var(--mist-100); --text-summary-color: var(--black-color); --text-tooltip-color: var(--white-color); --text-rzslider-color: var(--grey-36); @@ -214,7 +203,6 @@ --text-button-group-color: var(--ui-gray-9); --text-button-dangerlight-color: var(--ui-error-5); --text-stepper-active-color: var(--ui-blue-8); - --border-color: var(--grey-42); --border-widget-color: var(--grey-43); --border-sidebar-color: var(--ui-blue-9); @@ -230,8 +218,7 @@ --border-pre-color: var(--grey-43); --border-pagination-span-color: var(--ui-white); --border-pagination-hover-color: var(--ui-white); - --border-motd-body-color: var(--mist-300); - --border-panel-color: var(--mist-300); + --border-panel-color: var(--white-color); --border-input-sm-color: var(--grey-47); --border-daterangepicker-color: var(--grey-19); --border-calendar-table: var(--white-color); @@ -278,7 +265,8 @@ --text-log-viewer-color-json-red: var(--text-log-viewer-color); --text-log-viewer-color-json-blue: var(--text-log-viewer-color); - --bg-body-color: var(--grey-63); + --bg-body-color: var(--grey-2); + --bg-btn-default-color: var(--grey-3); --bg-blocklist-hover-color: var(--ui-gray-iron-10); --bg-blocklist-item-selected-color: var(--ui-gray-iron-10); --bg-card-color: var(--grey-1); @@ -286,6 +274,8 @@ --bg-code-color: var(--grey-2); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-main-color: var(--grey-2); + --bg-sidebar-color: var(--grey-1); + --bg-sidebar-nav-color: var(--grey-2); --bg-widget-color: var(--grey-1); --bg-widget-header-color: var(--grey-3); --bg-widget-table-color: var(--grey-3); @@ -306,8 +296,7 @@ --bg-pagination-color: var(--grey-3); --bg-pagination-span-color: var(--grey-1); --bg-pagination-hover-color: var(--grey-3); - --bg-motd-body-color: var(--graphite-800); - --bg-motd-btn-color: var(--mist-100); + --bg-motd-body-color: var(--grey-1); --bg-item-highlighted-color: var(--grey-2); --bg-item-highlighted-null-color: var(--grey-2); --bg-panel-body-color: var(--grey-1); @@ -327,6 +316,8 @@ --bg-daterangepicker-in-range: var(--ui-gray-warm-11); --bg-daterangepicker-active: var(--blue-14); --bg-input-autofill-color: var(--bg-inputbox); + --bg-btn-default-hover-color: var(--grey-4); + --bg-btn-focus: var(--grey-3); --bg-small-select-color: var(--grey-2); --bg-stepper-item-active: var(--grey-1); --bg-stepper-item-counter: var(--grey-7); @@ -357,6 +348,7 @@ --text-navtabs-color: var(--grey-8); --text-navtabs-hover-color: var(--grey-9); --text-nav-tab-active-color: var(--white-color); + --text-dropdown-menu-color: var(--white-color); --text-log-viewer-color: var(--white-color); --text-json-tree-color: var(--grey-40); @@ -368,8 +360,6 @@ --text-pagination-color: var(--white-color); --text-pagination-span-color: var(--ui-white); --text-pagination-span-hover-color: var(--ui-white); - --text-motd-body-color: var(--mist-100); - --text-motd-btn-color: var(--graphite-700); --text-summary-color: var(--white-color); --text-tooltip-color: var(--white-color); --text-rzslider-color: var(--white-color); @@ -384,7 +374,6 @@ --text-button-group-color: var(--ui-white); --text-button-dangerlight-color: var(--ui-error-7); --text-stepper-active-color: var(--ui-white); - --border-color: var(--grey-3); --border-widget-color: var(--grey-1); --border-sidebar-color: var(--ui-gray-8); @@ -402,7 +391,6 @@ --border-blocklist-item-selected-color: var(--grey-31); --border-pagination-span-color: var(--grey-1); --border-pagination-hover-color: var(--grey-3); - --border-motd-body-color: var(--graphite-800); --border-panel-color: var(--grey-2); --border-input-sm-color: var(--grey-3); --border-daterangepicker-color: var(--grey-3); @@ -462,7 +450,6 @@ --bg-panel-body-color: var(--black-color); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-motd-body-color: var(--black-color); - --bg-motd-btn-color: var(--white-color); --bg-blocklist-hover-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color); --bg-input-group-addon-color: var(--grey-3); @@ -494,8 +481,11 @@ --bg-navtabs-hover-color: var(--grey-3); --bg-nav-tab-active-color: var(--ui-black); + --bg-btn-default-color: var(--black-color); --bg-input-autofill-color: var(--bg-inputbox); --bg-code-color: var(--ui-black); + --bg-btn-default-hover-color: var(--grey-4); + --bg-btn-focus: var(--black-color); --bg-small-select-color: var(--black-color); --bg-stepper-item-active: var(--black-color); --bg-stepper-item-counter: var(--grey-3); @@ -533,8 +523,6 @@ --text-daterangepicker-end-date: var(--ui-white); --text-daterangepicker-in-range: var(--white-color); --text-daterangepicker-active: var(--white-color); - --text-motd-body-color: var(--white-color); - --text-motd-btn-color: var(--black-color); --text-json-tree-color: var(--white-color); --text-json-tree-leaf-color: var(--white-color); --text-json-tree-branch-preview-color: var(--white-color); @@ -565,7 +553,6 @@ --border-input-sm-color: var(--white-color); --border-pagination-color: var(--grey-1); --border-pagination-span-color: var(--grey-1); - --border-motd-body-color: var(--white-color); --border-daterangepicker-color: var(--white-color); --border-calendar-table: var(--black-color); --border-daterangepicker: var(--black-color); diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 12e0fc947..74fa94d4e 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -201,18 +201,8 @@ pre { background-color: var(--bg-progress-color); } -.widget-body.motd-body { - border: 1px solid var(--border-motd-body-color); - color: var(--text-motd-body-color); - background: var(--bg-motd-body-color) url(../images/purple-gradient.svg) top right / 40% no-repeat; -} - -.widget-body.motd-body .btn.btn-link, -.widget-body.motd-body .btn.btn-link:hover { - padding: 0 5px 0 4px; - border-radius: 4px; - background-color: var(--bg-motd-btn-color); - color: var(--text-motd-btn-color); +.motd-body { + background-color: var(--bg-motd-body-color) !important; } .panel-body { @@ -418,10 +408,14 @@ input:-webkit-autofill { } .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { - border-right: 8px solid var(--graphite-600); + border-right: 8px solid var(--ui-blue-9); border-width: 6px 8px 6px 0; } +[theme='dark'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { + border-right: 8px solid var(--ui-gray-true-9); +} + [theme='highcontrast'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before { border-right: 8px solid var(--ui-white); } diff --git a/app/assets/ico/android-chrome-192x192.png b/app/assets/ico/android-chrome-192x192.png index 236db0e2b..8f31e405a 100644 Binary files a/app/assets/ico/android-chrome-192x192.png and b/app/assets/ico/android-chrome-192x192.png differ diff --git a/app/assets/ico/android-chrome-256x256.png b/app/assets/ico/android-chrome-256x256.png index 52848e019..cc95d0044 100644 Binary files a/app/assets/ico/android-chrome-256x256.png and b/app/assets/ico/android-chrome-256x256.png differ diff --git a/app/assets/ico/apple-touch-icon.png b/app/assets/ico/apple-touch-icon.png index f05e9c161..aeea31ce8 100644 Binary files a/app/assets/ico/apple-touch-icon.png and b/app/assets/ico/apple-touch-icon.png differ diff --git a/app/assets/ico/favicon-16x16.png b/app/assets/ico/favicon-16x16.png index 8c60e5d9f..f7a26b564 100644 Binary files a/app/assets/ico/favicon-16x16.png and b/app/assets/ico/favicon-16x16.png differ diff --git a/app/assets/ico/favicon-32x32.png b/app/assets/ico/favicon-32x32.png index 8735718a2..d1ccc9cea 100644 Binary files a/app/assets/ico/favicon-32x32.png and b/app/assets/ico/favicon-32x32.png differ diff --git a/app/assets/ico/favicon.ico b/app/assets/ico/favicon.ico index 066969400..28ed661f9 100644 Binary files a/app/assets/ico/favicon.ico and b/app/assets/ico/favicon.ico differ diff --git a/app/assets/ico/logomark.svg b/app/assets/ico/logomark.svg index 140c1b494..b7679d482 100644 --- a/app/assets/ico/logomark.svg +++ b/app/assets/ico/logomark.svg @@ -1,12 +1,35 @@ - - - - - + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + diff --git a/app/assets/ico/mstile-150x150.png b/app/assets/ico/mstile-150x150.png index f48374538..5e7eb6873 100644 Binary files a/app/assets/ico/mstile-150x150.png and b/app/assets/ico/mstile-150x150.png differ diff --git a/app/assets/ico/safari-pinned-tab.svg b/app/assets/ico/safari-pinned-tab.svg index d0509a572..79ce7b6fa 100644 --- a/app/assets/ico/safari-pinned-tab.svg +++ b/app/assets/ico/safari-pinned-tab.svg @@ -1,6 +1 @@ - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png new file mode 100644 index 000000000..2e46594f2 Binary files /dev/null and b/app/assets/images/logo.png differ diff --git a/app/assets/images/logo_alt.png b/app/assets/images/logo_alt.png new file mode 100644 index 000000000..a6c6707ca Binary files /dev/null and b/app/assets/images/logo_alt.png differ diff --git a/app/assets/images/logo_alt.svg b/app/assets/images/logo_alt.svg index 8d254e4e5..90e164ca1 100644 --- a/app/assets/images/logo_alt.svg +++ b/app/assets/images/logo_alt.svg @@ -1,14 +1,60 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/logo_alt_black.svg b/app/assets/images/logo_alt_black.svg deleted file mode 100644 index d9243b464..000000000 --- a/app/assets/images/logo_alt_black.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/assets/images/logo_ico.png b/app/assets/images/logo_ico.png new file mode 100644 index 000000000..b4bfd2924 Binary files /dev/null and b/app/assets/images/logo_ico.png differ diff --git a/app/assets/images/logo_small.png b/app/assets/images/logo_small.png new file mode 100644 index 000000000..76d3a46b0 Binary files /dev/null and b/app/assets/images/logo_small.png differ diff --git a/app/assets/images/logo_small_alt.png b/app/assets/images/logo_small_alt.png new file mode 100644 index 000000000..a5bc64771 Binary files /dev/null and b/app/assets/images/logo_small_alt.png differ diff --git a/app/assets/images/purple-gradient.svg b/app/assets/images/purple-gradient.svg deleted file mode 100644 index 0b3bc7160..000000000 --- a/app/assets/images/purple-gradient.svg +++ /dev/null @@ -1,522 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index f1ff8f27c..78f153e27 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -54,7 +54,7 @@ angular.module('portainer.docker').controller('ContainerController', [ $scope.computeDockerGPUCommand = () => { const gpuOptions = _.find($scope.container.HostConfig.DeviceRequests, function (o) { - return o.Driver === 'nvidia' || (o.Capabilities && o.Capabilities.length > 0 && o.Capabilities[0] > 0 && o.Capabilities[0][0] === 'gpu'); + return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu'; }); if (!gpuOptions) { return 'No GPU config found'; diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html index af37cd0e2..7bee83a2b 100644 --- a/app/docker/views/images/edit/image.html +++ b/app/docker/views/images/edit/image.html @@ -16,19 +16,19 @@ - + - + - + diff --git a/app/index.html b/app/index.html index 52b9b5d10..370070b48 100644 --- a/app/index.html +++ b/app/index.html @@ -20,7 +20,7 @@ - + @@ -47,10 +47,7 @@
-
- - -
+
diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index cfb103823..27aa04444 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -92,7 +92,6 @@ export const ngModule = angular 'onChange', 'placeholder', 'value', - 'allowSelectAll', ]) ) .component( diff --git a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html index 5c5e68255..11184ae0f 100644 --- a/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html +++ b/app/kubernetes/registries/kube-registry-access-view/kube-registry-access-view.html @@ -19,7 +19,6 @@ namespaces="$ctrl.resourcePools" placeholder="'Select one or more namespaces'" on-change="($ctrl.onChangeResourcePools)" - allow-select-all="true" >
diff --git a/app/kubernetes/views/applications/logs/logsController.js b/app/kubernetes/views/applications/logs/logsController.js index 37cae6cad..66601d98b 100644 --- a/app/kubernetes/views/applications/logs/logsController.js +++ b/app/kubernetes/views/applications/logs/logsController.js @@ -77,7 +77,6 @@ class KubernetesApplicationLogsController { await this.getApplicationLogsAsync(); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve application logs'); - this.stopRepeater(); } finally { this.state.viewReady = true; } diff --git a/app/kubernetes/views/configurations/configmap/edit/configMap.html b/app/kubernetes/views/configurations/configmap/edit/configMap.html index 70a2d4f1a..89e1c38c5 100644 --- a/app/kubernetes/views/configurations/configmap/edit/configMap.html +++ b/app/kubernetes/views/configurations/configmap/edit/configMap.html @@ -58,7 +58,7 @@ diff --git a/app/kubernetes/views/configurations/secret/edit/secret.html b/app/kubernetes/views/configurations/secret/edit/secret.html index 2e939c87e..0309d356c 100644 --- a/app/kubernetes/views/configurations/secret/edit/secret.html +++ b/app/kubernetes/views/configurations/secret/edit/secret.html @@ -65,7 +65,7 @@ diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index 60e7b0144..5eda2d3a7 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -31,7 +31,7 @@ -
+
- -
- Namespace +
+ Namespaces specified in the manifest will be used @@ -64,10 +66,10 @@
-
+
Resource names specified in the manifest will be used
-
+
-
+
+
Selected Helm chart
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index b44d3d7bb..89f416ac3 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -101,10 +101,9 @@ class KubernetesDeployController { this.onChangeNamespace = this.onChangeNamespace.bind(this); } - onChangeNamespace(namespaceName) { + onChangeNamespace() { return this.$async(async () => { - this.formValues.Namespace = namespaceName; - const applications = await this.KubernetesApplicationService.get(namespaceName); + const applications = await this.KubernetesApplicationService.get(this.formValues.Namespace); const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== ''); this.stacks = _.uniq(stacks); }); @@ -372,10 +371,6 @@ class KubernetesDeployController { if (this.namespaces.length > 0) { this.formValues.Namespace = this.namespaces[0].Name; } - this.namespaceOptions = _.map(namespaces, (namespace) => ({ - label: namespace.Name, - value: namespace.Name, - })); } catch (err) { this.Notifications.error('Failure', err, 'Unable to load namespaces data'); } @@ -409,8 +404,7 @@ class KubernetesDeployController { } } - this.onChangeNamespace(this.formValues.Namespace); - + this.onChangeNamespace(); this.state.viewReady = true; this.$window.onbeforeunload = () => { diff --git a/app/kubernetes/views/stacks/logs/logsController.js b/app/kubernetes/views/stacks/logs/logsController.js index d4e1b5ba7..536ea2ae4 100644 --- a/app/kubernetes/views/stacks/logs/logsController.js +++ b/app/kubernetes/views/stacks/logs/logsController.js @@ -104,7 +104,6 @@ class KubernetesStackLogsController { await this.getStackLogsAsync(); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retrieve stack logs'); - this.stopRepeater(); } finally { this.state.viewReady = true; } diff --git a/app/portainer/components/custom-template-selector/custom-template-selector.html b/app/portainer/components/custom-template-selector/custom-template-selector.html index cde665d49..ce05aa1ce 100644 --- a/app/portainer/components/custom-template-selector/custom-template-selector.html +++ b/app/portainer/components/custom-template-selector/custom-template-selector.html @@ -1,7 +1,7 @@
-
+

diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 9b2f7325d..8a9c1f589 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -9,7 +9,6 @@ import { withFormValidation } from '@/react-tools/withFormValidation'; import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable'; import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector'; import { withControlledInput } from '@/react-tools/withControlledInput'; -import { NamespacePortainerSelect } from '@/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector'; import { EnvironmentVariablesFieldset, @@ -200,22 +199,11 @@ export const ngModule = angular 'onChange', 'options', 'isMulti', - 'filterOption', 'isClearable', 'components', 'isLoading', 'noOptionsMessage', 'aria-label', - 'loadingMessage', - ]) - ) - .component( - 'namespacePortainerSelect', - r2a(NamespacePortainerSelect, [ - 'value', - 'onChange', - 'isDisabled', - 'options', ]) ) .component( @@ -247,7 +235,6 @@ export const ngModule = angular 'schema', 'fileName', 'placeholder', - 'showToolbar', ]) ) .component( diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 2504141a7..09dadf050 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -4,10 +4,7 @@

-
- - -
+
diff --git a/app/portainer/views/init/admin/initAdmin.html b/app/portainer/views/init/admin/initAdmin.html index afff165b2..b5cfcfeb4 100644 --- a/app/portainer/views/init/admin/initAdmin.html +++ b/app/portainer/views/init/admin/initAdmin.html @@ -5,10 +5,7 @@
-
- - -
+
diff --git a/app/portainer/views/logout/logout.html b/app/portainer/views/logout/logout.html index 95299d5d0..fe9b2513d 100644 --- a/app/portainer/views/logout/logout.html +++ b/app/portainer/views/logout/logout.html @@ -4,10 +4,7 @@
-
- - -
+
diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index d9c7d273c..20fe7dee3 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -10,7 +10,7 @@ import { export function createMockUsers( count: number, - roles: Role | Role[] | ((id: UserId) => Role) + roles: Role | Role[] | ((id: UserId) => Role) = () => _.random(1, 3) ): User[] { return _.range(1, count + 1).map((value) => ({ Id: value, @@ -40,14 +40,7 @@ function getRoles( return roles; } - // Roles is an array - if (roles.length === 0) { - throw new Error('No roles provided'); - } - - // The number of roles is not necessarily the same length as the number of users - // so we need to distribute the roles evenly and consistently - return roles[(id - 1) % roles.length]; + return roles[id]; } export function createMockTeams(count: number): Team[] { diff --git a/app/react/components/CodeEditor/CodeEditor.module.css b/app/react/components/CodeEditor/CodeEditor.module.css index 6a02d3ed5..2bf9cae88 100644 --- a/app/react/components/CodeEditor/CodeEditor.module.css +++ b/app/react/components/CodeEditor/CodeEditor.module.css @@ -141,11 +141,9 @@ } .root :global(.cm-content[aria-readonly='true']) { - /* make sure the bg has transparency, so that the selected text is visible */ - /* https://discuss.codemirror.net/t/how-do-i-get-selected-text-to-highlight/7115/2 */ - @apply bg-gray-3/50; - @apply th-dark:bg-gray-iron-10/50; - @apply th-highcontrast:bg-black/50; + @apply bg-gray-3; + @apply th-dark:bg-gray-iron-10; + @apply th-highcontrast:bg-black; } .root :global(.cm-textfield) { diff --git a/app/react/components/CodeEditor/CodeEditor.test.tsx b/app/react/components/CodeEditor/CodeEditor.test.tsx index a269cd192..7b100b0e3 100644 --- a/app/react/components/CodeEditor/CodeEditor.test.tsx +++ b/app/react/components/CodeEditor/CodeEditor.test.tsx @@ -122,7 +122,7 @@ test('should apply custom height', async () => { ); - const editor = await findByRole('textbox'); + const editor = (await findByRole('textbox')).parentElement?.parentElement; expect(editor).toHaveStyle({ height: customHeight }); }); diff --git a/app/react/components/CodeEditor/CodeEditor.tsx b/app/react/components/CodeEditor/CodeEditor.tsx index d841400f0..3df9f4193 100644 --- a/app/react/components/CodeEditor/CodeEditor.tsx +++ b/app/react/components/CodeEditor/CodeEditor.tsx @@ -33,7 +33,6 @@ interface Props extends AutomationTestingProps { schema?: JSONSchema7; fileName?: string; placeholder?: string; - showToolbar?: boolean; } export const theme = createTheme({ @@ -76,7 +75,6 @@ export function CodeEditor({ 'data-cy': dataCy, fileName, placeholder, - showToolbar = true, }: Props) { const [isRollback, setIsRollback] = useState(false); @@ -96,40 +94,38 @@ export function CodeEditor({ return ( <> - {showToolbar && ( -
-
-
- {!!textTip && {textTip}} -
- {/* the copy button is in the file name header, when fileName is provided */} - {!fileName && ( -
- - Copy - -
- )} +
+
+
+ {!!textTip && {textTip}}
- {versions && ( -
-
- -
+ {/* the copy button is in the file name header, when fileName is provided */} + {!fileName && ( +
+ + Copy +
)}
- )} + {versions && ( +
+
+ +
+
+ )} +
{fileName && ( diff --git a/app/react/components/CodeEditor/ShortcutsTooltip.tsx b/app/react/components/CodeEditor/ShortcutsTooltip.tsx deleted file mode 100644 index e04cda2c8..000000000 --- a/app/react/components/CodeEditor/ShortcutsTooltip.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { BROWSER_OS_PLATFORM } from '@/react/constants'; - -import { Tooltip } from '@@/Tip/Tooltip'; - -const otherEditorConfig = { - tooltip: ( - <> -
Ctrl+F - Start searching
-
Ctrl+G - Find next
-
Ctrl+Shift+G - Find previous
-
Ctrl+Shift+F - Replace
-
Ctrl+Shift+R - Replace all
-
Alt+G - Jump to line
-
Persistent search:
-
Enter - Find next
-
Shift+Enter - Find previous
- - ), - searchCmdLabel: 'Ctrl+F for search', -} as const; - -export const editorConfig = { - mac: { - tooltip: ( - <> -
Cmd+F - Start searching
-
Cmd+G - Find next
-
Cmd+Shift+G - Find previous
-
Cmd+Option+F - Replace
-
Cmd+Option+R - Replace all
-
Option+G - Jump to line
-
Persistent search:
-
Enter - Find next
-
Shift+Enter - Find previous
- - ), - searchCmdLabel: 'Cmd+F for search', - }, - - lin: otherEditorConfig, - win: otherEditorConfig, -} as const; - -export function ShortcutsTooltip() { - return ( -
- {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel} - - -
- ); -} diff --git a/app/react/components/CodeEditor/useCodeEditorExtensions.ts b/app/react/components/CodeEditor/useCodeEditorExtensions.ts index 8050b59da..3b46a543e 100644 --- a/app/react/components/CodeEditor/useCodeEditorExtensions.ts +++ b/app/react/components/CodeEditor/useCodeEditorExtensions.ts @@ -48,7 +48,7 @@ function yamlLanguage(schema?: JSONSchema7) { syntaxHighlighting(oneDarkHighlightStyle), // explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor lineNumbers(), - !!schema && lintGutter(), + lintGutter(), keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]), // only show completions when a schema is provided !!schema && diff --git a/app/react/components/ExternalLink.tsx b/app/react/components/ExternalLink.tsx deleted file mode 100644 index 1bd839cad..000000000 --- a/app/react/components/ExternalLink.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ArrowUpRight } from 'lucide-react'; -import { PropsWithChildren } from 'react'; -import clsx from 'clsx'; - -import { AutomationTestingProps } from '@/types'; - -interface Props { - to: string; - className?: string; - showIcon?: boolean; -} - -export function ExternalLink({ - to, - className, - children, - showIcon = true, - 'data-cy': dataCy, -}: PropsWithChildren) { - return ( - - {children} - {showIcon && } - - ); -} diff --git a/app/react/components/FallbackImage.tsx b/app/react/components/FallbackImage.tsx index eaa4f1272..ee6956f24 100644 --- a/app/react/components/FallbackImage.tsx +++ b/app/react/components/FallbackImage.tsx @@ -27,5 +27,5 @@ export function FallbackImage({ src, fallbackIcon, alt, className }: Props) { } // fallback icon if there is an error loading the image - return
{fallbackIcon}
; + return <>{fallbackIcon}; } diff --git a/app/react/components/InformationPanel.tsx b/app/react/components/InformationPanel.tsx index f25afadba..b5c9dafc9 100644 --- a/app/react/components/InformationPanel.tsx +++ b/app/react/components/InformationPanel.tsx @@ -19,7 +19,7 @@ export function InformationPanel({ children, }: PropsWithChildren) { return ( - +
{title && ( diff --git a/app/react/components/ViewLoading/ViewLoading.tsx b/app/react/components/ViewLoading/ViewLoading.tsx index 139c67637..5ade961bb 100644 --- a/app/react/components/ViewLoading/ViewLoading.tsx +++ b/app/react/components/ViewLoading/ViewLoading.tsx @@ -1,4 +1,7 @@ import clsx from 'clsx'; +import { Settings } from 'lucide-react'; + +import { Icon } from '@@/Icon'; import styles from './ViewLoading.module.css'; @@ -15,7 +18,12 @@ export function ViewLoading({ message }: Props) {
- {message && {message}} + {message && ( + + {message} + + + )}
); } diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index 84c37c0df..75031a454 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -8,14 +8,55 @@ import { import { useTransitionHook } from '@uirouter/react'; import { JSONSchema7 } from 'json-schema'; +import { BROWSER_OS_PLATFORM } from '@/react/constants'; + import { CodeEditor } from '@@/CodeEditor'; +import { Tooltip } from '@@/Tip/Tooltip'; import { FormSectionTitle } from './form-components/FormSectionTitle'; import { FormError } from './form-components/FormError'; import { confirm } from './modals/confirm'; import { ModalType } from './modals'; import { buildConfirmButton } from './modals/utils'; -import { ShortcutsTooltip } from './CodeEditor/ShortcutsTooltip'; + +const otherEditorConfig = { + tooltip: ( + <> +
Ctrl+F - Start searching
+
Ctrl+G - Find next
+
Ctrl+Shift+G - Find previous
+
Ctrl+Shift+F - Replace
+
Ctrl+Shift+R - Replace all
+
Alt+G - Jump to line
+
Persistent search:
+
Enter - Find next
+
Shift+Enter - Find previous
+ + ), + searchCmdLabel: 'Ctrl+F for search', +} as const; + +export const editorConfig = { + mac: { + tooltip: ( + <> +
Cmd+F - Start searching
+
Cmd+G - Find next
+
Cmd+Shift+G - Find previous
+
Cmd+Option+F - Replace
+
Cmd+Option+R - Replace all
+
Option+G - Jump to line
+
Persistent search:
+
Enter - Find next
+
Shift+Enter - Find previous
+ + ), + searchCmdLabel: 'Cmd+F for search', + }, + + lin: otherEditorConfig, + win: otherEditorConfig, +} as const; type CodeEditorProps = ComponentProps; @@ -28,7 +69,7 @@ interface Props extends CodeEditorProps { export function WebEditorForm({ id, - titleContent = 'Web editor', + titleContent = '', hideTitle, children, error, @@ -40,7 +81,10 @@ export function WebEditorForm({
{!hideTitle && ( - {titleContent ?? null} + <> + + {titleContent ?? null} + )} {children && (
@@ -67,11 +111,15 @@ export function WebEditorForm({ ); } -function DefaultTitle({ id, children }: { id: string; children?: ReactNode }) { +function DefaultTitle({ id }: { id: string }) { return ( - {children} - + Web editor +
+ {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel} + + +
); } diff --git a/app/react/components/datatables/NestedDatatable.tsx b/app/react/components/datatables/NestedDatatable.tsx index 17081e166..dfa7f6d54 100644 --- a/app/react/components/datatables/NestedDatatable.tsx +++ b/app/react/components/datatables/NestedDatatable.tsx @@ -25,7 +25,7 @@ interface Props extends AutomationTestingProps { initialTableState?: Partial; isLoading?: boolean; initialSortBy?: BasicTableSettings['sortBy']; - enablePagination?: boolean; + /** * keyword to filter by */ @@ -42,7 +42,6 @@ export function NestedDatatable({ initialTableState = {}, isLoading, initialSortBy, - enablePagination = true, search, 'data-cy': dataCy, 'aria-label': ariaLabel, @@ -66,7 +65,7 @@ export function NestedDatatable({ getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), - ...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }), + getPaginationRowModel: getPaginationRowModel(), }); return ( diff --git a/app/react/components/datatables/index.ts b/app/react/components/datatables/index.ts index 3ab420889..809efc23b 100644 --- a/app/react/components/datatables/index.ts +++ b/app/react/components/datatables/index.ts @@ -11,4 +11,3 @@ export { TableHeaderRow } from './TableHeaderRow'; export { TableRow } from './TableRow'; export { TableContent } from './TableContent'; export { TableFooter } from './TableFooter'; -export { TableSettingsMenuAutoRefresh } from './TableSettingsMenuAutoRefresh'; diff --git a/app/react/components/form-components/FormSection/FormSection.tsx b/app/react/components/form-components/FormSection/FormSection.tsx index 51dbab534..6ea747762 100644 --- a/app/react/components/form-components/FormSection/FormSection.tsx +++ b/app/react/components/form-components/FormSection/FormSection.tsx @@ -12,7 +12,6 @@ interface Props { titleClassName?: string; className?: string; htmlFor?: string; - setIsDefaultFolded?: (isDefaultFolded: boolean) => void; } export function FormSection({ @@ -24,7 +23,6 @@ export function FormSection({ titleClassName, className, htmlFor = '', - setIsDefaultFolded, }: PropsWithChildren) { const [isExpanded, setIsExpanded] = useState(!defaultFolded); const id = `foldingButton${title}`; @@ -41,10 +39,7 @@ export function FormSection({ isExpanded={isExpanded} data-cy={id} id={id} - onClick={() => { - setIsExpanded((isExpanded) => !isExpanded); - setIsDefaultFolded?.(isExpanded); - }} + onClick={() => setIsExpanded((isExpanded) => !isExpanded)} /> )} diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index 6800d0013..9ddf234da 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -5,25 +5,15 @@ import { } from 'react-select'; import _ from 'lodash'; import { AriaAttributes } from 'react'; -import { FilterOptionOption } from 'react-select/dist/declarations/src/filters'; import { AutomationTestingProps } from '@/types'; -import { - Creatable, - Select as ReactSelect, -} from '@@/form-components/ReactSelect'; +import { Select as ReactSelect } from '@@/form-components/ReactSelect'; export interface Option { value: TValue; label: string; disabled?: boolean; - [key: string]: unknown; -} - -export interface GroupOption { - label: string; - options: Option[]; } type Options = OptionsOrGroups< @@ -31,7 +21,7 @@ type Options = OptionsOrGroups< GroupBase> >; -interface SharedProps +interface SharedProps extends AutomationTestingProps, Pick { name?: string; @@ -42,14 +32,9 @@ interface SharedProps bindToBody?: boolean; isLoading?: boolean; noOptionsMessage?: () => string; - loadingMessage?: () => string; - filterOption?: ( - option: FilterOptionOption>, - rawInput: string - ) => boolean; } -interface MultiProps extends SharedProps { +interface MultiProps extends SharedProps { value: readonly TValue[]; onChange(value: TValue[]): void; options: Options; @@ -59,12 +44,9 @@ interface MultiProps extends SharedProps { true, GroupBase> >; - formatCreateLabel?: (input: string) => string; - onCreateOption?: (input: string) => void; - isCreatable?: boolean; } -interface SingleProps extends SharedProps { +interface SingleProps extends SharedProps { value: TValue; onChange(value: TValue | null): void; options: Options; @@ -76,13 +58,9 @@ interface SingleProps extends SharedProps { >; } -export type PortainerSelectProps = - | MultiProps - | SingleProps; +type Props = MultiProps | SingleProps; -export function PortainerSelect( - props: PortainerSelectProps -) { +export function PortainerSelect(props: Props) { return isMultiProps(props) ? ( // eslint-disable-next-line react/jsx-props-no-spreading @@ -93,7 +71,7 @@ export function PortainerSelect( } function isMultiProps( - props: PortainerSelectProps + props: Props ): props is MultiProps { return 'isMulti' in props && !!props.isMulti; } @@ -109,11 +87,9 @@ export function SingleSelect({ placeholder, isClearable, bindToBody, - filterOption, components, isLoading, noOptionsMessage, - loadingMessage, isMulti, ...aria }: SingleProps) { @@ -140,11 +116,9 @@ export function SingleSelect({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} - filterOption={filterOption} components={components} isLoading={isLoading} noOptionsMessage={noOptionsMessage} - loadingMessage={loadingMessage} // eslint-disable-next-line react/jsx-props-no-spreading {...aria} /> @@ -185,20 +159,14 @@ export function MultiSelect({ disabled, isClearable, bindToBody, - filterOption, components, isLoading, noOptionsMessage, - loadingMessage, - formatCreateLabel, - onCreateOption, - isCreatable, ...aria }: Omit, 'isMulti'>) { const selectedOptions = findSelectedOptions(options, value); - const SelectComponent = isCreatable ? Creatable : ReactSelect; return ( - ({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} - filterOption={filterOption} components={components} isLoading={isLoading} noOptionsMessage={noOptionsMessage} - loadingMessage={loadingMessage} - formatCreateLabel={formatCreateLabel} - onCreateOption={onCreateOption} // eslint-disable-next-line react/jsx-props-no-spreading {...aria} /> diff --git a/app/react/components/form-components/ReactSelect.tsx b/app/react/components/form-components/ReactSelect.tsx index c7ea47366..a9f4e6282 100644 --- a/app/react/components/form-components/ReactSelect.tsx +++ b/app/react/components/form-components/ReactSelect.tsx @@ -5,14 +5,12 @@ import ReactSelectAsync, { AsyncProps as ReactSelectAsyncProps, } from 'react-select/async'; import ReactSelect, { - components, GroupBase, - InputProps, OptionsOrGroups, Props as ReactSelectProps, } from 'react-select'; import clsx from 'clsx'; -import { RefAttributes, useMemo, useCallback } from 'react'; +import { RefAttributes, useMemo } from 'react'; import ReactSelectType from 'react-select/dist/declarations/src/Select'; import './ReactSelect.css'; @@ -54,9 +52,6 @@ type Props< | CreatableProps | RegularProps; -/** - * DO NOT use this component directly, use PortainerSelect instead. - */ export function Select< Option = DefaultOption, IsMulti extends boolean = false, @@ -73,37 +68,24 @@ export function Select< id: string; }) { const Component = isCreatable ? ReactSelectCreatable : ReactSelect; - const { - options, - 'data-cy': dataCy, - components: componentsProp, - ...rest - } = props; - - const memoizedComponents = useMemoizedSelectComponents< - Option, - IsMulti, - Group - >(dataCy, componentsProp); + const { options } = props; if ((options?.length || 0) > 1000) { return ( ); } return ( ); } @@ -112,25 +94,13 @@ export function Creatable< Option = DefaultOption, IsMulti extends boolean = false, Group extends GroupBase