Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd551b275 | ||
|
|
d7794a06b3 | ||
|
|
d0e74d6ef4 | ||
|
|
1831af9c48 | ||
|
|
dca0e35e24 | ||
|
|
4b5b682d0c | ||
|
|
078dca33b8 | ||
|
|
17ebe221bb | ||
|
|
1963edda66 | ||
|
|
c9e3717ce3 | ||
|
|
9a85246631 | ||
|
|
75f165d1ff | ||
|
|
eaf0deb2f6 | ||
|
|
a9061e5258 | ||
|
|
caac45b834 | ||
|
|
24ff7a7911 | ||
|
|
b767dcb27e | ||
|
|
731afbee46 | ||
|
|
07dfd981a2 | ||
|
|
32ef208278 | ||
|
|
a80b185e10 | ||
|
|
b96328e098 | ||
|
|
45471ce86d | ||
|
|
1bc91d0c7c | ||
|
|
799325d9f8 | ||
|
|
b540709e03 | ||
|
|
44daab04ac |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
89
api/dataservices/edgestackstatus/edgestackstatus.go
Normal file
89
api/dataservices/edgestackstatus/edgestackstatus.go
Normal 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))...)
|
||||
}
|
||||
95
api/dataservices/edgestackstatus/tx.go
Normal file
95
api/dataservices/edgestackstatus/tx.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
31
api/datastore/migrator/migrate_2_31_0.go
Normal file
31
api/datastore/migrator/migrate_2_31_0.go
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.30.0
|
||||
// @version 2.31.3
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
102
api/http/handler/kubernetes/event.go
Normal file
102
api/http/handler/kubernetes/event.go
Normal 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)
|
||||
}
|
||||
60
api/http/handler/kubernetes/event_test.go
Normal file
60
api/http/handler/kubernetes/event_test.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: ""})
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
173
api/http/middlewares/plaintext_http_request_test.go
Normal file
173
api/http/middlewares/plaintext_http_request_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
25
api/http/models/kubernetes/event.go
Normal file
25
api/http/models/kubernetes/event.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
19
api/internal/testhelpers/kube_client.go
Normal file
19
api/internal/testhelpers/kube_client.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
93
api/kubernetes/cli/event.go
Normal file
93
api/kubernetes/cli/event.go
Normal 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
|
||||
}
|
||||
108
api/kubernetes/cli/event_test.go
Normal file
108
api/kubernetes/cli/event_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
176
api/portainer.go
176
api/portainer.go
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -235,6 +235,7 @@ export const ngModule = angular
|
||||
'schema',
|
||||
'fileName',
|
||||
'placeholder',
|
||||
'showToolbar',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
52
app/react/components/CodeEditor/ShortcutsTooltip.tsx
Normal file
52
app/react/components/CodeEditor/ShortcutsTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
app/react/components/ExternalLink.tsx
Normal file
32
app/react/components/ExternalLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export function InnerTable({
|
||||
dataset={dataset}
|
||||
columns={columns}
|
||||
data-cy="applications-nested-datatable"
|
||||
enablePagination={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
176
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx
Normal file
176
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
94
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx
Normal file
94
app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx
Normal 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');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user