Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e75e379b33 | |||
| 2791bd123c | |||
| e1f9b69cd5 | |||
| 2c05496962 | |||
| 66bcf9223a | |||
| 993f69db37 | |||
| 58317edb6d | |||
| 417891675d | |||
| 8b7aef883a | |||
| b5961d79f8 | |||
| 0d25f3f430 | |||
| 798fa2396a | |||
| 28b222fffa | |||
| b57855f20d | |||
| 438b1f9815 | |||
| 2bccb3589e | |||
| 52bb06eb7b | |||
| 8e6d0e7d42 | |||
| 5526fd8296 | |||
| a554a8c49f | |||
| 7759d762ab | |||
| dd98097897 | |||
| cc73b7831f | |||
| 9c243cc8dd | |||
| 5d568a3f32 | |||
| 1b83542d41 | |||
| cf95d91db3 | |||
| 41c1d88615 | |||
| df8673ba40 | |||
| 96b1869a0c | |||
| e45b852c09 | |||
| 2d3e5c3499 | |||
| b25bf1e341 | |||
| 4bb80d3e3a | |||
| 03575186a7 | |||
| 935c7dd496 | |||
| 1b2dc6a133 | |||
| d4e2b2188e | |||
| 9658f757c2 | |||
| 371e84d9a5 | |||
| 5423a2f1b9 | |||
| 7001f8e088 | |||
| 678cd54553 | |||
| bc19d6592f | |||
| 5af0859f67 | |||
| 379711951c | |||
| a50a9c5617 | |||
| c0d30a455f | |||
| 9a3f6b21d2 | |||
| 9ea41f68bc | |||
| e943aa8f03 | |||
| 17a4750d8e | |||
| 7d18c22aa1 | |||
| c80cc6e268 | |||
| b30a1b5250 | |||
| b753371700 | |||
| 3ca5ab180f | |||
| 4971f5510c | |||
| 20fa7e508d | |||
| ebffc340d9 | |||
| 9a86737caa | |||
| d35d8a7307 | |||
| 701ff5d6bc | |||
| 9044b25a23 | |||
| 7f089fab86 | |||
| a259c28678 | |||
| db48da185a | |||
| cab667c23b | |||
| 154ca9f1b1 | |||
| 2abe40b786 | |||
| 6be2420b32 | |||
| 9405cc0e04 | |||
| 55c98912ed | |||
| 45bd7984b0 | |||
| 1ed9a0106e | |||
| f8b2ee8c0d | |||
| d32b0f8b7e | |||
| 24fdb1f600 | |||
| 4010174f66 | |||
| e2b812a611 | |||
| d72b3a9ba2 | |||
| 85f52d2574 | |||
| 33ea22c0a9 | |||
| 0d52f9dd0e | |||
| 3caffe1e85 | |||
| 87b8dd61c3 | |||
| ad77cd195c | |||
| eb2a754580 | |||
| 9258db58db | |||
| 8d1c90f912 | |||
| 1c62bd6ca5 | |||
| 13317ec43c | |||
| 35dcb5ca46 |
@@ -2,18 +2,17 @@ name: Bug Report
|
||||
description: Create a report to help us improve.
|
||||
labels: kind/bug,bug/need-confirmation
|
||||
body:
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
|
||||
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
|
||||
|
||||
|
||||
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
|
||||
|
||||
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
|
||||
|
||||
|
||||
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
|
||||
|
||||
- type: checkboxes
|
||||
@@ -45,7 +44,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -71,7 +70,7 @@ body:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -95,10 +94,18 @@ 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 [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.28.0'
|
||||
- '2.27.1'
|
||||
- '2.27.0'
|
||||
- '2.26.1'
|
||||
- '2.26.0'
|
||||
- '2.25.1'
|
||||
- '2.25.0'
|
||||
- '2.24.1'
|
||||
- '2.24.0'
|
||||
- '2.23.0'
|
||||
- '2.22.0'
|
||||
- '2.21.5'
|
||||
- '2.21.4'
|
||||
- '2.21.3'
|
||||
- '2.21.2'
|
||||
@@ -114,12 +121,6 @@ body:
|
||||
- '2.19.2'
|
||||
- '2.19.1'
|
||||
- '2.19.0'
|
||||
- '2.18.4'
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
- '2.17.1'
|
||||
- '2.17.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -157,7 +158,7 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser
|
||||
description: |
|
||||
description: |
|
||||
Enter your browser and version. Example: Google Chrome 114.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -20,8 +20,6 @@ linters-settings:
|
||||
deny:
|
||||
- pkg: 'encoding/json'
|
||||
desc: 'use github.com/segmentio/encoding/json'
|
||||
- pkg: 'github.com/sirupsen/logrus'
|
||||
desc: 'logging is allowed only by github.com/rs/zerolog'
|
||||
- pkg: 'golang.org/x/exp'
|
||||
desc: 'exp is not allowed'
|
||||
- pkg: 'github.com/portainer/libcrypto'
|
||||
|
||||
+1
-3
@@ -19,7 +19,5 @@ func Confirm(message string) (bool, error) {
|
||||
}
|
||||
|
||||
answer = strings.ReplaceAll(answer, "\n", "")
|
||||
answer = strings.ToLower(answer)
|
||||
|
||||
return answer == "y" || answer == "yes", nil
|
||||
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/build"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
@@ -169,8 +170,8 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||
}
|
||||
|
||||
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
|
||||
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager()
|
||||
}
|
||||
|
||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||
@@ -238,10 +239,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
||||
return err
|
||||
}
|
||||
|
||||
settings.SnapshotInterval = *cmp.Or(flags.SnapshotInterval, &settings.SnapshotInterval)
|
||||
settings.LogoURL = *cmp.Or(flags.Logo, &settings.LogoURL)
|
||||
settings.EnableEdgeComputeFeatures = *cmp.Or(flags.EnableEdgeComputeFeatures, &settings.EnableEdgeComputeFeatures)
|
||||
settings.TemplatesURL = *cmp.Or(flags.Templates, &settings.TemplatesURL)
|
||||
settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
|
||||
settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
|
||||
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
||||
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
||||
|
||||
if *flags.Labels != nil {
|
||||
settings.BlackListedLabels = *flags.Labels
|
||||
@@ -437,7 +438,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||
helmPackageManager, err := initHelmPackageManager()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type Service struct {
|
||||
connection portainer.Connection
|
||||
idxVersion map[portainer.EdgeStackID]int
|
||||
mu sync.RWMutex
|
||||
cacheInvalidationFn func(portainer.EdgeStackID)
|
||||
cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
@@ -23,7 +23,7 @@ func (service *Service) BucketName() string {
|
||||
}
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
|
||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -36,7 +36,7 @@ func NewService(connection portainer.Connection, cacheInvalidationFn func(portai
|
||||
}
|
||||
|
||||
if s.cacheInvalidationFn == nil {
|
||||
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
|
||||
s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {}
|
||||
}
|
||||
|
||||
es, err := s.EdgeStacks()
|
||||
@@ -106,7 +106,7 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
|
||||
|
||||
service.mu.Lock()
|
||||
service.idxVersion[id] = edgeStack.Version
|
||||
service.cacheInvalidationFn(id)
|
||||
service.cacheInvalidationFn(service.connection, id)
|
||||
service.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -125,7 +125,7 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
|
||||
}
|
||||
|
||||
service.idxVersion[ID] = edgeStack.Version
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
|
||||
updateFunc(edgeStack)
|
||||
|
||||
service.idxVersion[ID] = edgeStack.Version
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
||||
|
||||
delete(service.idxVersion, ID)
|
||||
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@ func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeSta
|
||||
var stack portainer.EdgeStack
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.GetObject(BucketName, identifier, &stack)
|
||||
if err != nil {
|
||||
if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -65,18 +64,17 @@ func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
||||
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
|
||||
edgeStack.ID = id
|
||||
|
||||
err := service.tx.CreateObjectWithId(
|
||||
if err := service.tx.CreateObjectWithId(
|
||||
BucketName,
|
||||
int(edgeStack.ID),
|
||||
edgeStack,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.idxVersion[id] = edgeStack.Version
|
||||
service.service.cacheInvalidationFn(id)
|
||||
service.service.cacheInvalidationFn(service.tx, id)
|
||||
service.service.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -89,13 +87,12 @@ func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *po
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.UpdateObject(BucketName, identifier, edgeStack)
|
||||
if err != nil {
|
||||
if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.idxVersion[ID] = edgeStack.Version
|
||||
service.service.cacheInvalidationFn(ID)
|
||||
service.service.cacheInvalidationFn(service.tx, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -119,14 +116,13 @@ func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.DeleteObject(BucketName, identifier)
|
||||
if err != nil {
|
||||
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(service.service.idxVersion, ID)
|
||||
|
||||
service.service.cacheInvalidationFn(ID)
|
||||
service.service.cacheInvalidationFn(service.tx, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package endpointrelation
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
@@ -13,11 +15,15 @@ const BucketName = "endpoint_relations"
|
||||
|
||||
// Service represents a service for managing environment(endpoint) relation data.
|
||||
type Service struct {
|
||||
connection portainer.Connection
|
||||
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
connection portainer.Connection
|
||||
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
endpointRelationsCache []portainer.EndpointRelation
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &Service{}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -76,6 +82,10 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) err
|
||||
err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||
cache.Del(endpointRelation.EndpointID)
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -92,11 +102,27 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
|
||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||
@@ -108,27 +134,15 @@ func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID)
|
||||
return err
|
||||
}
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||
rels, err := service.EndpointRelations()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||
return
|
||||
}
|
||||
|
||||
for _, rel := range rels {
|
||||
for id := range rel.EdgeStacks {
|
||||
if edgeStackID == id {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||
relations, _ := service.EndpointRelations()
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ type ServiceTx struct {
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &ServiceTx{}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -45,6 +47,10 @@ func (service ServiceTx) Create(endpointRelation *portainer.EndpointRelation) er
|
||||
err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||
cache.Del(endpointRelation.EndpointID)
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,11 +67,67 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
|
||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
rel, err := service.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel.EdgeStacks[edgeStackID] = true
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||
cache.Del(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments += len(endpointIDs)
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
rel, err := service.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(rel.EdgeStacks, edgeStackID)
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||
cache.Del(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments -= len(endpointIDs)
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||
@@ -77,27 +139,44 @@ func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID)
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||
rels, err := service.EndpointRelations()
|
||||
rels, err := service.cachedEndpointRelations()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||
return
|
||||
}
|
||||
|
||||
for _, rel := range rels {
|
||||
for id := range rel.EdgeStacks {
|
||||
if edgeStackID == id {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
if _, ok := rel.EdgeStacks[edgeStackID]; ok {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service ServiceTx) cachedEndpointRelations() ([]portainer.EndpointRelation, error) {
|
||||
service.service.mu.Lock()
|
||||
defer service.service.mu.Unlock()
|
||||
|
||||
if service.service.endpointRelationsCache == nil {
|
||||
var err error
|
||||
service.service.endpointRelationsCache, err = service.EndpointRelations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return service.service.endpointRelationsCache, nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||
relations, _ := service.EndpointRelations()
|
||||
|
||||
@@ -133,6 +212,7 @@ func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationSta
|
||||
}
|
||||
|
||||
numDeployments := 0
|
||||
|
||||
for _, r := range relations {
|
||||
for sId, enabled := range r.EdgeStacks {
|
||||
if enabled && sId == refStackId {
|
||||
|
||||
@@ -115,6 +115,8 @@ type (
|
||||
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
||||
Create(endpointRelation *portainer.EndpointRelation) error
|
||||
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
||||
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
||||
BucketName() string
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
|
||||
continue
|
||||
}
|
||||
|
||||
if environmentStatus.Details == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusArray := []portainer.EdgeStackDeploymentStatus{}
|
||||
if environmentStatus.Details.Pending {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
|
||||
@@ -75,6 +75,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
||||
|
||||
for _, edgeStack := range edgeStacks {
|
||||
for endpointId, status := range edgeStack.Status {
|
||||
if status.Details == nil {
|
||||
status.Details = &portainer.EdgeStackStatusDetails{}
|
||||
}
|
||||
|
||||
switch status.Type {
|
||||
case portainer.EdgeStackStatusPending:
|
||||
status.Details.Pending = true
|
||||
@@ -93,10 +97,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
||||
edgeStack.Status[endpointId] = status
|
||||
}
|
||||
|
||||
err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
|
||||
if err != nil {
|
||||
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,7 +100,9 @@ func (store *Store) initServices() error {
|
||||
}
|
||||
store.EndpointRelationService = endpointRelationService
|
||||
|
||||
edgeStackService, err := edgestack.NewService(store.connection, endpointRelationService.InvalidateEdgeCacheForEdgeStack)
|
||||
edgeStackService, err := edgestack.NewService(store.connection, func(tx portainer.Transaction, ID portainer.EdgeStackID) {
|
||||
endpointRelationService.Tx(tx).InvalidateEdgeCacheForEdgeStack(ID)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.25.1",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.28.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.25.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.28.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -141,7 +141,6 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
|
||||
|
||||
type NodeNameTransport struct {
|
||||
*http.Transport
|
||||
nodeNames map[string]string
|
||||
}
|
||||
|
||||
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -176,18 +175,19 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
t.nodeNames = make(map[string]string)
|
||||
for _, r := range rs {
|
||||
t.nodeNames[r.ID] = r.Portainer.Agent.NodeName
|
||||
nodeNames, ok := req.Context().Value("nodeNames").(map[string]string)
|
||||
if ok {
|
||||
for idx, r := range rs {
|
||||
// as there is no way to differentiate the same image available in multiple nodes only by their ID
|
||||
// we append the index of the image in the payload response to match the node name later
|
||||
// from the image.Summary[] list returned by docker's client.ImageList()
|
||||
nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (t *NodeNameTransport) NodeNames() map[string]string {
|
||||
return maps.Clone(t.nodeNames)
|
||||
}
|
||||
|
||||
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
||||
transport := &NodeNameTransport{
|
||||
Transport: &http.Transport{},
|
||||
|
||||
+6
-1
@@ -64,9 +64,14 @@ type (
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
// Prune is a flag indicating if the agent must prune the containers or not when creating/updating an edge stack
|
||||
// This flag drives docker compose `--remove-orphans` and docker stack `--prune` options
|
||||
// This flag drives `docker compose up --remove-orphans` and `docker stack up --prune` options
|
||||
// Used only for EE
|
||||
Prune bool
|
||||
// RemoveVolumes is a flag indicating if the agent must remove the named volumes declared
|
||||
// in the compose file and anonymouse volumes attached to containers
|
||||
// This flag drives `docker compose down --volumes` option
|
||||
// Used only for EE
|
||||
RemoveVolumes bool
|
||||
}
|
||||
|
||||
// RegistryCredentials holds the credentials for a Docker registry.
|
||||
|
||||
@@ -127,7 +127,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
|
||||
return err
|
||||
}
|
||||
|
||||
args = append(args, "stack", "rm", stack.Name)
|
||||
args = append(args, "stack", "rm", "--detach=false", stack.Name)
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
}
|
||||
|
||||
@@ -841,11 +841,11 @@ func (service *Service) GetDefaultSSLCertsPath() (string, string) {
|
||||
}
|
||||
|
||||
func defaultMTLSCertPathUnderFileStore() (string, string, string) {
|
||||
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
|
||||
caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename)
|
||||
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
|
||||
keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename)
|
||||
|
||||
return certPath, caCertPath, keyPath
|
||||
return caCertPath, certPath, keyPath
|
||||
}
|
||||
|
||||
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
|
||||
@@ -1014,26 +1014,45 @@ func CreateFile(path string, r io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (service *Service) StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error) {
|
||||
certPath, caCertPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||
func (service *Service) StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) {
|
||||
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||
|
||||
r := bytes.NewReader(cert)
|
||||
err := service.createFileInStore(certPath, r)
|
||||
if err != nil {
|
||||
r := bytes.NewReader(caCert)
|
||||
if err := service.createFileInStore(caCertPath, r); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
r = bytes.NewReader(caCert)
|
||||
err = service.createFileInStore(caCertPath, r)
|
||||
if err != nil {
|
||||
r = bytes.NewReader(cert)
|
||||
if err := service.createFileInStore(certPath, r); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
r = bytes.NewReader(key)
|
||||
err = service.createFileInStore(keyPath, r)
|
||||
if err != nil {
|
||||
if err := service.createFileInStore(keyPath, r); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
return service.wrapFileStore(certPath), service.wrapFileStore(caCertPath), service.wrapFileStore(keyPath), nil
|
||||
return service.wrapFileStore(caCertPath), service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil
|
||||
}
|
||||
|
||||
func (service *Service) GetMTLSCertificates() (string, string, string, error) {
|
||||
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||
|
||||
caCertPath = service.wrapFileStore(caCertPath)
|
||||
certPath = service.wrapFileStore(certPath)
|
||||
keyPath = service.wrapFileStore(keyPath)
|
||||
|
||||
paths := [...]string{caCertPath, certPath, keyPath}
|
||||
for _, path := range paths {
|
||||
exists, err := service.FileExists(path)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", "", "", fmt.Errorf("file %s does not exist", path)
|
||||
}
|
||||
}
|
||||
|
||||
return caCertPath, certPath, keyPath, nil
|
||||
}
|
||||
|
||||
@@ -15,15 +15,19 @@ type MultiFilterArgs []struct {
|
||||
}
|
||||
|
||||
// MultiFilterDirForPerDevConfigs filers the given dirEntries with multiple filter args, returns the merged entries for the given device
|
||||
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) []DirEntry {
|
||||
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) ([]DirEntry, []string) {
|
||||
var filteredDirEntries []DirEntry
|
||||
|
||||
var envFiles []string
|
||||
|
||||
for _, multiFilterArg := range multiFilterArgs {
|
||||
tmp := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
|
||||
tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
|
||||
filteredDirEntries = append(filteredDirEntries, tmp...)
|
||||
|
||||
envFiles = append(envFiles, efs...)
|
||||
}
|
||||
|
||||
return deduplicate(filteredDirEntries)
|
||||
return deduplicate(filteredDirEntries), envFiles
|
||||
}
|
||||
|
||||
func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
@@ -32,8 +36,7 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
marks := make(map[string]struct{})
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
_, ok := marks[dirEntry.Name]
|
||||
if !ok {
|
||||
if _, ok := marks[dirEntry.Name]; !ok {
|
||||
marks[dirEntry.Name] = struct{}{}
|
||||
deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry)
|
||||
}
|
||||
@@ -44,34 +47,33 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
|
||||
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
|
||||
// For given configPath A/B/C, return entries:
|
||||
// 1. all entries outside of dir A
|
||||
// 2. dir entries A, A/B, A/B/C
|
||||
// 3. For filterType file:
|
||||
// 1. all entries outside of dir A/B/C
|
||||
// 2. For filterType file:
|
||||
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
|
||||
// 4. For filterType dir:
|
||||
// 3. For filterType dir:
|
||||
// dir entry: A/B/C/<deviceName>
|
||||
// all entries: A/B/C/<deviceName>/*
|
||||
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
|
||||
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) ([]DirEntry, []string) {
|
||||
var filteredDirEntries []DirEntry
|
||||
|
||||
var envFiles []string
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
if shouldIncludeEntry(dirEntry, deviceName, configPath, filterType) {
|
||||
filteredDirEntries = append(filteredDirEntries, dirEntry)
|
||||
|
||||
if shouldParseEnvVars(dirEntry, deviceName, configPath, filterType) {
|
||||
envFiles = append(envFiles, dirEntry.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredDirEntries
|
||||
return filteredDirEntries, envFiles
|
||||
}
|
||||
|
||||
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
|
||||
|
||||
// Include all entries outside of dir A
|
||||
if !isInConfigRootDir(dirEntry, configPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Include dir entries A, A/B, A/B/C
|
||||
if isParentDir(dirEntry, configPath) {
|
||||
if !isInConfigDir(dirEntry, configPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -90,21 +92,9 @@ func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filter
|
||||
return false
|
||||
}
|
||||
|
||||
func isInConfigRootDir(dirEntry DirEntry, configPath string) bool {
|
||||
// get the first element of the configPath
|
||||
rootDir := strings.Split(configPath, string(os.PathSeparator))[0]
|
||||
|
||||
// return true if entry name starts with "A/"
|
||||
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(rootDir))
|
||||
}
|
||||
|
||||
func isParentDir(dirEntry DirEntry, configPath string) bool {
|
||||
if dirEntry.IsFile {
|
||||
return false
|
||||
}
|
||||
|
||||
// return true for dir entries A, A/B, A/B/C
|
||||
return strings.HasPrefix(appendTailSeparator(configPath), appendTailSeparator(dirEntry.Name))
|
||||
func isInConfigDir(dirEntry DirEntry, configPath string) bool {
|
||||
// return true if entry name starts with "A/B"
|
||||
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
|
||||
}
|
||||
|
||||
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
|
||||
@@ -138,6 +128,15 @@ func shouldIncludeDir(dirEntry DirEntry, deviceName, configPath string) bool {
|
||||
return strings.HasPrefix(dirEntry.Name, filterPrefix)
|
||||
}
|
||||
|
||||
func shouldParseEnvVars(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
|
||||
if !dirEntry.IsFile {
|
||||
return false
|
||||
}
|
||||
|
||||
return isInConfigDir(dirEntry, configPath) &&
|
||||
filepath.Base(dirEntry.Name) == deviceName+".env"
|
||||
}
|
||||
|
||||
func appendTailSeparator(path string) string {
|
||||
return fmt.Sprintf("%s%c", path, os.PathSeparator)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
type args struct {
|
||||
dirEntries []DirEntry
|
||||
configPath string
|
||||
multiFilterArgs MultiFilterArgs
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
|
||||
t.Helper()
|
||||
|
||||
dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
|
||||
require.Equal(t, wantDirEntries, dirEntries)
|
||||
}
|
||||
|
||||
baseDirEntries := []DirEntry{
|
||||
@@ -26,67 +29,94 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
{"configs/folder2/config2", "", true, 420},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want []DirEntry
|
||||
}{
|
||||
{
|
||||
name: "filter file1",
|
||||
args: args{
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
|
||||
},
|
||||
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
|
||||
// Filter file1
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
|
||||
)
|
||||
|
||||
// Filter folder1
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
|
||||
)
|
||||
|
||||
// Filter file1 and folder1
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
|
||||
)
|
||||
|
||||
// Filter file1 and file2
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"file1", portainer.PerDevConfigsTypeFile},
|
||||
{"file2", portainer.PerDevConfigsTypeFile},
|
||||
},
|
||||
{
|
||||
name: "filter folder1",
|
||||
args: args{
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
|
||||
},
|
||||
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
|
||||
},
|
||||
{
|
||||
name: "filter file1 and folder1",
|
||||
args: args{
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
|
||||
},
|
||||
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
|
||||
},
|
||||
{
|
||||
name: "filter file1 and file2",
|
||||
args: args{
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"file1", portainer.PerDevConfigsTypeFile},
|
||||
{"file2", portainer.PerDevConfigsTypeFile},
|
||||
},
|
||||
},
|
||||
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
|
||||
},
|
||||
{
|
||||
name: "filter folder1 and folder2",
|
||||
args: args{
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"folder1", portainer.PerDevConfigsTypeDir},
|
||||
{"folder2", portainer.PerDevConfigsTypeDir},
|
||||
},
|
||||
},
|
||||
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
|
||||
)
|
||||
|
||||
// Filter folder1 and folder2
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{
|
||||
{"folder1", portainer.PerDevConfigsTypeDir},
|
||||
{"folder2", portainer.PerDevConfigsTypeDir},
|
||||
},
|
||||
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
|
||||
)
|
||||
}
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
|
||||
t.Helper()
|
||||
|
||||
_, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
|
||||
require.Equal(t, wantEnvFiles, envFiles)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tt.want, MultiFilterDirForPerDevConfigs(tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs), "MultiFilterDirForPerDevConfigs(%v, %v, %v)", tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs)
|
||||
})
|
||||
baseDirEntries := []DirEntry{
|
||||
{".env", "", true, 420},
|
||||
{"docker-compose.yaml", "", true, 420},
|
||||
{"configs", "", false, 420},
|
||||
{"configs/edge-id/edge-id.env", "", true, 420},
|
||||
}
|
||||
|
||||
f(
|
||||
baseDirEntries,
|
||||
"configs",
|
||||
MultiFilterArgs{{"edge-id", portainer.PerDevConfigsTypeDir}},
|
||||
[]string{"configs/edge-id/edge-id.env"},
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func TestIsInConfigDir(t *testing.T) {
|
||||
f := func(dirEntry DirEntry, configPath string, expect bool) {
|
||||
t.Helper()
|
||||
|
||||
actual := isInConfigDir(dirEntry, configPath)
|
||||
assert.Equal(t, expect, actual)
|
||||
}
|
||||
|
||||
f(DirEntry{Name: "edge-configs"}, "edge-configs", false)
|
||||
f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false)
|
||||
f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true)
|
||||
f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
|
||||
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfi
|
||||
}
|
||||
|
||||
func parseAction(actionRaw string) (portainer.PowerState, error) {
|
||||
switch strings.ToLower(actionRaw) {
|
||||
case "power on":
|
||||
if strings.EqualFold(actionRaw, "power on") {
|
||||
return powerOnState, nil
|
||||
case "power off":
|
||||
} else if strings.EqualFold(actionRaw, "power off") {
|
||||
return powerOffState, nil
|
||||
case "restart":
|
||||
} else if strings.EqualFold(actionRaw, "restart") {
|
||||
return restartState, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
|
||||
}
|
||||
|
||||
+14
-4
@@ -13,6 +13,12 @@ import (
|
||||
"github.com/urfave/negroni"
|
||||
)
|
||||
|
||||
const csrfSkipHeader = "X-CSRF-Token-Skip"
|
||||
|
||||
func SkipCSRFToken(w http.ResponseWriter) {
|
||||
w.Header().Set(csrfSkipHeader, "1")
|
||||
}
|
||||
|
||||
func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
||||
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
||||
@@ -42,10 +48,14 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
sw := negroni.NewResponseWriter(w)
|
||||
|
||||
sw.Before(func(sw negroni.ResponseWriter) {
|
||||
statusCode := sw.Status()
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
csrfToken := gorillacsrf.Token(r)
|
||||
sw.Header().Set("X-CSRF-Token", csrfToken)
|
||||
if len(sw.Header().Get(csrfSkipHeader)) > 0 {
|
||||
sw.Header().Del(csrfSkipHeader)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
||||
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -482,28 +482,3 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
|
||||
|
||||
return customTemplate, nil
|
||||
}
|
||||
|
||||
// @id CustomTemplateCreate
|
||||
// @summary Create a custom template
|
||||
// @description Create a custom template.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags custom_templates
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json,multipart/form-data
|
||||
// @produce json
|
||||
// @param method query string true "method for creating template" Enums(string, file, repository)
|
||||
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
|
||||
// @success 200 {object} portainer.CustomTemplate
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /custom_templates [post]
|
||||
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method", err)
|
||||
}
|
||||
|
||||
return "/custom_templates/create/" + method, nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
@@ -33,7 +32,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
|
||||
h.Handle("/custom_templates/create/{method}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/custom_templates",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
|
||||
h.Handle("/custom_templates/{id}",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package images
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/handler/docker/utils"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
@@ -46,17 +47,16 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
return httpErr
|
||||
}
|
||||
|
||||
images, err := cli.ImageList(r.Context(), image.ListOptions{})
|
||||
nodeNames := make(map[string]string)
|
||||
|
||||
// Pass the node names map to the context so the custom NodeNameTransport can use it
|
||||
ctx := context.WithValue(r.Context(), "nodeNames", nodeNames)
|
||||
|
||||
images, err := cli.ImageList(ctx, image.ListOptions{})
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve Docker images", err)
|
||||
}
|
||||
|
||||
// Extract the node name from the custom transport
|
||||
nodeNames := make(map[string]string)
|
||||
if t, ok := cli.HTTPClient().Transport.(*client.NodeNameTransport); ok {
|
||||
nodeNames = t.NodeNames()
|
||||
}
|
||||
|
||||
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: withUsage", err)
|
||||
@@ -85,8 +85,12 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
}
|
||||
|
||||
imagesList[i] = ImageResponse{
|
||||
Created: image.Created,
|
||||
NodeName: nodeNames[image.ID],
|
||||
Created: image.Created,
|
||||
// Only works if the order of `images` is not changed between unmarshaling the agent's response
|
||||
// in NodeNameTransport.RoundTrip() (api/docker/client/client.go)
|
||||
// and docker's cli.ImageList()
|
||||
// As both functions unmarshal the same response body, the resulting array will be ordered the same way.
|
||||
NodeName: nodeNames[fmt.Sprintf("%s-%d", image.ID, i)],
|
||||
ID: image.ID,
|
||||
Size: image.Size,
|
||||
Tags: image.RepoTags,
|
||||
|
||||
@@ -167,7 +167,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||
if err != nil {
|
||||
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -183,6 +183,12 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
|
||||
edgeStackSet[edgeStackID] = true
|
||||
}
|
||||
|
||||
if relation == nil {
|
||||
relation = &portainer.EndpointRelation{
|
||||
EndpointID: endpoint.ID,
|
||||
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||
}
|
||||
}
|
||||
relation.EdgeStacks = edgeStackSet
|
||||
|
||||
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
|
||||
|
||||
@@ -271,26 +271,3 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
|
||||
|
||||
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
|
||||
}
|
||||
|
||||
// @id EdgeJobCreate
|
||||
// @summary Create an EdgeJob
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_jobs
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param method query string true "Creation Method" Enums(file, string)
|
||||
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
|
||||
// @success 200 {object} portainer.EdgeGroup
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @failure 500
|
||||
// @deprecated
|
||||
// @router /edge_jobs [post]
|
||||
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
return "/edge_jobs/create/" + method, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -30,8 +29,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
h.Handle("/edge_jobs",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_jobs",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_jobs/create/{method}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_jobs/{id}",
|
||||
|
||||
@@ -55,26 +55,3 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
|
||||
|
||||
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||
}
|
||||
|
||||
// @id EdgeStackCreate
|
||||
// @summary Create an EdgeStack
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param method query string true "Creation Method" Enums(file,string,repository)
|
||||
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @deprecated
|
||||
// @router /edge_stacks [post]
|
||||
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
return "/edge_stacks/create/" + method, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package edgestacks
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -52,10 +53,14 @@ func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups)
|
||||
if err != nil {
|
||||
if err := handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups); err != nil {
|
||||
return httperror.InternalServerError("Unable to delete edge stack", err)
|
||||
}
|
||||
|
||||
stackFolder := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(edgeStack.ID)))
|
||||
if err := handler.FileService.RemoveDirectory(stackFolder); err != nil {
|
||||
return httperror.InternalServerError("Unable to remove edge stack project folder", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -101,3 +103,52 @@ func TestDeleteInvalidEdgeStack(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
edgeGroup := createEdgeGroup(t, handler.DataStore)
|
||||
|
||||
payload := edgeStackFromStringPayload{
|
||||
Name: "test-stack",
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
||||
StackFileContent: "version: '3.7'\nservices:\n test:\n image: test",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
|
||||
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
|
||||
// Delete
|
||||
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
|
||||
assert.NoDirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
|
||||
if err != nil {
|
||||
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
fileName := stack.EntryPoint
|
||||
|
||||
@@ -30,7 +30,7 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if err != nil {
|
||||
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
return response.JSON(w, edgeStack)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id EdgeStackStatusDelete
|
||||
// @summary Delete an EdgeStack status
|
||||
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
|
||||
// @tags edge_stacks
|
||||
// @produce json
|
||||
// @param id path int true "EdgeStack Id"
|
||||
// @param environmentId path int true "Environment identifier"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 404
|
||||
// @failure 403
|
||||
// @deprecated
|
||||
// @router /edge_stacks/{id}/status/{environmentId} [delete]
|
||||
func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid stack identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve a valid endpoint from the handler context", err)
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
stack, err = handler.deleteEdgeStackStatus(tx, portainer.EdgeStackID(stackID), endpoint)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, endpoint *portainer.Endpoint) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
environmentStatus, ok := stack.Status[endpoint.ID]
|
||||
if !ok {
|
||||
environmentStatus = portainer.EdgeStackStatus{}
|
||||
}
|
||||
|
||||
environmentStatus.Status = append(environmentStatus.Status, portainer.EdgeStackDeploymentStatus{
|
||||
Time: time.Now().Unix(),
|
||||
Type: portainer.EdgeStackStatusRemoved,
|
||||
})
|
||||
|
||||
stack.Status[endpoint.ID] = environmentStatus
|
||||
|
||||
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func TestDeleteStatus(t *testing.T) {
|
||||
handler, _ := setupHandler(t)
|
||||
|
||||
endpoint := createEndpoint(t, handler.DataStore)
|
||||
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil)
|
||||
if err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
|
||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"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"
|
||||
@@ -20,6 +21,7 @@ type updateStatusPayload struct {
|
||||
Status *portainer.EdgeStackStatusType
|
||||
EndpointID portainer.EndpointID
|
||||
Time int64
|
||||
Version int
|
||||
}
|
||||
|
||||
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
||||
@@ -67,11 +69,21 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID))
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
|
||||
return err
|
||||
}); err != nil {
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
|
||||
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
|
||||
}
|
||||
|
||||
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
@@ -80,32 +92,16 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
if ok, _ := strconv.ParseBool(r.Header.Get("X-Portainer-No-Body")); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// skip error because agent tries to report on deleted stack
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("stackID", int(stackID)).
|
||||
Int("status", int(*payload.Status)).
|
||||
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. Environment ID: %d", err, payload.EndpointID)
|
||||
}
|
||||
|
||||
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
||||
if payload.Version > 0 && payload.Version < stack.Version {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
status := *payload.Status
|
||||
@@ -123,10 +119,6 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
|
||||
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
|
||||
|
||||
if err := tx.EdgeStack().UpdateEdgeStack(stackID, stack); err != nil {
|
||||
return nil, handler.handlerDBErr(fmt.Errorf("unable to update Edge stack to the database: %w. Environment name: %s", err, endpoint.Name), "unable to update Edge stack")
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
@@ -145,7 +137,11 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
|
||||
}
|
||||
}
|
||||
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
if containsStatus := slices.ContainsFunc(environmentStatus.Status, func(e portainer.EdgeStackDeploymentStatus) bool {
|
||||
return e.Type == deploymentStatus.Type
|
||||
}); !containsStatus {
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,14 @@ 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
|
||||
@@ -144,3 +148,15 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
|
||||
|
||||
return edgeStack
|
||||
}
|
||||
|
||||
func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeGroup {
|
||||
edgeGroup := portainer.EdgeGroup{
|
||||
ID: 1,
|
||||
Name: "EdgeGroup 1",
|
||||
}
|
||||
|
||||
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return edgeGroup
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||
func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
relationConfig, err := edge.FetchEndpointRelationsConfig(tx)
|
||||
@@ -107,7 +107,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
||||
|
||||
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
|
||||
if err != nil {
|
||||
return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err)
|
||||
return nil, httperror.InternalServerError("unable to check for existence of non fitting environments: %w", err)
|
||||
}
|
||||
if hasWrongType {
|
||||
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
|
||||
@@ -138,48 +138,19 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||
newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||
oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||
newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||
|
||||
endpointsToRemove := set.Set[portainer.EndpointID]{}
|
||||
for endpointID := range oldRelatedSet {
|
||||
if !newRelatedSet[endpointID] {
|
||||
endpointsToRemove[endpointID] = true
|
||||
}
|
||||
relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
|
||||
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
|
||||
|
||||
if len(relatedEnvironmentsToRemove) > 0 {
|
||||
tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
|
||||
}
|
||||
|
||||
for endpointID := range endpointsToRemove {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
delete(relation.EdgeStacks, edgeStackID)
|
||||
|
||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||
}
|
||||
if len(relatedEnvironmentsToAdd) > 0 {
|
||||
tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID)
|
||||
}
|
||||
|
||||
endpointsToAdd := set.Set[portainer.EndpointID]{}
|
||||
for endpointID := range newRelatedSet {
|
||||
if !oldRelatedSet[endpointID] {
|
||||
endpointsToAdd[endpointID] = true
|
||||
}
|
||||
}
|
||||
|
||||
for endpointID := range endpointsToAdd {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
relation.EdgeStacks[edgeStackID] = true
|
||||
|
||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||
}
|
||||
}
|
||||
|
||||
return newRelatedEnvironmentIDs, endpointsToAdd, nil
|
||||
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
|
||||
}
|
||||
|
||||
@@ -22,21 +22,21 @@ 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}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_stacks/{id}",
|
||||
@@ -53,15 +53,13 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
edgeStackStatusRouter := h.NewRoute().Subrouter()
|
||||
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
|
||||
|
||||
edgeStackStatusRouter.PathPrefix("/edge_stacks/{id}/status/{endpoint_id}").Handler(bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusDelete))).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
func handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
httpErr := httperror.InternalServerError(msg, err)
|
||||
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
httpErr.StatusCode = http.StatusNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package edgetemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
type templateFileFormat struct {
|
||||
Version string `json:"version"`
|
||||
Templates []portainer.Template `json:"templates"`
|
||||
}
|
||||
|
||||
// @id EdgeTemplateList
|
||||
// @deprecated
|
||||
// @summary Fetches the list of Edge Templates
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_templates
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @success 200 {array} portainer.Template
|
||||
// @failure 500
|
||||
// @router /edge_templates [get]
|
||||
func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
url := portainer.DefaultTemplatesURL
|
||||
if settings.TemplatesURL != "" {
|
||||
url = settings.TemplatesURL
|
||||
}
|
||||
|
||||
var templateData []byte
|
||||
templateData, err = client.Get(url, 10)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve external templates", err)
|
||||
}
|
||||
|
||||
var templateFile templateFileFormat
|
||||
|
||||
err = json.Unmarshal(templateData, &templateFile)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to parse template file", err)
|
||||
}
|
||||
|
||||
// We only support version 3 of the template format
|
||||
// this is only a temporary fix until we have custom edge templates
|
||||
if templateFile.Version != "3" {
|
||||
return httperror.InternalServerError("Unsupported template version", nil)
|
||||
}
|
||||
|
||||
filteredTemplates := make([]portainer.Template, 0)
|
||||
|
||||
for _, template := range templateFile.Templates {
|
||||
if slices.Contains(template.Categories, "edge") && slices.Contains([]portainer.TemplateType{portainer.ComposeStackTemplate, portainer.SwarmStackTemplate}, template.Type) {
|
||||
filteredTemplates = append(filteredTemplates, template)
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, filteredTemplates)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package edgetemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle edge environment(endpoint) operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer security.BouncerService
|
||||
DataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
||||
func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
|
||||
h.Handle("/edge_templates",
|
||||
bouncer.AdminAccess(middlewares.Deprecated(httperror.LoggerHandler(h.edgeTemplateList), func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { return "", nil }))).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package endpointedge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edge"
|
||||
@@ -13,8 +15,12 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var edgeStackSingleFlightGroup = singleflight.Group{}
|
||||
|
||||
// @summary Inspect an Edge Stack for an Environment(Endpoint)
|
||||
// @description **Access policy**: public
|
||||
// @tags edge, endpoints, edge_stacks
|
||||
@@ -42,13 +48,26 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
return edgeStack, err
|
||||
})
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
// WARNING: this variable must not be mutated
|
||||
edgeStack := s.(*portainer.EdgeStack)
|
||||
|
||||
fileName := edgeStack.EntryPoint
|
||||
if endpointutils.IsDockerEndpoint(endpoint) {
|
||||
if fileName == "" {
|
||||
|
||||
@@ -264,6 +264,9 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p
|
||||
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
|
||||
}
|
||||
|
||||
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||
if err != nil {
|
||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if endpointRelation == nil {
|
||||
endpointRelation = &portainer.EndpointRelation{
|
||||
EndpointID: endpoint.ID,
|
||||
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
edgeGroups, err := tx.EdgeGroup().ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -32,6 +39,9 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
|
||||
|
||||
edgeStacks, err := tx.EdgeStack().EdgeStacks()
|
||||
if err != nil {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 403 "Unauthorized access or operation not allowed."
|
||||
// @failure 500 "Server error occurred while attempting to delete the specified environments."
|
||||
// @router /endpoints [delete]
|
||||
// @router /endpoints/delete [post]
|
||||
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var p endpointDeleteBatchPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
|
||||
@@ -127,6 +127,27 @@ func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Reque
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
// @id EndpointDeleteBatchDeprecated
|
||||
// @summary Remove multiple environments
|
||||
// @deprecated
|
||||
// @description Deprecated: use the `POST` endpoint instead.
|
||||
// @description Remove multiple environments and optionally clean-up associated resources.
|
||||
// @description **Access policy**: Administrator only.
|
||||
// @tags endpoints
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up associated resources (cloud environments only)"
|
||||
// @success 204 "Environment(s) successfully deleted."
|
||||
// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 403 "Unauthorized access or operation not allowed."
|
||||
// @failure 500 "Server error occurred while attempting to delete the specified environments."
|
||||
// @router /endpoints [delete]
|
||||
func (handler *Handler) endpointDeleteBatchDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
return handler.endpointDeleteBatch(w, r)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
|
||||
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
|
||||
@@ -68,8 +68,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/{id}",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/endpoints",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
|
||||
h.Handle("/endpoints/delete",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}/snapshot",
|
||||
@@ -85,6 +85,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
// DEPRECATED
|
||||
h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatchDeprecated))).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
|
||||
|
||||
relation = &portainer.EndpointRelation{
|
||||
EndpointID: endpoint.ID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||
}
|
||||
if err := tx.EndpointRelation().Create(relation); err != nil {
|
||||
return errors.WithMessage(err, "Unable to create environment relation inside the database")
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||
@@ -50,7 +49,6 @@ type Handler struct {
|
||||
EdgeGroupsHandler *edgegroups.Handler
|
||||
EdgeJobsHandler *edgejobs.Handler
|
||||
EdgeStacksHandler *edgestacks.Handler
|
||||
EdgeTemplatesHandler *edgetemplates.Handler
|
||||
EndpointEdgeHandler *endpointedge.Handler
|
||||
EndpointGroupHandler *endpointgroups.Handler
|
||||
EndpointHandler *endpoints.Handler
|
||||
@@ -83,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.25.1
|
||||
// @version 2.28.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
@@ -190,8 +188,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/edge_jobs"):
|
||||
http.StripPrefix("/api", h.EdgeJobsHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/edge_templates"):
|
||||
http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
|
||||
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -8,8 +9,8 @@ import (
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -23,11 +24,11 @@ type Handler struct {
|
||||
jwtService portainer.JWTService
|
||||
kubeClusterAccessService kubernetes.KubeClusterAccessService
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
helmPackageManager libhelm.HelmPackageManager
|
||||
helmPackageManager libhelmtypes.HelmPackageManager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage endpoint group operations.
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelmtypes.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
@@ -53,17 +54,11 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||
|
||||
// Deprecated
|
||||
h.Handle("/{id}/kubernetes/helm/repositories",
|
||||
httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
|
||||
h.Handle("/{id}/kubernetes/helm/repositories",
|
||||
httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// NewTemplateHandler creates a template handler to manage environment(endpoint) group operations.
|
||||
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelm.HelmPackageManager) *Handler {
|
||||
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelmtypes.HelmPackageManager) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
helmPackageManager: helmPackageManager,
|
||||
@@ -84,7 +79,7 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
|
||||
|
||||
// getHelmClusterAccess obtains the core k8s cluster access details from request.
|
||||
// The cluster access includes the cluster server url, the user's bearer token and the tls certificate.
|
||||
// The cluster access is passed in as kube config CLI params to helm binary.
|
||||
// The cluster access is passed in as kube config CLI params to helm.
|
||||
func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
@@ -113,6 +108,9 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
|
||||
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
|
||||
return &options.KubernetesClusterAccess{
|
||||
ClusterName: fmt.Sprintf("%s-%s", "portainer-cluster", endpoint.Name),
|
||||
ContextName: fmt.Sprintf("%s-%s", "portainer-ctx", endpoint.Name),
|
||||
UserName: fmt.Sprintf("%s-%s", "portainer-sa-user", tokenData.Username),
|
||||
ClusterServerURL: kubeConfigInternal.ClusterServerURL,
|
||||
CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile,
|
||||
AuthToken: bearerToken,
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -34,7 +34,7 @@ func Test_helmDelete(t *testing.T) {
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
|
||||
@@ -99,15 +99,11 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
||||
}
|
||||
|
||||
installOpts := options.InstallOptions{
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
Namespace: p.Namespace,
|
||||
Repo: p.Repo,
|
||||
KubernetesClusterAccess: &options.KubernetesClusterAccess{
|
||||
ClusterServerURL: clusterAccess.ClusterServerURL,
|
||||
CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile,
|
||||
AuthToken: clusterAccess.AuthToken,
|
||||
},
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
Namespace: p.Namespace,
|
||||
Repo: p.Repo,
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
}
|
||||
|
||||
if p.Values != "" {
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -38,7 +38,7 @@ func Test_helmInstall(t *testing.T) {
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -37,7 +37,7 @@ func Test_helmList(t *testing.T) {
|
||||
is.NoError(err, "Error initialising jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ import (
|
||||
"testing"
|
||||
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_helmRepoSearch(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
|
||||
|
||||
assert.NotNil(t, h, "Handler should not fail")
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"testing"
|
||||
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_helmShow(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
|
||||
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type helmUserRepositoryResponse struct {
|
||||
GlobalRepository string `json:"GlobalRepository"`
|
||||
UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"`
|
||||
}
|
||||
|
||||
type addHelmRepoUrlPayload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
|
||||
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
|
||||
}
|
||||
|
||||
// @id HelmUserRepositoryCreateDeprecated
|
||||
// @summary Create a user helm repository
|
||||
// @description Create a user helm repository.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param payload body addHelmRepoUrlPayload true "Helm Repository"
|
||||
// @success 200 {object} portainer.HelmUserRepository "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /endpoints/{id}/kubernetes/helm/repositories [post]
|
||||
func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
userID := tokenData.ID
|
||||
|
||||
p := new(addHelmRepoUrlPayload)
|
||||
err = request.DecodeAndValidateJSONPayload(r, p)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid Helm repository URL", err)
|
||||
}
|
||||
|
||||
// lowercase, remove trailing slash
|
||||
p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/")
|
||||
|
||||
records, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to access the DataStore", err)
|
||||
}
|
||||
|
||||
// check if repo already exists - by doing case insensitive comparison
|
||||
for _, record := range records {
|
||||
if strings.EqualFold(record.URL, p.URL) {
|
||||
errMsg := "Helm repo already registered for user"
|
||||
return httperror.BadRequest(errMsg, errors.New(errMsg))
|
||||
}
|
||||
}
|
||||
|
||||
record := portainer.HelmUserRepository{
|
||||
UserID: userID,
|
||||
URL: p.URL,
|
||||
}
|
||||
|
||||
err = handler.dataStore.HelmUserRepository().Create(&record)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to save a user Helm repository URL", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, record)
|
||||
}
|
||||
|
||||
// @id HelmUserRepositoriesListDeprecated
|
||||
// @summary List a users helm repositories
|
||||
// @description Inspect a user helm repositories.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @success 200 {object} helmUserRepositoryResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /endpoints/{id}/kubernetes/helm/repositories [get]
|
||||
func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
userID := tokenData.ID
|
||||
|
||||
settings, err := handler.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
userRepos, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to get user Helm repositories", err)
|
||||
}
|
||||
|
||||
resp := helmUserRepositoryResponse{
|
||||
GlobalRepository: settings.HelmRepositoryURL,
|
||||
UserRepositories: userRepos,
|
||||
}
|
||||
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
@@ -69,7 +69,6 @@ func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param namespace query string true "Namespace name"
|
||||
// @param nodeName query string true "Node name"
|
||||
// @param withDependencies query boolean false "Include dependencies in the response"
|
||||
// @success 200 {array} models.K8sApplication "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."
|
||||
@@ -117,12 +116,6 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
|
||||
return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err)
|
||||
}
|
||||
|
||||
withDependencies, err := request.RetrieveBooleanQueryParameter(r, "withDependencies", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the withDependencies query parameter")
|
||||
return nil, httperror.BadRequest("Unable to parse the withDependencies query parameter", err)
|
||||
}
|
||||
|
||||
nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter")
|
||||
@@ -135,7 +128,7 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
|
||||
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
|
||||
}
|
||||
|
||||
applications, err := cli.GetApplications(namespace, nodeName, withDependencies)
|
||||
applications, err := cli.GetApplications(namespace, nodeName)
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications")
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -162,11 +167,38 @@ func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenD
|
||||
func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint, isInternal bool) clientV1.NamedCluster {
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(r.Host, endpoint.ID, isInternal)
|
||||
|
||||
selfSignedCert := false
|
||||
serverUrl, err := url.Parse(kubeConfigInternal.ClusterServerURL)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to parse server URL")
|
||||
}
|
||||
|
||||
if strings.EqualFold(serverUrl.Scheme, "https") {
|
||||
var certPem []byte
|
||||
var err error
|
||||
|
||||
if kubeConfigInternal.CertificateAuthorityData != "" {
|
||||
certPem = []byte(kubeConfigInternal.CertificateAuthorityData)
|
||||
} else if kubeConfigInternal.CertificateAuthorityFile != "" {
|
||||
certPem, err = os.ReadFile(kubeConfigInternal.CertificateAuthorityFile)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to open certificate file")
|
||||
}
|
||||
}
|
||||
|
||||
if certPem != nil {
|
||||
selfSignedCert, err = IsSelfSignedCertificate(certPem)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to verify if certificate is self-signed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clientV1.NamedCluster{
|
||||
Name: buildClusterName(endpoint.Name),
|
||||
Cluster: clientV1.Cluster{
|
||||
Server: kubeConfigInternal.ClusterServerURL,
|
||||
InsecureSkipTLSVerify: true,
|
||||
InsecureSkipTLSVerify: selfSignedCert,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -215,3 +247,38 @@ func writeFileContent(w http.ResponseWriter, r *http.Request, endpoints []portai
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase))
|
||||
return response.JSON(w, config)
|
||||
}
|
||||
|
||||
func IsSelfSignedCertificate(certPem []byte) (bool, error) {
|
||||
if certPem == nil {
|
||||
return false, errors.New("certificate data is empty")
|
||||
}
|
||||
|
||||
if !strings.Contains(string(certPem), "BEGIN CERTIFICATE") {
|
||||
certPem = []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", string(certPem)))
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certPem)
|
||||
if block == nil {
|
||||
return false, errors.New("failed to decode certificate")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if cert.Issuer.String() != cert.Subject.String() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(cert)
|
||||
|
||||
opts := x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
CurrentTime: cert.NotBefore,
|
||||
}
|
||||
|
||||
_, err = cert.Verify(opts)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsSelfSignedCertificate(t *testing.T) {
|
||||
|
||||
tc := []struct {
|
||||
name string
|
||||
cert string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "portainer self-signed",
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIBUTCB+KADAgECAhBB7psNiJlJd/nRCCKUPVenMAoGCCqGSM49BAMCMAAwHhcN
|
||||
MjUwMzEzMDQwODI0WhcNMzAwMzEzMDQwODI0WjAAMFkwEwYHKoZIzj0CAQYIKoZI
|
||||
zj0DAQcDQgAESdGCaXq0r1GDxF89yKjjLeCIixiPDdXAg+lw4NqAWeJq2AOo+8IH
|
||||
vcCq9bSlYlezK8RzTsbf9Z1m5jRqUEbSjqNUMFIwDgYDVR0PAQH/BAQDAgWgMBMG
|
||||
A1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0RAQH/BBMwEYIJ
|
||||
bG9jYWxob3N0hwQAAAAAMAoGCCqGSM49BAMCA0gAMEUCIApLliukFaCZHbc/2pkH
|
||||
0VDY+fBMb12jhmVpgKh1Cqg9AiEAwFrMQLUkzATUpiHuukdUg5VsUiMIkWTPLglz
|
||||
E4+1dRc=
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "portainer self-signed without header",
|
||||
cert: `MIIBUzCB+aADAgECAhEAjsskPzuCS5BeHjXGwYqc2jAKBggqhkjOPQQDAjAAMB4XDTI1MDMxMzA0MzQyNloXDTMwMDMxMzA0MzQyNlowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABITD+dNDLYQbLYDE3UMlTzD61OYRSVkVZspdp1MvZITIG4VOxtfQUqcW3P7OHQdoi52GIQ/GM6iDgxwB1BOyi3mjVDBSMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdEQEB/wQTMBGCCWxvY2FsaG9zdIcEAAAAADAKBggqhkjOPQQDAgNJADBGAiEA8SmyeYLhrnrNLAFcxZp0dk6nMN70XVAfqGnbK/s8NR8CIQDgQdqhfge8QvN2TsH4gg98a9VHDv+RlcOlJ80SS+G/Ww==`,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "custom certificate generated by openssl",
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIB9TCCAZugAwIBAgIULTkNYfYHiqfOiX7mKOIGxRefx/YwCgYIKoZIzj0EAwIw
|
||||
SDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp
|
||||
c2NvMRQwEgYDVQQDEwtleGFtcGxlLm5ldDAeFw0yNTAyMjgwNjI3MDBaFw0zNTAy
|
||||
MjYwNjI3MDBaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT3WlLvbGw7wPkQ
|
||||
3LuHFJEaNrDv3n359JMV1CkjQi3U37u0fJrjd+8o7TxPBYgt9HDD9vsURhy41DNo
|
||||
g71F2AIto4GqMIGnMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
|
||||
AQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU+nMxx/VCE9fzrlHI
|
||||
FX9mF5SRPrkwHwYDVR0jBBgwFoAUOlUIToGwnBOqzZ1dBfOvdKbwNaAwKAYDVR0R
|
||||
AQH/BB4wHIIaZWRnZS4xNzIuMTcuMjIxLjIwOC5uaXAuaW8wCgYIKoZIzj0EAwID
|
||||
SAAwRQIgeYrkjY0z/ypMKXZbvbMi8qOK44qoISKkSErBUCBLuwoCIQDRaJA9r931
|
||||
utpXXnysVGecVXHHKOOl1YhWglmuPvcZhw==
|
||||
-----END CERTIFICATE-----`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "google.com certificate",
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIOITCCDQmgAwIBAgIQKS0IQxknY8USDjt3IYchljANBgkqhkiG9w0BAQsFADA7
|
||||
MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww
|
||||
CgYDVQQDEwNXUjIwHhcNMjUwMjI2MTUzMjU1WhcNMjUwNTIxMTUzMjU0WjAXMRUw
|
||||
EwYDVQQDDAwqLmdvb2dsZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARx
|
||||
nMOmIG3BuO7my/BbF/rGPAMH/JbxBDufbYFQHV+6l5pF5sdT/Zov3X+qsR3IYFl7
|
||||
F2a0gAUmK1Bq7//zTb3uo4IMDjCCDAowDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM
|
||||
MAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFN+aEjBz3PaUtelz
|
||||
3g9rVTkGRgU0MB8GA1UdIwQYMBaAFN4bHu15FdQ+NyTDIbvsNDltQrIwMFgGCCsG
|
||||
AQUFBwEBBEwwSjAhBggrBgEFBQcwAYYVaHR0cDovL28ucGtpLmdvb2cvd3IyMCUG
|
||||
CCsGAQUFBzAChhlodHRwOi8vaS5wa2kuZ29vZy93cjIuY3J0MIIJ5AYDVR0RBIIJ
|
||||
2zCCCdeCDCouZ29vZ2xlLmNvbYIWKi5hcHBlbmdpbmUuZ29vZ2xlLmNvbYIJKi5i
|
||||
ZG4uZGV2ghUqLm9yaWdpbi10ZXN0LmJkbi5kZXaCEiouY2xvdWQuZ29vZ2xlLmNv
|
||||
bYIYKi5jcm93ZHNvdXJjZS5nb29nbGUuY29tghgqLmRhdGFjb21wdXRlLmdvb2ds
|
||||
ZS5jb22CCyouZ29vZ2xlLmNhggsqLmdvb2dsZS5jbIIOKi5nb29nbGUuY28uaW6C
|
||||
DiouZ29vZ2xlLmNvLmpwgg4qLmdvb2dsZS5jby51a4IPKi5nb29nbGUuY29tLmFy
|
||||
gg8qLmdvb2dsZS5jb20uYXWCDyouZ29vZ2xlLmNvbS5icoIPKi5nb29nbGUuY29t
|
||||
LmNvgg8qLmdvb2dsZS5jb20ubXiCDyouZ29vZ2xlLmNvbS50coIPKi5nb29nbGUu
|
||||
Y29tLnZuggsqLmdvb2dsZS5kZYILKi5nb29nbGUuZXOCCyouZ29vZ2xlLmZyggsq
|
||||
Lmdvb2dsZS5odYILKi5nb29nbGUuaXSCCyouZ29vZ2xlLm5sggsqLmdvb2dsZS5w
|
||||
bIILKi5nb29nbGUucHSCDyouZ29vZ2xlYXBpcy5jboIRKi5nb29nbGV2aWRlby5j
|
||||
b22CDCouZ3N0YXRpYy5jboIQKi5nc3RhdGljLWNuLmNvbYIPZ29vZ2xlY25hcHBz
|
||||
LmNughEqLmdvb2dsZWNuYXBwcy5jboIRZ29vZ2xlYXBwcy1jbi5jb22CEyouZ29v
|
||||
Z2xlYXBwcy1jbi5jb22CDGdrZWNuYXBwcy5jboIOKi5na2VjbmFwcHMuY26CEmdv
|
||||
b2dsZWRvd25sb2Fkcy5jboIUKi5nb29nbGVkb3dubG9hZHMuY26CEHJlY2FwdGNo
|
||||
YS5uZXQuY26CEioucmVjYXB0Y2hhLm5ldC5jboIQcmVjYXB0Y2hhLWNuLm5ldIIS
|
||||
Ki5yZWNhcHRjaGEtY24ubmV0ggt3aWRldmluZS5jboINKi53aWRldmluZS5jboIR
|
||||
YW1wcHJvamVjdC5vcmcuY26CEyouYW1wcHJvamVjdC5vcmcuY26CEWFtcHByb2pl
|
||||
Y3QubmV0LmNughMqLmFtcHByb2plY3QubmV0LmNughdnb29nbGUtYW5hbHl0aWNz
|
||||
LWNuLmNvbYIZKi5nb29nbGUtYW5hbHl0aWNzLWNuLmNvbYIXZ29vZ2xlYWRzZXJ2
|
||||
aWNlcy1jbi5jb22CGSouZ29vZ2xlYWRzZXJ2aWNlcy1jbi5jb22CEWdvb2dsZXZh
|
||||
ZHMtY24uY29tghMqLmdvb2dsZXZhZHMtY24uY29tghFnb29nbGVhcGlzLWNuLmNv
|
||||
bYITKi5nb29nbGVhcGlzLWNuLmNvbYIVZ29vZ2xlb3B0aW1pemUtY24uY29tghcq
|
||||
Lmdvb2dsZW9wdGltaXplLWNuLmNvbYISZG91YmxlY2xpY2stY24ubmV0ghQqLmRv
|
||||
dWJsZWNsaWNrLWNuLm5ldIIYKi5mbHMuZG91YmxlY2xpY2stY24ubmV0ghYqLmcu
|
||||
ZG91YmxlY2xpY2stY24ubmV0gg5kb3VibGVjbGljay5jboIQKi5kb3VibGVjbGlj
|
||||
ay5jboIUKi5mbHMuZG91YmxlY2xpY2suY26CEiouZy5kb3VibGVjbGljay5jboIR
|
||||
ZGFydHNlYXJjaC1jbi5uZXSCEyouZGFydHNlYXJjaC1jbi5uZXSCHWdvb2dsZXRy
|
||||
YXZlbGFkc2VydmljZXMtY24uY29tgh8qLmdvb2dsZXRyYXZlbGFkc2VydmljZXMt
|
||||
Y24uY29tghhnb29nbGV0YWdzZXJ2aWNlcy1jbi5jb22CGiouZ29vZ2xldGFnc2Vy
|
||||
dmljZXMtY24uY29tghdnb29nbGV0YWdtYW5hZ2VyLWNuLmNvbYIZKi5nb29nbGV0
|
||||
YWdtYW5hZ2VyLWNuLmNvbYIYZ29vZ2xlc3luZGljYXRpb24tY24uY29tghoqLmdv
|
||||
b2dsZXN5bmRpY2F0aW9uLWNuLmNvbYIkKi5zYWZlZnJhbWUuZ29vZ2xlc3luZGlj
|
||||
YXRpb24tY24uY29tghZhcHAtbWVhc3VyZW1lbnQtY24uY29tghgqLmFwcC1tZWFz
|
||||
dXJlbWVudC1jbi5jb22CC2d2dDEtY24uY29tgg0qLmd2dDEtY24uY29tggtndnQy
|
||||
LWNuLmNvbYINKi5ndnQyLWNuLmNvbYILMm1kbi1jbi5uZXSCDSouMm1kbi1jbi5u
|
||||
ZXSCFGdvb2dsZWZsaWdodHMtY24ubmV0ghYqLmdvb2dsZWZsaWdodHMtY24ubmV0
|
||||
ggxhZG1vYi1jbi5jb22CDiouYWRtb2ItY24uY29tghRnb29nbGVzYW5kYm94LWNu
|
||||
LmNvbYIWKi5nb29nbGVzYW5kYm94LWNuLmNvbYIeKi5zYWZlbnVwLmdvb2dsZXNh
|
||||
bmRib3gtY24uY29tgg0qLmdzdGF0aWMuY29tghQqLm1ldHJpYy5nc3RhdGljLmNv
|
||||
bYIKKi5ndnQxLmNvbYIRKi5nY3BjZG4uZ3Z0MS5jb22CCiouZ3Z0Mi5jb22CDiou
|
||||
Z2NwLmd2dDIuY29tghAqLnVybC5nb29nbGUuY29tghYqLnlvdXR1YmUtbm9jb29r
|
||||
aWUuY29tggsqLnl0aW1nLmNvbYILYW5kcm9pZC5jb22CDSouYW5kcm9pZC5jb22C
|
||||
EyouZmxhc2guYW5kcm9pZC5jb22CBGcuY26CBiouZy5jboIEZy5jb4IGKi5nLmNv
|
||||
ggZnb28uZ2yCCnd3dy5nb28uZ2yCFGdvb2dsZS1hbmFseXRpY3MuY29tghYqLmdv
|
||||
b2dsZS1hbmFseXRpY3MuY29tggpnb29nbGUuY29tghJnb29nbGVjb21tZXJjZS5j
|
||||
b22CFCouZ29vZ2xlY29tbWVyY2UuY29tgghnZ3BodC5jboIKKi5nZ3BodC5jboIK
|
||||
dXJjaGluLmNvbYIMKi51cmNoaW4uY29tggh5b3V0dS5iZYILeW91dHViZS5jb22C
|
||||
DSoueW91dHViZS5jb22CEW11c2ljLnlvdXR1YmUuY29tghMqLm11c2ljLnlvdXR1
|
||||
YmUuY29tghR5b3V0dWJlZWR1Y2F0aW9uLmNvbYIWKi55b3V0dWJlZWR1Y2F0aW9u
|
||||
LmNvbYIPeW91dHViZWtpZHMuY29tghEqLnlvdXR1YmVraWRzLmNvbYIFeXQuYmWC
|
||||
ByoueXQuYmWCGmFuZHJvaWQuY2xpZW50cy5nb29nbGUuY29tghMqLmFuZHJvaWQu
|
||||
Z29vZ2xlLmNughIqLmNocm9tZS5nb29nbGUuY26CFiouZGV2ZWxvcGVycy5nb29n
|
||||
bGUuY26CFSouYWlzdHVkaW8uZ29vZ2xlLmNvbTATBgNVHSAEDDAKMAgGBmeBDAEC
|
||||
ATA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vYy5wa2kuZ29vZy93cjIvb0JGWVlh
|
||||
aHpnVkkuY3JsMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHcAzxFW7tUufK/zh1vZ
|
||||
aS6b6RpxZ0qwF+ysAdJbd87MOwgAAAGVQxqxaQAABAMASDBGAiEAk6r74vfyJIaa
|
||||
hYTWqNRsjl/RpCWq/wyzzMi21zgGmfkCIQCZafyS/fl0tiutICL9aOSnDBRfPYqd
|
||||
CeNqKOy11EjvigB1AN6FgddQJHxrzcuvVjfF54HGTORu1hdjn480pybJ4r03AAAB
|
||||
lUMasUkAAAQDAEYwRAIgYfG2iyRnmn8MI86RFDxOQW1/IOBAjQxNfIQ8toZlZkoC
|
||||
IA1BHw7cqmlTP7Ks+ebX6hGfNlVsgTQS8iYyKL5/BSvTMA0GCSqGSIb3DQEBCwUA
|
||||
A4IBAQAYSNtoW72rqhPfjV5Ug1ENbbimfqmqiJS4JdzaEFRpftzachTuvx8relaY
|
||||
+7FAz5y4YULu9LGNjpBRYW8yW9pgfWyc53CCHSkDODguUOMCRo3hdglxZ2d5pJ/8
|
||||
TQY4zRBd8OHzOAx2kH6jLEj9I0nDie3vowSYm7FCBRLjzfForRNQWmzPu+5hS3De
|
||||
QM0R2jWpmPcG3ffQ5qQwnAQnP9HCK9oEZ5cFqLvOQWfttj/rzKOz856iSEoRpf8S
|
||||
wVFRu3Uv2TXQ6UYF2cDfiWCe6/mO35CIynC6FVkunze/Q/2rtaCDttLRYZcLllj8
|
||||
PSl7nmLhtqDlO7da/S34BFiyyRjN
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "let's encrypt certificate",
|
||||
cert: `-----BEGIN CERTIFICATE-----
|
||||
MIIGMjCCBRqgAwIBAgISBVHH05rEMkaCuDQvABDjiam0MA0GCSqGSIb3DQEBCwUA
|
||||
MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD
|
||||
EwNSMTAwHhcNMjUwMzEzMDIyMzE2WhcNMjUwNjExMDIyMzE1WjAkMSIwIAYDVQQD
|
||||
Exlvei1kZW1vLnBvcnRhaW5lcmNsb3VkLmlvMIICIjANBgkqhkiG9w0BAQEFAAOC
|
||||
Ag8AMIICCgKCAgEAwNCcr9azSaldEwgL54bQScuWBnmw3FMHgEATxDVp2MEawQkV
|
||||
I3VScUcJWBnlHlb7TUanRC/c/vJGbzc+KDuCRTZ2/Ob2yQ9G5mZjGttBAnBSQPpV
|
||||
arEEBFCClhVBn4LhLNmIsCjCy25+m0HY/dwWbKjTMT/KxpTa3L3mdmIFa7XNs6W2
|
||||
vEZGwYM+2JPMJ9DwemVrrrvRqd5vLWTZcWvWJQ7HMfw3PoELpeqyycmxDqd9PCMz
|
||||
yMp8q3UwLDur3+KfDXGtGOoubxcOuJrpemOe8JeM5cEYEhvOy8D16zmWwWYDT19D
|
||||
ElFfUbM0GGITpJ41Qie03DvmI0hDYDqTEZfKza967VsvD7K9bFgLHmHdv7gLNutB
|
||||
FConpziNqslapWwQ5j7bKircxKjRQVkOiXH48m2IUzylqWgJPVMvHukRu0YVnvbt
|
||||
Q53xNVZQEbjvZmIuz8jqo22Y/1Jr7Plnb1lUvvDznA58MHT0KA4LSZwk9tvMJJCw
|
||||
vh7AoWB6/Jnl8QVnApOdCa6M/An128rBwgrCmp0wSvhMecTkWC8/gsah0Q5wKFL3
|
||||
ziBth728Qy8RlNghRUw88e/y4pdGHN8egjK1NpdgsvTFdRNQ8qwu0lx9pO3b6TNQ
|
||||
qDG5pirXjS/DhPYvZtJRDK6SMTHJNm+0NGdWB8qpNssFrU6u2cRl0533LtECAwEA
|
||||
AaOCAk0wggJJMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
|
||||
KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUiQi/3pZamfPxRGPI8DTZ
|
||||
tej1494wHwYDVR0jBBgwFoAUu7zDR6XkvKnGw6RyDBCNojXhyOgwVwYIKwYBBQUH
|
||||
AQEESzBJMCIGCCsGAQUFBzABhhZodHRwOi8vcjEwLm8ubGVuY3Iub3JnMCMGCCsG
|
||||
AQUFBzAChhdodHRwOi8vcjEwLmkubGVuY3Iub3JnLzAkBgNVHREEHTAbghlvei1k
|
||||
ZW1vLnBvcnRhaW5lcmNsb3VkLmlvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC4GA1Ud
|
||||
HwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTAuYy5sZW5jci5vcmcvNTMuY3JsMIIBBAYK
|
||||
KwYBBAHWeQIEAgSB9QSB8gDwAHcAzPsPaoVxCWX+lZtTzumyfCLphVwNl422qX5U
|
||||
wP5MDbAAAAGVjYW7/QAABAMASDBGAiEA8CjMOIj7wqQ60BX22A5pDkA23IxZPzwV
|
||||
1MF5+VSgdqgCIQCZhry5AK2VyZX/cIODEl6eHBCUWS4vHB+J8RxeclKCpAB1AKLj
|
||||
CuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABlY2Fu/QAAAQDAEYwRAIg
|
||||
bwjJgZJew/1LoL9yzDD1P4Xkd8ezFucxfU3AzlV1XEYCIH5RPyW1HP9GSr+aAx+I
|
||||
o3inVl1NagJFYiApAPvFmIEgMA0GCSqGSIb3DQEBCwUAA4IBAQATJWi1sJSBstO+
|
||||
hyH7DsrAtDhiQTOWzUZezBlgCn8hfmA3nX5uKsHyxPPPEQ/GFYOltRD/+34X9kFF
|
||||
YNzUjJOP0bGk45I1JbspxRRvtbDpk0+dj2VE2toM8vLRDz3+DB4YB2lFofYlex++
|
||||
16xFzOIE+ZW41qBs3G8InsyHADsaFY2CQ9re/kZvenptU/ax1U2a21JJ3TT2DmXW
|
||||
AHZYQ5/whVIowsebw1e28I12VhLl2BKn7v4MpCn3GUzBBQAEbJ6TIjHtFKWWnVfH
|
||||
FisaUX6N4hMzGZVJOsbH4QVBGuNwUshHiD8MSpbans2w+T4bCe11XayerqxFhTao
|
||||
w/pjiPVy
|
||||
-----END CERTIFICATE-----
|
||||
`,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tc {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
actual, err := IsSelfSignedCertificate([]byte(tt.cert))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @id GetKubernetesCronJobs
|
||||
// @summary Get a list of kubernetes Cron Jobs
|
||||
// @description Get a list of kubernetes Cron Jobs that the user has access to.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @success 200 {array} models.K8sCronJob "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 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the list of Cron Jobs."
|
||||
// @router /kubernetes/{id}/cron_jobs [get]
|
||||
func (handler *Handler) getAllKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
cli, httpErr := handler.prepareKubeClient(r)
|
||||
if httpErr != nil {
|
||||
log.Error().Err(httpErr).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to prepare kube client")
|
||||
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
|
||||
}
|
||||
|
||||
cronJobs, err := cli.GetCronJobs("")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to fetch Cron Jobs across all namespaces")
|
||||
return httperror.InternalServerError("unable to fetch Cron Jobs. Error: ", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, cronJobs)
|
||||
}
|
||||
|
||||
// @id DeleteCronJobs
|
||||
// @summary Delete Cron Jobs
|
||||
// @description Delete the provided list of Cron Jobs.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @accept json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param payload body models.K8sCronJobDeleteRequests true "A map where the key is the namespace and the value is an array of Cron Jobs to delete"
|
||||
// @success 204 "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 404 "Unable to find an environment with the specified identifier or unable to find a specific service account."
|
||||
// @failure 500 "Server error occurred while attempting to delete Cron Jobs."
|
||||
// @router /kubernetes/{id}/cron_jobs/delete [POST]
|
||||
func (handler *Handler) deleteKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload models.K8sCronJobDeleteRequests
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
cli, handlerErr := handler.getProxyKubeClient(r)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
err = cli.DeleteCronJobs(payload)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete Cron Jobs", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -55,6 +55,10 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
|
||||
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("/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)
|
||||
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @id GetKubernetesJobs
|
||||
// @summary Get a list of kubernetes Jobs
|
||||
// @description Get a list of kubernetes Jobs that the user has access to.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param includeCronJobChildren query bool false "Whether to include Jobs that have a cronjob owner"
|
||||
// @success 200 {array} models.K8sJob "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 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the list of Jobs."
|
||||
// @router /kubernetes/{id}/jobs [get]
|
||||
func (handler *Handler) getAllKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
includeCronJobChildren, err := request.RetrieveBooleanQueryParameter(r, "includeCronJobChildren", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Invalid query parameter includeCronJobChildren")
|
||||
return httperror.BadRequest("an error occurred during the GetAllKubernetesJobs operation, invalid query parameter includeCronJobChildren. Error: ", err)
|
||||
}
|
||||
|
||||
cli, httpErr := handler.prepareKubeClient(r)
|
||||
if httpErr != nil {
|
||||
log.Error().Err(httpErr).Str("context", "GetAllKubernetesJobs").Msg("Unable to prepare kube client")
|
||||
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
|
||||
}
|
||||
|
||||
jobs, err := cli.GetJobs("", includeCronJobChildren)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Unable to fetch Jobs across all namespaces")
|
||||
return httperror.InternalServerError("unable to fetch Jobs. Error: ", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, jobs)
|
||||
}
|
||||
|
||||
// @id DeleteJobs
|
||||
// @summary Delete Jobs
|
||||
// @description Delete the provided list of Jobs.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @accept json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param payload body models.K8sJobDeleteRequests true "A map where the key is the namespace and the value is an array of Jobs to delete"
|
||||
// @success 204 "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 404 "Unable to find an environment with the specified identifier or unable to find a specific service account."
|
||||
// @failure 500 "Server error occurred while attempting to delete Jobs."
|
||||
// @router /kubernetes/{id}/jobs/delete [POST]
|
||||
func (handler *Handler) deleteKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload models.K8sJobDeleteRequests
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
cli, handlerErr := handler.getProxyKubeClient(r)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
err = cli.DeleteJobs(payload)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete Jobs", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -36,5 +36,9 @@ func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *ht
|
||||
return httperror.InternalServerError("Unable to retrieve registries from the database", err)
|
||||
}
|
||||
|
||||
for idx := range registries {
|
||||
hideFields(®istries[idx], false)
|
||||
}
|
||||
|
||||
return response.JSON(w, registries)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/docker/consts"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -62,8 +61,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
h.Handle("/stacks/create/{type}/{method}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/stacks",
|
||||
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/stacks",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
|
||||
h.Handle("/stacks/{id}",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -141,53 +140,3 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
|
||||
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch stackType {
|
||||
case 1:
|
||||
return "swarm", nil
|
||||
case 2:
|
||||
return "standalone", nil
|
||||
case 3:
|
||||
return "kubernetes", nil
|
||||
}
|
||||
|
||||
return "", errors.New(request.ErrInvalidQueryParameter)
|
||||
}
|
||||
|
||||
// @id StackCreate
|
||||
// @summary Deploy a new stack
|
||||
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json,multipart/form-data
|
||||
// @produce json
|
||||
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
|
||||
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
|
||||
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
|
||||
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
|
||||
// @success 200 {object} portainer.Stack
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /stacks [post]
|
||||
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
stackType, err := getStackTypeFromQueryParameter(r)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: type", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
|
||||
}
|
||||
|
||||
@@ -59,10 +59,6 @@ func NewHandler(bouncer security.BouncerService,
|
||||
// Deprecated /status endpoint, will be removed in the future.
|
||||
h.Handle("/status",
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet)
|
||||
h.Handle("/status/version",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.versionDeprecated))).Methods(http.MethodGet)
|
||||
h.Handle("/status/nodes",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusNodesCountDeprecated))).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ package system
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
statusutil "github.com/portainer/portainer/api/internal/nodes"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type nodesCountResponse struct {
|
||||
@@ -31,32 +30,15 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.InternalServerError("Failed to get environment list", err)
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
err = snapshot.FillSnapshotData(handler.dataStore, &endpoints[i])
|
||||
if err != nil {
|
||||
var nodes int
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
|
||||
nodes := statusutil.NodesCount(endpoints)
|
||||
nodes += statusutil.NodesCount([]portainer.Endpoint{endpoint})
|
||||
}
|
||||
|
||||
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
|
||||
}
|
||||
|
||||
// @id statusNodesCount
|
||||
// @summary Retrieve the count of nodes
|
||||
// @deprecated
|
||||
// @description Deprecated: use the `/system/nodes` endpoint instead.
|
||||
// @description **Access policy**: authenticated
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @tags status
|
||||
// @produce json
|
||||
// @success 200 {object} nodesCountResponse "Success"
|
||||
// @failure 500 "Server error"
|
||||
// @router /status/nodes [get]
|
||||
func (handler *Handler) statusNodesCountDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
log.Warn().Msg("The /status/nodes endpoint is deprecated, please use the /system/nodes endpoint instead")
|
||||
|
||||
return handler.systemNodesCount(w, r)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package system
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
plf "github.com/portainer/portainer/api/platform"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
@@ -46,7 +47,12 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
|
||||
|
||||
platform, err := handler.platformService.GetPlatform()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Failed to get platform", err)
|
||||
if !errors.Is(err, plf.ErrNoLocalEnvironment) {
|
||||
return httperror.InternalServerError("Failed to get platform", err)
|
||||
}
|
||||
// If no local environment is detected, we assume the platform is Docker
|
||||
// UI will stop showing the upgrade banner
|
||||
platform = plf.PlatformDocker
|
||||
}
|
||||
|
||||
return response.JSON(w, &systemInfoResponse{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
ceplf "github.com/portainer/portainer/api/platform"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -45,6 +46,9 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
environment, err := handler.platformService.GetLocalEnvironment()
|
||||
if err != nil {
|
||||
if errors.Is(err, ceplf.ErrNoLocalEnvironment) {
|
||||
return httperror.NotFound("The system upgrade feature is disabled because no local environment was detected.", err)
|
||||
}
|
||||
return httperror.InternalServerError("Failed to get local environment", err)
|
||||
}
|
||||
|
||||
@@ -53,8 +57,7 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
|
||||
return httperror.InternalServerError("Failed to get platform", err)
|
||||
}
|
||||
|
||||
err = handler.upgradeService.Upgrade(platform, environment, payload.License)
|
||||
if err != nil {
|
||||
if err := handler.upgradeService.Upgrade(platform, environment, payload.License); err != nil {
|
||||
return httperror.InternalServerError("Failed to upgrade Portainer", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -106,21 +106,3 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
|
||||
|
||||
return currentVersionSemver.LessThan(*latestVersionSemver)
|
||||
}
|
||||
|
||||
// @id Version
|
||||
// @summary Check for portainer updates
|
||||
// @deprecated
|
||||
// @description Deprecated: use the `/system/version` endpoint instead.
|
||||
// @description Check if portainer has an update available
|
||||
// @description **Access policy**: authenticated
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @tags status
|
||||
// @produce json
|
||||
// @success 200 {object} versionResponse "Success"
|
||||
// @router /status/version [get]
|
||||
func (handler *Handler) versionDeprecated(w http.ResponseWriter, r *http.Request) {
|
||||
log.Warn().Msg("The /status/version endpoint is deprecated, please use the /system/version endpoint instead")
|
||||
|
||||
handler.version(w, r)
|
||||
}
|
||||
|
||||
@@ -133,10 +133,17 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
|
||||
|
||||
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
|
||||
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
|
||||
if err != nil {
|
||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if endpointRelation == nil {
|
||||
endpointRelation = &portainer.EndpointRelation{
|
||||
EndpointID: endpoint.ID,
|
||||
EdgeStacks: make(map[portainer.EdgeStackID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -147,6 +154,7 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
|
||||
for _, edgeStackID := range endpointStacks {
|
||||
stacksSet[edgeStackID] = true
|
||||
}
|
||||
|
||||
endpointRelation.EdgeStacks = stacksSet
|
||||
|
||||
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
|
||||
|
||||
@@ -29,7 +29,5 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
|
||||
h.Handle("/templates/{id}/file",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
|
||||
h.Handle("/templates/file",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFileOld))).Methods(http.MethodPost)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
type filePayload struct {
|
||||
// URL of a git repository where the file is stored
|
||||
RepositoryURL string `example:"https://github.com/portainer/portainer-compose" validate:"required"`
|
||||
// Path to the file inside the git repository
|
||||
ComposeFilePathInRepository string `example:"./subfolder/docker-compose.yml" validate:"required"`
|
||||
}
|
||||
|
||||
func (payload *filePayload) Validate(r *http.Request) error {
|
||||
if len(payload.RepositoryURL) == 0 {
|
||||
return errors.New("Invalid repository url")
|
||||
}
|
||||
|
||||
if len(payload.ComposeFilePathInRepository) == 0 {
|
||||
return errors.New("Invalid file path")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperror.HandlerError {
|
||||
response, httpErr := handler.fetchTemplates()
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
for _, t := range response.Templates {
|
||||
if t.Repository.URL == payload.RepositoryURL && t.Repository.StackFile == payload.ComposeFilePathInRepository {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
|
||||
}
|
||||
|
||||
// @id TemplateFileOld
|
||||
// @summary Get a template's file
|
||||
// @deprecated
|
||||
// @description Get a template's file
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags templates
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body filePayload true "File details"
|
||||
// @success 200 {object} fileResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /templates/file [post]
|
||||
func (handler *Handler) templateFileOld(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
log.Warn().Msg("This api is deprecated. Please use /templates/{id}/file instead")
|
||||
|
||||
var payload filePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
if err := handler.ifRequestedTemplateExists(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projectPath, err := handler.FileService.GetTemporaryPath()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to create temporary folder", err)
|
||||
}
|
||||
|
||||
defer handler.cleanUp(projectPath)
|
||||
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to clone git repository", err)
|
||||
}
|
||||
|
||||
fileContent, err := handler.FileService.GetFileContent(projectPath, payload.ComposeFilePathInRepository)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Failed loading file content", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, fileResponse{FileContent: string(fileContent)})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type K8sCronJob struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Namespace string `json:"Namespace"`
|
||||
Command string `json:"Command"`
|
||||
Schedule string `json:"Schedule"`
|
||||
Timezone string `json:"Timezone"`
|
||||
Suspend bool `json:"Suspend"`
|
||||
Jobs []K8sJob `json:"Jobs"`
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
}
|
||||
|
||||
type (
|
||||
K8sCronJobDeleteRequests map[string][]string
|
||||
)
|
||||
|
||||
func (r K8sCronJobDeleteRequests) Validate(request *http.Request) error {
|
||||
if len(r) == 0 {
|
||||
return errors.New("missing deletion request list in payload")
|
||||
}
|
||||
|
||||
for ns := range r {
|
||||
if len(ns) == 0 {
|
||||
return errors.New("deletion given with empty namespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
// K8sJob struct
|
||||
type K8sJob struct {
|
||||
ID string `json:"Id"`
|
||||
Namespace string `json:"Namespace"`
|
||||
Name string `json:"Name"`
|
||||
PodName string `json:"PodName"`
|
||||
Container corev1.Container `json:"Container,omitempty"`
|
||||
Command string `json:"Command,omitempty"`
|
||||
BackoffLimit int32 `json:"BackoffLimit,omitempty"`
|
||||
Completions int32 `json:"Completions,omitempty"`
|
||||
StartTime string `json:"StartTime"`
|
||||
FinishTime string `json:"FinishTime"`
|
||||
Duration string `json:"Duration"`
|
||||
Status string `json:"Status"`
|
||||
FailedReason string `json:"FailedReason"`
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
}
|
||||
|
||||
type (
|
||||
K8sJobDeleteRequests map[string][]string
|
||||
)
|
||||
|
||||
func (r K8sJobDeleteRequests) Validate(request *http.Request) error {
|
||||
if len(r) == 0 {
|
||||
return errors.New("missing deletion request list in payload")
|
||||
}
|
||||
|
||||
for ns := range r {
|
||||
if len(ns) == 0 {
|
||||
return errors.New("deletion given with empty namespace")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -35,8 +35,8 @@ type (
|
||||
}
|
||||
|
||||
K8sServiceIngress struct {
|
||||
IP string `json:"IP"`
|
||||
Host string `json:"Host"`
|
||||
IP string `json:"IP"`
|
||||
Hostname string `json:"Hostname"`
|
||||
}
|
||||
|
||||
// K8sServiceDeleteRequests is a mapping of namespace names to a slice of
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -37,6 +38,8 @@ type (
|
||||
dockerClientFactory *dockerclient.ClientFactory
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
dockerID string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// TransportParameters is used to create a new Transport
|
||||
@@ -679,9 +682,7 @@ func (transport *Transport) executeGenericResourceDeletionOperation(request *htt
|
||||
}
|
||||
|
||||
if resourceControl != nil {
|
||||
if err := transport.dataStore.ResourceControl().Delete(resourceControl.ID); err != nil {
|
||||
return response, err
|
||||
}
|
||||
err = transport.dataStore.ResourceControl().Delete(resourceControl.ID)
|
||||
}
|
||||
|
||||
return response, err
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const volumeObjectIdentifier = "ResourceID"
|
||||
@@ -50,15 +49,6 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
|
||||
volumeData := responseObject["Volumes"].([]any)
|
||||
|
||||
if transport.snapshotService != nil {
|
||||
// Filling snapshot data can improve the performance of getVolumeResourceID
|
||||
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
|
||||
log.Info().Err(err).
|
||||
Int("endpoint id", int(transport.endpoint.ID)).
|
||||
Msg("snapshot is not filled into the endpoint.")
|
||||
}
|
||||
}
|
||||
|
||||
for _, volumeObject := range volumeData {
|
||||
volume := volumeObject.(map[string]any)
|
||||
|
||||
@@ -147,7 +137,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
if _, err = cli.VolumeInspect(context.Background(), volumeID); err == nil {
|
||||
if _, err := cli.VolumeInspect(context.Background(), volumeID); err == nil {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusConflict,
|
||||
}, errors.New("a volume with the same name already exists")
|
||||
@@ -222,14 +212,27 @@ func (transport *Transport) getVolumeResourceID(volumeName string) (string, erro
|
||||
}
|
||||
|
||||
func (transport *Transport) getDockerID() (string, error) {
|
||||
if len(transport.endpoint.Snapshots) > 0 {
|
||||
dockerID, err := snapshot.FetchDockerID(transport.endpoint.Snapshots[0])
|
||||
// ignore err - in case of error, just generate not from snapshot
|
||||
if err == nil {
|
||||
return dockerID, nil
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
|
||||
// Local cache
|
||||
if transport.dockerID != "" {
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
// Snapshot cache
|
||||
if transport.snapshotService != nil {
|
||||
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
|
||||
|
||||
if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
|
||||
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
|
||||
transport.dockerID = dockerID
|
||||
return dockerID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remote value
|
||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -242,8 +245,11 @@ func (transport *Transport) getDockerID() (string, error) {
|
||||
}
|
||||
|
||||
if info.Swarm.Cluster != nil {
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
transport.dockerID = info.Swarm.Cluster.ID
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
return info.ID, nil
|
||||
transport.dockerID = info.ID
|
||||
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
+6
-8
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||
@@ -68,7 +67,7 @@ import (
|
||||
"github.com/portainer/portainer/api/platform"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -104,7 +103,7 @@ type Server struct {
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
HelmPackageManager libhelm.HelmPackageManager
|
||||
HelmPackageManager libhelmtypes.HelmPackageManager
|
||||
Scheduler *scheduler.Scheduler
|
||||
ShutdownCtx context.Context
|
||||
ShutdownTrigger context.CancelFunc
|
||||
@@ -161,14 +160,14 @@ 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
|
||||
|
||||
var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer)
|
||||
edgeTemplatesHandler.DataStore = server.DataStore
|
||||
|
||||
var endpointHandler = endpoints.NewHandler(requestBouncer)
|
||||
endpointHandler.DataStore = server.DataStore
|
||||
endpointHandler.FileService = server.FileService
|
||||
@@ -303,7 +302,6 @@ func (server *Server) Start() error {
|
||||
EdgeGroupsHandler: edgeGroupsHandler,
|
||||
EdgeJobsHandler: edgeJobsHandler,
|
||||
EdgeStacksHandler: edgeStacksHandler,
|
||||
EdgeTemplatesHandler: edgeTemplatesHandler,
|
||||
EndpointGroupHandler: endpointGroupHandler,
|
||||
EndpointHandler: endpointHandler,
|
||||
EndpointHelmHandler: endpointHelmHandler,
|
||||
|
||||
@@ -99,12 +99,15 @@ func (service *Service) PersistEdgeStack(
|
||||
stack.ManifestPath = manifestPath
|
||||
stack.ProjectPath = projectPath
|
||||
stack.EntryPoint = composePath
|
||||
stack.NumDeployments = len(relatedEndpointIds)
|
||||
|
||||
if err := tx.EdgeStack().Create(stack.ID, stack); 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)
|
||||
}
|
||||
|
||||
if err := service.updateEndpointRelations(tx, stack.ID, relatedEndpointIds); err != nil {
|
||||
return nil, fmt.Errorf("unable to update endpoint relations: %w", err)
|
||||
}
|
||||
@@ -119,6 +122,9 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg
|
||||
for _, endpointID := range relatedEndpointIds {
|
||||
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
||||
}
|
||||
|
||||
@@ -144,17 +150,8 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
for _, endpointID := range relatedEndpointIds {
|
||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
delete(relation.EdgeStacks, edgeStackID)
|
||||
|
||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
||||
return errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||
}
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEndpointIds, edgeStackID); err != nil {
|
||||
return errors.WithMessage(err, "unable to remove environment relation in database")
|
||||
}
|
||||
|
||||
if err := tx.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, rel
|
||||
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
||||
|
||||
for _, environmentID := range relatedEnvironmentIDs {
|
||||
|
||||
newEnvStatus := portainer.EdgeStackStatus{
|
||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||
EndpointID: environmentID,
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Service repesents a service to manage environment(endpoint) snapshots.
|
||||
// Service represents a service to manage environment(endpoint) snapshots.
|
||||
// It provides an interface to start background snapshots as well as
|
||||
// specific Docker/Kubernetes environment(endpoint) snapshot methods.
|
||||
type Service struct {
|
||||
@@ -174,30 +174,6 @@ func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
|
||||
return FillSnapshotData(service.dataStore, endpoint)
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
|
||||
}
|
||||
|
||||
if snapshot.Kubernetes != nil {
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
|
||||
kubernetesSnapshot, err := service.kubernetesSnapshotter.CreateSnapshot(endpoint)
|
||||
if err != nil {
|
||||
@@ -285,11 +261,16 @@ func (service *Service) snapshotEndpoints() error {
|
||||
|
||||
snapshotError := service.SnapshotEndpoint(&endpoint)
|
||||
|
||||
service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
updateEndpointStatus(tx, &endpoint, snapshotError, service.pendingActionsService)
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("unable to update environment status")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -340,12 +321,31 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
swarmInfo := info.Swarm
|
||||
if swarmInfo.Cluster == nil {
|
||||
if info.Swarm.Cluster == nil {
|
||||
return "", errors.New("swarm environment is missing cluster info snapshot")
|
||||
}
|
||||
|
||||
clusterInfo := swarmInfo.Cluster
|
||||
|
||||
return clusterInfo.ID, nil
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
|
||||
}
|
||||
|
||||
if snapshot.Kubernetes != nil {
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-10
@@ -64,8 +64,7 @@ func (service *Service) Init(host, certPath, keyPath string) error {
|
||||
// path not supplied and certificates doesn't exist - generate self-signed
|
||||
certPath, keyPath = service.fileService.GetDefaultSSLCertsPath()
|
||||
|
||||
err = generateSelfSignedCertificates(host, certPath, keyPath)
|
||||
if err != nil {
|
||||
if err := generateSelfSignedCertificates(host, certPath, keyPath); err != nil {
|
||||
return errors.Wrap(err, "failed generating self signed certs")
|
||||
}
|
||||
|
||||
@@ -98,8 +97,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
|
||||
return errors.New("missing certificate files")
|
||||
}
|
||||
|
||||
_, err := tls.X509KeyPair(certData, keyData)
|
||||
if err != nil {
|
||||
if _, err := tls.X509KeyPair(certData, keyData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -108,8 +106,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = service.cacheInfo(certPath, keyPath, false)
|
||||
if err != nil {
|
||||
if err := service.cacheInfo(certPath, keyPath, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -130,8 +127,7 @@ func (service *Service) SetHTTPEnabled(httpEnabled bool) error {
|
||||
|
||||
settings.HTTPEnabled = httpEnabled
|
||||
|
||||
err = service.dataStore.SSLSettings().UpdateSettings(settings)
|
||||
if err != nil {
|
||||
if err := service.dataStore.SSLSettings().UpdateSettings(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -152,8 +148,7 @@ func (service *Service) cacheCertificate(certPath, keyPath string) error {
|
||||
}
|
||||
|
||||
func (service *Service) cacheInfo(certPath string, keyPath string, selfSigned bool) error {
|
||||
err := service.cacheCertificate(certPath, keyPath)
|
||||
if err != nil {
|
||||
if err := service.cacheCertificate(certPath, keyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
)
|
||||
|
||||
var _ dataservices.DataStore = &testDatastore{}
|
||||
|
||||
type testDatastore struct {
|
||||
customTemplate dataservices.CustomTemplateService
|
||||
edgeGroup dataservices.EdgeGroupService
|
||||
@@ -227,6 +229,30 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == endpointID {
|
||||
s.relations[i].EdgeStacks[edgeStackID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == endpointID {
|
||||
delete(s.relations[i].EdgeStacks, edgeStackID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
+280
-130
@@ -12,45 +12,58 @@ import (
|
||||
labels "k8s.io/apimachinery/pkg/labels"
|
||||
)
|
||||
|
||||
// PortainerApplicationResources contains collections of various Kubernetes resources
|
||||
// associated with a Portainer application.
|
||||
type PortainerApplicationResources struct {
|
||||
Pods []corev1.Pod
|
||||
ReplicaSets []appsv1.ReplicaSet
|
||||
Deployments []appsv1.Deployment
|
||||
StatefulSets []appsv1.StatefulSet
|
||||
DaemonSets []appsv1.DaemonSet
|
||||
Services []corev1.Service
|
||||
HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler
|
||||
}
|
||||
|
||||
// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster
|
||||
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
|
||||
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
|
||||
func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||
if kcl.IsKubeAdmin {
|
||||
return kcl.fetchApplications(namespace, nodeName, withDependencies)
|
||||
return kcl.fetchApplications(namespace, nodeName)
|
||||
}
|
||||
|
||||
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies)
|
||||
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
|
||||
}
|
||||
|
||||
// fetchApplications fetches the applications in the namespaces the user has access to.
|
||||
// This function is called when the user is an admin.
|
||||
func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||
podListOptions := metav1.ListOptions{}
|
||||
if nodeName != "" {
|
||||
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
||||
}
|
||||
if !withDependencies {
|
||||
// TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
|
||||
pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
|
||||
}
|
||||
|
||||
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
||||
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(applications, unhealthyApplications...), nil
|
||||
}
|
||||
|
||||
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
|
||||
// This function is called when the user is not an admin.
|
||||
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
@@ -62,28 +75,24 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
|
||||
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
||||
}
|
||||
|
||||
if !withDependencies {
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
||||
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||
results := make([]models.K8sApplication, 0)
|
||||
for _, application := range applications {
|
||||
for _, application := range append(applications, unhealthyApplications...) {
|
||||
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
|
||||
results = append(results, application)
|
||||
}
|
||||
@@ -93,11 +102,11 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
|
||||
}
|
||||
|
||||
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
|
||||
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
|
||||
applications := []models.K8sApplication{}
|
||||
processedOwners := make(map[string]struct{})
|
||||
|
||||
for _, pod := range pods {
|
||||
for _, pod := range portainerApplicationResources.Pods {
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
ownerUID := string(pod.OwnerReferences[0].UID)
|
||||
if _, exists := processedOwners[ownerUID]; exists {
|
||||
@@ -106,7 +115,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
|
||||
processedOwners[ownerUID] = struct{}{}
|
||||
}
|
||||
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
|
||||
application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,7 +160,9 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,7 +179,9 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,12 +194,12 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
|
||||
}
|
||||
|
||||
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
|
||||
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
|
||||
if isReplicaSetOwner(pod) {
|
||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
||||
updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
|
||||
}
|
||||
|
||||
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
|
||||
application := createApplicationFromPod(&pod, portainerApplicationResources)
|
||||
if application.ID == "" && application.Name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -203,9 +216,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// createApplication creates a K8sApplication object from a pod
|
||||
// createApplicationFromPod creates a K8sApplication object from a pod
|
||||
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
|
||||
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
|
||||
func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
|
||||
kind := "Pod"
|
||||
name := pod.Name
|
||||
|
||||
@@ -221,120 +234,172 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
|
||||
|
||||
switch kind {
|
||||
case "Deployment":
|
||||
for _, deployment := range deployments {
|
||||
for _, deployment := range portainerApplicationResources.Deployments {
|
||||
if deployment.Name == name && deployment.Namespace == pod.Namespace {
|
||||
application.ApplicationType = "Deployment"
|
||||
application.Kind = "Deployment"
|
||||
application.ID = string(deployment.UID)
|
||||
application.ResourcePool = deployment.Namespace
|
||||
application.Name = name
|
||||
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = deployment.Labels
|
||||
application.MatchLabels = deployment.Spec.Selector.MatchLabels
|
||||
application.CreationDate = deployment.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(deployment.Status.Replicas)
|
||||
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: deployment.Labels,
|
||||
}
|
||||
|
||||
populateApplicationFromDeployment(&application, deployment)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "StatefulSet":
|
||||
for _, statefulSet := range statefulSets {
|
||||
for _, statefulSet := range portainerApplicationResources.StatefulSets {
|
||||
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
|
||||
application.Kind = "StatefulSet"
|
||||
application.ApplicationType = "StatefulSet"
|
||||
application.ID = string(statefulSet.UID)
|
||||
application.ResourcePool = statefulSet.Namespace
|
||||
application.Name = name
|
||||
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = statefulSet.Labels
|
||||
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = statefulSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(statefulSet.Status.Replicas)
|
||||
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: statefulSet.Labels,
|
||||
}
|
||||
|
||||
populateApplicationFromStatefulSet(&application, statefulSet)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "DaemonSet":
|
||||
for _, daemonSet := range daemonSets {
|
||||
for _, daemonSet := range portainerApplicationResources.DaemonSets {
|
||||
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
|
||||
application.Kind = "DaemonSet"
|
||||
application.ApplicationType = "DaemonSet"
|
||||
application.ID = string(daemonSet.UID)
|
||||
application.ResourcePool = daemonSet.Namespace
|
||||
application.Name = name
|
||||
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = daemonSet.Labels
|
||||
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = daemonSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
|
||||
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
||||
application.DeploymentType = "Global"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: daemonSet.Labels,
|
||||
}
|
||||
|
||||
populateApplicationFromDaemonSet(&application, daemonSet)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "Pod":
|
||||
runningPodsCount := 1
|
||||
if pod.Status.Phase != corev1.PodRunning {
|
||||
runningPodsCount = 0
|
||||
}
|
||||
|
||||
application.ApplicationType = "Pod"
|
||||
application.Kind = "Pod"
|
||||
application.ID = string(pod.UID)
|
||||
application.ResourcePool = pod.Namespace
|
||||
application.Name = pod.Name
|
||||
application.Image = pod.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = pod.Labels
|
||||
application.MatchLabels = pod.Labels
|
||||
application.CreationDate = pod.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 1
|
||||
application.RunningPodsCount = runningPodsCount
|
||||
application.DeploymentType = string(pod.Status.Phase)
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: pod.Labels,
|
||||
}
|
||||
populateApplicationFromPod(&application, *pod)
|
||||
}
|
||||
|
||||
if application.ID != "" && application.Name != "" && len(services) > 0 {
|
||||
updateApplicationWithService(&application, services)
|
||||
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 {
|
||||
updateApplicationWithService(&application, portainerApplicationResources.Services)
|
||||
}
|
||||
|
||||
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
|
||||
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
|
||||
}
|
||||
|
||||
return application
|
||||
}
|
||||
|
||||
// createApplicationFromDeployment creates a K8sApplication from a Deployment
|
||||
func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication {
|
||||
var app models.K8sApplication
|
||||
populateApplicationFromDeployment(&app, deployment)
|
||||
return app
|
||||
}
|
||||
|
||||
// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet
|
||||
func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication {
|
||||
var app models.K8sApplication
|
||||
populateApplicationFromStatefulSet(&app, statefulSet)
|
||||
return app
|
||||
}
|
||||
|
||||
// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet
|
||||
func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication {
|
||||
var app models.K8sApplication
|
||||
populateApplicationFromDaemonSet(&app, daemonSet)
|
||||
return app
|
||||
}
|
||||
|
||||
func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) {
|
||||
application.ApplicationType = "Deployment"
|
||||
application.Kind = "Deployment"
|
||||
application.ID = string(deployment.UID)
|
||||
application.ResourcePool = deployment.Namespace
|
||||
application.Name = deployment.Name
|
||||
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = deployment.Labels
|
||||
application.MatchLabels = deployment.Spec.Selector.MatchLabels
|
||||
application.CreationDate = deployment.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 0
|
||||
if deployment.Spec.Replicas != nil {
|
||||
application.TotalPodsCount = int(*deployment.Spec.Replicas)
|
||||
}
|
||||
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: deployment.Labels,
|
||||
}
|
||||
|
||||
// If the deployment has containers, use the first container's image
|
||||
if len(deployment.Spec.Template.Spec.Containers) > 0 {
|
||||
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) {
|
||||
application.Kind = "StatefulSet"
|
||||
application.ApplicationType = "StatefulSet"
|
||||
application.ID = string(statefulSet.UID)
|
||||
application.ResourcePool = statefulSet.Namespace
|
||||
application.Name = statefulSet.Name
|
||||
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = statefulSet.Labels
|
||||
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = statefulSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 0
|
||||
if statefulSet.Spec.Replicas != nil {
|
||||
application.TotalPodsCount = int(*statefulSet.Spec.Replicas)
|
||||
}
|
||||
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: statefulSet.Labels,
|
||||
}
|
||||
|
||||
// If the statefulSet has containers, use the first container's image
|
||||
if len(statefulSet.Spec.Template.Spec.Containers) > 0 {
|
||||
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) {
|
||||
application.Kind = "DaemonSet"
|
||||
application.ApplicationType = "DaemonSet"
|
||||
application.ID = string(daemonSet.UID)
|
||||
application.ResourcePool = daemonSet.Namespace
|
||||
application.Name = daemonSet.Name
|
||||
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = daemonSet.Labels
|
||||
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = daemonSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
|
||||
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
||||
application.DeploymentType = "Global"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: daemonSet.Labels,
|
||||
}
|
||||
|
||||
if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
|
||||
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
func populateApplicationFromPod(application *models.K8sApplication, pod corev1.Pod) {
|
||||
runningPodsCount := 1
|
||||
if pod.Status.Phase != corev1.PodRunning {
|
||||
runningPodsCount = 0
|
||||
}
|
||||
|
||||
application.ApplicationType = "Pod"
|
||||
application.Kind = "Pod"
|
||||
application.ID = string(pod.UID)
|
||||
application.ResourcePool = pod.Namespace
|
||||
application.Name = pod.Name
|
||||
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = pod.Labels
|
||||
application.MatchLabels = pod.Labels
|
||||
application.CreationDate = pod.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 1
|
||||
application.RunningPodsCount = runningPodsCount
|
||||
application.DeploymentType = string(pod.Status.Phase)
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: pod.Labels,
|
||||
}
|
||||
|
||||
// If the pod has containers, use the first container's image
|
||||
if len(pod.Spec.Containers) > 0 {
|
||||
application.Image = pod.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
// updateApplicationWithService updates the application with the services that match the application's selector match labels
|
||||
// and are in the same namespace as the application
|
||||
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
|
||||
@@ -410,7 +475,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -436,7 +503,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -454,3 +523,84 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
|
||||
|
||||
return configurationOwners, nil
|
||||
}
|
||||
|
||||
// fetchUnhealthyApplications fetches applications that failed to schedule any pods
|
||||
// due to issues like missing resource limits or other scheduling constraints
|
||||
func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) {
|
||||
var unhealthyApplications []models.K8sApplication
|
||||
|
||||
// Process Deployments
|
||||
for _, deployment := range resources.Deployments {
|
||||
if hasNoScheduledPods(deployment) {
|
||||
app := createApplicationFromDeployment(deployment)
|
||||
addRelatedResourcesToApplication(&app, resources)
|
||||
unhealthyApplications = append(unhealthyApplications, app)
|
||||
}
|
||||
}
|
||||
|
||||
// Process StatefulSets
|
||||
for _, statefulSet := range resources.StatefulSets {
|
||||
if hasNoScheduledPods(statefulSet) {
|
||||
app := createApplicationFromStatefulSet(statefulSet)
|
||||
addRelatedResourcesToApplication(&app, resources)
|
||||
unhealthyApplications = append(unhealthyApplications, app)
|
||||
}
|
||||
}
|
||||
|
||||
// Process DaemonSets
|
||||
for _, daemonSet := range resources.DaemonSets {
|
||||
if hasNoScheduledPods(daemonSet) {
|
||||
app := createApplicationFromDaemonSet(daemonSet)
|
||||
addRelatedResourcesToApplication(&app, resources)
|
||||
unhealthyApplications = append(unhealthyApplications, app)
|
||||
}
|
||||
}
|
||||
|
||||
return unhealthyApplications, nil
|
||||
}
|
||||
|
||||
// addRelatedResourcesToApplication adds Services and HPA information to the application
|
||||
func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) {
|
||||
if app.ID == "" || app.Name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources.Services) > 0 {
|
||||
updateApplicationWithService(app, resources.Services)
|
||||
}
|
||||
|
||||
if len(resources.HorizontalPodAutoscalers) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers)
|
||||
}
|
||||
}
|
||||
|
||||
// hasNoScheduledPods checks if a workload has completely failed to schedule any pods
|
||||
// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running
|
||||
// if any pods exist at all (even if not ready), it returns false
|
||||
func hasNoScheduledPods(obj interface{}) bool {
|
||||
switch resource := obj.(type) {
|
||||
case appsv1.Deployment:
|
||||
if resource.Status.Replicas > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0
|
||||
|
||||
case appsv1.StatefulSet:
|
||||
if resource.Status.Replicas > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0
|
||||
|
||||
case appsv1.DaemonSet:
|
||||
if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// Helper functions to create test resources
|
||||
func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("deploy-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "nginx:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.DeploymentStatus{
|
||||
Replicas: replicas,
|
||||
ReadyReplicas: replicas,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
|
||||
return &appsv1.ReplicaSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("rs-" + name),
|
||||
OwnerReferences: []metav1.OwnerReference{
|
||||
{
|
||||
Kind: "Deployment",
|
||||
Name: deploymentName,
|
||||
UID: types.UID("deploy-" + deploymentName),
|
||||
},
|
||||
},
|
||||
},
|
||||
Spec: appsv1.ReplicaSetSpec{
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": deploymentName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
|
||||
return &appsv1.StatefulSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("sts-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "redis:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.StatefulSetStatus{
|
||||
Replicas: replicas,
|
||||
ReadyReplicas: replicas,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
|
||||
return &appsv1.DaemonSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("ds-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DaemonSetSpec{
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "fluentd:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.DaemonSetStatus{
|
||||
DesiredNumberScheduled: 2,
|
||||
NumberReady: 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
|
||||
phase := corev1.PodPending
|
||||
if isRunning {
|
||||
phase = corev1.PodRunning
|
||||
}
|
||||
|
||||
var ownerReferences []metav1.OwnerReference
|
||||
if ownerKind != "" && ownerName != "" {
|
||||
ownerReferences = []metav1.OwnerReference{
|
||||
{
|
||||
Kind: ownerKind,
|
||||
Name: ownerName,
|
||||
UID: types.UID(ownerKind + "-" + ownerName),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("pod-" + name),
|
||||
OwnerReferences: ownerReferences,
|
||||
Labels: map[string]string{
|
||||
"app": ownerName,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "container-" + name,
|
||||
Image: "busybox:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1.PodStatus{
|
||||
Phase: phase,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("svc-" + name),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: selector,
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApplications(t *testing.T) {
|
||||
t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup the test namespace
|
||||
namespace := "test-namespace"
|
||||
defaultNamespace := "default"
|
||||
|
||||
// Create resources in the test namespace
|
||||
// 1. Deployment with pods
|
||||
deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
|
||||
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
|
||||
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 2. Deployment without pods (scaled to 0)
|
||||
deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
|
||||
_, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 3. StatefulSet with pods
|
||||
stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 4. StatefulSet without pods
|
||||
stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 5. DaemonSet with pods
|
||||
dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
|
||||
_, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 6. Naked Pod (no owner reference)
|
||||
nakedPod := createTestPod("naked-pod", namespace, "", "", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 7. Resources in another namespace
|
||||
deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
|
||||
_, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
|
||||
_, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 8. Add a service (dependency)
|
||||
service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
|
||||
_, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient with admin privileges
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: true,
|
||||
}
|
||||
|
||||
// Test cases
|
||||
|
||||
// 1. All resources, no filtering
|
||||
t.Run("All resources with dependencies", func(t *testing.T) {
|
||||
apps, err := kubeClient.GetApplications("", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
|
||||
// Note: Each controller with pods should count once, not per pod
|
||||
assert.Equal(t, 7, len(apps))
|
||||
|
||||
// Verify one of the deployments has services attached
|
||||
appsWithServices := []models.K8sApplication{}
|
||||
for _, app := range apps {
|
||||
if len(app.Services) > 0 {
|
||||
appsWithServices = append(appsWithServices, app)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, len(appsWithServices))
|
||||
assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
|
||||
})
|
||||
|
||||
// 2. Filter by namespace
|
||||
t.Run("Filter by namespace", func(t *testing.T) {
|
||||
apps, err := kubeClient.GetApplications(namespace, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 6 resources in the test namespace
|
||||
assert.Equal(t, 6, len(apps))
|
||||
|
||||
// Verify resources from other namespaces are not included
|
||||
for _, app := range apps {
|
||||
assert.Equal(t, namespace, app.ResourcePool)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup the test namespaces
|
||||
namespace1 := "allowed-ns"
|
||||
namespace2 := "restricted-ns"
|
||||
|
||||
// Create resources in the allowed namespace
|
||||
sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
|
||||
_, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Add a StatefulSet without pods in the allowed namespace
|
||||
stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create resources in the restricted namespace
|
||||
sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient with non-admin privileges (only allowed namespace1)
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: false,
|
||||
NonAdminNamespaces: []string{namespace1},
|
||||
}
|
||||
|
||||
// Test that only resources from allowed namespace are returned
|
||||
apps, err := kubeClient.GetApplications("", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
|
||||
assert.Equal(t, 2, len(apps))
|
||||
|
||||
// Verify resources are from the allowed namespace
|
||||
for _, app := range apps {
|
||||
assert.Equal(t, namespace1, app.ResourcePool)
|
||||
assert.Equal(t, "StatefulSet", app.Kind)
|
||||
}
|
||||
|
||||
// Verify names of returned resources
|
||||
stsNames := make(map[string]bool)
|
||||
for _, app := range apps {
|
||||
stsNames[app.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
|
||||
assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
|
||||
})
|
||||
|
||||
t.Run("Filter by node name", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup test namespace
|
||||
namespace := "node-filter-ns"
|
||||
nodeName := "worker-node-1"
|
||||
|
||||
// Create a deployment with pods on specific node
|
||||
deploy := createTestDeployment("node-deploy", namespace, 2)
|
||||
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create ReplicaSet for the deployment
|
||||
rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
|
||||
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create 2 pods, one on the specified node, one on a different node
|
||||
pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||
pod1.Spec.NodeName = nodeName
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||
pod2.Spec.NodeName = "worker-node-2"
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: true,
|
||||
}
|
||||
|
||||
// Test filtering by node name
|
||||
apps, err := kubeClient.GetApplications(namespace, nodeName)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect to find only the pod on the specified node
|
||||
assert.Equal(t, 1, len(apps))
|
||||
if len(apps) > 0 {
|
||||
assert.Equal(t, "node-deploy", apps[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, e
|
||||
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
|
||||
// This function is called when the user is not an admin.
|
||||
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
|
||||
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
return nil, nil
|
||||
@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
|
||||
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
|
||||
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
|
||||
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8s
|
||||
for index, configMap := range configMaps {
|
||||
updatedConfigMap := configMap
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, replicaSets)
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetCronJobs returns all cronjobs in the given namespace
|
||||
// If the user is a kube admin, it returns all cronjobs in the namespace
|
||||
// Otherwise, it returns only the cronjobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) {
|
||||
if kcl.IsKubeAdmin {
|
||||
return kcl.fetchCronJobs(namespace)
|
||||
}
|
||||
|
||||
return kcl.fetchCronJobsForNonAdmin(namespace)
|
||||
}
|
||||
|
||||
// fetchCronJobsForNonAdmin returns all cronjobs in the given namespace
|
||||
// It returns only the cronjobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) fetchCronJobsForNonAdmin(namespace string) ([]models.K8sCronJob, error) {
|
||||
cronJobs, err := kcl.fetchCronJobs(namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||
results := make([]models.K8sCronJob, 0)
|
||||
for _, cronJob := range cronJobs {
|
||||
if _, ok := nonAdminNamespaceSet[cronJob.Namespace]; ok {
|
||||
results = append(results, cronJob)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// fetchCronJobs returns all cronjobs in the given namespace
|
||||
// It returns all cronjobs in the namespace
|
||||
func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, error) {
|
||||
cronJobs, err := kcl.cli.BatchV1().CronJobs(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]models.K8sCronJob, 0)
|
||||
for _, cronJob := range cronJobs.Items {
|
||||
results = append(results, kcl.parseCronJob(cronJob, jobs))
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
|
||||
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
|
||||
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
|
||||
if err != nil {
|
||||
return models.K8sCronJob{}
|
||||
}
|
||||
|
||||
timezone := "<none>"
|
||||
if cronJob.Spec.TimeZone != nil {
|
||||
timezone = *cronJob.Spec.TimeZone
|
||||
}
|
||||
|
||||
suspend := false
|
||||
if cronJob.Spec.Suspend != nil {
|
||||
suspend = *cronJob.Spec.Suspend
|
||||
}
|
||||
|
||||
return models.K8sCronJob{
|
||||
Id: string(cronJob.UID),
|
||||
Name: cronJob.Name,
|
||||
Namespace: cronJob.Namespace,
|
||||
Command: strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " "),
|
||||
Schedule: cronJob.Spec.Schedule,
|
||||
Timezone: timezone,
|
||||
Suspend: suspend,
|
||||
Jobs: jobs,
|
||||
IsSystem: kcl.isSystemCronJob(cronJob.Namespace),
|
||||
}
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) isSystemCronJob(namespace string) bool {
|
||||
return kcl.isSystemNamespace(namespace)
|
||||
}
|
||||
|
||||
// DeleteCronJobs deletes the provided list of cronjobs in its namespace
|
||||
// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs
|
||||
func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error {
|
||||
var errors []error
|
||||
for namespace := range payload {
|
||||
for _, cronJobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().CronJobs(namespace)
|
||||
|
||||
_, err := client.Get(context.Background(), cronJobName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// TestFetchCronJobs tests the fetchCronJobs method for both admin and non-admin clients
|
||||
// It creates a fake Kubernetes client and passes it to the fetchCronJobs method
|
||||
// It then logs the fetched Cron Jobs
|
||||
// non-admin client will have access to the default namespace only
|
||||
func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
|
||||
t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = true
|
||||
|
||||
cronJobs, err := kcl.GetCronJobs("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched Cron Jobs: %v", cronJobs)
|
||||
})
|
||||
|
||||
t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = false
|
||||
kcl.NonAdminNamespaces = []string{"default"}
|
||||
|
||||
cronJobs, err := kcl.GetCronJobs("")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched Cron Jobs: %v", cronJobs)
|
||||
})
|
||||
|
||||
t.Run("delete Cron Jobs", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
|
||||
_, err := kcl.cli.BatchV1().CronJobs("default").Create(context.Background(), &batchv1.CronJob{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob"},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cron job: %v", err)
|
||||
}
|
||||
|
||||
err = kcl.DeleteCronJobs(models.K8sCronJobDeleteRequests{
|
||||
"default": []string{"test-cronjob"},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete Cron Jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Deleted Cron Jobs")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetJobs returns all jobs in the given namespace
|
||||
// If the user is a kube admin, it returns all jobs in the namespace
|
||||
// Otherwise, it returns only the jobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
|
||||
if kcl.IsKubeAdmin {
|
||||
return kcl.fetchJobs(namespace, includeCronJobChildren)
|
||||
}
|
||||
|
||||
return kcl.fetchJobsForNonAdmin(namespace, includeCronJobChildren)
|
||||
}
|
||||
|
||||
// fetchJobsForNonAdmin returns all jobs in the given namespace
|
||||
// It returns only the jobs in the non-admin namespaces
|
||||
func (kcl *KubeClient) fetchJobsForNonAdmin(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
|
||||
jobs, err := kcl.fetchJobs(namespace, includeCronJobChildren)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||
results := make([]models.K8sJob, 0)
|
||||
for _, job := range jobs {
|
||||
if _, ok := nonAdminNamespaceSet[job.Namespace]; ok {
|
||||
results = append(results, job)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// fetchJobs returns all jobs in the given namespace
|
||||
// It returns all jobs in the namespace
|
||||
func (kcl *KubeClient) fetchJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
|
||||
jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]models.K8sJob, 0)
|
||||
for _, job := range jobs.Items {
|
||||
if !includeCronJobChildren && checkCronJobOwner(job) {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, kcl.parseJob(job))
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// checkCronJobOwner checks if the job has a cronjob owner
|
||||
// it returns true if the job has a cronjob owner
|
||||
// otherwise, it returns false
|
||||
func checkCronJobOwner(job batchv1.Job) bool {
|
||||
for _, owner := range job.OwnerReferences {
|
||||
if owner.Kind == "CronJob" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// parseJob converts a batchv1.Job object to a models.K8sJob object.
|
||||
func (kcl *KubeClient) parseJob(job batchv1.Job) models.K8sJob {
|
||||
times := parseJobTimes(job)
|
||||
status, failedReason := determineJobStatus(job)
|
||||
podName := getJobPodName(kcl, job)
|
||||
|
||||
return models.K8sJob{
|
||||
ID: string(job.UID),
|
||||
Namespace: job.Namespace,
|
||||
Name: job.Name,
|
||||
PodName: podName,
|
||||
Command: strings.Join(job.Spec.Template.Spec.Containers[0].Command, " "),
|
||||
Container: job.Spec.Template.Spec.Containers[0],
|
||||
BackoffLimit: *job.Spec.BackoffLimit,
|
||||
Completions: *job.Spec.Completions,
|
||||
StartTime: times.start,
|
||||
FinishTime: times.finish,
|
||||
Duration: times.duration,
|
||||
Status: status,
|
||||
FailedReason: failedReason,
|
||||
IsSystem: kcl.isSystemJob(job.Namespace),
|
||||
}
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) isSystemJob(namespace string) bool {
|
||||
return kcl.isSystemNamespace(namespace)
|
||||
}
|
||||
|
||||
type jobTimes struct {
|
||||
start string
|
||||
finish string
|
||||
duration string
|
||||
}
|
||||
|
||||
func parseJobTimes(job batchv1.Job) jobTimes {
|
||||
times := jobTimes{
|
||||
start: "N/A",
|
||||
finish: "N/A",
|
||||
duration: "N/A",
|
||||
}
|
||||
|
||||
if st := job.Status.StartTime; st != nil {
|
||||
times.start = st.Time.Format(time.RFC3339)
|
||||
times.duration = time.Since(st.Time).Truncate(time.Minute).String()
|
||||
|
||||
if ct := job.Status.CompletionTime; ct != nil {
|
||||
times.finish = ct.Time.Format(time.RFC3339)
|
||||
times.duration = ct.Time.Sub(st.Time).String()
|
||||
}
|
||||
}
|
||||
|
||||
return times
|
||||
}
|
||||
|
||||
func determineJobStatus(job batchv1.Job) (status, failedReason string) {
|
||||
failedReason = "N/A"
|
||||
|
||||
switch {
|
||||
case job.Status.Failed > 0:
|
||||
return "Failed", getLatestJobCondition(job.Status.Conditions)
|
||||
case job.Status.Succeeded > 0:
|
||||
return "Succeeded", failedReason
|
||||
case job.Status.Active == 0:
|
||||
return "Completed", failedReason
|
||||
default:
|
||||
return "Running", failedReason
|
||||
}
|
||||
}
|
||||
|
||||
func getJobPodName(kcl *KubeClient, job batchv1.Job) string {
|
||||
pod, err := kcl.getLatestJobPod(job.Namespace, job.Name)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).
|
||||
Str("job", job.Name).
|
||||
Str("namespace", job.Namespace).
|
||||
Msg("Failed to get latest job pod")
|
||||
return ""
|
||||
}
|
||||
|
||||
if pod != nil {
|
||||
return pod.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getCronJobExecutions returns the jobs for a given cronjob
|
||||
// it returns the jobs for the cronjob
|
||||
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
|
||||
maxItems := 5
|
||||
|
||||
results := make([]models.K8sJob, 0)
|
||||
for _, job := range jobs.Items {
|
||||
for _, owner := range job.OwnerReferences {
|
||||
if owner.Kind == "CronJob" && owner.Name == cronJobName {
|
||||
results = append(results, kcl.parseJob(job))
|
||||
|
||||
if len(results) >= maxItems {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DeleteJobs deletes the provided list of jobs
|
||||
// it returns an error if any of the jobs are not found or if there is an error deleting the jobs
|
||||
func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error {
|
||||
var errors []error
|
||||
for namespace := range payload {
|
||||
for _, jobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().Jobs(namespace)
|
||||
|
||||
_, err := client.Get(context.Background(), jobName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), jobName, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
// getLatestJobCondition returns the latest condition of the job
|
||||
// it returns the latest condition of the job
|
||||
// this is only used for the failed reason
|
||||
func getLatestJobCondition(conditions []batchv1.JobCondition) string {
|
||||
if len(conditions) == 0 {
|
||||
return "No conditions"
|
||||
}
|
||||
|
||||
sort.Slice(conditions, func(i, j int) bool {
|
||||
return conditions[i].LastTransitionTime.After(conditions[j].LastTransitionTime.Time)
|
||||
})
|
||||
|
||||
latest := conditions[0]
|
||||
return fmt.Sprintf("%s: %s", latest.Type, latest.Message)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// TestFetchJobs tests the fetchJobs method for both admin and non-admin clients
|
||||
// It creates a fake Kubernetes client and passes it to the fetchJobs method
|
||||
// It then logs the fetched jobs
|
||||
// non-admin client will have access to the default namespace only
|
||||
func (kcl *KubeClient) TestFetchJobs(t *testing.T) {
|
||||
t.Run("admin client can fetch jobs from all namespaces", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = true
|
||||
|
||||
jobs, err := kcl.GetJobs("", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched jobs: %v", jobs)
|
||||
})
|
||||
|
||||
t.Run("non-admin client can fetch jobs from the default namespace only", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
kcl.IsKubeAdmin = false
|
||||
kcl.NonAdminNamespaces = []string{"default"}
|
||||
|
||||
jobs, err := kcl.GetJobs("", false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch jobs: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Fetched jobs: %v", jobs)
|
||||
})
|
||||
|
||||
t.Run("delete jobs", func(t *testing.T) {
|
||||
kcl.cli = kfake.NewSimpleClientset()
|
||||
kcl.instanceID = "test"
|
||||
|
||||
_, err := kcl.cli.BatchV1().Jobs("default").Create(context.Background(), &batchv1.Job{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "test-job"},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create job: %v", err)
|
||||
}
|
||||
|
||||
err = kcl.DeleteJobs(models.K8sJobDeleteRequests{
|
||||
"default": []string{"test-job"},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete jobs: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -265,9 +265,12 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
|
||||
return systemLabelValue == "true"
|
||||
}
|
||||
|
||||
systemNamespaces := defaultSystemNamespaces()
|
||||
return isSystemDefaultNamespace(namespace.Name)
|
||||
}
|
||||
|
||||
_, isSystem := systemNamespaces[namespace.Name]
|
||||
func isSystemDefaultNamespace(namespace string) bool {
|
||||
systemNamespaces := defaultSystemNamespaces()
|
||||
_, isSystem := systemNamespaces[namespace]
|
||||
return isSystem
|
||||
}
|
||||
|
||||
@@ -390,7 +393,9 @@ func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8s
|
||||
func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} {
|
||||
nonAdminNamespaceSet := make(map[string]struct{}, len(kcl.NonAdminNamespaces))
|
||||
for _, namespace := range kcl.NonAdminNamespaces {
|
||||
nonAdminNamespaceSet[namespace] = struct{}{}
|
||||
if !isSystemDefaultNamespace(namespace) {
|
||||
nonAdminNamespaceSet[namespace] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return nonAdminNamespaceSet
|
||||
|
||||
+52
-37
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -110,7 +109,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
|
||||
},
|
||||
}
|
||||
|
||||
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
|
||||
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error creating shell pod")
|
||||
}
|
||||
@@ -158,7 +157,7 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
|
||||
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,70 +171,67 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces
|
||||
func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false)
|
||||
}
|
||||
|
||||
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
|
||||
// this is required for the applications list view
|
||||
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) (PortainerApplicationResources, error) {
|
||||
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
|
||||
}
|
||||
|
||||
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
|
||||
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) {
|
||||
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil
|
||||
return PortainerApplicationResources{}, nil
|
||||
}
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
||||
}
|
||||
|
||||
// if replicaSet owner reference exists, fetch the replica sets
|
||||
// this also means that the deployments will be fetched because deployments own replica sets
|
||||
replicaSets := &appsv1.ReplicaSetList{}
|
||||
deployments := &appsv1.DeploymentList{}
|
||||
if containsReplicaSetOwnerReference(pods) {
|
||||
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
|
||||
}
|
||||
|
||||
deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources := PortainerApplicationResources{
|
||||
Pods: pods.Items,
|
||||
}
|
||||
|
||||
statefulSets := &appsv1.StatefulSetList{}
|
||||
if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
|
||||
statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.ReplicaSets = replicaSets.Items
|
||||
|
||||
deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.Deployments = deployments.Items
|
||||
|
||||
if includeStatefulSets {
|
||||
statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.StatefulSets = statefulSets.Items
|
||||
}
|
||||
|
||||
daemonSets := &appsv1.DaemonSetList{}
|
||||
if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
|
||||
daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if includeDaemonSets {
|
||||
daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.DaemonSets = daemonSets.Items
|
||||
}
|
||||
|
||||
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.Services = services.Items
|
||||
|
||||
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
|
||||
|
||||
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
|
||||
return portainerApplicationResources, nil
|
||||
}
|
||||
|
||||
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
|
||||
@@ -275,3 +271,22 @@ func isPodUsingSecret(pod *corev1.Pod, secretName string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getLatestJobPod returns the pods that are owned by a job
|
||||
// it returns an error if there is an error fetching the pods
|
||||
func (kcl *KubeClient) getLatestJobPod(namespace string, jobName string) (*corev1.Pod, error) {
|
||||
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pod := range pods.Items {
|
||||
for _, owner := range pod.OwnerReferences {
|
||||
if owner.Kind == "Job" && owner.Name == jobName {
|
||||
return &pod, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
|
||||
@@ -137,7 +136,7 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
|
||||
role, err := client.Get(context.Background(), name, v1.GetOptions{})
|
||||
role, err := client.Get(context.Background(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
corev1 "k8s.io/api/rbac/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
|
||||
@@ -98,7 +96,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
|
||||
func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
return client.Get(context.Background(), name, metav1.GetOptions{})
|
||||
}
|
||||
@@ -111,7 +109,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().RoleBindings(namespace)
|
||||
|
||||
roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
|
||||
roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
@@ -125,7 +123,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error)
|
||||
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
|
||||
// This function is called when the user is not an admin.
|
||||
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
|
||||
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
return nil, nil
|
||||
@@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
|
||||
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
|
||||
updatedSecrets := make([]models.K8sSecret, len(secrets))
|
||||
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret
|
||||
for index, secret := range secrets {
|
||||
updatedSecret := secret
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets)
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ func parseService(service corev1.Service) models.K8sServiceInfo {
|
||||
ingressStatus := make([]models.K8sServiceIngress, 0)
|
||||
for _, status := range service.Status.LoadBalancer.Ingress {
|
||||
ingressStatus = append(ingressStatus, models.K8sServiceIngress{
|
||||
IP: status.IP,
|
||||
Host: status.Hostname,
|
||||
IP: status.IP,
|
||||
Hostname: status.Hostname,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func (kcl *KubeClient) convertToK8sService(info models.K8sServiceInfo) corev1.Se
|
||||
for _, i := range info.IngressStatus {
|
||||
service.Status.LoadBalancer.Ingress = append(
|
||||
service.Status.LoadBalancer.Ingress,
|
||||
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host},
|
||||
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Hostname},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
|
||||
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
|
||||
if containsServiceWithSelector(services) {
|
||||
updatedServices := make([]models.K8sServiceInfo, len(services))
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServ
|
||||
for index, service := range services {
|
||||
updatedService := service
|
||||
|
||||
application, err := kcl.GetApplicationFromServiceSelector(pods, service, replicaSets)
|
||||
application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -92,7 +91,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
|
||||
|
||||
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
|
||||
var errors []error
|
||||
for namespace := range reqs {
|
||||
for _, serviceName := range reqs[namespace] {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/rs/zerolog/log"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
storagev1 "k8s.io/api/storage/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -265,7 +264,12 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
|
||||
if pod.Spec.Volumes != nil {
|
||||
for _, podVolume := range pod.Spec.Volumes {
|
||||
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSetItems,
|
||||
Deployments: deploymentItems,
|
||||
StatefulSets: statefulSetItems,
|
||||
DaemonSets: daemonSetItems,
|
||||
}, false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to convert pod to application")
|
||||
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings
|
||||
return "", err
|
||||
}
|
||||
|
||||
maps.Copy(idToken, resource)
|
||||
maps.Copy(resource, idToken)
|
||||
|
||||
username, err := GetUsername(resource, configuration.UserIdentifier)
|
||||
if err != nil {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user