Compare commits

..

2 Commits

Author SHA1 Message Date
Ali 915bec0bd7 chore(release): bump version to 2.30.1 (#748) 2025-05-20 12:59:04 +12:00
Oscar Zhou e243a6bf1c fix(libclient): option to disable external http request [BE-11696] (#745) 2025-05-20 09:41:14 +12:00
117 changed files with 1710 additions and 3022 deletions
-2
View File
@@ -94,8 +94,6 @@ 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'
@@ -1,89 +0,0 @@
package edgestackstatus
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
var _ dataservices.EdgeStackStatusService = &Service{}
const BucketName = "edge_stack_status"
type Service struct {
conn portainer.Connection
}
func (service *Service) BucketName() string {
return BucketName
}
func NewService(connection portainer.Connection) (*Service, error) {
if err := connection.SetServiceName(BucketName); err != nil {
return nil, err
}
return &Service{conn: connection}, nil
}
func (s *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
service: s,
tx: tx,
}
}
func (s *Service) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Create(edgeStackID, endpointID, status)
})
}
func (s *Service) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
var element *portainer.EdgeStackStatusForEnv
return element, s.conn.ViewTx(func(tx portainer.Transaction) error {
var err error
element, err = s.Tx(tx).Read(edgeStackID, endpointID)
return err
})
}
func (s *Service) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
var collection = make([]portainer.EdgeStackStatusForEnv, 0)
return collection, s.conn.ViewTx(func(tx portainer.Transaction) error {
var err error
collection, err = s.Tx(tx).ReadAll(edgeStackID)
return err
})
}
func (s *Service) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Update(edgeStackID, endpointID, status)
})
}
func (s *Service) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Delete(edgeStackID, endpointID)
})
}
func (s *Service) DeleteAll(edgeStackID portainer.EdgeStackID) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).DeleteAll(edgeStackID)
})
}
func (s *Service) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
return s.conn.UpdateTx(func(tx portainer.Transaction) error {
return s.Tx(tx).Clear(edgeStackID, relatedEnvironmentsIDs)
})
}
func (s *Service) key(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) []byte {
return append(s.conn.ConvertToKey(int(edgeStackID)), s.conn.ConvertToKey(int(endpointID))...)
}
-95
View File
@@ -1,95 +0,0 @@
package edgestackstatus
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
var _ dataservices.EdgeStackStatusService = &Service{}
type ServiceTx struct {
service *Service
tx portainer.Transaction
}
func (service ServiceTx) Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
identifier := service.service.key(edgeStackID, endpointID)
return service.tx.CreateObjectWithStringId(BucketName, identifier, status)
}
func (s ServiceTx) Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error) {
var status portainer.EdgeStackStatusForEnv
identifier := s.service.key(edgeStackID, endpointID)
if err := s.tx.GetObject(BucketName, identifier, &status); err != nil {
return nil, err
}
return &status, nil
}
func (s ServiceTx) ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error) {
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
return nil, fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
}
return statuses, nil
}
func (s ServiceTx) Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error {
identifier := s.service.key(edgeStackID, endpointID)
return s.tx.UpdateObject(BucketName, identifier, status)
}
func (s ServiceTx) Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error {
identifier := s.service.key(edgeStackID, endpointID)
return s.tx.DeleteObject(BucketName, identifier)
}
func (s ServiceTx) DeleteAll(edgeStackID portainer.EdgeStackID) error {
keyPrefix := s.service.conn.ConvertToKey(int(edgeStackID))
statuses := make([]portainer.EdgeStackStatusForEnv, 0)
if err := s.tx.GetAllWithKeyPrefix(BucketName, keyPrefix, &portainer.EdgeStackStatusForEnv{}, dataservices.AppendFn(&statuses)); err != nil {
return fmt.Errorf("unable to retrieve EdgeStackStatus for EdgeStack %d: %w", edgeStackID, err)
}
for _, status := range statuses {
if err := s.tx.DeleteObject(BucketName, s.service.key(edgeStackID, status.EndpointID)); err != nil {
return fmt.Errorf("unable to delete EdgeStackStatus for EdgeStack %d and Endpoint %d: %w", edgeStackID, status.EndpointID, err)
}
}
return nil
}
func (s ServiceTx) Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error {
for _, envID := range relatedEnvironmentsIDs {
existingStatus, err := s.Read(edgeStackID, envID)
if err != nil && !dataservices.IsErrObjectNotFound(err) {
return fmt.Errorf("unable to retrieve status for environment %d: %w", envID, err)
}
var deploymentInfo portainer.StackDeploymentInfo
if existingStatus != nil {
deploymentInfo = existingStatus.DeploymentInfo
}
if err := s.Update(edgeStackID, envID, &portainer.EdgeStackStatusForEnv{
EndpointID: envID,
Status: []portainer.EdgeStackDeploymentStatus{},
DeploymentInfo: deploymentInfo,
}); err != nil {
return err
}
}
return nil
}
+2 -13
View File
@@ -12,7 +12,6 @@ type (
EdgeGroup() EdgeGroupService
EdgeJob() EdgeJobService
EdgeStack() EdgeStackService
EdgeStackStatus() EdgeStackStatusService
Endpoint() EndpointService
EndpointGroup() EndpointGroupService
EndpointRelation() EndpointRelationService
@@ -40,8 +39,8 @@ type (
Open() (newStore bool, err error)
Init() error
Close() error
UpdateTx(func(tx DataStoreTx) error) error
ViewTx(func(tx DataStoreTx) error) error
UpdateTx(func(DataStoreTx) error) error
ViewTx(func(DataStoreTx) error) error
MigrateData() error
Rollback(force bool) error
CheckCurrentEdition() error
@@ -90,16 +89,6 @@ type (
BucketName() string
}
EdgeStackStatusService interface {
Create(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
Read(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) (*portainer.EdgeStackStatusForEnv, error)
ReadAll(edgeStackID portainer.EdgeStackID) ([]portainer.EdgeStackStatusForEnv, error)
Update(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID, status *portainer.EdgeStackStatusForEnv) error
Delete(edgeStackID portainer.EdgeStackID, endpointID portainer.EndpointID) error
DeleteAll(edgeStackID portainer.EdgeStackID) error
Clear(edgeStackID portainer.EdgeStackID, relatedEnvironmentsIDs []portainer.EndpointID) error
}
// EndpointService represents a service for managing environment(endpoint) data
EndpointService interface {
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
-17
View File
@@ -51,20 +51,3 @@ func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portai
return snapshot, err
}
func (service *Service) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
var snapshot *portainer.SnapshotRawMessage
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
snapshot, err = service.Tx(tx).ReadRawMessage(ID)
return err
})
return snapshot, err
}
func (service *Service) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
}
-16
View File
@@ -35,19 +35,3 @@ func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*porta
return &snapshot.Snapshot, nil
}
func (service ServiceTx) ReadRawMessage(ID portainer.EndpointID) (*portainer.SnapshotRawMessage, error) {
var snapshot = portainer.SnapshotRawMessage{}
identifier := service.Connection.ConvertToKey(int(ID))
if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
return nil, err
}
return &snapshot, nil
}
func (service ServiceTx) CreateRawMessage(snapshot *portainer.SnapshotRawMessage) error {
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
}
+6 -4
View File
@@ -40,11 +40,13 @@ func (store *Store) MigrateData() error {
}
// before we alter anything in the DB, create a backup
if _, err := store.Backup(""); err != nil {
_, err = store.Backup("")
if err != nil {
return errors.Wrap(err, "while backing up database")
}
if err := store.FailSafeMigrate(migrator, version); err != nil {
err = store.FailSafeMigrate(migrator, version)
if err != nil {
err = errors.Wrap(err, "failed to migrate database")
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
@@ -83,7 +85,6 @@ 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,
@@ -139,7 +140,8 @@ func (store *Store) connectionRollback(force bool) error {
}
}
if err := store.Restore(); err != nil {
err := store.Restore()
if err != nil {
return err
}
-31
View File
@@ -1,31 +0,0 @@
package migrator
import portainer "github.com/portainer/portainer/api"
func (m *Migrator) migrateEdgeStacksStatuses_2_31_0() error {
edgeStacks, err := m.edgeStackService.EdgeStacks()
if err != nil {
return err
}
for _, edgeStack := range edgeStacks {
for envID, status := range edgeStack.Status {
if err := m.edgeStackStatusService.Create(edgeStack.ID, envID, &portainer.EdgeStackStatusForEnv{
EndpointID: envID,
Status: status.Status,
DeploymentInfo: status.DeploymentInfo,
ReadyRePullImage: status.ReadyRePullImage,
}); err != nil {
return err
}
}
edgeStack.Status = nil
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
return err
}
}
return nil
}
+1 -8
View File
@@ -3,12 +3,12 @@ package migrator
import (
"errors"
"github.com/Masterminds/semver"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices/dockerhub"
"github.com/portainer/portainer/api/dataservices/edgejob"
"github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/dataservices/edgestackstatus"
"github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation"
@@ -27,8 +27,6 @@ import (
"github.com/portainer/portainer/api/dataservices/user"
"github.com/portainer/portainer/api/dataservices/version"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/Masterminds/semver"
"github.com/rs/zerolog/log"
)
@@ -58,7 +56,6 @@ type (
authorizationService *authorization.Service
dockerhubService *dockerhub.Service
edgeStackService *edgestack.Service
edgeStackStatusService *edgestackstatus.Service
edgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
pendingActionsService *pendingactions.Service
@@ -87,7 +84,6 @@ type (
AuthorizationService *authorization.Service
DockerhubService *dockerhub.Service
EdgeStackService *edgestack.Service
EdgeStackStatusService *edgestackstatus.Service
EdgeJobService *edgejob.Service
TunnelServerService *tunnelserver.Service
PendingActionsService *pendingactions.Service
@@ -118,7 +114,6 @@ 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,
@@ -247,8 +242,6 @@ 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.
}
-14
View File
@@ -13,7 +13,6 @@ import (
"github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/dataservices/edgejob"
"github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/dataservices/edgestackstatus"
"github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation"
@@ -40,8 +39,6 @@ import (
"github.com/segmentio/encoding/json"
)
var _ dataservices.DataStore = &Store{}
// Store defines the implementation of portainer.DataStore using
// BoltDB as the storage system.
type Store struct {
@@ -54,7 +51,6 @@ type Store struct {
EdgeGroupService *edgegroup.Service
EdgeJobService *edgejob.Service
EdgeStackService *edgestack.Service
EdgeStackStatusService *edgestackstatus.Service
EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service
EndpointRelationService *endpointrelation.Service
@@ -113,12 +109,6 @@ func (store *Store) initServices() error {
store.EdgeStackService = edgeStackService
endpointRelationService.RegisterUpdateStackFunction(edgeStackService.UpdateEdgeStackFunc, edgeStackService.UpdateEdgeStackFuncTx)
edgeStackStatusService, err := edgestackstatus.NewService(store.connection)
if err != nil {
return err
}
store.EdgeStackStatusService = edgeStackStatusService
edgeGroupService, err := edgegroup.NewService(store.connection)
if err != nil {
return err
@@ -279,10 +269,6 @@ func (store *Store) EdgeStack() dataservices.EdgeStackService {
return store.EdgeStackService
}
func (store *Store) EdgeStackStatus() dataservices.EdgeStackStatusService {
return store.EdgeStackStatusService
}
// Environment(Endpoint) gives access to the Environment(Endpoint) data management layer
func (store *Store) Endpoint() dataservices.EndpointService {
return store.EndpointService
-4
View File
@@ -32,10 +32,6 @@ func (tx *StoreTx) EdgeStack() dataservices.EdgeStackService {
return tx.store.EdgeStackService.Tx(tx.tx)
}
func (tx *StoreTx) EdgeStackStatus() dataservices.EdgeStackStatusService {
return tx.store.EdgeStackStatusService.Tx(tx.tx)
}
func (tx *StoreTx) Endpoint() dataservices.EndpointService {
return tx.store.EndpointService.Tx(tx.tx)
}
@@ -8,7 +8,6 @@
}
],
"edge_stack": null,
"edge_stack_status": null,
"edgegroups": null,
"edgejobs": null,
"endpoint_groups": [
@@ -611,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.31.0",
"KubectlShellImage": "portainer/kubectl-shell:2.30.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -679,11 +678,14 @@
"Images": null,
"Info": {
"Architecture": "",
"BridgeNfIp6tables": false,
"BridgeNfIptables": false,
"CDISpecDirs": null,
"CPUSet": false,
"CPUShares": false,
"CgroupDriver": "",
"ContainerdCommit": {
"Expected": "",
"ID": ""
},
"Containers": 0,
@@ -707,6 +709,7 @@
"IndexServerAddress": "",
"InitBinary": "",
"InitCommit": {
"Expected": "",
"ID": ""
},
"Isolation": "",
@@ -735,6 +738,7 @@
},
"RegistryConfig": null,
"RuncCommit": {
"Expected": "",
"ID": ""
},
"Runtimes": null,
@@ -939,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.31.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.30.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+2 -3
View File
@@ -6,7 +6,6 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/volume"
portainer "github.com/portainer/portainer/api"
@@ -117,12 +116,12 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
return err
}
networks, err := cli.NetworkList(r.Context(), network.ListOptions{})
networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker networks", err)
}
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c network.Summary) string {
networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string {
return c.Name
})
if err != nil {
@@ -101,7 +101,8 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
// @router /edge_stacks/create/file [post]
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
payload := &edgeStackFromFileUploadPayload{}
if err := payload.Validate(r); err != nil {
err := payload.Validate(r)
if err != nil {
return nil, err
}
@@ -103,7 +103,8 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
// @router /edge_stacks/create/repository [post]
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
var payload edgeStackFromGitRepositoryPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return nil, err
}
@@ -136,9 +137,11 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
}
func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStoreTx, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType)
if err != nil {
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
} else if hasWrongType {
}
if hasWrongType {
return "", "", "", errors.New("edge stack with config do not match the environment type")
}
@@ -150,7 +153,8 @@ func (handler *Handler) storeManifestFromGitRepository(tx dataservices.DataStore
repositoryPassword = repositoryConfig.Authentication.Password
}
if err := handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify); err != nil {
err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify)
if err != nil {
return "", "", "", err
}
@@ -76,7 +76,8 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
// @router /edge_stacks/create/string [post]
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
var payload edgeStackFromStringPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return nil, err
}
@@ -95,9 +96,11 @@ func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx datas
}
func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType)
if err != nil {
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
} else if hasWrongType {
}
if hasWrongType {
return "", "", "", errors.New("edge stack with config do not match the environment type")
}
@@ -121,6 +124,7 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
}
return "", manifestPath, projectPath, nil
}
errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType)
@@ -8,7 +8,6 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/require"
"github.com/segmentio/encoding/json"
)
@@ -29,7 +28,9 @@ func TestCreateAndInspect(t *testing.T) {
}
err := handler.DataStore.EdgeGroup().Create(&edgeGroup)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpoint.ID,
@@ -37,7 +38,9 @@ func TestCreateAndInspect(t *testing.T) {
}
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
payload := edgeStackFromStringPayload{
Name: "test-stack",
@@ -47,14 +50,16 @@ func TestCreateAndInspect(t *testing.T) {
}
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
if err != nil {
t.Fatal("JSON marshal error:", err)
}
r := bytes.NewBuffer(jsonPayload)
// Create EdgeStack
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -65,11 +70,15 @@ func TestCreateAndInspect(t *testing.T) {
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
require.NoError(t, err)
if err != nil {
t.Fatal("error decoding response:", err)
}
// Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -81,7 +90,9 @@ func TestCreateAndInspect(t *testing.T) {
data = portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
require.NoError(t, err)
if err != nil {
t.Fatal("error decoding response:", err)
}
if payload.Name != data.Name {
t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name)
@@ -30,9 +30,10 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID))
}); err != nil {
})
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -8,10 +8,9 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Delete
@@ -24,7 +23,9 @@ func TestDeleteAndInspect(t *testing.T) {
// Inspect
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
@@ -36,7 +37,9 @@ func TestDeleteAndInspect(t *testing.T) {
data := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&data)
require.NoError(t, err)
if err != nil {
t.Fatal("error decoding response:", err)
}
if data.ID != edgeStack.ID {
t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID)
@@ -44,7 +47,9 @@ func TestDeleteAndInspect(t *testing.T) {
// Delete
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -56,7 +61,9 @@ func TestDeleteAndInspect(t *testing.T) {
// Inspect
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -110,12 +117,15 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
}
var buf bytes.Buffer
err := json.NewEncoder(&buf).Encode(payload)
require.NoError(t, err)
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
t.Fatal("error encoding payload:", err)
}
// Create
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
@@ -128,8 +138,9 @@ func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
// Delete
req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil)
require.NoError(t, err)
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -4,7 +4,6 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -34,35 +33,5 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
}
if err := fillEdgeStackStatus(handler.DataStore, edgeStack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
return response.JSON(w, edgeStack)
}
func fillEdgeStackStatus(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack) error {
status, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
if err != nil {
return err
}
edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus, len(status))
emptyStatus := make([]portainer.EdgeStackDeploymentStatus, 0)
for _, s := range status {
if s.Status == nil {
s.Status = emptyStatus
}
edgeStack.Status[s.EndpointID] = portainer.EdgeStackStatus{
Status: s.Status,
EndpointID: s.EndpointID,
DeploymentInfo: s.DeploymentInfo,
ReadyRePullImage: s.ReadyRePullImage,
}
}
return nil
}
@@ -25,11 +25,5 @@ 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,10 +9,11 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
type updateStatusPayload struct {
@@ -77,25 +78,12 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
var stack *portainer.EdgeStack
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var err error
stack, err = tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
return nil
}
return httperror.InternalServerError("Unable to retrieve Edge stack from the database", err)
}
if err := handler.updateEdgeStackStatus(tx, stack, stack.ID, payload); err != nil {
return httperror.InternalServerError("Unable to update Edge stack status", err)
}
return nil
}); err != nil {
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -108,34 +96,43 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return nil
}
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
return response.JSON(w, stack)
}
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) error {
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
if payload.Version > 0 && payload.Version < stack.Version {
return nil
return stack, nil
}
status := *payload.Status
log.Debug().
Int("stackID", int(stackID)).
Int("status", int(status)).
Msg("Updating stack status")
deploymentStatus := portainer.EdgeStackDeploymentStatus{
Type: status,
Error: payload.Error,
Time: payload.Time,
}
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
return stack, nil
}
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
return tx.EdgeStackStatus().Delete(stackID, payload.EndpointID)
delete(stack.Status, environmentId)
return
}
environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
if err != nil {
environmentStatus = &portainer.EdgeStackStatusForEnv{
EndpointID: payload.EndpointID,
environmentStatus, ok := stack.Status[environmentId]
if !ok {
environmentStatus = portainer.EdgeStackStatus{
EndpointID: environmentId,
Status: []portainer.EdgeStackDeploymentStatus{},
}
}
@@ -146,5 +143,5 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
}
return tx.EdgeStackStatus().Update(stackID, payload.EndpointID, environmentStatus)
stack.Status[environmentId] = environmentStatus
}
@@ -0,0 +1,155 @@
package edgestacks
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
type statusRequest struct {
respCh chan statusResponse
stackID portainer.EdgeStackID
updateFn statusUpdateFn
}
type statusResponse struct {
Stack *portainer.EdgeStack
Error error
}
type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error)
type EdgeStackStatusUpdateCoordinator struct {
updateCh chan statusRequest
dataStore dataservices.DataStore
}
var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress")
func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator {
return &EdgeStackStatusUpdateCoordinator{
updateCh: make(chan statusRequest),
dataStore: dataStore,
}
}
func (c *EdgeStackStatusUpdateCoordinator) Start() {
for {
c.loop()
}
}
func (c *EdgeStackStatusUpdateCoordinator) loop() {
u := <-c.updateCh
respChs := []chan statusResponse{u.respCh}
var stack *portainer.EdgeStack
err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
// 1. Load the edge stack
var err error
stack, err = loadEdgeStack(tx, u.stackID)
if err != nil {
return err
}
// Return early when the agent tries to update the status on a deleted stack
if stack == nil {
return nil
}
// 2. Mutate the edge stack opportunistically until there are no more pending updates
for {
stack, err = u.updateFn(stack)
if err != nil {
return err
}
if m, ok := c.getNextUpdate(stack.ID); ok {
u = m
} else {
break
}
respChs = append(respChs, u.respCh)
}
// 3. Save the changes back to the database
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database")
}
return nil
})
// 4. Send back the responses
for _, ch := range respChs {
ch <- statusResponse{Stack: stack, Error: err}
}
}
func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// Skip the error when the agent tries to update the status on a deleted stack
log.Debug().
Err(err).
Int("stackID", int(stackID)).
Msg("Unable to find a stack inside the database, skipping error")
return nil, nil
}
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err)
}
return stack, nil
}
func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) {
for {
select {
case u := <-c.updateCh:
// Discard the update and let the agent retry
if u.stackID != stackID {
u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress}
continue
}
return u, true
default:
return statusRequest{}, false
}
}
}
func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) {
respCh := make(chan statusResponse)
defer close(respCh)
msg := statusRequest{
respCh: respCh,
stackID: stackID,
updateFn: updateFn,
}
select {
case c.updateCh <- msg:
r := <-respCh
return r.Stack, r.Error
case <-r.Context().Done():
return nil, r.Context().Err()
}
}
@@ -10,7 +10,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
// Update Status
@@ -29,11 +28,15 @@ func TestUpdateStatusAndInspect(t *testing.T) {
}
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()
@@ -45,7 +48,9 @@ func TestUpdateStatusAndInspect(t *testing.T) {
// Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -57,10 +62,14 @@ func TestUpdateStatusAndInspect(t *testing.T) {
updatedStack := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
require.NoError(t, err)
if err != nil {
t.Fatal("error decoding response:", err)
}
endpointStatus, ok := updatedStack.Status[payload.EndpointID]
require.True(t, ok)
if !ok {
t.Fatal("Missing status")
}
lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
@@ -75,8 +84,8 @@ func TestUpdateStatusAndInspect(t *testing.T) {
if endpointStatus.EndpointID != payload.EndpointID {
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
}
}
}
func TestUpdateStatusWithInvalidPayload(t *testing.T) {
handler, _ := setupHandler(t)
@@ -127,11 +136,15 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
rec := httptest.NewRecorder()
+26 -16
View File
@@ -17,7 +17,6 @@ import (
"github.com/portainer/portainer/api/jwt"
"github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
// Helpers
@@ -52,21 +51,27 @@ func setupHandler(t *testing.T) (*Handler, string) {
t.Fatal(err)
}
coord := NewEdgeStackStatusUpdateCoordinator(store)
go coord.Start()
handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService),
store,
edgestacks.NewService(store),
coord,
)
handler.FileService = fs
settings, err := handler.DataStore.Settings().Settings()
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
settings.EnableEdgeComputeFeatures = true
err = handler.DataStore.Settings().UpdateSettings(settings)
require.NoError(t, err)
if err := handler.DataStore.Settings().UpdateSettings(settings); err != nil {
t.Fatal(err)
}
handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id")
@@ -85,8 +90,9 @@ func createEndpointWithId(t *testing.T, store dataservices.DataStore, endpointID
LastCheckInDate: time.Now().Unix(),
}
err := store.Endpoint().Create(&endpoint)
require.NoError(t, err)
if err := store.Endpoint().Create(&endpoint); err != nil {
t.Fatal(err)
}
return endpoint
}
@@ -107,13 +113,15 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
PartialMatch: false,
}
err := store.EdgeGroup().Create(&edgeGroup)
require.NoError(t, err)
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
t.Fatal(err)
}
edgeStackID := portainer.EdgeStackID(14)
edgeStack := portainer.EdgeStack{
ID: edgeStackID,
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{},
CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
ProjectPath: "/project/path",
@@ -130,11 +138,13 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
},
}
err = store.EdgeStack().Create(edgeStack.ID, &edgeStack)
require.NoError(t, err)
if err := store.EdgeStack().Create(edgeStack.ID, &edgeStack); err != nil {
t.Fatal(err)
}
err = store.EndpointRelation().Create(&endpointRelation)
require.NoError(t, err)
if err := store.EndpointRelation().Create(&endpointRelation); err != nil {
t.Fatal(err)
}
return edgeStack
}
@@ -145,8 +155,8 @@ func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeG
Name: "EdgeGroup 1",
}
err := store.EdgeGroup().Create(&edgeGroup)
require.NoError(t, err)
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
t.Fatal(err)
}
return edgeGroup
}
@@ -74,10 +74,6 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unexpected error", err)
}
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
return response.JSON(w, stack)
}
@@ -124,7 +120,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
stack.EdgeGroups = groupsIds
if payload.UpdateVersion {
if err := handler.updateStackVersion(tx, stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
return nil, httperror.InternalServerError("Unable to update stack version", err)
}
}
@@ -25,8 +25,9 @@ func TestUpdateAndInspect(t *testing.T) {
endpointID := portainer.EndpointID(6)
newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID)
err := handler.DataStore.Endpoint().Create(&newEndpoint)
require.NoError(t, err)
if err := handler.DataStore.Endpoint().Create(&newEndpoint); err != nil {
t.Fatal(err)
}
endpointRelation := portainer.EndpointRelation{
EndpointID: endpointID,
@@ -35,8 +36,9 @@ func TestUpdateAndInspect(t *testing.T) {
},
}
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
require.NoError(t, err)
if err := handler.DataStore.EndpointRelation().Create(&endpointRelation); err != nil {
t.Fatal(err)
}
newEdgeGroup := portainer.EdgeGroup{
ID: 2,
@@ -47,8 +49,9 @@ func TestUpdateAndInspect(t *testing.T) {
PartialMatch: false,
}
err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
require.NoError(t, err)
if err := handler.DataStore.EdgeGroup().Create(&newEdgeGroup); err != nil {
t.Fatal(err)
}
payload := updateEdgeStackPayload{
StackFileContent: "update-test",
@@ -58,11 +61,15 @@ func TestUpdateAndInspect(t *testing.T) {
}
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
@@ -74,7 +81,9 @@ func TestUpdateAndInspect(t *testing.T) {
// Get updated edge stack
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
@@ -85,8 +94,9 @@ func TestUpdateAndInspect(t *testing.T) {
}
updatedStack := portainer.EdgeStack{}
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
require.NoError(t, err)
if err := json.NewDecoder(rec.Body).Decode(&updatedStack); err != nil {
t.Fatal("error decoding response:", err)
}
if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
@@ -216,11 +226,15 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
jsonPayload, err := json.Marshal(tc.Payload)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
r := bytes.NewBuffer(jsonPayload)
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
require.NoError(t, err)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
+3 -1
View File
@@ -22,15 +22,17 @@ type Handler struct {
GitService portainer.GitService
edgeStacksService *edgestackservice.Service
KubernetesDeployer portainer.KubernetesDeployer
stackCoordinator *EdgeStackStatusUpdateCoordinator
}
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
DataStore: dataStore,
edgeStacksService: edgeStacksService,
stackCoordinator: stackCoordinator,
}
h.Handle("/edge_stacks/create/{method}",
@@ -5,18 +5,15 @@ import (
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
edgestackutils "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/rs/zerolog/log"
)
func (handler *Handler) updateStackVersion(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
stack.Version++
if err := tx.EdgeStackStatus().Clear(stack.ID, relatedEnvironmentsIDs); err != nil {
return err
}
func (handler *Handler) updateStackVersion(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
stack.Version = stack.Version + 1
stack.Status = edgestackutils.NewStatus(stack.Status, relatedEnvironmentsIDs)
return handler.storeStackFile(stack, deploymentType, config)
}
@@ -287,8 +287,11 @@ func TestEdgeStackStatus(t *testing.T) {
edgeStackID := portainer.EdgeStackID(17)
edgeStack := portainer.EdgeStack{
ID: edgeStackID,
Name: "test-edge-stack-17",
ID: edgeStackID,
Name: "test-edge-stack-17",
Status: map[portainer.EndpointID]portainer.EdgeStackStatus{
endpointID: {},
},
CreationDate: time.Now().Unix(),
EdgeGroups: []portainer.EdgeGroupID{1, 2},
ProjectPath: "/project/path",
@@ -214,9 +214,14 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
}
for _, edgeStack := range edgeStacks {
if err := tx.EdgeStackStatus().Delete(edgeStack.ID, endpoint.ID); err != nil {
log.Warn().Err(err).Msg("Unable to delete edge stack status")
for idx := range edgeStacks {
edgeStack := &edgeStacks[idx]
if _, ok := edgeStack.Status[endpoint.ID]; ok {
delete(edgeStack.Status, endpoint.ID)
if err := tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack); err != nil {
log.Warn().Err(err).Msg("Unable to update edge stack")
}
}
}
+7 -10
View File
@@ -247,17 +247,19 @@ func (handler *Handler) filterEndpointsByQuery(
return filteredEndpoints, totalAvailableEndpoints, nil
}
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID]portainer.EdgeStackStatus, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
status, ok := edgeStackStatus[envId]
// consider that if the env has no status in the stack it is in Pending state
if statusFilter == portainer.EdgeStackStatusPending {
return stackStatus == nil || len(stackStatus.Status) == 0
return !ok || len(status.Status) == 0
}
if stackStatus == nil {
if !ok {
return false
}
return slices.ContainsFunc(stackStatus.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == statusFilter
})
}
@@ -289,12 +291,7 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
if statusFilter != nil {
n := 0
for _, envId := range envIds {
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) {
if endpointStatusInStackMatchesFilter(stack.Status, envId, *statusFilter) {
envIds[n] = envId
n++
}
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.31.0
// @version 2.30.1
// @description.markdown api-description.md
// @termsOfService
+1 -10
View File
@@ -7,7 +7,6 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors"
)
@@ -18,8 +17,6 @@ import (
// @description **Access policy**: authenticated
// @tags helm
// @param repo query string true "Helm repository URL"
// @param chart query string false "Helm chart name"
// @param useCache query string false "If true will use cache to search"
// @security ApiKeyAuth
// @security jwt
// @produce json
@@ -35,19 +32,13 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter"))
}
chart, _ := request.RetrieveQueryParameter(r, "chart", false)
// If true will useCache to search, will always add to cache after
useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false)
_, err := url.ParseRequestURI(repo)
if err != nil {
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
}
searchOpts := options.SearchRepoOptions{
Repo: repo,
Chart: chart,
UseCache: useCache,
Repo: repo,
}
result, err := handler.helmPackageManager.SearchRepo(searchOpts)
-7
View File
@@ -20,7 +20,6 @@ import (
// @tags helm
// @param repo query string true "Helm repository URL"
// @param chart query string true "Chart name"
// @param version query string true "Chart version"
// @param command path string true "chart/values/readme"
// @security ApiKeyAuth
// @security jwt
@@ -46,11 +45,6 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
}
version, err := request.RetrieveQueryParameter(r, "version", true)
if err != nil {
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version)))
}
cmd, err := request.RetrieveRouteVariableValue(r, "command")
if err != nil {
cmd = "all"
@@ -61,7 +55,6 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
OutputFormat: options.ShowOutputFormat(cmd),
Chart: chart,
Repo: repo,
Version: version,
}
result, err := handler.helmPackageManager.Show(showOptions)
if err != nil {
+2 -2
View File
@@ -30,8 +30,8 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.")
return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err)
}
pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin())
pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces())
pcli.IsKubeAdmin = cli.IsKubeAdmin
pcli.NonAdminNamespaces = cli.NonAdminNamespaces
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.GetIsKubeAdmin() {
if !cli.IsKubeAdmin {
log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil)
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr)
}
if !cli.GetIsKubeAdmin() {
if !cli.IsKubeAdmin {
log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil)
}
-102
View File
@@ -1,102 +0,0 @@
package kubernetes
import (
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
)
// @id getKubernetesEventsForNamespace
// @summary Gets kubernetes events for namespace
// @description Get events by optional query param resourceId for a given namespace.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param namespace path string true "The namespace name the events are associated to"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} models.Event[] "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} models.Event[] "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
View File
@@ -1,60 +0,0 @@
package kubernetes
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubeClient "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/stretchr/testify/assert"
)
// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order
// test the logic in event.go
func TestGetKubernetesEvents(t *testing.T) {
is := assert.New(t)
_, store := datastore.MustNewTestStore(t, true, true)
err := store.Endpoint().Create(&portainer.Endpoint{
ID: 1,
Type: portainer.AgentOnKubernetesEnvironment,
},
)
is.NoError(err, "error creating environment")
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
is.NoError(err, "error creating a user")
jwtService, err := jwt.NewService("1h", store)
is.NoError(err, "Error initiating jwt service")
tk, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole})
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
cli := testhelpers.NewKubernetesClient()
factory, _ := kubeClient.NewClientFactory(nil, nil, store, "", "", "")
authorizationService := authorization.NewService(store)
handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService,
factory, cli)
is.NotNil(handler, "Handler should not fail")
req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil)
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
req = req.WithContext(ctx)
testhelpers.AddTestSecurityCookie(req, tk)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
}
+2 -4
View File
@@ -58,7 +58,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
@@ -111,7 +110,6 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// to keep it simple, we've decided to leave it like this.
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
@@ -135,7 +133,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient
// from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using
// admin permissions. If you're unsure which one to use, use this.
func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) {
func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err)
@@ -255,7 +253,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
serverURL.Scheme = "https"
serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS()
serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS
config.Clusters[0].Cluster.Server = serverURL.String()
yaml, err := cli.GenerateYAML(config)
-25
View File
@@ -1,25 +0,0 @@
package kubernetes
import "time"
type K8sEvent struct {
Type string `json:"type"`
Name string `json:"name"`
Reason string `json:"reason"`
Message string `json:"message"`
Namespace string `json:"namespace"`
EventTime time.Time `json:"eventTime"`
Kind string `json:"kind,omitempty"`
Count int32 `json:"count"`
FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"`
LastTimestamp *time.Time `json:"lastTimestamp,omitempty"`
UID string `json:"uid"`
InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"`
}
type K8sEventInvolvedObject struct {
Kind string `json:"kind,omitempty"`
UID string `json:"uid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
}
+2 -2
View File
@@ -6,7 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
@@ -20,7 +20,7 @@ const (
)
func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{})
network, err := dockerClient.NetworkInspect(context.Background(), networkID, types.NetworkInspectOptions{})
if err != nil {
return nil, err
}
+3 -20
View File
@@ -7,21 +7,6 @@ import (
"strings"
)
// Note that we discard any non-canonical headers by design
var allowedHeaders = map[string]struct{}{
"Accept": {},
"Accept-Encoding": {},
"Accept-Language": {},
"Cache-Control": {},
"Content-Length": {},
"Content-Type": {},
"Private-Token": {},
"User-Agent": {},
"X-Portaineragent-Target": {},
"X-Portainer-Volumename": {},
"X-Registry-Auth": {},
}
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
@@ -30,6 +15,7 @@ func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP
}
func createDirector(target *url.URL) func(*http.Request) {
sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"}
targetQuery := target.RawQuery
return func(req *http.Request) {
req.URL.Scheme = target.Scheme
@@ -46,11 +32,8 @@ func createDirector(target *url.URL) func(*http.Request) {
req.Header.Set("User-Agent", "")
}
for k := range req.Header {
if _, ok := allowedHeaders[k]; !ok {
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
delete(req.Header, k)
}
for _, header := range sensitiveHeaders {
delete(req.Header, header)
}
}
}
+12 -86
View File
@@ -6,7 +6,6 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
portainer "github.com/portainer/portainer/api"
)
func Test_createDirector(t *testing.T) {
@@ -24,14 +23,12 @@ func Test_createDirector(t *testing.T) {
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
),
},
{
@@ -42,14 +39,12 @@ func Test_createDirector(t *testing.T) {
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
true,
),
},
{
@@ -60,83 +55,18 @@ func Test_createDirector(t *testing.T) {
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Authorization": "secret",
"Proxy-Authorization": "secret",
"Cookie": "secret",
"X-Csrf-Token": "secret",
"X-Api-Key": "secret",
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
},
{
name: "Non canonical Headers",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
portainer.PortainerAgentTargetHeader: "test-agent-1",
"X-Portainer-VolumeName": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
false,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Registry-Auth": "test-registry-auth",
"Accept": "application/json",
"User-Agent": "something",
"Cookie": "junk",
"X-Csrf-Token": "junk",
},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
),
},
}
@@ -162,17 +92,13 @@ func createURL(t *testing.T, urlString string) *url.URL {
return parsedURL
}
func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request {
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("Failed to create http request: %s", err)
} else {
for k, v := range headers {
if canonicalHeaders {
req.Header.Add(k, v)
} else {
req.Header[k] = []string{v}
}
req.Header.Add(k, v)
}
}
+4 -1
View File
@@ -161,7 +161,10 @@ func (server *Server) Start() error {
edgeJobsHandler.FileService = server.FileService
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore)
go edgeStackCoordinator.Start()
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator)
edgeStacksHandler.FileService = server.FileService
edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
+1 -12
View File
@@ -49,6 +49,7 @@ func (service *Service) BuildEdgeStack(
DeploymentType: deploymentType,
CreationDate: time.Now().Unix(),
EdgeGroups: edgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus, 0),
Version: 1,
UseManifestNamespaces: useManifestNamespaces,
}, nil
@@ -103,14 +104,6 @@ func (service *Service) PersistEdgeStack(
return nil, err
}
for _, endpointID := range relatedEndpointIds {
status := &portainer.EdgeStackStatusForEnv{EndpointID: endpointID}
if err := tx.EdgeStackStatus().Create(stack.ID, endpointID, status); err != nil {
return nil, err
}
}
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
}
@@ -165,9 +158,5 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
return errors.WithMessage(err, "Unable to remove the edge stack from the database")
}
if err := tx.EdgeStackStatus().DeleteAll(edgeStackID); err != nil {
return errors.WithMessage(err, "unable to remove edge stack statuses from the database")
}
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package edgestacks
import (
portainer "github.com/portainer/portainer/api"
)
// NewStatus returns a new status object for an Edge stack
func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, relatedEnvironmentIDs []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeStackStatus {
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
for _, environmentID := range relatedEnvironmentIDs {
newEnvStatus := portainer.EdgeStackStatus{
Status: []portainer.EdgeStackDeploymentStatus{},
EndpointID: environmentID,
}
oldEnvStatus, ok := oldStatus[environmentID]
if ok {
newEnvStatus.DeploymentInfo = oldEnvStatus.DeploymentInfo
}
status[environmentID] = newEnvStatus
}
return status
}
+2 -6
View File
@@ -16,7 +16,6 @@ type testDatastore struct {
edgeGroup dataservices.EdgeGroupService
edgeJob dataservices.EdgeJobService
edgeStack dataservices.EdgeStackService
edgeStackStatus dataservices.EdgeStackStatusService
endpoint dataservices.EndpointService
endpointGroup dataservices.EndpointGroupService
endpointRelation dataservices.EndpointRelationService
@@ -54,11 +53,8 @@ func (d *testDatastore) CustomTemplate() dataservices.CustomTemplateService { re
func (d *testDatastore) EdgeGroup() dataservices.EdgeGroupService { return d.edgeGroup }
func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { return d.edgeJob }
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
func (d *testDatastore) EdgeStackStatus() dataservices.EdgeStackStatusService {
return d.edgeStackStatus
}
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
return d.endpointRelation
-19
View File
@@ -1,19 +0,0 @@
package testhelpers
import (
portainer "github.com/portainer/portainer/api"
models "github.com/portainer/portainer/api/http/models/kubernetes"
)
type testKubeClient struct {
portainer.KubeClient
}
func NewKubernetesClient() portainer.KubeClient {
return &testKubeClient{}
}
// Event
func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
return nil, nil
}
-20
View File
@@ -143,23 +143,3 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri
return nonAdminNamespaces, nil
}
// GetIsKubeAdmin retrieves true if client is admin
func (client *KubeClient) GetIsKubeAdmin() bool {
return client.IsKubeAdmin
}
// UpdateIsKubeAdmin sets whether the kube client is admin
func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
client.IsKubeAdmin = isKubeAdmin
}
// GetClientNonAdminNamespaces retrieves non-admin namespaces
func (client *KubeClient) GetClientNonAdminNamespaces() []string {
return client.NonAdminNamespaces
}
// UpdateClientNonAdminNamespaces sets the client non admin namespace list
func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
client.NonAdminNamespaces = nonAdminNamespaces
}
-4
View File
@@ -82,10 +82,6 @@ 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
View File
@@ -1,93 +0,0 @@
package cli
import (
"context"
models "github.com/portainer/portainer/api/http/models/kubernetes"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetEvents gets all the Events for a given namespace and resource
// If the user is a kube admin, it returns all events in the namespace
// Otherwise, it returns only the events in the non-admin namespaces
func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
if kcl.IsKubeAdmin {
return kcl.fetchAllEvents(namespace, resourceId)
}
return kcl.fetchEventsForNonAdmin(namespace, resourceId)
}
// fetchEventsForNonAdmin returns all events in the given namespace and resource
// It returns only the events in the non-admin namespaces
func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) {
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
}
events, err := kcl.fetchAllEvents(namespace, resourceId)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sEvent, 0)
for _, event := range events {
if _, ok := nonAdminNamespaceSet[event.Namespace]; ok {
results = append(results, event)
}
}
return results, nil
}
// fetchEventsForNonAdmin returns all events in the given namespace and resource
// It returns all events in the namespace and resource
func (kcl *KubeClient) fetchAllEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
options := metav1.ListOptions{}
if resourceId != "" {
options.FieldSelector = "involvedObject.uid=" + resourceId
}
list, err := kcl.cli.CoreV1().Events(namespace).List(context.TODO(), options)
if err != nil {
return nil, err
}
results := make([]models.K8sEvent, 0)
for _, event := range list.Items {
results = append(results, parseEvent(&event))
}
return results, nil
}
func parseEvent(event *corev1.Event) models.K8sEvent {
result := models.K8sEvent{
Type: event.Type,
Name: event.Name,
Message: event.Message,
Reason: event.Reason,
Namespace: event.Namespace,
EventTime: event.EventTime.UTC(),
Kind: event.Kind,
Count: event.Count,
UID: string(event.ObjectMeta.GetUID()),
InvolvedObjectKind: models.K8sEventInvolvedObject{
Kind: event.InvolvedObject.Kind,
UID: string(event.InvolvedObject.UID),
Name: event.InvolvedObject.Name,
Namespace: event.InvolvedObject.Namespace,
},
}
if !event.LastTimestamp.Time.IsZero() {
result.LastTimestamp = &event.LastTimestamp.Time
}
if !event.FirstTimestamp.Time.IsZero() {
result.FirstTimestamp = &event.FirstTimestamp.Time
}
return result
}
-108
View File
@@ -1,108 +0,0 @@
package cli
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
// TestGetEvents tests the GetEvents method
// It creates a fake Kubernetes client and passes it to the GetEvents method
// It then logs the fetched events and validated the data returned
func TestGetEvents(t *testing.T) {
t.Run("can get events for resource id when admin", func(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: true,
}
event := corev1.Event{
InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
Action: "something",
ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "myEvent"},
EventTime: metav1.NowMicro(),
Type: "warning",
Message: "This event has a very serious warning",
}
_, err := kcl.cli.CoreV1().Events("default").Create(context.TODO(), &event, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create Event: %v", err)
}
events, err := kcl.GetEvents("default", "resourceId")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Events: %v", events)
require.Equal(t, 1, len(events), "Expected to return 1 event")
assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created")
assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created")
assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created")
})
t.Run("can get kubernetes events for non admin namespace when non admin", func(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
Action: "something",
ObjectMeta: metav1.ObjectMeta{Namespace: "nonAdmin", Name: "myEvent"},
EventTime: metav1.NowMicro(),
Type: "warning",
Message: "This event has a very serious warning",
}
_, err := kcl.cli.CoreV1().Events("nonAdmin").Create(context.TODO(), &event, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create Event: %v", err)
}
events, err := kcl.GetEvents("nonAdmin", "resourceId")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Events: %v", events)
require.Equal(t, 1, len(events), "Expected to return 1 event")
assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created")
assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created")
assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created")
})
t.Run("cannot get kubernetes events for admin namespace when non admin", func(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
Action: "something",
ObjectMeta: metav1.ObjectMeta{Namespace: "admin", Name: "myEvent"},
EventTime: metav1.NowMicro(),
Type: "warning",
Message: "This event has a very serious warning",
}
_, err := kcl.cli.CoreV1().Events("admin").Create(context.TODO(), &event, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create Event: %v", err)
}
events, err := kcl.GetEvents("admin", "resourceId")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Events: %v", events)
assert.Equal(t, 0, len(events), "Expected to return 0 events")
})
}
+40 -130
View File
@@ -4,19 +4,15 @@ 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"
@@ -246,7 +242,7 @@ type (
DockerSnapshotRaw struct {
Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"`
Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"`
Networks []network.Summary `json:"Networks" swaggerignore:"true"`
Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"`
Images []image.Summary `json:"Images" swaggerignore:"true"`
Info system.Info `json:"Info" swaggerignore:"true"`
Version types.Version `json:"Version" swaggerignore:"true"`
@@ -336,15 +332,6 @@ type (
UseManifestNamespaces bool
}
EdgeStackStatusForEnv struct {
EndpointID EndpointID
Status []EdgeStackDeploymentStatus
// EE only feature
DeploymentInfo StackDeploymentInfo
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"`
}
EdgeStackDeploymentType int
// EdgeStackID represents an edge stack id
@@ -1387,12 +1374,6 @@ type (
Kubernetes *KubernetesSnapshot `json:"Kubernetes"`
}
SnapshotRawMessage struct {
EndpointID EndpointID `json:"EndpointId"`
Docker json.RawMessage `json:"Docker"`
Kubernetes json.RawMessage `json:"Kubernetes"`
}
// CLIService represents a service for managing CLI
CLIService interface {
ParseFlags(version string) (*CLIFlags, error)
@@ -1543,127 +1524,56 @@ type (
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
KubeClient interface {
// Access
GetIsKubeAdmin() bool
SetIsKubeAdmin(isKubeAdmin bool)
GetClientNonAdminNamespaces() []string
SetClientNonAdminNamespaces([]string)
NamespaceAccessPoliciesDeleteNamespace(ns string) error
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error)
ServerVersion() (*version.Info, error)
// Applications
GetApplications(namespace, nodeName string) ([]models.K8sApplication, error)
GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error)
// ClusterRole
GetClusterRoles() ([]models.K8sClusterRole, error)
DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error
// ConfigMap
GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error)
CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error)
// CronJob
GetCronJobs(namespace string) ([]models.K8sCronJob, error)
DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error
// Event
GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error)
// Exec
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
IsRBACEnabled() (bool, error)
GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error)
GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error)
DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error
GetServiceAccountBearerToken(userID int) (string, error)
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
// ClusterRoleBinding
GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error)
DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error
// Dashboard
GetDashboard() (models.K8sDashboard, error)
// Deployment
HasStackName(namespace string, stackName string) (bool, error)
// Ingress
GetIngressControllers() (models.K8sIngressControllers, error)
GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error)
GetIngresses(namespace string) ([]models.K8sIngressInfo, error)
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
DeleteIngresses(reqs models.K8sIngressDeleteRequests) error
UpdateIngress(namespace string, info models.K8sIngressInfo) error
CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error)
CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error)
// Job
GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error)
DeleteJobs(payload models.K8sJobDeleteRequests) error
// Metrics
GetMetrics() (models.K8sMetrics, error)
// Namespace
ToggleSystemState(namespaceName string, isSystem bool) error
UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
GetNamespace(name string) (K8sNamespaceInfo, error)
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
GetNamespaces() (map[string]K8sNamespaceInfo, error)
CombineNamespaceWithResourceQuota(namespace K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError
DeleteNamespace(namespaceName string) (*corev1.Namespace, error)
CombineNamespacesWithResourceQuotas(namespaces map[string]K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError
ConvertNamespaceMapToSlice(namespaces map[string]K8sNamespaceInfo) []K8sNamespaceInfo
// NodeLimits
GetNamespace(string) (K8sNamespaceInfo, error)
DeleteNamespace(namespace string) (*corev1.Namespace, error)
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetIngressControllers() (models.K8sIngressControllers, error)
GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
GetMetrics() (models.K8sMetrics, error)
GetStorage() ([]KubernetesStorageClassConfig, error)
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
UpdateIngress(namespace string, info models.K8sIngressInfo) error
GetIngresses(namespace string) ([]models.K8sIngressInfo, error)
DeleteIngresses(reqs models.K8sIngressDeleteRequests) error
CreateService(namespace string, service models.K8sServiceInfo) error
UpdateService(namespace string, service models.K8sServiceInfo) error
GetServices(namespace string) ([]models.K8sServiceInfo, error)
DeleteServices(reqs models.K8sServiceDeleteRequests) error
GetNodesLimits() (K8sNodesLimits, error)
GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
// Pod
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
// RBAC
IsRBACEnabled() (bool, error)
// Registries
GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
DeleteRegistrySecret(registry RegistryID, namespace string) error
CreateRegistrySecret(registry *Registry, namespace string) error
IsRegistrySecret(namespace, secretName string) (bool, error)
ToggleSystemState(namespace string, isSystem bool) error
// RoleBinding
GetClusterRoles() ([]models.K8sClusterRole, error)
DeleteClusterRoles(models.K8sClusterRoleDeleteRequests) error
GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error)
DeleteClusterRoleBindings(models.K8sClusterRoleBindingDeleteRequests) error
GetRoles(namespace string) ([]models.K8sRole, error)
DeleteRoles(models.K8sRoleDeleteRequests) error
GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error)
DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error
// Role
DeleteRoles(reqs models.K8sRoleDeleteRequests) error
// Secret
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetSecret(namespace string, secretName string) (models.K8sSecret, error)
CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error)
// ServiceAccount
GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error)
DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error
SetupUserServiceAccount(int, []int, bool) error
GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error)
GetServiceAccountBearerToken(userID int) (string, error)
// Service
GetServices(namespace string) ([]models.K8sServiceInfo, error)
CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error)
CreateService(namespace string, info models.K8sServiceInfo) error
DeleteServices(reqs models.K8sServiceDeleteRequests) error
UpdateService(namespace string, info models.K8sServiceInfo) error
// ServerVersion
ServerVersion() (*version.Info, error)
// Storage
GetStorage() ([]KubernetesStorageClassConfig, error)
// Volumes
GetVolumes(namespace string) ([]models.K8sVolumeInfo, error)
GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error)
CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error)
DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) error
}
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)
@@ -1728,7 +1638,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.31.0"
APIVersion = "2.30.1"
// 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
+2 -13
View File
@@ -24,10 +24,6 @@ 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;
@@ -75,9 +71,6 @@ 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);
@@ -90,8 +83,8 @@ fieldset[disabled] .btn {
/* secondary-grey */
.btn.btn-default,
.btn.btn-light {
@apply border-gray-5 bg-white text-gray-7;
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-9;
@apply border-gray-5 bg-white text-gray-9;
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10;
/* dark mode */
@apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4;
@@ -145,10 +138,6 @@ 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 && o.Capabilities.length > 0 && o.Capabilities[0] > 0 && o.Capabilities[0][0] === 'gpu');
return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu';
});
if (!gpuOptions) {
return 'No GPU config found';
@@ -58,7 +58,7 @@
<resource-events-datatable
resource-id="ctrl.configuration.Id"
storage-key="'kubernetes.configmap.events'"
namespace="ctrl.configuration.Namespace"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></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.configuration.Namespace"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></resource-events-datatable>
</uib-tab>
<uib-tab index="2" ng-if="ctrl.configuration.Yaml" classes="btn-sm" select="ctrl.showEditor()" data-cy="k8sConfigDetail-yamlTab">
+5 -5
View File
@@ -31,7 +31,7 @@
<portainer-tooltip message="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'">
</portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10 vertical-center pt-1">
<div class="col-sm-8 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-9 col-lg-10">
<div class="col-sm-8">
<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-9 col-lg-10 small text-muted pt-[7px]" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<div class="col-sm-8 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-9 col-lg-10" ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<div class="col-sm-8" 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-9 col-lg-10">
<div class="col-sm-8">
<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-9 col-lg-10 flex flex-col gap-y-1">
<div class="col-sm-8 col-sm-8 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">
-1
View File
@@ -235,7 +235,6 @@ export const ngModule = angular
'schema',
'fileName',
'placeholder',
'showToolbar',
])
)
.component(
@@ -141,11 +141,9 @@
}
.root :global(.cm-content[aria-readonly='true']) {
/* make sure the bg has transparency, so that the selected text is visible */
/* https://discuss.codemirror.net/t/how-do-i-get-selected-text-to-highlight/7115/2 */
@apply bg-gray-3/50;
@apply th-dark:bg-gray-iron-10/50;
@apply th-highcontrast:bg-black/50;
@apply bg-gray-3;
@apply th-dark:bg-gray-iron-10;
@apply th-highcontrast:bg-black;
}
.root :global(.cm-textfield) {
+28 -32
View File
@@ -33,7 +33,6 @@ interface Props extends AutomationTestingProps {
schema?: JSONSchema7;
fileName?: string;
placeholder?: string;
showToolbar?: boolean;
}
export const theme = createTheme({
@@ -76,7 +75,6 @@ export function CodeEditor({
'data-cy': dataCy,
fileName,
placeholder,
showToolbar = true,
}: Props) {
const [isRollback, setIsRollback] = useState(false);
@@ -96,40 +94,38 @@ export function CodeEditor({
return (
<>
{showToolbar && (
<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 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>
{versions && (
<div className="mt-2 flex">
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
onChange={handleVersionChange}
/>
</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>
)}
{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>
@@ -1,52 +0,0 @@
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
View File
@@ -1,32 +0,0 @@
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>
);
}
+54 -6
View File
@@ -8,14 +8,55 @@ import {
import { useTransitionHook } from '@uirouter/react';
import { JSONSchema7 } from 'json-schema';
import { BROWSER_OS_PLATFORM } from '@/react/constants';
import { CodeEditor } from '@@/CodeEditor';
import { Tooltip } from '@@/Tip/Tooltip';
import { FormSectionTitle } from './form-components/FormSectionTitle';
import { FormError } from './form-components/FormError';
import { confirm } from './modals/confirm';
import { ModalType } from './modals';
import { buildConfirmButton } from './modals/utils';
import { ShortcutsTooltip } from './CodeEditor/ShortcutsTooltip';
const otherEditorConfig = {
tooltip: (
<>
<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;
type CodeEditorProps = ComponentProps<typeof CodeEditor>;
@@ -28,7 +69,7 @@ interface Props extends CodeEditorProps {
export function WebEditorForm({
id,
titleContent = 'Web editor',
titleContent = '',
hideTitle,
children,
error,
@@ -40,7 +81,10 @@ export function WebEditorForm({
<div>
<div className="web-editor overflow-x-hidden">
{!hideTitle && (
<DefaultTitle id={id}>{titleContent ?? null}</DefaultTitle>
<>
<DefaultTitle id={id} />
{titleContent ?? null}
</>
)}
{children && (
<div className="form-group text-muted small">
@@ -67,11 +111,15 @@ export function WebEditorForm({
);
}
function DefaultTitle({ id, children }: { id: string; children?: ReactNode }) {
function DefaultTitle({ id }: { id: string }) {
return (
<FormSectionTitle htmlFor={id}>
{children}
<ShortcutsTooltip />
Web editor
<div className="text-muted small vertical-center ml-auto">
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
</div>
</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,7 +42,6 @@ export function NestedDatatable<D extends DefaultType>({
initialTableState = {},
isLoading,
initialSortBy,
enablePagination = true,
search,
'data-cy': dataCy,
'aria-label': ariaLabel,
@@ -66,7 +65,7 @@ export function NestedDatatable<D extends DefaultType>({
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),
getPaginationRowModel: getPaginationRowModel(),
});
return (
+1 -2
View File
@@ -21,7 +21,7 @@ interface Props {
onDismiss?(): void;
'aria-label'?: string;
'aria-labelledby'?: string;
size?: 'md' | 'lg' | 'xl';
size?: 'md' | 'lg';
className?: string;
}
@@ -53,7 +53,6 @@ export function Modal({
{
'w-[450px]': size === 'md',
'w-[700px]': size === 'lg',
'w-[1000px]': size === 'xl',
}
)}
>
@@ -65,7 +65,7 @@ function getStatus(
};
}
if (!envStatus.length) {
if (envStatus.length < numDeployments) {
return {
label: 'Deploying',
icon: Loader2,
@@ -84,15 +84,6 @@ 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-9 col-lg-10'}>
<div className={inputClassName || 'col-sm-8'}>
<AutocompleteSelect
searchResults={stackResults?.map((result) => ({
value: result,
@@ -1,43 +0,0 @@
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,7 +17,6 @@ export function InnerTable({
dataset={dataset}
columns={columns}
data-cy="applications-nested-datatable"
enablePagination={false}
/>
);
}
@@ -1,4 +1,4 @@
import { CellContext, Row } from '@tanstack/react-table';
import { CellContext } from '@tanstack/react-table';
import clsx from 'clsx';
import {
@@ -6,22 +6,14 @@ 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(getStatusSummary, {
export const status = helper.accessor('Status', {
header: 'Status',
cell: Cell,
meta: {
filter: filterHOC('Filter by status'),
},
enableColumnFilter: true,
filterFn: (row: Row<ApplicationRowData>, _: string, filterValue: string[]) =>
filterValue.length === 0 ||
filterValue.includes(getStatusSummary(row.original)),
enableSorting: false,
});
function Cell({
@@ -75,17 +67,3 @@ 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,11 +1,10 @@
import { CellContext, Row } from '@tanstack/react-table';
import { CellContext } 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';
@@ -50,15 +49,7 @@ export const image = helper.accessor('Image', {
});
export const appType = helper.accessor('ApplicationType', {
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)),
header: 'Application Type',
});
export const published = helper.accessor('Services', {
@@ -1,83 +0,0 @@
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.uid || ''}
getRowId={(row) => row.metadata?.uid || ''}
disableSelect
renderTableSettings={() => (
<TableSettingsMenu>
@@ -29,7 +29,9 @@ export function ResourceEventsDatatable({
params: { endpointId },
} = useCurrentStateAndParams();
const params = resourceId ? { resourceId: `${resourceId}` } : {};
const params = resourceId
? { fieldSelector: `involvedObject.uid=${resourceId}` }
: {};
const resourceEventsQuery = useEvents(endpointId, {
namespace,
params,
@@ -1,6 +1,5 @@
import { Row } from '@tanstack/react-table';
import { Event } from '@/react/kubernetes/queries/types';
import { Event } from 'kubernetes-types/core/v1';
import { Badge, BadgeType } from '@@/Badge';
import { filterHOC } from '@@/datatables/Filter';
@@ -1,5 +1,4 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Event } from '@/react/kubernetes/queries/types';
import { Event } from 'kubernetes-types/core/v1';
export const columnHelper = createColumnHelper<Event>();
@@ -1,6 +1,5 @@
import { Row } from '@tanstack/react-table';
import { Event } from '@/react/kubernetes/queries/types';
import { Event } from 'kubernetes-types/core/v1';
import { filterHOC } from '@@/datatables/Filter';
@@ -4,12 +4,11 @@ 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/useHelmRepoVersions';
} from '../queries/useHelmRepositories';
import { HelmRelease } from '../../types';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
@@ -25,30 +24,20 @@ vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
}));
vi.mock('../../queries/useHelmRegistries', () => ({
useHelmRegistries: vi.fn(() => ({
data: ['repo1', 'repo2'],
// 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,
})),
}));
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,
useHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'],
isInitialLoading: false,
isError: false,
})),
}));
@@ -74,27 +63,11 @@ function renderButton(props = {}) {
...props,
};
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(UpgradeButton))
);
const Wrapped = withTestQueryProvider(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();
@@ -108,8 +81,6 @@ describe('UpgradeButton', () => {
data,
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -123,8 +94,6 @@ describe('UpgradeButton', () => {
data: [],
isInitialLoading: true,
isError: false,
isFetching: true,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -140,8 +109,6 @@ describe('UpgradeButton', () => {
data,
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -172,8 +139,6 @@ describe('UpgradeButton', () => {
],
isInitialLoading: false,
isError: false,
isFetching: false,
refetch: vi.fn(() => Promise.resolve([])),
});
renderButton({ release: mockRelease });
@@ -1,21 +1,26 @@
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 { Button, LoadingButton } from '@@/buttons';
import { LoadingButton } from '@@/buttons';
import { InlineLoader } from '@@/InlineLoader';
import { Tooltip } from '@@/Tip/Tooltip';
import { Link } from '@@/Link';
import { HelmRelease, UpdateHelmReleasePayload } from '../../types';
import { useUpdateHelmReleaseMutation } from '../../queries/useUpdateHelmReleaseMutation';
import { useHelmRepoVersions } from '../../queries/useHelmRepoVersions';
import { HelmRelease } from '../../types';
import {
useUpdateHelmReleaseMutation,
UpdateHelmReleasePayload,
} from '../queries/useUpdateHelmReleaseMutation';
import {
ChartVersion,
useHelmRepoVersions,
useHelmRepositories,
} from '../queries/useHelmRepositories';
import { useHelmRelease } from '../queries/useHelmRelease';
import { useHelmRegistries } from '../../queries/useHelmRegistries';
import { openUpgradeHelmModal } from './UpgradeHelmModal';
@@ -33,45 +38,36 @@ export function UpgradeButton({
updateRelease: (release: HelmRelease) => void;
}) {
const router = useRouter();
const [useCache, setUseCache] = useState(true);
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const registriesQuery = useHelmRegistries();
const repositoriesQuery = useHelmRepositories();
const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour
registriesQuery.data,
useCache
repositoriesQuery.data
);
const versions = helmRepoVersionsQuery.data;
const repo = versions?.[0]?.Repo;
// Combined loading state
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 isInitialLoading =
repositoriesQuery.isInitialLoading ||
helmRepoVersionsQuery.isInitialLoading;
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
select: (data) => data.chart.metadata?.version,
});
const latestVersionAvailable = versions[0]?.Version ?? '';
const isNewVersionAvailable = Boolean(
latestVersionQuery?.data &&
semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1
);
const currentVersion = release?.chart.metadata?.version;
const isNewVersionAvailable =
latestVersion?.data &&
semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName,
namespace: namespace || '',
values: release?.values?.userSuppliedValues,
chart: release?.chart.metadata?.name || '',
version: currentVersion,
repo,
version: release?.chart.metadata?.version,
};
return (
@@ -79,10 +75,10 @@ export function UpgradeButton({
<LoadingButton
color="secondary"
data-cy="k8sApp-upgradeHelmChartButton"
onClick={handleUpgrade}
onClick={() => openUpgradeForm(versions, release)}
disabled={
versions.length === 0 ||
isLoading ||
isInitialLoading ||
isError ||
release?.info?.status?.startsWith('pending')
}
@@ -93,7 +89,7 @@ export function UpgradeButton({
>
Upgrade
</LoadingButton>
{isLoading && (
{versions.length === 0 && isInitialLoading && (
<InlineLoader
size="xs"
className="absolute -bottom-5 left-0 right-0 whitespace-nowrap"
@@ -101,100 +97,71 @@ export function UpgradeButton({
Checking for new versions...
</InlineLoader>
)}
{!isLoading && !isError && (
{versions.length === 0 && !isInitialLoading && !isError && (
<span className="absolute flex items-center -bottom-5 left-0 right-0 text-xs text-muted text-center whitespace-nowrap">
{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>
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})
</span>
)}
</div>
);
function handleRefreshVersions() {
if (useCache) {
// clicking 'refresh versions' should get the latest versions from the repo, not the cached versions
setUseCache(false);
async function openUpgradeForm(
versions: ChartVersion[],
release?: HelmRelease
) {
const result = await openUpgradeHelmModal(editableHelmRelease, versions);
if (result) {
handleUpgrade(result, release);
}
helmRepoVersionsQuery.refetch();
}
async function handleUpgrade() {
const submittedUpgradeValues = await openUpgradeHelmModal(
editableHelmRelease,
versions
);
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,
});
function handleUpgrade(
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,
});
},
});
}
}
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,35 +3,26 @@ 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 '../../types';
import { HelmValuesInput } from '../../components/HelmValuesInput';
import { useHelmChartValues } from '../../queries/useHelmChartValues';
import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
import { ChartVersion } from '../queries/useHelmRepositories';
interface Props {
onSubmit: OnSubmit<UpdateHelmReleasePayload>;
values: UpdateHelmReleasePayload;
versions: ChartVersion[];
chartName: string;
repo: string;
}
export function UpgradeHelmModal({
values,
versions,
onSubmit,
chartName,
repo,
}: Props) {
export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
const isCurrentVersion = version.Version === values.version;
const label = `${version.Repo}@${version.Version}${
@@ -47,18 +38,11 @@ export function UpgradeHelmModal({
versionOptions[0]?.value;
const [version, setVersion] = useState<ChartVersion>(defaultVersion);
const [userValues, setUserValues] = useState<string>(values.values || '');
const [atomic, setAtomic] = useState<boolean>(true);
const chartValuesRefQuery = useHelmChartValues({
chart: chartName,
repo,
version: version.Version,
});
const [atomic, setAtomic] = useState<boolean>(false);
return (
<Modal
onDismiss={() => onSubmit()}
size="xl"
size="lg"
className="flex flex-col h-[80vh] px-0"
aria-label="upgrade-helm"
>
@@ -67,65 +51,73 @@ export function UpgradeHelmModal({
/>
<div className="flex-1 overflow-y-auto px-5">
<Modal.Body>
<div className="form-horizontal">
<FormControl
label="Release name"
inputId="release-name-input"
size="medium"
>
<Input
id="release-name-input"
value={values.name}
readOnly
disabled
data-cy="helm-release-name-input"
/>
</FormControl>
<FormControl
label="Namespace"
inputId="namespace-input"
size="medium"
>
<Input
id="namespace-input"
value={values.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 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>
</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>
</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">
@@ -171,7 +163,5 @@ export async function openUpgradeHelmModal(
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
values,
versions,
chartName: values.chart,
repo: values.repo ?? '',
});
}
@@ -184,8 +184,15 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
http.get(
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
)
);
@@ -229,8 +236,15 @@ describe(
HttpResponse.error()
),
// Add mock for events endpoint
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
http.get(
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
)
);
@@ -260,8 +274,15 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
http.get('/api/kubernetes/3/namespaces/default/events', () =>
HttpResponse.json([])
http.get(
'/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
() =>
HttpResponse.json({
kind: 'EventList',
apiVersion: 'v1',
metadata: { resourceVersion: '12345' },
items: [],
})
)
);
@@ -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,84 +56,136 @@ const testResources: GenericResource[] = [
},
];
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',
const mockEventsResponse: EventList = {
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
},
{
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',
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: '',
},
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',
},
];
{
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: '',
},
],
};
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',
const mixedEventsResponse: EventList = {
kind: 'EventList',
apiVersion: 'v1',
metadata: {
resourceVersion: '12345',
},
{
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
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: '',
},
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,
},
];
{
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: '',
},
],
};
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
@@ -177,7 +229,7 @@ describe('HelmEventsDatatable', () => {
it('should correctly filter related events using the filterRelatedEvents function', () => {
const filteredEvents = filterRelatedEvents(
mixedEventsResponse as Event[],
mixedEventsResponse.items 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';
@@ -1,9 +1,12 @@
import { useQueries } from '@tanstack/react-query';
import { useQuery, useQueries } from '@tanstack/react-query';
import { useMemo } from 'react';
import { compact, flatMap } from 'lodash';
import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { useCurrentUser } from '@/react/hooks/useUser';
import { getHelmRepositories } from '../../queries/useHelmChartList';
interface HelmSearch {
entries: Entries;
@@ -18,32 +21,44 @@ export interface ChartVersion {
Version: string;
}
/**
* Hook to fetch all Helm repositories for the current user
*/
export function useHelmRepositories() {
const { user } = useCurrentUser();
return useQuery(
['helm', 'repositories'],
async () => getHelmRepositories(user.Id),
{
enabled: !!user.Id,
...withGlobalError('Unable to retrieve helm repositories'),
}
);
}
/**
* React hook to get a list of available versions for a chart from specified repositories
*
* @param chart The chart name to get versions for
* @param repositories Array of repository URLs to search in
* @param staleTime Stale time for the query
* @param useCache Whether to use the cache for the query
*/
export function useHelmRepoVersions(
chart: string,
staleTime: number,
repositories: string[] = [],
useCache: boolean = true
repositories: string[] = []
) {
// Fetch versions from each repository in parallel as separate queries
const versionQueries = useQueries({
queries: useMemo(
() =>
repositories.map((repo) => ({
queryKey: ['helm', 'repositories', chart, repo, useCache],
queryFn: () => getSearchHelmRepo(repo, chart, useCache),
queryKey: ['helm', 'repositories', chart, repo],
queryFn: () => getSearchHelmRepo(repo, chart),
enabled: !!chart && repositories.length > 0,
staleTime,
...withGlobalError(`Unable to retrieve versions from ${repo}`),
})),
[repositories, chart, staleTime, useCache]
[repositories, chart, staleTime]
),
});
@@ -57,8 +72,6 @@ export function useHelmRepoVersions(
data: allVersions,
isInitialLoading: versionQueries.some((q) => q.isLoading),
isError: versionQueries.some((q) => q.isError),
isFetching: versionQueries.some((q) => q.isFetching),
refetch: () => Promise.all(versionQueries.map((q) => q.refetch())),
};
}
@@ -67,12 +80,11 @@ export function useHelmRepoVersions(
*/
async function getSearchHelmRepo(
repo: string,
chart: string,
useCache: boolean = true
chart: string
): Promise<ChartVersion[]> {
try {
const { data } = await axios.get<HelmSearch>(`templates/helm`, {
params: { repo, chart, useCache },
params: { repo, chart },
});
const versions = data.entries[chart];
return (
@@ -5,8 +5,17 @@ import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { HelmRelease, UpdateHelmReleasePayload } from '../types';
import { HelmRelease } from '../../types';
export interface UpdateHelmReleasePayload {
namespace: string;
values?: string;
repo?: string;
name: string;
chart: string;
version?: string;
atomic?: boolean;
}
export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation({
@@ -1,176 +0,0 @@
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');
});
});
@@ -1,94 +0,0 @@
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');
},
}
);
}
}
@@ -1,82 +0,0 @@
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>
);
}
@@ -1,15 +1,15 @@
import { useState } from 'react';
import { compact } from 'lodash';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Chart } from '../types';
import { useHelmChartList } from '../queries/useHelmChartList';
import { useHelmRegistries } from '../queries/useHelmRegistries';
import {
useHelmChartList,
useHelmRepositories,
} from '../queries/useHelmChartList';
import { HelmTemplatesList } from './HelmTemplatesList';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
import { HelmInstallForm } from './HelmInstallForm';
interface Props {
onSelectHelmChart: (chartName: string) => void;
@@ -19,11 +19,10 @@ interface Props {
export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
const [selectedChart, setSelectedChart] = useState<Chart | null>(null);
const [selectedRegistry, setSelectedRegistry] = useState<string | null>(null);
const { user } = useCurrentUser();
const helmReposQuery = useHelmRegistries();
const chartListQuery = useHelmChartList(user.Id, compact([selectedRegistry]));
const helmReposQuery = useHelmRepositories(user.Id);
const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []);
function clearHelmChart() {
setSelectedChart(null);
onSelectHelmChart('');
@@ -38,25 +37,17 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
<div className="row">
<div className="col-sm-12 p-0">
{selectedChart ? (
<>
<HelmTemplatesSelectedItem
selectedChart={selectedChart}
clearHelmChart={clearHelmChart}
/>
<HelmInstallForm
selectedChart={selectedChart}
namespace={namespace}
name={name}
/>
</>
<HelmTemplatesSelectedItem
selectedChart={selectedChart}
clearHelmChart={clearHelmChart}
namespace={namespace}
name={name}
/>
) : (
<HelmTemplatesList
charts={chartListQuery.data}
selectAction={handleChartSelection}
isLoading={chartListQuery.isInitialLoading}
registries={helmReposQuery.data ?? []}
selectedRegistry={selectedRegistry}
setSelectedRegistry={setSelectedRegistry}
/>
)}
</div>
@@ -19,8 +19,6 @@ const mockCharts: Chart[] = [
annotations: {
category: 'database',
},
version: '1.0.0',
versions: ['1.0.0', '1.0.1'],
},
{
name: 'test-chart-2',
@@ -29,18 +27,14 @@ const mockCharts: Chart[] = [
annotations: {
category: 'database',
},
version: '1.0.0',
versions: ['1.0.0', '1.0.1'],
},
{
name: 'nginx-chart',
description: 'Nginx Web Server',
repo: 'https://example.com/2',
repo: 'https://example.com',
annotations: {
category: 'web',
},
version: '1.0.0',
versions: ['1.0.0', '1.0.1'],
},
];
@@ -50,11 +44,8 @@ function renderComponent({
loading = false,
charts = mockCharts,
selectAction = selectActionMock,
selectedRegistry = '',
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const registries = ['https://example.com', 'https://example.com/2'];
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
@@ -62,9 +53,6 @@ function renderComponent({
isLoading={loading}
charts={charts}
selectAction={selectAction}
registries={registries}
selectedRegistry={selectedRegistry}
setSelectedRegistry={() => {}}
/>
)),
user
@@ -89,7 +77,6 @@ describe('HelmTemplatesList', () => {
expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
expect(screen.getByText('https://example.com/2')).toBeInTheDocument();
});
it('should call selectAction when a chart is clicked', async () => {
@@ -159,24 +146,11 @@ describe('HelmTemplatesList', () => {
).toBeInTheDocument();
});
it('should show empty message when no charts are available and a registry is selected', async () => {
renderComponent({ charts: [], selectedRegistry: 'https://example.com' });
// Check for empty message
expect(
screen.getByText('No helm charts available in this registry.')
).toBeInTheDocument();
});
it("should show 'select registry' message when no charts are available and no registry is selected", async () => {
it('should show empty message when no charts are available', async () => {
renderComponent({ charts: [] });
// Check for message
expect(
screen.getByText(
'Please select a registry to view available Helm charts.'
)
).toBeInTheDocument();
// Check for empty message
expect(screen.getByText('No helm charts available.')).toBeInTheDocument();
});
it('should show no results message when search has no matches', async () => {
@@ -1,10 +1,6 @@
import { useState, useMemo } from 'react';
import { components, OptionProps } from 'react-select';
import {
PortainerSelect,
Option,
} from '@/react/components/form-components/PortainerSelect';
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
@@ -19,31 +15,70 @@ interface Props {
isLoading: boolean;
charts?: Chart[];
selectAction: (chart: Chart) => void;
registries: string[];
selectedRegistry: string | null;
setSelectedRegistry: (registry: string | null) => void;
}
/**
* Get categories from charts
* @param charts - The charts to get the categories from
* @returns Categories
*/
function getCategories(charts: Chart[]) {
const annotationCategories = charts
.map((chart) => chart.annotations?.category) // get category
.filter((c): c is string => !!c); // filter out nulls/undefined
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
// Create options array in the format expected by PortainerSelect
return availableCategories.map((cat) => ({
label: cat,
value: cat,
}));
}
/**
* Get filtered charts
* @param charts - The charts to get the filtered charts from
* @param textFilter - The text filter
* @param selectedCategory - The selected category
* @returns Filtered charts
*/
function getFilteredCharts(
charts: Chart[],
textFilter: string,
selectedCategory: string | null
) {
return charts.filter((chart) => {
// Text filter
if (
textFilter &&
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
) {
return false;
}
// Category filter
if (
selectedCategory &&
(!chart.annotations || chart.annotations.category !== selectedCategory)
) {
return false;
}
return true;
});
}
export function HelmTemplatesList({
isLoading,
charts = [],
selectAction,
registries,
selectedRegistry,
setSelectedRegistry,
}: Props) {
const [textFilter, setTextFilter] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = useMemo(() => getCategories(charts), [charts]);
const registryOptions = useMemo(
() =>
registries.map((registry) => ({
label: registry,
value: registry,
})),
[registries]
);
const filteredCharts = useMemo(
() => getFilteredCharts(charts, textFilter, selectedCategory),
@@ -52,7 +87,7 @@ export function HelmTemplatesList({
return (
<section className="datatable" aria-label="Helm charts">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0 !overflow-visible">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div className="toolBarTitle vertical-center">Helm chart</div>
<SearchBar
@@ -63,25 +98,12 @@ export function HelmTemplatesList({
className="!mr-0 h-9"
/>
<div className="w-full sm:w-1/4">
<PortainerSelect
placeholder="Select a registry"
value={selectedRegistry ?? ''}
options={registryOptions}
onChange={setSelectedRegistry}
isClearable
bindToBody
components={{ Option: RegistryOption }}
data-cy="helm-registry-select"
/>
</div>
<div className="w-full sm:w-1/4">
<div className="w-full sm:w-1/5">
<PortainerSelect
placeholder="Select a category"
value={selectedCategory}
options={categories}
onChange={setSelectedCategory}
onChange={(value) => setSelectedCategory(value)}
isClearable
bindToBody
data-cy="helm-category-select"
@@ -151,85 +173,12 @@ export function HelmTemplatesList({
</div>
)}
{!isLoading && charts.length === 0 && selectedRegistry && (
{!isLoading && charts.length === 0 && (
<div className="text-muted text-center">
No helm charts available in this registry.
</div>
)}
{!selectedRegistry && (
<div className="text-muted text-center">
Please select a registry to view available Helm charts.
No helm charts available.
</div>
)}
</div>
</section>
);
}
// truncate the registry text, because some registry names are urls, which are too long
function RegistryOption(props: OptionProps<Option<string>>) {
const { data: registry } = props;
return (
<div title={registry.value}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<components.Option {...props} className="whitespace-nowrap truncate">
{registry.value}
</components.Option>
</div>
);
}
/**
* Get categories from charts
* @param charts - The charts to get the categories from
* @returns Categories
*/
function getCategories(charts: Chart[]) {
const annotationCategories = charts
.map((chart) => chart.annotations?.category) // get category
.filter((c): c is string => !!c); // filter out nulls/undefined
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
// Create options array in the format expected by PortainerSelect
return availableCategories.map((cat) => ({
label: cat,
value: cat,
}));
}
/**
* Get filtered charts
* @param charts - The charts to get the filtered charts from
* @param textFilter - The text filter
* @param selectedCategory - The selected category
* @returns Filtered charts
*/
function getFilteredCharts(
charts: Chart[],
textFilter: string,
selectedCategory: string | null
) {
return charts.filter((chart) => {
// Text filter
if (
textFilter &&
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
) {
return false;
}
// Category filter
if (
selectedCategory &&
(!chart.annotations || chart.annotations.category !== selectedCategory)
) {
return false;
}
return true;
});
}
@@ -2,6 +2,8 @@ import React from 'react';
import { FallbackImage } from '@/react/components/FallbackImage';
import Svg from '@@/Svg';
import { Chart } from '../types';
import { HelmIcon } from './HelmIcon';
@@ -38,10 +40,15 @@ export function HelmTemplatesListItem(props: HelmTemplatesListItemProps) {
<div className="col-sm-12 flex flex-wrap justify-between gap-2">
<div className="blocklist-item-line">
<div>
<div className="blocklist-item-title">{model.name}</div>
<div className="small text-muted mt-1">{model.repo}</div>
</div>
<span>
<span className="blocklist-item-title">{model.name}</span>
<span className="space-left blocklist-item-subtitle">
<span className="vertical-center">
<Svg icon="helm" className="icon icon-primary" />
</span>
<span> Helm </span>
</span>
</span>
</div>
<span className="blocklist-item-actions">{actions}</span>
@@ -1,4 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MutationOptions } from '@tanstack/react-query';
import { vi } from 'vitest';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
@@ -10,6 +12,36 @@ import { Chart } from '../types';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
const mockMutate = vi.fn();
const mockNotifySuccess = vi.fn();
// Mock dependencies
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: (title: string, text: string) =>
mockNotifySuccess(title, text),
}));
vi.mock('./queries/useHelmChartValues', () => ({
useHelmChartValues: vi.fn().mockReturnValue({
data: { values: 'test-values' },
isLoading: false,
}),
}));
vi.mock('./queries/useHelmChartInstall', () => ({
useHelmChartInstall: vi.fn().mockReturnValue({
mutate: (params: Record<string, string>, options?: MutationOptions) =>
mockMutate(params, options),
isLoading: false,
}),
}));
vi.mock('@/react/hooks/useAnalytics', () => ({
useAnalytics: vi.fn().mockReturnValue({
trackEvent: vi.fn(),
}),
}));
// Sample test data
const mockChart: Chart = {
name: 'test-chart',
@@ -19,8 +51,6 @@ const mockChart: Chart = {
annotations: {
category: 'database',
},
version: '1.0.1',
versions: ['1.0.0', '1.0.1'],
};
const clearHelmChartMock = vi.fn();
@@ -28,15 +58,22 @@ const mockRouterStateService = {
go: vi.fn(),
};
function renderComponent({ selectedChart = mockChart, isAdmin = true } = {}) {
const user = new UserViewModel({ Username: 'user', Role: isAdmin ? 1 : 2 });
function renderComponent({
selectedChart = mockChart,
clearHelmChart = clearHelmChartMock,
namespace = 'test-namespace',
name = 'test-name',
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<HelmTemplatesSelectedItem
selectedChart={selectedChart}
clearHelmChart={clearHelmChartMock}
clearHelmChart={clearHelmChart}
namespace={namespace}
name={name}
/>
)),
user
@@ -62,6 +99,47 @@ describe('HelmTemplatesSelectedItem', () => {
expect(screen.getByText('test-chart')).toBeInTheDocument();
expect(screen.getByText('Test Chart Description')).toBeInTheDocument();
expect(screen.getByText('Clear selection')).toBeInTheDocument();
expect(screen.getByText('https://example.com')).toBeInTheDocument();
expect(screen.getByText('Helm')).toBeInTheDocument();
});
it('should toggle custom values editor', async () => {
renderComponent();
const user = userEvent.setup();
// Verify editor is visible by default
expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
// Now hide the editor
await user.click(await screen.findByText('Custom values'));
// Editor should be hidden
expect(
screen.queryByTestId('helm-app-creation-editor')
).not.toBeInTheDocument();
});
it('should install helm chart and navigate when install button is clicked', async () => {
const user = userEvent.setup();
renderComponent();
// Click install button
await user.click(screen.getByText('Install'));
// Check mutate was called with correct values
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({
Name: 'test-name',
Repo: 'https://example.com',
Chart: 'test-chart',
Values: 'test-values',
Namespace: 'test-namespace',
}),
expect.objectContaining({ onSuccess: expect.any(Function) })
);
});
it('should disable install button when namespace or name is undefined', () => {
renderComponent({ namespace: '' });
expect(screen.getByText('Install')).toBeDisabled();
});
});
@@ -1,58 +1,193 @@
import { useRef } from 'react';
import { X } from 'lucide-react';
import { Form, 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 { Widget } from '@@/Widget';
import { Button } from '@@/buttons/Button';
import { FallbackImage } from '@@/FallbackImage';
import Svg from '@@/Svg';
import { Icon } from '@@/Icon';
import { WebEditorForm } from '@@/WebEditorForm';
import { confirmGenericDiscard } from '@@/modals/confirm';
import { FormSection } from '@@/form-components/FormSection';
import { InlineLoader } from '@@/InlineLoader';
import { FormActions } from '@@/form-components/FormActions';
import { Chart } from '../types';
import { useHelmChartValues } from './queries/useHelmChartValues';
import { HelmIcon } from './HelmIcon';
import { useHelmChartInstall } from './queries/useHelmChartInstall';
type Props = {
selectedChart: Chart;
clearHelmChart: () => void;
namespace?: string;
name?: string;
};
type FormValues = {
values: string;
};
const emptyValues: FormValues = {
values: '',
};
export function HelmTemplatesSelectedItem({
selectedChart,
clearHelmChart,
namespace,
name,
}: Props) {
const router = useRouter();
const analytics = useAnalytics();
const { mutate: installHelmChart, isLoading: isInstalling } =
useHelmChartInstall();
const { data: initialValues, isLoading: loadingValues } =
useHelmChartValues(selectedChart);
const formikRef = useRef<FormikProps<FormValues>>(null);
useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
function handleSubmit(values: FormValues) {
if (!name || !namespace) {
// Theoretically this should never happen and is mainly to keep typescript happy
return;
}
installHelmChart(
{
Name: name,
Repo: selectedChart.repo,
Chart: selectedChart.name,
Values: values.values,
Namespace: namespace,
},
{
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');
},
}
);
}
return (
<Widget>
<div className="flex">
<div className="basis-3/4 rounded-lg m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
<div className="vertical-center p-5">
<FallbackImage
src={selectedChart.icon}
fallbackIcon={HelmIcon}
className="h-16 w-16"
/>
<div className="col-sm-12">
<div>
<div className="text-2xl font-bold">{selectedChart.name}</div>
<div className="small text-muted mt-1">
{selectedChart.repo}
<>
<Widget>
<div className="flex">
<div className="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
<div className="vertical-center p-5">
<FallbackImage
src={selectedChart.icon}
fallbackIcon={HelmIcon}
className="h-16 w-16"
/>
<div className="col-sm-12">
<div className="flex justify-between">
<span>
<span className="text-2xl font-bold">
{selectedChart.name}
</span>
<span className="space-left pr-2 text-xs">
<span className="vertical-center">
<Svg icon="helm" className="icon icon-primary" />
</span>{' '}
<span>Helm</span>
</span>
</span>
</div>
<div className="text-muted text-xs">
{selectedChart.description}
</div>
</div>
<div className="text-xs mt-2">{selectedChart.description}</div>
</div>
</div>
<div className="basis-1/4">
<div className="h-full w-full vertical-center justify-end pr-5">
<Button
color="link"
className="!text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white"
onClick={clearHelmChart}
data-cy="clear-selection"
>
Clear selection
<Icon icon={X} className="ml-1" />
</Button>
</div>
</div>
</div>
<div className="basis-1/4">
<div className="h-full w-full vertical-center justify-end pr-5">
<Button
color="link"
className="!text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white"
onClick={clearHelmChart}
data-cy="clear-selection"
>
Clear selection
<Icon icon={X} className="ml-1" />
</Button>
</div>
</div>
</div>
</Widget>
</Widget>
<Formik
innerRef={formikRef}
initialValues={initialValues ?? emptyValues}
enableReinitialize
onSubmit={(values) => handleSubmit(values)}
>
{({ values, setFieldValue }) => (
<Form className="form-horizontal">
<div className="form-group !m-0">
<FormSection
title="Custom values"
isFoldable
defaultFolded={false}
className="mt-4"
>
{loadingValues && (
<div className="col-sm-12 p-0">
<InlineLoader>Loading values.yaml...</InlineLoader>
</div>
)}
{!!initialValues && (
<WebEditorForm
id="helm-app-creation-editor"
value={values.values}
onChange={(value) => setFieldValue('values', value)}
type="yaml"
data-cy="helm-app-creation-editor"
textTip="Define or paste the content of your values yaml file here"
>
You can get more information about Helm values file format
in the{' '}
<a
href="https://helm.sh/docs/chart_template_guide/values_files/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</WebEditorForm>
)}
</FormSection>
</div>
<FormActions
submitLabel="Install"
loadingText="Installing Helm chart"
isLoading={isInstalling}
isValid={!!namespace && !!name && !loadingValues}
data-cy="helm-install"
/>
</Form>
)}
</Formik>
</>
);
}
@@ -0,0 +1,40 @@
import { useMutation } from '@tanstack/react-query';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
queryClient,
withGlobalError,
withInvalidate,
} from '@/react-tools/react-query';
import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys';
import { InstallChartPayload } from '../../types';
async function installHelmChart(
payload: InstallChartPayload,
environmentId: EnvironmentId
) {
try {
const response = await axios.post(
`endpoints/${environmentId}/kubernetes/helm`,
payload
);
return response.data;
} catch (err) {
throw parseAxiosError(err as Error, 'Installation error');
}
}
export function useHelmChartInstall() {
const environmentId = useEnvironmentId();
return useMutation(
(values: InstallChartPayload) => installHelmChart(values, environmentId),
{
...withGlobalError('Unable to install Helm chart'),
...withInvalidate(queryClient, [queryKeys.applications(environmentId)]),
}
);
}
@@ -3,16 +3,15 @@ import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
type Params = {
chart: string;
repo: string;
version?: string;
};
import { Chart } from '../../types';
async function getHelmChartValues(params: Params) {
async function getHelmChartValues(chart: string, repo: string) {
try {
const response = await axios.get<string>(`/templates/helm/values`, {
params,
params: {
repo,
chart,
},
});
return response.data;
} catch (err) {
@@ -20,15 +19,14 @@ async function getHelmChartValues(params: Params) {
}
}
export function useHelmChartValues(params: Params) {
export function useHelmChartValues(chart: Chart) {
return useQuery({
queryKey: ['helm-chart-values', params.repo, params.chart, params.version],
queryFn: () => getHelmChartValues(params),
enabled: !!params.chart && !!params.repo,
queryKey: ['helm-chart-values', chart.repo, chart.name],
queryFn: () => getHelmChartValues(chart.name, chart.repo),
enabled: !!chart.name,
select: (data) => ({
values: data,
}),
staleTime: 60 * 1000 * 20, // 60 minutes, because values are not expected to change often
...withGlobalError('Unable to get Helm chart values'),
});
}

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