Compare commits

...

27 Commits

Author SHA1 Message Date
Malcolm Lockyer
9bd551b275 chore: bump version to 2.31.3 (#858) 2025-07-03 13:57:06 +12:00
andres-portainer
d7794a06b3 feat(csrf): add trusted origins cli flags [BE-11972] (#856)
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
2025-07-03 12:00:39 +12:00
Malcolm Lockyer
d0e74d6ef4 chore: bump version to 2.31.2 (#831) 2025-06-26 11:30:44 +12:00
Steven Kang
1831af9c48 fix: fetching values from both install and upgrade views - release 2.31 [R8S-368] (#821) 2025-06-24 15:46:13 +12:00
Malcolm Lockyer
dca0e35e24 chore: bump version to 2.31.1 (#815) 2025-06-19 11:15:00 +12:00
James Player
4b5b682d0c feat(k8s): CRD in applications list and details 2.31.1 - [R8S-357] (#806)
Co-authored-by: stevensbkang <skan070@gmail.com>
2025-06-18 09:06:58 +12:00
Yajith Dayarathna
078dca33b8 fix(api-documentation): swagger document genration error (#794) 2025-06-12 13:39:15 +12:00
Malcolm Lockyer
17ebe221bb chore: bump version to 2.31.0 (#789) 2025-06-10 16:47:17 +12:00
Ali
1963edda66 feat(helm): add registry dropdown [r8s-340] (#779) 2025-06-09 20:08:50 +12:00
Cara Ryan
c9e3717ce3 fix(kubernetes): Display more than 10 workloads under Helm expandable in the Applications view [R8S-339] (#781) 2025-06-09 15:12:24 +12:00
Oscar Zhou
9a85246631 fix(edgestack): display deploying status by default after creating edgestack [BE-11924] (#783) 2025-06-07 09:06:57 +12:00
andres-portainer
75f165d1ff feat(edgestackstatus): optimize the Edge Stack structures BE-11740 (#756) 2025-06-05 19:46:10 -03:00
Viktor Pettersson
eaf0deb2f6 feat(update-schedules): new update schedules view [BE-11754, BE-11887] (#686) 2025-06-05 17:03:43 +12:00
Ali
a9061e5258 feat(helm): enhance helm chart install [r8s-341] (#766) 2025-06-05 13:13:45 +12:00
James Player
caac45b834 feat(UI): Add repository url to Helm chart installation list items (#769) 2025-06-05 10:14:39 +12:00
LP B
24ff7a7911 chore(deps): upgrade docker/cli to v28.2.1 | docker/docker to v28.2.1 | docker/compose to v2.36.2 (#758) 2025-05-30 09:12:27 +02:00
Devon Steenberg
b767dcb27e fix(proxy): whitelist headers for proxy to forward [BE-11819] (#665) 2025-05-30 11:49:23 +12:00
Cara Ryan
731afbee46 feat(helm): filter on chart versions at API level [R8S-324] (#754) 2025-05-27 15:20:28 +12:00
Cara Ryan
07dfd981a2 fix(kubernetes): events api to call the backend [R8S-243] (#563) 2025-05-27 13:55:31 +12:00
Cara Ryan
32ef208278 Revert "feat(helm): filter on chart versions at API level [R8S-324]" (#753) 2025-05-26 16:58:53 +12:00
Cara Ryan
a80b185e10 feat(helm): filter on chart versions at API level [R8S-324] (#747) 2025-05-26 14:10:38 +12:00
Malcolm Lockyer
b96328e098 fix(async-perf): In async poll snapshot handling, reduce redundant json marshal [be-11861] (#726) 2025-05-23 12:42:45 +12:00
Devon Steenberg
45471ce86d fix(docker): check len of device capabilities [BE-11898] (#750) 2025-05-22 14:27:14 +12:00
Viktor Pettersson
1bc91d0c7c fix(edge-update): set edge stack status to EdgeStackStatusError to avoid redeployment of portainer-updater [BE-11855] (#714) 2025-05-20 08:28:40 +02:00
James Carppe
799325d9f8 Update bug report template for 2.30.1 (#749) 2025-05-20 14:40:43 +12:00
James Carppe
b540709e03 Update bug report template for 2.30.0 (#737) 2025-05-15 12:09:28 +12:00
Oscar Zhou
44daab04ac fix(libclient): option to disable external http request [BE-11696] (#719) 2025-05-15 09:54:35 +12:00
131 changed files with 3545 additions and 1760 deletions

View File

@@ -94,6 +94,8 @@ 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.30.1'
- '2.30.0'
- '2.29.2'
- '2.29.1'
- '2.29.0'

View File

@@ -61,6 +61,7 @@ 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(),
}
}

View File

@@ -52,6 +52,7 @@ 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"
@@ -330,6 +331,18 @@ 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 {
@@ -578,6 +591,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
PendingActionsService: pendingActionsService,
PlatformService: platformService,
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
TrustedOrigins: trustedOrigins,
}
}

View File

@@ -0,0 +1,89 @@
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))...)
}

View File

@@ -0,0 +1,95 @@
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
}

View File

@@ -12,6 +12,7 @@ type (
EdgeGroup() EdgeGroupService
EdgeJob() EdgeJobService
EdgeStack() EdgeStackService
EdgeStackStatus() EdgeStackStatusService
Endpoint() EndpointService
EndpointGroup() EndpointGroupService
EndpointRelation() EndpointRelationService
@@ -39,8 +40,8 @@ type (
Open() (newStore bool, err error)
Init() error
Close() error
UpdateTx(func(DataStoreTx) error) error
ViewTx(func(DataStoreTx) error) error
UpdateTx(func(tx DataStoreTx) error) error
ViewTx(func(tx DataStoreTx) error) error
MigrateData() error
Rollback(force bool) error
CheckCurrentEdition() error
@@ -89,6 +90,16 @@ 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)

View File

@@ -51,3 +51,20 @@ 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)
}

View File

@@ -35,3 +35,19 @@ 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)
}

View File

@@ -40,13 +40,11 @@ func (store *Store) MigrateData() error {
}
// before we alter anything in the DB, create a backup
_, err = store.Backup("")
if err != nil {
if _, err := store.Backup(""); err != nil {
return errors.Wrap(err, "while backing up database")
}
err = store.FailSafeMigrate(migrator, version)
if err != nil {
if err := store.FailSafeMigrate(migrator, version); err != nil {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
@@ -85,6 +83,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,
TunnelServerService: store.TunnelServerService,
PendingActionsService: store.PendingActionsService,
@@ -140,8 +139,7 @@ func (store *Store) connectionRollback(force bool) error {
}
}
err := store.Restore()
if err != nil {
if err := store.Restore(); err != nil {
return err
}

View File

@@ -0,0 +1,31 @@
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
}

View File

@@ -3,12 +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/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"
@@ -27,6 +27,8 @@ 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"
)
@@ -56,6 +58,7 @@ type (
authorizationService *authorization.Service
dockerhubService *dockerhub.Service
edgeStackService *edgestack.Service
edgeStackStatusService *edgestackstatus.Service
edgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service
@@ -84,6 +87,7 @@ type (
AuthorizationService *authorization.Service
DockerhubService *dockerhub.Service
EdgeStackService *edgestack.Service
EdgeStackStatusService *edgestackstatus.Service
EdgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service
@@ -114,6 +118,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
authorizationService: parameters.AuthorizationService,
dockerhubService: parameters.DockerhubService,
edgeStackService: parameters.EdgeStackService,
edgeStackStatusService: parameters.EdgeStackStatusService,
edgeJobService: parameters.EdgeJobService,
TunnelServerService: parameters.TunnelServerService,
pendingActionsService: parameters.PendingActionsService,
@@ -242,6 +247,8 @@ func (m *Migrator) initMigrations() {
m.migratePendingActionsDataForDB130,
)
m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0)
// Add new migrations above...
// One function per migration, each versions migration funcs in the same file.
}

View File

@@ -13,6 +13,7 @@ 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"
@@ -39,6 +40,8 @@ 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 {
@@ -51,6 +54,7 @@ type Store struct {
EdgeGroupService *edgegroup.Service
EdgeJobService *edgejob.Service
EdgeStackService *edgestack.Service
EdgeStackStatusService *edgestackstatus.Service
EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service
EndpointRelationService *endpointrelation.Service
@@ -109,6 +113,12 @@ 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
@@ -269,6 +279,10 @@ 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

View File

@@ -32,6 +32,10 @@ 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)
}

View File

@@ -8,6 +8,7 @@
}
],
"edge_stack": null,
"edge_stack_status": null,
"edgegroups": null,
"edgejobs": null,
"endpoint_groups": [
@@ -610,7 +611,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.30.0",
"KubectlShellImage": "portainer/kubectl-shell:2.31.3",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -678,14 +679,11 @@
"Images": null,
"Info": {
"Architecture": "",
"BridgeNfIp6tables": false,
"BridgeNfIptables": false,
"CDISpecDirs": null,
"CPUSet": false,
"CPUShares": false,
"CgroupDriver": "",
"ContainerdCommit": {
"Expected": "",
"ID": ""
},
"Containers": 0,
@@ -709,7 +707,6 @@
"IndexServerAddress": "",
"InitBinary": "",
"InitCommit": {
"Expected": "",
"ID": ""
},
"Isolation": "",
@@ -738,7 +735,6 @@
},
"RegistryConfig": null,
"RuncCommit": {
"Expected": "",
"ID": ""
},
"Runtimes": null,
@@ -943,7 +939,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.30.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.31.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -2,6 +2,7 @@ package csrf
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"os"
@@ -9,7 +10,8 @@ import (
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
gorillacsrf "github.com/gorilla/csrf"
gcsrf "github.com/gorilla/csrf"
"github.com/rs/zerolog/log"
"github.com/urfave/negroni"
)
@@ -19,7 +21,7 @@ func SkipCSRFToken(w http.ResponseWriter) {
w.Header().Set(csrfSkipHeader, "1")
}
func WithProtect(handler http.Handler) (http.Handler, error) {
func WithProtect(handler http.Handler, trustedOrigins []string) (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
@@ -34,10 +36,12 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
handler = gorillacsrf.Protect(
handler = gcsrf.Protect(
token,
gorillacsrf.Path("/"),
gorillacsrf.Secure(false),
gcsrf.Path("/"),
gcsrf.Secure(false),
gcsrf.TrustedOrigins(trustedOrigins),
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
)(handler)
return withSkipCSRF(handler, isDockerDesktopExtension), nil
@@ -55,7 +59,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
}
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
}
})
@@ -73,9 +77,33 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
}
if skip {
r = gorillacsrf.UnsafeSkipCheck(r)
r = gcsrf.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,
)
})
}

View File

@@ -6,6 +6,7 @@ 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"
@@ -116,12 +117,12 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
return err
}
networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{})
networks, err := cli.NetworkList(r.Context(), network.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker networks", err)
}
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string {
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c network.Summary) string {
return c.Name
})
if err != nil {

View File

@@ -101,8 +101,7 @@ 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{}
err := payload.Validate(r)
if err != nil {
if err := payload.Validate(r); err != nil {
return nil, err
}

View File

@@ -103,8 +103,7 @@ 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
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return nil, err
}
@@ -137,11 +136,9 @@ 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) {
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType)
if err != nil {
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
}
if hasWrongType {
} else if hasWrongType {
return "", "", "", errors.New("edge stack with config do not match the environment type")
}
@@ -153,8 +150,7 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
repositoryPassword = repositoryConfig.Authentication.Password
}
err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify)
if err != nil {
if err := handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify); err != nil {
return "", "", "", err
}

View File

@@ -76,8 +76,7 @@ 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
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return nil, err
}
@@ -96,11 +95,9 @@ 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) {
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType)
if err != nil {
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
}
if hasWrongType {
} else if hasWrongType {
return "", "", "", errors.New("edge stack with config do not match the environment type")
}
@@ -124,7 +121,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
}
return "", manifestPath, projectPath, nil
}
errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType)

View File

@@ -8,6 +8,7 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
"github.com/segmentio/encoding/json"
)
@@ -28,9 +29,7 @@ func TestCreateAndInspect(t *testing.T) {
}
err := handler.DataStore.EdgeGroup().Create(&edgeGroup)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID,
@@ -38,9 +37,7 @@ func TestCreateAndInspect(t *testing.T) {
}
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
payload := edgeStackFromStringPayload{
Name: "test-stack",
@@ -50,16 +47,14 @@ func TestCreateAndInspect(t *testing.T) {
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
t.Fatal("JSON marshal error:", err)
}
require.NoError(t, err)
r := bytes.NewBuffer(jsonPayload)
// Create EdgeStack
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -70,15 +65,11 @@ func TestCreateAndInspect(t *testing.T) {
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
require.NoError(t, err)
// Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -90,9 +81,7 @@ func TestCreateAndInspect(t *testing.T) {
data = portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
require.NoError(t, err)
if payload.Name != data.Name {
t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name)

View File

@@ -30,10 +30,9 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
}
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID))
})
if err != nil {
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr

View File

@@ -8,9 +8,10 @@ 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
@@ -23,9 +24,7 @@ func TestDeleteAndInspect(t *testing.T) {
// Inspect
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
@@ -37,9 +36,7 @@ func TestDeleteAndInspect(t *testing.T) {
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
if err != nil {
t.Fatal("error decoding response:", err)
}
require.NoError(t, err)
if data.ID != edgeStack.ID {
t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID)
@@ -47,9 +44,7 @@ func TestDeleteAndInspect(t *testing.T) {
// Delete
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -61,9 +56,7 @@ func TestDeleteAndInspect(t *testing.T) {
// Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -117,15 +110,12 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
t.Fatal("error encoding payload:", err)
}
err := json.NewEncoder(&buf).Encode(payload)
require.NoError(t, err)
// Create
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
@@ -138,9 +128,8 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
// Delete
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
t.Fatal("request error:", err)
}
req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil)
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()

View File

@@ -4,6 +4,7 @@ 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"
@@ -33,5 +34,35 @@ 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
}

View File

@@ -25,5 +25,11 @@ func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
}
for i := range edgeStacks {
if err := fillEdgeStackStatus(handler.DataStore, &edgeStacks[i]); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
}
return response.JSON(w, edgeStacks)
}

View File

@@ -9,11 +9,10 @@ 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 {
@@ -78,12 +77,25 @@ 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))
}
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
}
var stack *portainer.EdgeStack
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
if err != nil {
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 {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -96,43 +108,34 @@ 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(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) error {
if payload.Version > 0 && payload.Version < stack.Version {
return stack, nil
return 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 {
delete(stack.Status, environmentId)
return
return tx.EdgeStackStatus().Delete(stackID, payload.EndpointID)
}
environmentStatus, ok := stack.Status[environmentId]
if !ok {
environmentStatus = portainer.EdgeStackStatus{
EndpointID: environmentId,
environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
if err != nil {
environmentStatus = &portainer.EdgeStackStatusForEnv{
EndpointID: payload.EndpointID,
Status: []portainer.EdgeStackDeploymentStatus{},
}
}
@@ -143,5 +146,5 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
}
stack.Status[environmentId] = environmentStatus
return tx.EdgeStackStatus().Update(stackID, payload.EndpointID, environmentStatus)
}

View File

@@ -1,155 +0,0 @@
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()
}
}

View File

@@ -10,6 +10,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
// Update Status
@@ -28,15 +29,11 @@ func TestUpdateStatusAndInspect(t *testing.T) {
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()
@@ -48,9 +45,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
// Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -62,14 +57,10 @@ func TestUpdateStatusAndInspect(t *testing.T) {
updatedStack := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
if err != nil {
t.Fatal("error decoding response:", err)
}
require.NoError(t, err)
endpointStatus, ok := updatedStack.Status[payload.EndpointID]
if !ok {
t.Fatal("Missing status")
}
require.True(t, ok)
lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
@@ -84,8 +75,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)
@@ -136,15 +127,11 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()

View File

@@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/jwt"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
// Helpers
@@ -51,27 +52,21 @@ 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()
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
settings.EnableEdgeComputeFeatures = true
if err := handler.DataStore.Settings().UpdateSettings(settings); err != nil {
t.Fatal(err)
}
err = handler.DataStore.Settings().UpdateSettings(settings)
require.NoError(t, err)
handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id")
@@ -90,9 +85,8 @@ func createEndpointWithId(t *testing.T, store dataservices.DataStore, endpointID
LastCheckInDate: time.Now().Unix(),
}
if err := store.Endpoint().Create(&endpoint); err != nil {
t.Fatal(err)
}
err := store.Endpoint().Create(&endpoint)
require.NoError(t, err)
return endpoint
}
@@ -113,15 +107,13 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
PartialMatch: false,
}
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
t.Fatal(err)
}
err := store.EdgeGroup().Create(&edgeGroup)
require.NoError(t, 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",
@@ -138,13 +130,11 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
},
}
if err := store.EdgeStack().Create(edgeStack.ID, &edgeStack); err != nil {
t.Fatal(err)
}
err = store.EdgeStack().Create(edgeStack.ID, &edgeStack)
require.NoError(t, err)
if err := store.EndpointRelation().Create(&endpointRelation); err != nil {
t.Fatal(err)
}
err = store.EndpointRelation().Create(&endpointRelation)
require.NoError(t, err)
return edgeStack
}
@@ -155,8 +145,8 @@ func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeG
Name: "EdgeGroup 1",
}
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
t.Fatal(err)
}
err := store.EdgeGroup().Create(&edgeGroup)
require.NoError(t, err)
return edgeGroup
}

View File

@@ -74,6 +74,10 @@ 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)
}
@@ -120,7 +124,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
stack.EdgeGroups = groupsIds
if payload.UpdateVersion {
if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
if err := handler.updateStackVersion(tx, stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
return nil, httperror.InternalServerError("Unable to update stack version", err)
}
}

View File

@@ -25,9 +25,8 @@ func TestUpdateAndInspect(t *testing.T) {
endpointID := portainer.EndpointID(6)
newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID)
if err := handler.DataStore.Endpoint().Create(&newEndpoint); err != nil {
t.Fatal(err)
}
err := handler.DataStore.Endpoint().Create(&newEndpoint)
require.NoError(t, err)
endpointRelation := portainer.EndpointRelation{
EndpointID: endpointID,
@@ -36,9 +35,8 @@ func TestUpdateAndInspect(t *testing.T) {
},
}
if err := handler.DataStore.EndpointRelation().Create(&endpointRelation); err != nil {
t.Fatal(err)
}
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
require.NoError(t, err)
newEdgeGroup := portainer.EdgeGroup{
ID: 2,
@@ -49,9 +47,8 @@ func TestUpdateAndInspect(t *testing.T) {
PartialMatch: false,
}
if err := handler.DataStore.EdgeGroup().Create(&newEdgeGroup); err != nil {
t.Fatal(err)
}
err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
require.NoError(t, err)
payload := updateEdgeStackPayload{
StackFileContent: "update-test",
@@ -61,15 +58,11 @@ func TestUpdateAndInspect(t *testing.T) {
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
@@ -81,9 +74,7 @@ func TestUpdateAndInspect(t *testing.T) {
// Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -94,9 +85,8 @@ func TestUpdateAndInspect(t *testing.T) {
}
updatedStack := portainer.EdgeStack{}
if err := json.NewDecoder(rec.Body).Decode(&updatedStack); err != nil {
t.Fatal("error decoding response:", err)
}
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
require.NoError(t, err)
if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
@@ -226,15 +216,11 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
if err != nil {
t.Fatal("request error:", err)
}
require.NoError(t, err)
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()

View File

@@ -22,17 +22,15 @@ 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, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
DataStore: dataStore,
edgeStacksService: edgeStacksService,
stackCoordinator: stackCoordinator,
}
h.Handle("/edge_stacks/create/{method}",

View File

@@ -5,15 +5,18 @@ 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(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)
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
}
return handler.storeStackFile(stack, deploymentType, config)
}

View File

@@ -287,11 +287,8 @@ func TestEdgeStackStatus(t *testing.T) {
edgeStackID := portainer.EdgeStackID(17)
edgeStack := portainer.EdgeStack{
ID: edgeStackID,
Name: "test-edge-stack-17",
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
endpointID: {},
},
ID: edgeStackID,
Name: "test-edge-stack-17",
CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{1, 2},
ProjectPath: "/project/path",

View File

@@ -214,14 +214,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
}
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")
}
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")
}
}

View File

@@ -247,19 +247,17 @@ func (handler *Handler) filterEndpointsByQuery(
return filteredEndpoints, totalAvailableEndpoints, nil
}
func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
status, ok := edgeStackStatus[envId]
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
// consider that if the env has no status in the stack it is in Pending state
if statusFilter == portainer.EdgeStackStatusPending {
return !ok || len(status.Status) == 0
return stackStatus == nil || len(stackStatus.Status) == 0
}
if !ok {
if stackStatus == nil {
return false
}
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
return slices.ContainsFunc(stackStatus.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == statusFilter
})
}
@@ -291,7 +289,12 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
if statusFilter != nil {
n := 0
for _, envId := range envIds {
if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) {
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
if err != nil {
return nil, errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
}
if endpointStatusInStackMatchesFilter(edgeStackStatus, envId, *statusFilter) {
envIds[n] = envId
n++
}

View File

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

View File

@@ -7,6 +7,7 @@ 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"
)
@@ -17,6 +18,8 @@ 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
@@ -32,13 +35,19 @@ 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,
Repo: repo,
Chart: chart,
UseCache: useCache,
}
result, err := handler.helmPackageManager.SearchRepo(searchOpts)

View File

@@ -20,6 +20,7 @@ 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
@@ -45,6 +46,11 @@ 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"
@@ -55,6 +61,7 @@ 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 {

View File

@@ -30,8 +30,8 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
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.IsKubeAdmin = cli.IsKubeAdmin
pcli.NonAdminNamespaces = cli.NonAdminNamespaces
pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin())
pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces())
return pcli, nil
}

View File

@@ -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.IsKubeAdmin {
if !cli.GetIsKubeAdmin() {
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)
}

View File

@@ -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.IsKubeAdmin {
if !cli.GetIsKubeAdmin() {
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)
}

View File

@@ -0,0 +1,102 @@
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)
}

View File

@@ -0,0 +1,60 @@
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")
}

View File

@@ -58,6 +58,7 @@ 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)
@@ -110,6 +111,7 @@ 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)
@@ -133,7 +135,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) (*cli.KubeClient, *httperror.HandlerError) {
func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.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)
@@ -253,7 +255,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
serverURL.Scheme = "https"
serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS
serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS()
config.Clusters[0].Cluster.Server = serverURL.String()
yaml, err := cli.GenerateYAML(config)

View File

@@ -7,7 +7,9 @@ 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"
)
@@ -37,6 +39,12 @@ 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: ""})

View File

@@ -7,6 +7,7 @@ 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"
@@ -69,10 +70,14 @@ 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 ""
}

View File

@@ -4,7 +4,9 @@ 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"
)
@@ -24,13 +26,20 @@ 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()
var body *listResponse
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
return nil, httperror.InternalServerError("Unable to parse template file", err)

View File

@@ -3,6 +3,7 @@ package middlewares
import (
"net/http"
"slices"
"strings"
"github.com/gorilla/csrf"
)
@@ -16,6 +17,45 @@ type plainTextHTTPRequestHandler struct {
next http.Handler
}
// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol.
// The Forwarded header format supports:
// - Single proxy: Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
// - 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)
@@ -24,7 +64,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R
req := r
// If original request was HTTPS (via proxy), keep CSRF checks.
if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
if !isHTTPSRequest(r) {
req = csrf.PlaintextHTTPRequest(r)
}

View File

@@ -0,0 +1,173 @@
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)
})
}

View File

@@ -38,14 +38,30 @@ 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 {
Status string `json:"Status"`
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"`
}
type Configuration struct {
@@ -72,8 +88,8 @@ type TLSInfo struct {
// Existing types
type K8sApplicationResource struct {
CPURequest float64 `json:"CpuRequest"`
CPULimit float64 `json:"CpuLimit"`
MemoryRequest int64 `json:"MemoryRequest"`
MemoryLimit int64 `json:"MemoryLimit"`
CPURequest float64 `json:"CpuRequest,omitempty"`
CPULimit float64 `json:"CpuLimit,omitempty"`
MemoryRequest int64 `json:"MemoryRequest,omitempty"`
MemoryLimit int64 `json:"MemoryLimit,omitempty"`
}

View File

@@ -0,0 +1,25 @@
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"`
}

View File

@@ -6,7 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/network"
"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, types.NetworkInspectOptions{})
network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{})
if err != nil {
return nil, err
}

View File

@@ -7,6 +7,21 @@ 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.
@@ -15,7 +30,6 @@ 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
@@ -32,8 +46,11 @@ func createDirector(target *url.URL) func(*http.Request) {
req.Header.Set("User-Agent", "")
}
for _, header := range sensitiveHeaders {
delete(req.Header, header)
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)
}
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
portainer "github.com/portainer/portainer/api"
)
func Test_createDirector(t *testing.T) {
@@ -23,12 +24,14 @@ 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,
),
},
{
@@ -39,12 +42,14 @@ 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,
),
},
{
@@ -55,18 +60,83 @@ 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",
"Cookie": "junk",
"X-Csrf-Token": "junk",
"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-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
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",
},
true,
),
},
}
@@ -92,13 +162,17 @@ func createURL(t *testing.T, urlString string) *url.URL {
return parsedURL
}
func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request {
func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *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 {
req.Header.Add(k, v)
if canonicalHeaders {
req.Header.Add(k, v)
} else {
req.Header[k] = []string{v}
}
}
}

View File

@@ -113,6 +113,7 @@ type Server struct {
PendingActionsService *pendingactions.PendingActionsService
PlatformService platform.Service
PullLimitCheckDisabled bool
TrustedOrigins []string
}
// Start starts the HTTP server
@@ -161,10 +162,7 @@ func (server *Server) Start() error {
edgeJobsHandler.FileService = server.FileService
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore)
go edgeStackCoordinator.Start()
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator)
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
edgeStacksHandler.FileService = server.FileService
edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
@@ -339,7 +337,7 @@ func (server *Server) Start() error {
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
handler, err := csrf.WithProtect(handler)
handler, err := csrf.WithProtect(handler, server.TrustedOrigins)
if err != nil {
return errors.Wrap(err, "failed to create CSRF middleware")
}

View File

@@ -49,7 +49,6 @@ 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
@@ -104,6 +103,14 @@ 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)
}
@@ -158,5 +165,9 @@ 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
}

View File

@@ -1,26 +0,0 @@
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
}

View File

@@ -16,6 +16,7 @@ type testDatastore struct {
edgeGroup dataservices.EdgeGroupService
edgeJob dataservices.EdgeJobService
edgeStack dataservices.EdgeStackService
edgeStackStatus dataservices.EdgeStackStatusService
endpoint dataservices.EndpointService
endpointGroup dataservices.EndpointGroupService
endpointRelation dataservices.EndpointRelationService
@@ -53,8 +54,11 @@ 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) Endpoint() dataservices.EndpointService { return d.endpoint }
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
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) EndpointRelation() dataservices.EndpointRelationService {
return d.endpointRelation

View File

@@ -0,0 +1,19 @@
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
}

View File

@@ -143,3 +143,23 @@ 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
}

View File

@@ -82,6 +82,10 @@ func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID)
factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID)))
}
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.
// If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {

View File

@@ -0,0 +1,93 @@
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
}

View File

@@ -0,0 +1,108 @@
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")
})
}

View File

@@ -4,15 +4,19 @@ import (
"context"
"fmt"
"io"
"net/http"
"time"
"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"
gittypes "github.com/portainer/portainer/api/git/types"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/pkg/featureflags"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"golang.org/x/oauth2"
corev1 "k8s.io/api/core/v1"
@@ -135,6 +139,7 @@ type (
LogMode *string
KubectlShellImage *string
PullLimitCheckDisabled *bool
TrustedOrigins *string
}
// CustomTemplateVariableDefinition
@@ -242,7 +247,7 @@ type (
DockerSnapshotRaw struct {
Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"`
Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"`
Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"`
Networks []network.Summary `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"`
@@ -332,6 +337,15 @@ 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
@@ -1374,6 +1388,12 @@ 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)
@@ -1524,56 +1544,127 @@ type (
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
KubeClient interface {
ServerVersion() (*version.Info, error)
// 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)
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)
// 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
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)
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
GetNamespaces() (map[string]K8sNamespaceInfo, error)
GetNamespace(string) (K8sNamespaceInfo, error)
DeleteNamespace(namespace string) (*corev1.Namespace, error)
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
GetSecrets(namespace string) ([]models.K8sSecret, error)
// Ingress
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
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
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
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)
CreateNamespace(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
GetNodesLimits() (K8sNodesLimits, error)
GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) 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
DeleteRegistrySecret(registry RegistryID, namespace string) error
CreateRegistrySecret(registry *Registry, namespace string) error
IsRegistrySecret(namespace, secretName string) (bool, error)
ToggleSystemState(namespace string, isSystem bool) error
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
// RoleBinding
GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error)
DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) 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)
}
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)
@@ -1638,7 +1729,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.30.0"
APIVersion = "2.31.3"
// 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
@@ -1692,6 +1783,13 @@ 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"
)
// List of supported features

View File

@@ -24,6 +24,10 @@ fieldset[disabled] .btn {
box-shadow: none;
}
.btn-icon {
@apply !border-none !bg-transparent p-0;
}
.btn.btn-primary {
@apply border-blue-8 bg-blue-8 text-white;
@apply hover:border-blue-9 hover:bg-blue-9 hover:text-white;
@@ -71,6 +75,9 @@ fieldset[disabled] .btn {
@apply border-error-5 th-highcontrast:border-error-7 th-dark:border-error-7;
@apply border border-solid;
}
.btn.btn-icon.btn-dangerlight {
@apply hover:text-error-11 th-dark:hover:text-error-7;
}
.btn.btn-success {
background-color: var(--ui-success-7);
@@ -83,8 +90,8 @@ fieldset[disabled] .btn {
/* secondary-grey */
.btn.btn-default,
.btn.btn-light {
@apply border-gray-5 bg-white text-gray-9;
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10;
@apply border-gray-5 bg-white text-gray-7;
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-9;
/* dark mode */
@apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4;
@@ -138,6 +145,10 @@ fieldset[disabled] .btn {
box-shadow: 0px 0px 0px 4px var(--btn-focus-color);
}
.btn.btn-icon:focus {
box-shadow: none !important;
}
[theme='dark'] .btn.btn-primary:focus,
[theme='dark'] .btn.btn-secondary:focus,
[theme='dark'] .btn.btn-light:focus,

View File

@@ -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[0][0] === 'gpu';
return o.Driver === 'nvidia' || (o.Capabilities && o.Capabilities.length > 0 && o.Capabilities[0] > 0 && o.Capabilities[0][0] === 'gpu');
});
if (!gpuOptions) {
return 'No GPU config found';

View File

@@ -58,7 +58,7 @@
<resource-events-datatable
resource-id="ctrl.configuration.Id"
storage-key="'kubernetes.configmap.events'"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
namespace="ctrl.configuration.Namespace"
></resource-events-datatable>
</uib-tab>
<uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab">

View File

@@ -65,7 +65,7 @@
<resource-events-datatable
resource-id="ctrl.configuration.Id"
storage-key="'kubernetes.secret.events'"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
namespace="ctrl.configuration.Namespace"
></resource-events-datatable>
</uib-tab>
<uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab">

View File

@@ -31,7 +31,7 @@
<portainer-tooltip message="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'">
</portainer-tooltip>
</label>
<div class="col-sm-8 vertical-center pt-1">
<div class="col-sm-9 col-lg-10 vertical-center pt-1">
<label class="switch">
<input type="checkbox" name="toggle_logo" ng-model="ctrl.formValues.namespace_toggle" data-cy="use-namespce-from-menifest" />
<span class="slider round"></span>
@@ -41,7 +41,7 @@
<div class="form-group" ng-if="ctrl.formValues.Namespace">
<label for="target_node" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label>
<div class="col-sm-8">
<div class="col-sm-9 col-lg-10">
<select
ng-if="!ctrl.formValues.namespace_toggle || ctrl.state.BuildMethod === ctrl.BuildMethods.HELM"
data-cy="namespace-select"
@@ -66,10 +66,10 @@
<div class="form-group">
<label for="name" class="col-lg-2 col-sm-3 control-label text-left" ng-class="{ required: ctrl.state.BuildMethod === ctrl.BuildMethods.HELM }">Name</label>
<div class="col-sm-8 small text-muted pt-[7px]" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<div class="col-sm-9 col-lg-10 small text-muted pt-[7px]" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
Resource names specified in the manifest will be used
</div>
<div class="col-sm-8" ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<div class="col-sm-9 col-lg-10" ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<input
type="text"
data-cy="name-input"
@@ -170,7 +170,7 @@
</div>
<div class="form-group">
<label for="manifest_url" class="col-sm-3 col-lg-2 control-label required text-left">URL</label>
<div class="col-sm-8">
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="k8sAppDeploy-urlFileUrl"

View File

@@ -1,7 +1,7 @@
<div>
<div class="form-group pt-3">
<label for="stack_template" class="col-sm-3 col-lg-2 control-label text-left"> Template </label>
<div class="col-sm-8 col-sm-8 flex flex-col gap-y-1">
<div class="col-sm-9 col-lg-10 flex flex-col gap-y-1">
<select
ng-if="$ctrl.templates.length"
data-cy="custom-template-selector"
@@ -10,7 +10,7 @@
ng-options="template.Id as template.label for template in $ctrl.templates"
ng-change="$ctrl.handleChangeTemplate($ctrl.value)"
>
<option value="" label="Select a Custom template" disabled selected="selected"> </option>
<option value="" label="Select a Custom Template" disabled selected="selected"> </option>
</select>
<span ng-if="$ctrl.isLoadFailed">
<p class="text-warning mb-5 !inline-flex gap-1 !align-top text-xs" ng-if="ctrl.currentUser.isAdmin || ctrl.currentUser.id === ctrl.state.template.CreatedByUserId">

View File

@@ -235,6 +235,7 @@ export const ngModule = angular
'schema',
'fileName',
'placeholder',
'showToolbar',
])
)
.component(

View File

@@ -141,9 +141,11 @@
}
.root :global(.cm-content[aria-readonly='true']) {
@apply bg-gray-3;
@apply th-dark:bg-gray-iron-10;
@apply th-highcontrast:bg-black;
/* 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;
}
.root :global(.cm-textfield) {

View File

@@ -33,6 +33,7 @@ interface Props extends AutomationTestingProps {
schema?: JSONSchema7;
fileName?: string;
placeholder?: string;
showToolbar?: boolean;
}
export const theme = createTheme({
@@ -75,6 +76,7 @@ export function CodeEditor({
'data-cy': dataCy,
fileName,
placeholder,
showToolbar = true,
}: Props) {
const [isRollback, setIsRollback] = useState(false);
@@ -94,38 +96,40 @@ export function CodeEditor({
return (
<>
<div className="mb-2 flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center">
{!!textTip && <TextTip color="blue">{textTip}</TextTip>}
{showToolbar && (
<div className="mb-2 flex flex-col">
<div className="flex items-center justify-between">
<div className="flex items-center">
{!!textTip && <TextTip color="blue">{textTip}</TextTip>}
</div>
{/* the copy button is in the file name header, when fileName is provided */}
{!fileName && (
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
<CopyButton
data-cy={`copy-code-button-${id}`}
fadeDelay={2500}
copyText={value}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
</div>
)}
</div>
{/* the copy button is in the file name header, when fileName is provided */}
{!fileName && (
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
<CopyButton
data-cy={`copy-code-button-${id}`}
fadeDelay={2500}
copyText={value}
color="link"
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
indicatorPosition="left"
>
Copy
</CopyButton>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</div>
</div>
)}
</div>
)}
<div className="overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2">
{fileName && (
<FileNameHeaderRow>

View File

@@ -0,0 +1,52 @@
import { BROWSER_OS_PLATFORM } from '@/react/constants';
import { Tooltip } from '@@/Tip/Tooltip';
const otherEditorConfig = {
tooltip: (
<>
<div>Ctrl+F - Start searching</div>
<div>Ctrl+G - Find next</div>
<div>Ctrl+Shift+G - Find previous</div>
<div>Ctrl+Shift+F - Replace</div>
<div>Ctrl+Shift+R - Replace all</div>
<div>Alt+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Ctrl+F for search',
} as const;
export const editorConfig = {
mac: {
tooltip: (
<>
<div>Cmd+F - Start searching</div>
<div>Cmd+G - Find next</div>
<div>Cmd+Shift+G - Find previous</div>
<div>Cmd+Option+F - Replace</div>
<div>Cmd+Option+R - Replace all</div>
<div>Option+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Cmd+F for search',
},
lin: otherEditorConfig,
win: otherEditorConfig,
} as const;
export function ShortcutsTooltip() {
return (
<div className="text-muted small vertical-center ml-auto">
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { ExternalLink as ExternalLinkIcon } from 'lucide-react';
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon';
interface Props {
to: string;
className?: string;
}
export function ExternalLink({
to,
className,
children,
'data-cy': dataCy,
}: PropsWithChildren<Props & AutomationTestingProps>) {
return (
<a
href={to}
target="_blank"
rel="noreferrer"
data-cy={dataCy}
className={clsx('inline-flex items-center gap-1', className)}
>
<Icon icon={ExternalLinkIcon} />
<span>{children}</span>
</a>
);
}

View File

@@ -1,7 +1,4 @@
import clsx from 'clsx';
import { Settings } from 'lucide-react';
import { Icon } from '@@/Icon';
import styles from './ViewLoading.module.css';
@@ -18,12 +15,7 @@ export function ViewLoading({ message }: Props) {
<div className="sk-fold-cube" />
<div className="sk-fold-cube" />
</div>
{message && (
<span className={styles.message}>
{message}
<Icon icon={Settings} className="!ml-1 animate-spin-slow" />
</span>
)}
{message && <span className={styles.message}>{message}</span>}
</div>
);
}

View File

@@ -8,55 +8,14 @@ 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';
const otherEditorConfig = {
tooltip: (
<>
<div>Ctrl+F - Start searching</div>
<div>Ctrl+G - Find next</div>
<div>Ctrl+Shift+G - Find previous</div>
<div>Ctrl+Shift+F - Replace</div>
<div>Ctrl+Shift+R - Replace all</div>
<div>Alt+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Ctrl+F for search',
} as const;
export const editorConfig = {
mac: {
tooltip: (
<>
<div>Cmd+F - Start searching</div>
<div>Cmd+G - Find next</div>
<div>Cmd+Shift+G - Find previous</div>
<div>Cmd+Option+F - Replace</div>
<div>Cmd+Option+R - Replace all</div>
<div>Option+G - Jump to line</div>
<div>Persistent search:</div>
<div className="ml-5">Enter - Find next</div>
<div className="ml-5">Shift+Enter - Find previous</div>
</>
),
searchCmdLabel: 'Cmd+F for search',
},
lin: otherEditorConfig,
win: otherEditorConfig,
} as const;
import { ShortcutsTooltip } from './CodeEditor/ShortcutsTooltip';
type CodeEditorProps = ComponentProps<typeof CodeEditor>;
@@ -69,7 +28,7 @@ interface Props extends CodeEditorProps {
export function WebEditorForm({
id,
titleContent = '',
titleContent = 'Web editor',
hideTitle,
children,
error,
@@ -81,10 +40,7 @@ export function WebEditorForm({
<div>
<div className="web-editor overflow-x-hidden">
{!hideTitle && (
<>
<DefaultTitle id={id} />
{titleContent ?? null}
</>
<DefaultTitle id={id}>{titleContent ?? null}</DefaultTitle>
)}
{children && (
<div className="form-group text-muted small">
@@ -111,15 +67,11 @@ export function WebEditorForm({
);
}
function DefaultTitle({ id }: { id: string }) {
function DefaultTitle({ id, children }: { id: string; children?: ReactNode }) {
return (
<FormSectionTitle htmlFor={id}>
Web editor
<div className="text-muted small vertical-center ml-auto">
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
</div>
{children}
<ShortcutsTooltip />
</FormSectionTitle>
);
}

View File

@@ -25,7 +25,7 @@ interface Props<D extends DefaultType> extends AutomationTestingProps {
initialTableState?: Partial<TableState>;
isLoading?: boolean;
initialSortBy?: BasicTableSettings['sortBy'];
enablePagination?: boolean;
/**
* keyword to filter by
*/
@@ -42,6 +42,7 @@ export function NestedDatatable<D extends DefaultType>({
initialTableState = {},
isLoading,
initialSortBy,
enablePagination = true,
search,
'data-cy': dataCy,
'aria-label': ariaLabel,
@@ -65,7 +66,7 @@ export function NestedDatatable<D extends DefaultType>({
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),
});
return (

View File

@@ -21,7 +21,7 @@ interface Props {
onDismiss?(): void;
'aria-label'?: string;
'aria-labelledby'?: string;
size?: 'md' | 'lg';
size?: 'md' | 'lg' | 'xl';
className?: string;
}
@@ -53,6 +53,7 @@ export function Modal({
{
'w-[450px]': size === 'md',
'w-[700px]': size === 'lg',
'w-[1000px]': size === 'xl',
}
)}
>

View File

@@ -65,7 +65,7 @@ function getStatus(
};
}
if (envStatus.length < numDeployments) {
if (!envStatus.length) {
return {
label: 'Deploying',
icon: Loader2,
@@ -84,6 +84,15 @@ function getStatus(
};
}
if (envStatus.length < numDeployments) {
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
}
const allCompleted = envStatus.every((s) => s.Type === StatusType.Completed);
if (allCompleted) {

View File

@@ -70,7 +70,7 @@ export function StackName({
Stack
<Tooltip message={tooltip} setHtmlMessage />
</label>
<div className={inputClassName || 'col-sm-8'}>
<div className={inputClassName || 'col-sm-9 col-lg-10'}>
<AutocompleteSelect
searchResults={stackResults?.map((result) => ({
value: result,

View File

@@ -0,0 +1,43 @@
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { InnerTable } from './InnerTable';
import { Application } from './types';
// Mock the necessary hooks
const mockUseEnvironmentId = vi.fn(() => 1);
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
describe('InnerTable', () => {
it('should render all rows from the dataset', () => {
const mockApplications: Application[] = Array.from(
{ length: 11 },
(_, index) => ({
Id: `app-${index}`,
Name: `Application ${index}`,
Image: `image-${index}`,
CreationDate: new Date().toISOString(),
ResourcePool: 'default',
ApplicationType: 'Deployment',
Status: 'Ready',
TotalPodsCount: 1,
RunningPodsCount: 1,
DeploymentType: 'Replicated',
})
);
const Wrapped = withTestQueryProvider(withTestRouter(InnerTable));
render(<Wrapped dataset={mockApplications} hideStacks={false} />);
// Verify that all 11 rows are rendered
const rows = screen.getAllByRole('row');
// Subtract 1 for the header row
expect(rows.length - 1).toBe(11);
});
});

View File

@@ -17,6 +17,7 @@ export function InnerTable({
dataset={dataset}
columns={columns}
data-cy="applications-nested-datatable"
enablePagination={false}
/>
);
}

View File

@@ -1,4 +1,4 @@
import { CellContext } from '@tanstack/react-table';
import { CellContext, Row } from '@tanstack/react-table';
import clsx from 'clsx';
import {
@@ -6,14 +6,22 @@ import {
KubernetesApplicationTypes,
} from '@/kubernetes/models/application/models/appConstants';
import { filterHOC } from '@@/datatables/Filter';
import styles from './columns.status.module.css';
import { helper } from './columns.helper';
import { ApplicationRowData } from './types';
export const status = helper.accessor('Status', {
export const status = helper.accessor(getStatusSummary, {
header: 'Status',
cell: Cell,
enableSorting: false,
meta: {
filter: filterHOC('Filter by status'),
},
enableColumnFilter: true,
filterFn: (row: Row<ApplicationRowData>, _: string, filterValue: string[]) =>
filterValue.length === 0 ||
filterValue.includes(getStatusSummary(row.original)),
});
function Cell({
@@ -67,3 +75,17 @@ function Cell({
</>
);
}
function getStatusSummary(item: ApplicationRowData): 'Ready' | 'Not Ready' {
if (
item.ApplicationType === KubernetesApplicationTypes.Pod &&
item.Pods &&
item.Pods.length > 0
) {
return item.Pods[0].Status === 'Running' ? 'Ready' : 'Not Ready';
}
return item.TotalPodsCount > 0 &&
item.TotalPodsCount === item.RunningPodsCount
? 'Ready'
: 'Not Ready';
}

View File

@@ -1,10 +1,11 @@
import { CellContext } from '@tanstack/react-table';
import { CellContext, Row } from '@tanstack/react-table';
import { isoDate, truncate } from '@/portainer/filters/filters';
import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
import { Link } from '@@/Link';
import { SystemBadge } from '@@/Badge/SystemBadge';
import { filterHOC } from '@@/datatables/Filter';
import { Application } from './types';
import { helper } from './columns.helper';
@@ -49,7 +50,15 @@ export const image = helper.accessor('Image', {
});
export const appType = helper.accessor('ApplicationType', {
header: 'Application Type',
header: 'Application type',
meta: {
filter: filterHOC('Filter by application type'),
},
enableColumnFilter: true,
filterFn: (row: Row<Application>, _: string, filterValue: string[]) =>
filterValue.length === 0 ||
(!!row.original.ApplicationType &&
filterValue.includes(row.original.ApplicationType)),
});
export const published = helper.accessor('Services', {

View File

@@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { TableState } from '@@/datatables/useTableState';
import { Event } from '../../queries/types';
import { EventsDatatable } from './EventsDatatable';
// Mock the necessary hooks and dependencies
const mockTableState: TableState<TableSettings> = {
sortBy: { id: 'Date', desc: true },
pageSize: 10,
search: '',
autoRefreshRate: 0,
showSystemResources: false,
setSortBy: vi.fn(),
setPageSize: vi.fn(),
setSearch: vi.fn(),
setAutoRefreshRate: vi.fn(),
setShowSystemResources: vi.fn(),
};
vi.mock('../../datatables/default-kube-datatable-store', () => ({
useKubeStore: () => mockTableState,
}));
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
const events: Event[] = [
{
type: 'Warning',
name: 'name',
message: 'not sure if this what you want to do',
namespace: 'default',
reason: 'unknown',
count: 1,
eventTime: new Date('2025-01-02T15:04:05Z'),
uid: '4500fc9c-0cc8-4695-b4c4-989ac021d1d6',
involvedObject: {
kind: 'configMap',
uid: '35',
name: 'name',
namespace: 'default',
},
},
];
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<EventsDatatable
dataset={events}
tableState={mockTableState}
isLoading={false}
data-cy="k8sNodeDetail-eventsTable"
noWidget
/>
)),
user
)
);
return { ...render(<Wrapped />), events };
}
describe('EventsDatatable', () => {
it('should display events when data is loaded', async () => {
const { events } = renderComponent();
const event = events[0];
expect(screen.getByText(event.message || '')).toBeInTheDocument();
expect(screen.getAllByText(event.type || '')).toHaveLength(2);
expect(screen.getAllByText(event.involvedObject.kind || '')).toHaveLength(
2
);
});
});

View File

@@ -1,7 +1,7 @@
import { Event } from 'kubernetes-types/core/v1';
import { History } from 'lucide-react';
import { ReactNode } from 'react';
import { Event } from '@/react/kubernetes/queries/types';
import { IndexOptional } from '@/react/kubernetes/configs/types';
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
@@ -38,7 +38,7 @@ export function EventsDatatable({
isLoading={isLoading}
title={title}
titleIcon={titleIcon}
getRowId={(row) => row.metadata?.uid || ''}
getRowId={(row) => row.uid || ''}
disableSelect
renderTableSettings={() => (
<TableSettingsMenu>

View File

@@ -29,9 +29,7 @@ export function ResourceEventsDatatable({
params: { endpointId },
} = useCurrentStateAndParams();
const params = resourceId
? { fieldSelector: `involvedObject.uid=${resourceId}` }
: {};
const params = resourceId ? { resourceId: `${resourceId}` } : {};
const resourceEventsQuery = useEvents(endpointId, {
namespace,
params,

View File

@@ -1,5 +1,6 @@
import { Row } from '@tanstack/react-table';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { Badge, BadgeType } from '@@/Badge';
import { filterHOC } from '@@/datatables/Filter';

View File

@@ -1,4 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
export const columnHelper = createColumnHelper<Event>();

View File

@@ -1,5 +1,6 @@
import { Row } from '@tanstack/react-table';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { filterHOC } from '@@/datatables/Filter';

View File

@@ -4,11 +4,12 @@ import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import {
useHelmRepoVersions,
ChartVersion,
} from '../queries/useHelmRepositories';
} from '../../queries/useHelmRepoVersions';
import { HelmRelease } from '../../types';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
@@ -24,23 +25,33 @@ vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
}));
// Mock the useHelmRepoVersions and useHelmRepositories hooks
vi.mock('../queries/useHelmRepositories', () => ({
useHelmRepoVersions: vi.fn(() => ({
data: [
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
],
isInitialLoading: false,
isError: false,
})),
useHelmRepositories: vi.fn(() => ({
vi.mock('../../queries/useHelmRegistries', () => ({
useHelmRegistries: vi.fn(() => ({
data: ['repo1', 'repo2'],
isInitialLoading: false,
isError: false,
})),
}));
vi.mock('../../queries/useHelmRepoVersions', () => ({
useHelmRepoVersions: vi.fn(),
}));
// Mock the useHelmRelease hook
vi.mock('../queries/useHelmRelease', () => ({
useHelmRelease: vi.fn(() => ({
data: '1.0.0',
})),
}));
// Mock the useUpdateHelmReleaseMutation hook
vi.mock('../../queries/useUpdateHelmReleaseMutation', () => ({
useUpdateHelmReleaseMutation: vi.fn(() => ({
mutate: vi.fn(),
isLoading: false,
})),
}));
function renderButton(props = {}) {
const defaultProps = {
environmentId: 1,
@@ -63,11 +74,27 @@ function renderButton(props = {}) {
...props,
};
const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton));
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(UpgradeButton))
);
return render(<Wrapped {...defaultProps} />);
}
describe('UpgradeButton', () => {
beforeEach(() => {
// Set up default mock return values
vi.mocked(useHelmRepoVersions).mockReturnValue({
data: [
{ Version: '1.0.0', Repo: 'stable' },
{ Version: '1.1.0', Repo: 'stable' },
],
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
});
test('should display the upgrade button', () => {
renderButton();
@@ -81,6 +108,8 @@ describe('UpgradeButton', () => {
data,
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -94,6 +123,8 @@ describe('UpgradeButton', () => {
data: [],
isInitialLoading: true,
isError: false,
isFetching: true,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -109,6 +140,8 @@ describe('UpgradeButton', () => {
data,
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -139,6 +172,8 @@ describe('UpgradeButton', () => {
],
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton({ release: mockRelease });

View File

@@ -1,26 +1,21 @@
import { ArrowUp } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { useState } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { semverCompare } from '@/react/common/semver-utils';
import { LoadingButton } from '@@/buttons';
import { Button, LoadingButton } from '@@/buttons';
import { InlineLoader } from '@@/InlineLoader';
import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link';
import { HelmRelease } from '../../types';
import {
useUpdateHelmReleaseMutation,
UpdateHelmReleasePayload,
} from '../queries/useUpdateHelmReleaseMutation';
import {
ChartVersion,
useHelmRepoVersions,
useHelmRepositories,
} from '../queries/useHelmRepositories';
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions';
import { useHelmRelease } from '../queries/useHelmRelease';
import { useHelmRegistries } from '../../queries/useHelmRegistries';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
@@ -38,47 +33,66 @@ export function UpgradeButton({
updateRelease: (release: HelmRelease) => void;
}) {
const router = useRouter();
const [useCache, setUseCache] = useState(true);
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const repositoriesQuery = useHelmRepositories();
const registriesQuery = useHelmRegistries();
const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour
repositoriesQuery.data
registriesQuery.data,
useCache
);
const versions = helmRepoVersionsQuery.data;
// Combined loading state
const isInitialLoading =
repositoriesQuery.isInitialLoading ||
helmRepoVersionsQuery.isInitialLoading;
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
select: (data) => data.chart.metadata?.version,
});
const isLoading =
registriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching; // use 'isFetching' for helmRepoVersionsQuery because we want to show when it's refetching
const isError = registriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersionQuery = useHelmRelease(
environmentId,
releaseName,
namespace,
{
select: (data) => data.chart.metadata?.version,
}
);
const latestVersionAvailable = versions[0]?.Version ?? '';
const isNewVersionAvailable =
latestVersion?.data &&
semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
const isNewVersionAvailable = Boolean(
latestVersionQuery?.data &&
semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1
);
const currentRepo = versions?.find(
(v) =>
v.Chart === release?.chart.metadata?.name &&
v.AppVersion === release?.chart.metadata?.appVersion &&
v.Version === release?.chart.metadata?.version
)?.Repo;
const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName,
namespace: namespace || '',
values: release?.values?.userSuppliedValues,
chart: release?.chart.metadata?.name || '',
appVersion: release?.chart.metadata?.appVersion,
version: release?.chart.metadata?.version,
repo: currentRepo ?? '',
};
const filteredVersions = currentRepo
? versions?.filter((v) => v.Repo === currentRepo) || []
: versions || [];
return (
<div className="relative">
<LoadingButton
color="secondary"
data-cy="k8sApp-upgradeHelmChartButton"
onClick={() => openUpgradeForm(versions, release)}
onClick={handleUpgrade}
disabled={
versions.length === 0 ||
isInitialLoading ||
isLoading ||
isError ||
release?.info?.status?.startsWith('pending')
}
@@ -89,7 +103,7 @@ export function UpgradeButton({
>
Upgrade
</LoadingButton>
{versions.length === 0 && isInitialLoading && (
{isLoading && (
<InlineLoader
size="xs"
className="absolute -bottom-5 left-0 right-0 whitespace-nowrap"
@@ -97,71 +111,100 @@ export function UpgradeButton({
Checking for new versions...
</InlineLoader>
)}
{versions.length === 0 && !isInitialLoading && !isError && (
{!isLoading && !isError && (
<span className="absolute flex items-center -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
No versions available
<Tooltip
message={
<div>
Portainer is unable to find any versions for this chart in the
repositories saved. Try adding a new repository which contains
the chart in the{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="user-settings-link"
>
Helm repositories settings
</Link>
</div>
}
/>
</span>
)}
{isNewVersionAvailable && (
<span className="absolute -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
New version available ({latestVersionAvailable})
{getStatusMessage(
versions.length === 0,
latestVersionAvailable,
isNewVersionAvailable
)}
{versions.length === 0 && (
<Tooltip
message={
<div>
Portainer is unable to find any versions for this chart in the
repositories saved. Try adding a new repository which contains
the chart in the{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="user-settings-link"
>
Helm repositories settings
</Link>
</div>
}
/>
)}
<Button
data-cy="k8sApp-refreshHelmChartVersionsButton"
color="link"
size="xsmall"
onClick={handleRefreshVersions}
type="button"
>
Refresh
</Button>
</span>
)}
</div>
);
async function openUpgradeForm(
versions: ChartVersion[],
release?: HelmRelease
) {
const result = await openUpgradeHelmModal(editableHelmRelease, versions);
if (result) {
handleUpgrade(result, release);
function handleRefreshVersions() {
if (useCache) {
// clicking 'refresh versions' should get the latest versions from the repo, not the cached versions
setUseCache(false);
}
helmRepoVersionsQuery.refetch();
}
function handleUpgrade(
payload: UpdateHelmReleasePayload,
release?: HelmRelease
) {
if (release?.info) {
const updatedRelease = {
...release,
info: {
...release.info,
status: 'pending-upgrade',
description: 'Preparing upgrade',
},
};
updateRelease(updatedRelease);
async function handleUpgrade() {
const submittedUpgradeValues = await openUpgradeHelmModal(
editableHelmRelease,
filteredVersions
);
if (submittedUpgradeValues) {
upgrade(submittedUpgradeValues, release);
}
function upgrade(payload: UpdateHelmReleasePayload, release?: HelmRelease) {
if (release?.info) {
const updatedRelease = {
...release,
info: {
...release.info,
status: 'pending-upgrade',
description: 'Preparing upgrade',
},
};
updateRelease(updatedRelease);
}
updateHelmReleaseMutation.mutate(payload, {
onSuccess: () => {
notifySuccess('Success', 'Helm chart upgraded successfully');
// set the revision url param to undefined to refresh the page at the latest revision
router.stateService.go('kubernetes.helm', {
namespace,
name: releaseName,
revision: undefined,
});
},
});
}
updateHelmReleaseMutation.mutate(payload, {
onSuccess: () => {
notifySuccess('Success', 'Helm chart upgraded successfully');
// set the revision url param to undefined to refresh the page at the latest revision
router.stateService.go('kubernetes.helm', {
namespace,
name: releaseName,
revision: undefined,
});
},
});
}
}
function getStatusMessage(
hasNoAvailableVersions: boolean,
latestVersionAvailable: string,
isNewVersionAvailable: boolean
) {
if (hasNoAvailableVersions) {
return 'No versions available ';
}
if (isNewVersionAvailable) {
return `New version available (${latestVersionAvailable}) `;
}
return 'Latest version installed';
}

View File

@@ -3,46 +3,71 @@ import { ArrowUp } from 'lucide-react';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ChartVersion } from '@/react/kubernetes/helm/queries/useHelmRepoVersions';
import { Modal, OnSubmit, openModal } from '@@/modals';
import { Button } from '@@/buttons';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { Input } from '@@/form-components/Input';
import { CodeEditor } from '@@/CodeEditor';
import { FormControl } from '@@/form-components/FormControl';
import { WidgetTitle } from '@@/Widget';
import { Checkbox } from '@@/form-components/Checkbox';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
import { ChartVersion } from '../queries/useHelmRepositories';
import { UpdateHelmReleasePayload } from '../../types';
import { HelmValuesInput } from '../../components/HelmValuesInput';
import { useHelmChartValues } from '../../queries/useHelmChartValues';
interface Props {
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
values: UpdateHelmReleasePayload;
payload: UpdateHelmReleasePayload;
versions: ChartVersion[];
chartName: string;
}
export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
export function UpgradeHelmModal({
payload,
versions,
onSubmit,
chartName,
}: Props) {
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
const isCurrentVersion = version.Version === values.version;
const label = `${version.Repo}@${version.Version}${
const repo = payload.repo === version.Repo ? version.Repo : '';
const isCurrentVersion =
version.AppVersion === payload.appVersion &&
version.Version === payload.version;
const label = `${repo}@${version.Version}${
isCurrentVersion ? ' (current)' : ''
}`;
return {
repo,
label,
value: version,
};
});
const defaultVersion =
versionOptions.find((v) => v.value.Version === values.version)?.value ||
versionOptions[0]?.value;
versionOptions.find(
(v) =>
v.value.AppVersion === payload.appVersion &&
v.value.Version === payload.version &&
v.value.Repo === payload.repo
)?.value || versionOptions[0]?.value;
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
const [userValues, setUserValues] = useState<string>(values.values || '');
const [atomic, setAtomic] = useState<boolean>(false);
const [userValues, setUserValues] = useState<string>(payload.values || '');
const [atomic, setAtomic] = useState<boolean>(true);
const chartValuesRefQuery = useHelmChartValues({
chart: chartName,
repo: version.Repo,
version: version.Version,
});
return (
<Modal
onDismiss={() => onSubmit()}
size="lg"
size="xl"
className="flex flex-col h-[80vh] px-0"
aria-label="upgrade-helm"
>
@@ -51,73 +76,65 @@ export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
/>
<div className="flex-1 overflow-y-auto px-5">
<Modal.Body>
<FormControl label="Version" inputId="version-input" size="vertical">
<PortainerSelect<ChartVersion>
value={version}
options={versionOptions}
onChange={(version) => {
if (version) {
setVersion(version);
}
}}
data-cy="helm-version-input"
<div className="form-horizontal">
<FormControl
label="Release name"
inputId="release-name-input"
size="medium"
>
<Input
id="release-name-input"
value={payload.name}
readOnly
disabled
data-cy="helm-release-name-input"
/>
</FormControl>
<FormControl
label="Namespace"
inputId="namespace-input"
size="medium"
>
<Input
id="namespace-input"
value={payload.namespace}
readOnly
disabled
data-cy="helm-namespace-input"
/>
</FormControl>
<FormControl label="Version" inputId="version-input" size="medium">
<PortainerSelect<ChartVersion>
value={version}
options={versionOptions}
onChange={(version) => {
if (version) {
setVersion(version);
}
}}
data-cy="helm-version-input"
/>
</FormControl>
<FormControl
label="Rollback on failure"
tooltip="Enables automatic rollback on failure (equivalent to the helm --atomic flag). It may increase the time to upgrade."
inputId="atomic-input"
size="medium"
>
<Checkbox
id="atomic-input"
checked={atomic}
data-cy="atomic-checkbox"
onChange={(e) => setAtomic(e.target.checked)}
/>
</FormControl>
<HelmValuesInput
values={userValues}
setValues={setUserValues}
valuesRef={chartValuesRefQuery.data?.values ?? ''}
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
/>
</FormControl>
<FormControl
label="Release name"
inputId="release-name-input"
size="vertical"
>
<Input
id="release-name-input"
value={values.name}
readOnly
disabled
data-cy="helm-release-name-input"
/>
</FormControl>
<FormControl
label="Namespace"
inputId="namespace-input"
size="vertical"
>
<Input
id="namespace-input"
value={values.namespace}
readOnly
disabled
data-cy="helm-namespace-input"
/>
</FormControl>
<FormControl
label="Rollback on failure"
tooltip="Enables automatic rollback on failure (equivalent to the helm --atomic flag). It may increase the time to upgrade."
inputId="atomic-input"
className="[&>label]:!pl-0"
size="medium"
>
<Checkbox
id="atomic-input"
checked={atomic}
data-cy="atomic-checkbox"
onChange={(e) => setAtomic(e.target.checked)}
/>
</FormControl>
<FormControl
label="User-defined values"
inputId="user-values-editor"
size="vertical"
>
<CodeEditor
id="user-values-editor"
value={userValues}
onChange={(value) => setUserValues(value)}
height="50vh"
type="yaml"
data-cy="helm-user-values-editor"
placeholder="Define or paste the content of your values yaml file here"
/>
</FormControl>
</div>
</Modal.Body>
</div>
<div className="px-5 border-solid border-0 border-t border-gray-5 th-dark:border-gray-7 th-highcontrast:border-white">
@@ -134,10 +151,10 @@ export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
<Button
onClick={() =>
onSubmit({
name: values.name,
name: payload.name,
values: userValues,
namespace: values.namespace,
chart: values.chart,
namespace: payload.namespace,
chart: payload.chart,
repo: version.Repo,
version: version.Version,
atomic,
@@ -157,11 +174,12 @@ export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
}
export async function openUpgradeHelmModal(
values: UpdateHelmReleasePayload,
payload: UpdateHelmReleasePayload,
versions: ChartVersion[]
) {
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
values,
payload,
versions,
chartName: payload.chart,
});
}

View File

@@ -184,15 +184,8 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get(
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
)
);
@@ -236,15 +229,8 @@ describe(
HttpResponse.error()
),
// Add mock for events endpoint
http.get(
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
)
);
@@ -274,15 +260,8 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get(
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
)
);

View File

@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse } from 'msw';
import { Event, EventList } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { server, http } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
@@ -56,136 +56,84 @@ const testResources: GenericResource[] = [
},
];
const mockEventsResponse: EventList = {
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
const mockEventsResponse: Event[] = [
{
name: 'test-deployment-123456',
namespace: 'default',
reason: 'CreatedLoadBalancer',
eventTime: new Date('2023-01-01T00:00:00Z'),
uid: 'event-uid-1',
involvedObject: {
kind: 'Deployment',
name: 'test-deployment',
uid: 'test-deployment-uid',
namespace: 'default',
},
message: 'Scaled up replica set test-deployment-abc123 to 1',
firstTimestamp: new Date('2023-01-01T00:00:00Z'),
lastTimestamp: new Date('2023-01-01T00:00:00Z'),
count: 1,
type: 'Normal',
},
items: [
{
metadata: {
name: 'test-deployment-123456',
namespace: 'default',
uid: 'event-uid-1',
resourceVersion: '1000',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Deployment',
namespace: 'default',
name: 'test-deployment',
uid: 'test-deployment-uid',
apiVersion: 'apps/v1',
resourceVersion: '2000',
},
reason: 'ScalingReplicaSet',
message: 'Scaled up replica set test-deployment-abc123 to 1',
source: {
component: 'deployment-controller',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
type: 'Normal',
reportingComponent: 'deployment-controller',
reportingInstance: '',
{
name: 'test-service-123456',
namespace: 'default',
uid: 'event-uid-2',
eventTime: new Date('2023-01-01T00:00:00Z'),
involvedObject: {
kind: 'Service',
namespace: 'default',
name: 'test-service',
uid: 'test-service-uid',
},
{
metadata: {
name: 'test-service-123456',
namespace: 'default',
uid: 'event-uid-2',
resourceVersion: '1001',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Service',
namespace: 'default',
name: 'test-service',
uid: 'test-service-uid',
apiVersion: 'v1',
resourceVersion: '2001',
},
reason: 'CreatedLoadBalancer',
message: 'Created load balancer',
source: {
component: 'service-controller',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
type: 'Normal',
reportingComponent: 'service-controller',
reportingInstance: '',
},
],
};
reason: 'CreatedLoadBalancer',
message: 'Created load balancer',
firstTimestamp: new Date('2023-01-01T00:00:00Z'),
lastTimestamp: new Date('2023-01-01T00:00:00Z'),
count: 1,
type: 'Normal',
},
];
const mixedEventsResponse: EventList = {
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
const mixedEventsResponse: Event[] = [
{
name: 'test-deployment-123456',
namespace: 'default',
uid: 'event-uid-1',
eventTime: new Date('2023-01-01T00:00:00Z'),
involvedObject: {
kind: 'Deployment',
namespace: 'default',
name: 'test-deployment',
uid: 'test-deployment-uid', // This matches a resource UID
},
reason: 'ScalingReplicaSet',
message: 'Scaled up replica set test-deployment-abc123 to 1',
firstTimestamp: new Date('2023-01-01T00:00:00Z'),
lastTimestamp: new Date('2023-01-01T00:00:00Z'),
count: 1,
type: 'Normal',
},
items: [
{
metadata: {
name: 'test-deployment-123456',
namespace: 'default',
uid: 'event-uid-1',
resourceVersion: '1000',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Deployment',
namespace: 'default',
name: 'test-deployment',
uid: 'test-deployment-uid', // This matches a resource UID
apiVersion: 'apps/v1',
resourceVersion: '2000',
},
reason: 'ScalingReplicaSet',
message: 'Scaled up replica set test-deployment-abc123 to 1',
source: {
component: 'deployment-controller',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
type: 'Normal',
reportingComponent: 'deployment-controller',
reportingInstance: '',
{
name: 'unrelated-pod-123456',
namespace: 'default',
uid: 'event-uid-3',
eventTime: new Date('2023-01-01T00:00:00Z'),
involvedObject: {
kind: 'Pod',
namespace: 'default',
name: 'unrelated-pod',
uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
},
{
metadata: {
name: 'unrelated-pod-123456',
namespace: 'default',
uid: 'event-uid-3',
resourceVersion: '1002',
creationTimestamp: '2023-01-01T00:00:00Z',
},
involvedObject: {
kind: 'Pod',
namespace: 'default',
name: 'unrelated-pod',
uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
apiVersion: 'v1',
resourceVersion: '2002',
},
reason: 'Scheduled',
message: 'Successfully assigned unrelated-pod to node',
source: {
component: 'default-scheduler',
},
firstTimestamp: '2023-01-01T00:00:00Z',
lastTimestamp: '2023-01-01T00:00:00Z',
count: 1,
reportingComponent: 'scheduler',
reportingInstance: '',
},
],
};
reason: 'Scheduled',
message: 'Successfully assigned unrelated-pod to node',
type: 'Normal',
firstTimestamp: new Date('2023-01-01T00:00:00Z'),
lastTimestamp: new Date('2023-01-01T00:00:00Z'),
count: 1,
},
];
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
@@ -229,7 +177,7 @@ describe('HelmEventsDatatable', () => {
it('should correctly filter related events using the filterRelatedEvents function', () => {
const filteredEvents = filterRelatedEvents(
mixedEventsResponse.items as Event[],
mixedEventsResponse as Event[],
testResources
);

View File

@@ -1,6 +1,6 @@
import { compact } from 'lodash';
import { Event } from 'kubernetes-types/core/v1';
import { Event } from '@/react/kubernetes/queries/types';
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
import { useEvents } from '@/react/kubernetes/queries/useEvents';

View File

@@ -0,0 +1,176 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { Chart } from '../types';
import { HelmInstallForm } from './HelmInstallForm';
const mockMutate = vi.fn();
const mockNotifySuccess = vi.fn();
const mockTrackEvent = vi.fn();
const mockRouterGo = vi.fn();
// Mock the router hook to provide endpointId
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: '1' },
})),
useRouter: vi.fn(() => ({
stateService: {
go: vi.fn((...args) => mockRouterGo(...args)),
},
})),
}));
// Mock dependencies
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn((title: string, text: string) =>
mockNotifySuccess(title, text)
),
}));
vi.mock('../queries/useUpdateHelmReleaseMutation', () => ({
useUpdateHelmReleaseMutation: vi.fn(() => ({
mutateAsync: vi.fn((...args) => mockMutate(...args)),
isLoading: false,
})),
}));
vi.mock('../queries/useHelmRepoVersions', () => ({
useHelmRepoVersions: vi.fn(() => ({
data: [
{ Version: '1.0.0', AppVersion: '1.0.0' },
{ Version: '0.9.0', AppVersion: '0.9.0' },
],
isInitialLoading: false,
})),
}));
vi.mock('./queries/useHelmChartValues', () => ({
useHelmChartValues: vi.fn().mockReturnValue({
data: { values: 'test-values' },
isInitialLoading: false,
}),
}));
vi.mock('@/react/hooks/useAnalytics', () => ({
useAnalytics: vi.fn().mockReturnValue({
trackEvent: vi.fn((...args) => mockTrackEvent(...args)),
}),
}));
// Sample test data
const mockChart: Chart = {
name: 'test-chart',
description: 'Test Chart Description',
repo: 'https://example.com',
icon: 'test-icon-url',
annotations: {
category: 'database',
},
version: '1.0.1',
versions: ['1.0.0', '1.0.1'],
};
const mockRouterStateService = {
go: vi.fn(),
};
function renderComponent({
selectedChart = mockChart,
namespace = 'test-namespace',
name = 'test-name',
isAdmin = true,
} = {}) {
const user = new UserViewModel({ Username: 'user', Role: isAdmin ? 1 : 2 });
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
/>
)),
user
)
);
return {
...render(<Wrapped />),
user,
mockRouterStateService,
};
}
describe('HelmInstallForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the form with version selector and values editor', async () => {
renderComponent();
expect(screen.getByText('Version')).toBeInTheDocument();
expect(screen.getByText('Install')).toBeInTheDocument();
});
it('should install helm chart when install button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
const installButton = screen.getByText('Install');
await user.click(installButton);
// Check mutate was called with correct values
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test-name',
repo: 'https://example.com',
chart: 'test-chart',
values: '',
namespace: 'test-namespace',
version: '1.0.0',
}),
expect.objectContaining({ onSuccess: expect.any(Function) })
);
});
it('should disable install button when namespace or name is undefined', () => {
renderComponent({ namespace: '' });
expect(screen.getByText('Install')).toBeDisabled();
});
it('should call success handlers when installation succeeds', async () => {
const user = userEvent.setup();
renderComponent();
const installButton = screen.getByText('Install');
await user.click(installButton);
// Get the onSuccess callback and call it
const onSuccessCallback = mockMutate.mock.calls[0][1].onSuccess;
onSuccessCallback();
// Check that success handlers were called
expect(mockTrackEvent).toHaveBeenCalledWith('kubernetes-helm-install', {
category: 'kubernetes',
metadata: {
'chart-name': 'test-chart',
},
});
expect(mockNotifySuccess).toHaveBeenCalledWith(
'Success',
'Helm chart successfully installed'
);
expect(mockRouterGo).toHaveBeenCalledWith('kubernetes.applications');
});
});

View File

@@ -0,0 +1,94 @@
import { useRef } from 'react';
import { Formik, FormikProps } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { useCanExit } from '@/react/hooks/useCanExit';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { confirmGenericDiscard } from '@@/modals/confirm';
import { Option } from '@@/form-components/PortainerSelect';
import { Chart } from '../types';
import { useUpdateHelmReleaseMutation } from '../queries/useUpdateHelmReleaseMutation';
import { HelmInstallInnerForm } from './HelmInstallInnerForm';
import { HelmInstallFormValues } from './types';
type Props = {
selectedChart: Chart;
namespace?: string;
name?: string;
};
export function HelmInstallForm({ selectedChart, namespace, name }: Props) {
const environmentId = useEnvironmentId();
const router = useRouter();
const analytics = useAnalytics();
const versionOptions: Option<string>[] = selectedChart.versions.map(
(version, index) => ({
label: index === 0 ? `${version} (latest)` : version,
value: version,
})
);
const defaultVersion = versionOptions[0]?.value;
const initialValues: HelmInstallFormValues = {
values: '',
version: defaultVersion ?? '',
};
const installHelmChartMutation = useUpdateHelmReleaseMutation(environmentId);
const formikRef = useRef<FormikProps<HelmInstallFormValues>>(null);
useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
return (
<Formik
innerRef={formikRef}
initialValues={initialValues}
enableReinitialize
onSubmit={handleSubmit}
>
<HelmInstallInnerForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
versionOptions={versionOptions}
/>
</Formik>
);
async function handleSubmit(values: HelmInstallFormValues) {
if (!name || !namespace) {
// Theoretically this should never happen and is mainly to keep typescript happy
return;
}
await installHelmChartMutation.mutateAsync(
{
name,
repo: selectedChart.repo,
chart: selectedChart.name,
values: values.values,
namespace,
version: values.version,
},
{
onSuccess() {
analytics.trackEvent('kubernetes-helm-install', {
category: 'kubernetes',
metadata: {
'chart-name': selectedChart.name,
},
});
notifySuccess('Success', 'Helm chart successfully installed');
// Reset the form so page can be navigated away from without getting "Are you sure?"
formikRef.current?.resetForm();
router.stateService.go('kubernetes.applications');
},
}
);
}
}

View File

@@ -0,0 +1,82 @@
import { Form, useFormikContext } from 'formik';
import { useMemo } from 'react';
import { FormActions } from '@@/form-components/FormActions';
import { FormControl } from '@@/form-components/FormControl';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { FormSection } from '@@/form-components/FormSection';
import { Chart } from '../types';
import { useHelmChartValues } from '../queries/useHelmChartValues';
import { HelmValuesInput } from '../components/HelmValuesInput';
import { HelmInstallFormValues } from './types';
type Props = {
selectedChart: Chart;
namespace?: string;
name?: string;
versionOptions: Option<string>[];
};
export function HelmInstallInnerForm({
selectedChart,
namespace,
name,
versionOptions,
}: Props) {
const { values, setFieldValue, isSubmitting } =
useFormikContext<HelmInstallFormValues>();
const chartValuesRefQuery = useHelmChartValues({
chart: selectedChart.name,
repo: selectedChart.repo,
version: values?.version,
});
const selectedVersion = useMemo(
() =>
versionOptions.find((v) => v.value === values.version)?.value ??
versionOptions[0]?.value,
[versionOptions, values.version]
);
return (
<Form className="form-horizontal">
<div className="form-group !m-0">
<FormSection title="Configuration" className="mt-4">
<FormControl
label="Version"
inputId="version-input"
loadingText="Loading versions..."
>
<PortainerSelect<string>
value={selectedVersion}
options={versionOptions}
onChange={(version) => {
if (version) {
setFieldValue('version', version);
}
}}
data-cy="helm-version-input"
/>
</FormControl>
<HelmValuesInput
values={values.values}
setValues={(values) => setFieldValue('values', values)}
valuesRef={chartValuesRefQuery.data?.values ?? ''}
isValuesRefLoading={chartValuesRefQuery.isInitialLoading}
/>
</FormSection>
</div>
<FormActions
submitLabel="Install"
loadingText="Installing Helm chart"
isLoading={isSubmitting}
isValid={!!namespace && !!name}
data-cy="helm-install"
/>
</Form>
);
}

Some files were not shown because too many files have changed in this diff Show More