Compare commits
36 Commits
release/2.
...
yd-develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -121,10 +121,6 @@ body:
|
||||
- '2.19.2'
|
||||
- '2.19.1'
|
||||
- '2.19.0'
|
||||
- '2.18.4'
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -60,8 +60,6 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
||||
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
||||
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,21 +4,20 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
)
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
)
|
||||
|
||||
@@ -50,7 +50,6 @@ import (
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -329,18 +328,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
||||
}
|
||||
|
||||
trustedOrigins := []string{}
|
||||
if *flags.TrustedOrigins != "" {
|
||||
// validate if the trusted origins are valid urls
|
||||
for _, origin := range strings.Split(*flags.TrustedOrigins, ",") {
|
||||
if !validate.IsTrustedOrigin(origin) {
|
||||
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
|
||||
}
|
||||
|
||||
trustedOrigins = append(trustedOrigins, origin)
|
||||
}
|
||||
}
|
||||
|
||||
fileService := initFileService(*flags.Data)
|
||||
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
||||
if encryptionKey == nil {
|
||||
@@ -588,8 +575,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
AdminCreationDone: adminCreationDone,
|
||||
PendingActionsService: pendingActionsService,
|
||||
PlatformService: platformService,
|
||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||
TrustedOrigins: trustedOrigins,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
|
||||
type ReadTransaction interface {
|
||||
GetObject(bucketName string, key []byte, object any) error
|
||||
GetRawBytes(bucketName string, key []byte) ([]byte, error)
|
||||
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
||||
KeyExists(bucketName string, key []byte) (bool, error)
|
||||
}
|
||||
|
||||
type Transaction interface {
|
||||
|
||||
@@ -244,32 +244,6 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
|
||||
})
|
||||
}
|
||||
|
||||
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
var value []byte
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
value, err = tx.GetRawBytes(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
exists, err = tx.KeyExists(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) getEncryptionKey() []byte {
|
||||
if !connection.isEncrypted {
|
||||
return nil
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -32,33 +31,6 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
|
||||
return tx.conn.UnmarshalObject(value, object)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
||||
}
|
||||
|
||||
if tx.conn.getEncryptionKey() != nil {
|
||||
var err error
|
||||
|
||||
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
|
||||
return value, errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
|
||||
return value != nil, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
||||
data, err := tx.conn.MarshalObject(object)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
type BaseCRUD[T any, I constraints.Integer] interface {
|
||||
Create(element *T) error
|
||||
Read(ID I) (*T, error)
|
||||
Exists(ID I) (bool, error)
|
||||
ReadAll() ([]T, error)
|
||||
Update(ID I, element *T) error
|
||||
Delete(ID I) error
|
||||
@@ -43,19 +42,6 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
exists, err = service.Tx(tx).Exists(ID)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
||||
var collection = make([]T, 0)
|
||||
|
||||
|
||||
@@ -28,12 +28,6 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
|
||||
return &element, nil
|
||||
}
|
||||
|
||||
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
||||
identifier := service.Connection.ConvertToKey(int(ID))
|
||||
|
||||
return service.Tx.KeyExists(service.Bucket, identifier)
|
||||
}
|
||||
|
||||
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
||||
var collection = make([]T, 0)
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ type Service struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &Service{}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -109,6 +111,18 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,8 @@ type ServiceTx struct {
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &ServiceTx{}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -74,6 +76,58 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.9",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.9\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func copyFile(src, dst string) error {
|
||||
defer from.Close()
|
||||
|
||||
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := os.Create(dst)
|
||||
|
||||
@@ -44,11 +44,10 @@ 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 {
|
||||
@@ -66,12 +65,7 @@ func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath str
|
||||
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 +84,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 {
|
||||
|
||||
@@ -90,3 +90,24 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package csrf
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,8 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
gcsrf "github.com/gorilla/csrf"
|
||||
"github.com/rs/zerolog/log"
|
||||
gorillacsrf "github.com/gorilla/csrf"
|
||||
"github.com/urfave/negroni"
|
||||
)
|
||||
|
||||
@@ -21,7 +19,7 @@ func SkipCSRFToken(w http.ResponseWriter) {
|
||||
w.Header().Set(csrfSkipHeader, "1")
|
||||
}
|
||||
|
||||
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
|
||||
func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
|
||||
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
|
||||
isDockerDesktopExtension := false
|
||||
@@ -36,12 +34,10 @@ func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, e
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
handler = gcsrf.Protect(
|
||||
handler = gorillacsrf.Protect(
|
||||
token,
|
||||
gcsrf.Path("/"),
|
||||
gcsrf.Secure(false),
|
||||
gcsrf.TrustedOrigins(trustedOrigins),
|
||||
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
|
||||
gorillacsrf.Path("/"),
|
||||
gorillacsrf.Secure(false),
|
||||
)(handler)
|
||||
|
||||
return withSkipCSRF(handler, isDockerDesktopExtension), nil
|
||||
@@ -59,7 +55,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
|
||||
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
|
||||
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -77,33 +73,9 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
|
||||
}
|
||||
|
||||
if skip {
|
||||
r = gcsrf.UnsafeSkipCheck(r)
|
||||
r = gorillacsrf.UnsafeSkipCheck(r)
|
||||
}
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func withErrorHandler(trustedOrigins []string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
err := gcsrf.FailureReason(r)
|
||||
|
||||
if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) {
|
||||
log.Error().Err(err).
|
||||
Str("request_url", r.URL.String()).
|
||||
Str("host", r.Host).
|
||||
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
|
||||
Str("forwarded", r.Header.Get("Forwarded")).
|
||||
Str("origin", r.Header.Get("Origin")).
|
||||
Str("referer", r.Header.Get("Referer")).
|
||||
Strs("trusted_origins", trustedOrigins).
|
||||
Msg("Failed to validate Origin or Referer")
|
||||
}
|
||||
|
||||
http.Error(
|
||||
w,
|
||||
http.StatusText(http.StatusForbidden)+" - "+err.Error(),
|
||||
http.StatusForbidden,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,57 +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 {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
continue
|
||||
}
|
||||
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 && !tx.IsErrObjectNotFound(err) {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
if relation == nil {
|
||||
relation = &portainer.EndpointRelation{
|
||||
EndpointID: endpointID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -80,13 +80,6 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
if handler.PullLimitCheckDisabled {
|
||||
return response.JSON(w, &dockerhubStatusResponse{
|
||||
Limit: 10,
|
||||
Remaining: 10,
|
||||
})
|
||||
}
|
||||
|
||||
httpClient := client.NewHTTPClient()
|
||||
token, err := getDockerHubToken(httpClient, registry)
|
||||
if err != nil {
|
||||
|
||||
@@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
|
||||
return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err)
|
||||
}
|
||||
|
||||
registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships)
|
||||
registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships)
|
||||
if handleError != nil {
|
||||
return nil, handleError
|
||||
}
|
||||
@@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
|
||||
return registries, err
|
||||
}
|
||||
|
||||
func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
if !endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||
return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil
|
||||
}
|
||||
|
||||
return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships)
|
||||
return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships)
|
||||
}
|
||||
|
||||
func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
isAdmin, err := security.IsAdmin(r)
|
||||
if err != nil {
|
||||
@@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataS
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user)
|
||||
return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user)
|
||||
}
|
||||
|
||||
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
|
||||
@@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc
|
||||
return false
|
||||
}
|
||||
|
||||
func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if errors.Is(err, security.ErrAuthorizationRequired) {
|
||||
return nil, httperror.Forbidden("User is not authorized", err)
|
||||
@@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat
|
||||
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
userNamespaces, err := handler.userNamespaces(tx, endpoint, user)
|
||||
userNamespaces, err := handler.userNamespaces(endpoint, user)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("unable to retrieve user namespaces", err)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat
|
||||
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
|
||||
}
|
||||
|
||||
func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
|
||||
func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
|
||||
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *po
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,20 +26,19 @@ func hideFields(endpoint *portainer.Endpoint) {
|
||||
// Handler is the HTTP handler used to handle environment(endpoint) operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer security.BouncerService
|
||||
DataStore dataservices.DataStore
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
SnapshotService portainer.SnapshotService
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
AuthorizationService *authorization.Service
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
BindAddress string
|
||||
BindAddressHTTPS string
|
||||
PendingActionsService *pendingactions.PendingActionsService
|
||||
PullLimitCheckDisabled bool
|
||||
requestBouncer security.BouncerService
|
||||
DataStore dataservices.DataStore
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
SnapshotService portainer.SnapshotService
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
AuthorizationService *authorization.Service
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
BindAddress string
|
||||
BindAddressHTTPS string
|
||||
PendingActionsService *pendingactions.PendingActionsService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.27.9
|
||||
// @version 2.27.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ 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."
|
||||
@@ -116,6 +117,12 @@ 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")
|
||||
@@ -128,7 +135,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)
|
||||
applications, err := cli.GetApplications(namespace, nodeName, withDependencies)
|
||||
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,76 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
)
|
||||
|
||||
var (
|
||||
// Idempotent (safe) methods as defined by RFC7231 section 4.2.2.
|
||||
safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
)
|
||||
|
||||
type plainTextHTTPRequestHandler struct {
|
||||
next http.Handler
|
||||
}
|
||||
|
||||
// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol.
|
||||
// The Forwarded header format supports:
|
||||
// - Single proxy: Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
|
||||
// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17
|
||||
// We take the first (leftmost) entry as it represents the original client
|
||||
func parseForwardedHeaderProto(forwarded string) string {
|
||||
if forwarded == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse the first part (leftmost proxy, closest to original client)
|
||||
firstPart, _, _ := strings.Cut(forwarded, ",")
|
||||
firstPart = strings.TrimSpace(firstPart)
|
||||
|
||||
// Split by semicolon to get key-value pairs within this proxy entry
|
||||
// Format: key=value;key=value;key=value
|
||||
pairs := strings.Split(firstPart, ";")
|
||||
for _, pair := range pairs {
|
||||
// Split by equals sign to separate key and value
|
||||
key, value, found := strings.Cut(pair, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(key), "proto") {
|
||||
return strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isHTTPSRequest checks if the original request was made over HTTPS
|
||||
// by examining both X-Forwarded-Proto and Forwarded headers
|
||||
func isHTTPSRequest(r *http.Request) bool {
|
||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") ||
|
||||
strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https")
|
||||
}
|
||||
|
||||
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if slices.Contains(safeMethods, r.Method) {
|
||||
h.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req := r
|
||||
// If original request was HTTPS (via proxy), keep CSRF checks.
|
||||
if !isHTTPSRequest(r) {
|
||||
req = csrf.PlaintextHTTPRequest(r)
|
||||
}
|
||||
|
||||
h.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func PlaintextHTTPRequest(next http.Handler) http.Handler {
|
||||
return &plainTextHTTPRequestHandler{next: next}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
var tests = []struct {
|
||||
name string
|
||||
forwarded string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty header",
|
||||
forwarded: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "single proxy with proto=https",
|
||||
forwarded: "proto=https",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "single proxy with proto=http",
|
||||
forwarded: "proto=http",
|
||||
expected: "http",
|
||||
},
|
||||
{
|
||||
name: "single proxy with multiple directives",
|
||||
forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "single proxy with proto in middle",
|
||||
forwarded: "for=192.0.2.60;proto=https;host=example.com",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "single proxy with proto at end",
|
||||
forwarded: "for=192.0.2.60;host=example.com;proto=https",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "multiple proxies - takes first",
|
||||
forwarded: "proto=https, proto=http",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "multiple proxies with complex format",
|
||||
forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "multiple proxies with for directive only",
|
||||
forwarded: "for=192.0.2.43, for=198.51.100.17",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "multiple proxies with proto only in second",
|
||||
forwarded: "for=192.0.2.43, proto=https",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "multiple proxies with proto only in first",
|
||||
forwarded: "proto=https, for=198.51.100.17",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "quoted protocol value",
|
||||
forwarded: "proto=\"https\"",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "single quoted protocol value",
|
||||
forwarded: "proto='https'",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "mixed case protocol",
|
||||
forwarded: "proto=HTTPS",
|
||||
expected: "HTTPS",
|
||||
},
|
||||
{
|
||||
name: "no proto directive",
|
||||
forwarded: "for=192.0.2.60;by=203.0.113.43",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty proto value",
|
||||
forwarded: "proto=",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "whitespace around values",
|
||||
forwarded: " proto = https ",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "whitespace around semicolons",
|
||||
forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "whitespace around commas",
|
||||
forwarded: "proto=https , proto=http",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "IPv6 address in for directive",
|
||||
forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "complex multiple proxies with IPv6",
|
||||
forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "obfuscated identifiers",
|
||||
forwarded: "for=_mdn;proto=https",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "unknown identifier",
|
||||
forwarded: "for=unknown;proto=https",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "malformed key-value pair",
|
||||
forwarded: "proto",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "malformed key-value pair with equals",
|
||||
forwarded: "proto=",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "multiple equals signs",
|
||||
forwarded: "proto=https=extra",
|
||||
expected: "https=extra",
|
||||
},
|
||||
{
|
||||
name: "mixed case directive name",
|
||||
forwarded: "PROTO=https",
|
||||
expected: "https",
|
||||
},
|
||||
{
|
||||
name: "mixed case directive name with spaces",
|
||||
forwarded: " Proto = https ",
|
||||
expected: "https",
|
||||
},
|
||||
}
|
||||
|
||||
func TestParseForwardedHeaderProto(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseForwardedHeaderProto(tt.forwarded)
|
||||
if result != tt.expected {
|
||||
t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParseForwardedHeaderProto(f *testing.F) {
|
||||
for _, t := range tests {
|
||||
f.Add(t.forwarded)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, forwarded string) {
|
||||
parseForwardedHeaderProto(forwarded)
|
||||
})
|
||||
}
|
||||
@@ -7,31 +7,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Note that we discard any non-canonical headers by design
|
||||
var allowedHeaders = map[string]struct{}{
|
||||
"Accept": {},
|
||||
"Accept-Encoding": {},
|
||||
"Accept-Language": {},
|
||||
"Cache-Control": {},
|
||||
"Content-Length": {},
|
||||
"Content-Type": {},
|
||||
"Private-Token": {},
|
||||
"User-Agent": {},
|
||||
"X-Portaineragent-Target": {},
|
||||
"X-Portainer-Volumename": {},
|
||||
"X-Registry-Auth": {},
|
||||
}
|
||||
|
||||
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
||||
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
|
||||
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
|
||||
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{Director: createDirector(target)}
|
||||
}
|
||||
|
||||
func createDirector(target *url.URL) func(*http.Request) {
|
||||
targetQuery := target.RawQuery
|
||||
return func(req *http.Request) {
|
||||
director := func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
|
||||
@@ -45,14 +26,8 @@ func createDirector(target *url.URL) func(*http.Request) {
|
||||
// explicitly disable User-Agent so it's not set to default value
|
||||
req.Header.Set("User-Agent", "")
|
||||
}
|
||||
|
||||
for k := range req.Header {
|
||||
if _, ok := allowedHeaders[k]; !ok {
|
||||
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
|
||||
delete(req.Header, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &httputil.ReverseProxy{Director: director}
|
||||
}
|
||||
|
||||
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func Test_createDirector(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
target *url.URL
|
||||
req *http.Request
|
||||
expectedReq *http.Request
|
||||
}{
|
||||
{
|
||||
name: "base case",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||
true,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "no User-Agent",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
|
||||
true,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Sensitive Headers",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{
|
||||
"Authorization": "secret",
|
||||
"Proxy-Authorization": "secret",
|
||||
"Cookie": "secret",
|
||||
"X-Csrf-Token": "secret",
|
||||
"X-Api-Key": "secret",
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
"X-Portaineragent-Target": "test-agent-1",
|
||||
"X-Portainer-Volumename": "test-volume-1",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
true,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
"X-Portaineragent-Target": "test-agent-1",
|
||||
"X-Portainer-Volumename": "test-volume-1",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Non canonical Headers",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
portainer.PortainerAgentTargetHeader: "test-agent-1",
|
||||
"X-Portainer-VolumeName": "test-volume-1",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
false,
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept-Language": "en-GB",
|
||||
"Cache-Control": "None",
|
||||
"Content-Length": "100",
|
||||
"Content-Type": "application/json",
|
||||
"Private-Token": "test-private-token",
|
||||
"User-Agent": "test-user-agent",
|
||||
"X-Registry-Auth": "test-registry-auth",
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
director := createDirector(tc.target)
|
||||
director(tc.req)
|
||||
|
||||
if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
|
||||
t.Fatalf("requests are different: \n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createURL(t *testing.T, urlString string) *url.URL {
|
||||
parsedURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create url: %s", err)
|
||||
}
|
||||
|
||||
return parsedURL
|
||||
}
|
||||
|
||||
func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create http request: %s", err)
|
||||
} else {
|
||||
for k, v := range headers {
|
||||
if canonicalHeaders {
|
||||
req.Header.Add(k, v)
|
||||
} else {
|
||||
req.Header[k] = []string{v}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func compareRequests(a, b *http.Request) bool {
|
||||
methodEqual := a.Method == b.Method
|
||||
urlEqual := cmp.Diff(a.URL, b.URL) == ""
|
||||
hostEqual := a.Host == b.Host
|
||||
protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor
|
||||
headersEqual := cmp.Diff(a.Header, b.Header) == ""
|
||||
|
||||
return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual
|
||||
}
|
||||
@@ -243,7 +243,8 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler,
|
||||
return
|
||||
}
|
||||
|
||||
if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok {
|
||||
_, err = bouncer.dataStore.User().Read(tokenData.ID)
|
||||
if bouncer.dataStore.IsErrObjectNotFound(err) {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
|
||||
return
|
||||
} else if err != nil {
|
||||
@@ -321,8 +322,9 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized)
|
||||
user, _ := bouncer.dataStore.User().Read(token.ID)
|
||||
if user == nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,8 +112,6 @@ type Server struct {
|
||||
AdminCreationDone chan struct{}
|
||||
PendingActionsService *pendingactions.PendingActionsService
|
||||
PlatformService platform.Service
|
||||
PullLimitCheckDisabled bool
|
||||
TrustedOrigins []string
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
@@ -183,7 +181,6 @@ func (server *Server) Start() error {
|
||||
endpointHandler.BindAddress = server.BindAddress
|
||||
endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS
|
||||
endpointHandler.PendingActionsService = server.PendingActionsService
|
||||
endpointHandler.PullLimitCheckDisabled = server.PullLimitCheckDisabled
|
||||
|
||||
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer, server.DataStore, server.FileService, server.ReverseTunnelService)
|
||||
|
||||
@@ -340,7 +337,7 @@ func (server *Server) Start() error {
|
||||
|
||||
handler = middlewares.WithSlowRequestsLogger(handler)
|
||||
|
||||
handler, err := csrf.WithProtect(handler, server.TrustedOrigins)
|
||||
handler, err := csrf.WithProtect(handler)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create CSRF middleware")
|
||||
}
|
||||
@@ -350,7 +347,7 @@ func (server *Server) Start() error {
|
||||
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
|
||||
httpServer := &http.Server{
|
||||
Addr: server.BindAddress,
|
||||
Handler: middlewares.PlaintextHTTPRequest(handler),
|
||||
Handler: handler,
|
||||
ErrorLog: errorLogger,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -100,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)
|
||||
}
|
||||
@@ -148,25 +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 {
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
log.Warn().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Msg("Unable to find endpoint relation in database, skipping")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
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,6 +9,8 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
)
|
||||
|
||||
var _ dataservices.DataStore = &testDatastore{}
|
||||
|
||||
type testDatastore struct {
|
||||
customTemplate dataservices.CustomTemplateService
|
||||
edgeGroup dataservices.EdgeGroupService
|
||||
@@ -151,7 +153,6 @@ func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User
|
||||
func (s *stubUserService) Create(user *portainer.User) error { return nil }
|
||||
func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
|
||||
func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
|
||||
func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
|
||||
|
||||
// WithUsers testDatastore option that will instruct testDatastore to return provided users
|
||||
func WithUsers(us []portainer.User) datastoreOption {
|
||||
@@ -187,9 +188,6 @@ func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFun
|
||||
}
|
||||
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
|
||||
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
|
||||
func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// WithEdgeJobs option will instruct testDatastore to return provided jobs
|
||||
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||
@@ -231,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
|
||||
}
|
||||
@@ -430,10 +452,6 @@ func (s *stubStacksService) GetNextIdentifier() int {
|
||||
return len(s.stacks)
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// WithStacks option will instruct testDatastore to return provided stacks
|
||||
func WithStacks(stacks []portainer.Stack) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
|
||||
@@ -12,58 +12,45 @@ 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) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
if kcl.IsKubeAdmin {
|
||||
return kcl.fetchApplications(namespace, nodeName)
|
||||
return kcl.fetchApplications(namespace, nodeName, withDependencies)
|
||||
}
|
||||
|
||||
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
|
||||
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies)
|
||||
}
|
||||
|
||||
// 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) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]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
|
||||
}
|
||||
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
|
||||
}
|
||||
|
||||
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
||||
}
|
||||
|
||||
// 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) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
@@ -75,24 +62,28 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string)
|
||||
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
||||
}
|
||||
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
|
||||
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||
results := make([]models.K8sApplication, 0)
|
||||
for _, application := range append(applications, unhealthyApplications...) {
|
||||
for _, application := range applications {
|
||||
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
|
||||
results = append(results, application)
|
||||
}
|
||||
@@ -102,11 +93,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(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
|
||||
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) {
|
||||
applications := []models.K8sApplication{}
|
||||
processedOwners := make(map[string]struct{})
|
||||
|
||||
for _, pod := range portainerApplicationResources.Pods {
|
||||
for _, pod := range pods {
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
ownerUID := string(pod.OwnerReferences[0].UID)
|
||||
if _, exists := processedOwners[ownerUID]; exists {
|
||||
@@ -115,7 +106,7 @@ func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources P
|
||||
processedOwners[ownerUID] = struct{}{}
|
||||
}
|
||||
|
||||
application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -160,9 +151,7 @@ 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, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,9 +168,7 @@ 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, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -194,12 +181,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, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
|
||||
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) {
|
||||
if isReplicaSetOwner(pod) {
|
||||
updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
|
||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
||||
}
|
||||
|
||||
application := createApplicationFromPod(&pod, portainerApplicationResources)
|
||||
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
|
||||
if application.ID == "" && application.Name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -216,9 +203,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicat
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// createApplicationFromPod creates a K8sApplication object from a pod
|
||||
// createApplication creates a K8sApplication object from a pod
|
||||
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
|
||||
func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
|
||||
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
|
||||
kind := "Pod"
|
||||
name := pod.Name
|
||||
|
||||
@@ -234,172 +221,120 @@ func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources Por
|
||||
|
||||
switch kind {
|
||||
case "Deployment":
|
||||
for _, deployment := range portainerApplicationResources.Deployments {
|
||||
for _, deployment := range deployments {
|
||||
if deployment.Name == name && deployment.Namespace == pod.Namespace {
|
||||
populateApplicationFromDeployment(&application, deployment)
|
||||
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,
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "StatefulSet":
|
||||
for _, statefulSet := range portainerApplicationResources.StatefulSets {
|
||||
for _, statefulSet := range statefulSets {
|
||||
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
|
||||
populateApplicationFromStatefulSet(&application, statefulSet)
|
||||
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,
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "DaemonSet":
|
||||
for _, daemonSet := range portainerApplicationResources.DaemonSets {
|
||||
for _, daemonSet := range daemonSets {
|
||||
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
|
||||
populateApplicationFromDaemonSet(&application, daemonSet)
|
||||
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,
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "Pod":
|
||||
populateApplicationFromPod(&application, *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,
|
||||
}
|
||||
}
|
||||
|
||||
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 {
|
||||
updateApplicationWithService(&application, portainerApplicationResources.Services)
|
||||
if application.ID != "" && application.Name != "" && len(services) > 0 {
|
||||
updateApplicationWithService(&application, services)
|
||||
}
|
||||
|
||||
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
|
||||
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -475,9 +410,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -503,9 +436,7 @@ 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, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -523,84 +454,3 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -87,14 +87,6 @@ func (factory *ClientFactory) ClearClientCache() {
|
||||
// Remove the cached kube client so a new one can be created
|
||||
func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) {
|
||||
factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID)))
|
||||
|
||||
endpointPrefix := strconv.Itoa(int(endpointID)) + "."
|
||||
|
||||
for key := range factory.endpointProxyClients.Items() {
|
||||
if strings.HasPrefix(key, endpointPrefix) {
|
||||
factory.endpointProxyClients.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
|
||||
|
||||
@@ -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 configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().Msgf("Fetching volumes 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))
|
||||
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", 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, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, 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)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -109,7 +110,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
|
||||
},
|
||||
}
|
||||
|
||||
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
|
||||
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error creating shell pod")
|
||||
}
|
||||
@@ -157,7 +158,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(context.TODO(), pod.Name, metav1.GetOptions{})
|
||||
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -171,67 +172,70 @@ 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) (PortainerApplicationResources, error) {
|
||||
func (kcl *KubeClient) fetchAllApplicationsListResources(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, true, true)
|
||||
}
|
||||
|
||||
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
|
||||
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) {
|
||||
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) {
|
||||
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return PortainerApplicationResources{}, nil
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil
|
||||
}
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
||||
}
|
||||
|
||||
portainerApplicationResources := PortainerApplicationResources{
|
||||
Pods: pods.Items,
|
||||
}
|
||||
|
||||
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 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 PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", 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.StatefulSets = statefulSets.Items
|
||||
}
|
||||
|
||||
if includeDaemonSets {
|
||||
daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
statefulSets := &appsv1.StatefulSetList{}
|
||||
if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
|
||||
statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
daemonSets := &appsv1.DaemonSetList{}
|
||||
if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
|
||||
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)
|
||||
}
|
||||
portainerApplicationResources.DaemonSets = daemonSets.Items
|
||||
}
|
||||
|
||||
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
|
||||
return nil, nil, nil, nil, nil, nil, nil, 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 PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
|
||||
|
||||
return portainerApplicationResources, nil
|
||||
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
|
||||
}
|
||||
|
||||
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
|
||||
|
||||
@@ -10,6 +10,7 @@ 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.
|
||||
@@ -136,7 +137,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, metav1.GetOptions{})
|
||||
role, err := client.Get(context.Background(), name, v1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
|
||||
@@ -7,9 +7,11 @@ 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.
|
||||
@@ -96,7 +98,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
|
||||
func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
return client.Get(context.Background(), name, metav1.GetOptions{})
|
||||
}
|
||||
@@ -109,7 +111,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, metav1.GetOptions{})
|
||||
roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
@@ -123,7 +125,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, metav1.DeleteOptions{}); err != nil {
|
||||
if err := client.Delete(context.Background(), name, v1.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 secrets for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().Msgf("Fetching volumes 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))
|
||||
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", 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, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", 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(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
|
||||
application, err := kcl.GetApplicationFromServiceSelector(pods, service, 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,6 +5,7 @@ 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"
|
||||
@@ -91,7 +92,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 models.K8sServiceAccountDeleteRequests) error {
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
|
||||
var errors []error
|
||||
for namespace := range reqs {
|
||||
for _, serviceName := range reqs[namespace] {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -264,12 +265,7 @@ 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, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSetItems,
|
||||
Deployments: deploymentItems,
|
||||
StatefulSets: statefulSetItems,
|
||||
DaemonSets: daemonSetItems,
|
||||
}, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, 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)
|
||||
|
||||
@@ -134,8 +134,6 @@ type (
|
||||
LogLevel *string
|
||||
LogMode *string
|
||||
KubectlShellImage *string
|
||||
PullLimitCheckDisabled *bool
|
||||
TrustedOrigins *string
|
||||
}
|
||||
|
||||
// CustomTemplateVariableDefinition
|
||||
@@ -1546,7 +1544,7 @@ type (
|
||||
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
|
||||
GetSecrets(namespace string) ([]models.K8sSecret, error)
|
||||
GetIngressControllers() (models.K8sIngressControllers, error)
|
||||
GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
|
||||
GetApplications(namespace, nodename string, withDependencies bool) ([]models.K8sApplication, error)
|
||||
GetMetrics() (models.K8sMetrics, error)
|
||||
GetStorage() ([]KubernetesStorageClassConfig, error)
|
||||
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
|
||||
@@ -1639,7 +1637,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.27.9"
|
||||
APIVersion = "2.27.1"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
@@ -1691,15 +1689,6 @@ const (
|
||||
PortainerCacheHeader = "X-Portainer-Cache"
|
||||
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
|
||||
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
|
||||
// PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check
|
||||
PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED"
|
||||
// LicenseServerBaseURL represents the base URL of the API used to validate
|
||||
// an extension license.
|
||||
LicenseServerBaseURL = "https://api.portainer.io"
|
||||
// URL to validate licenses along with system metadata.
|
||||
LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin"
|
||||
// TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection
|
||||
TrustedOriginsEnvVar = "TRUSTED_ORIGINS"
|
||||
)
|
||||
|
||||
// List of supported features
|
||||
|
||||
6114
api/swagger.yaml
6114
api/swagger.yaml
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { buildImageFullURIFromModel, imageContainsURL } from '@/react/docker/images/utils';
|
||||
import { buildImageFullURIFromModel, imageContainsURL, fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory);
|
||||
function ImageHelperFactory() {
|
||||
@@ -18,8 +18,12 @@ function ImageHelperFactory() {
|
||||
* @param {PorImageRegistryModel} registry
|
||||
*/
|
||||
function createImageConfigForContainer(imageModel) {
|
||||
const fromImage = buildImageFullURIFromModel(imageModel);
|
||||
const { tag, repo } = fullURIIntoRepoAndTag(fromImage);
|
||||
return {
|
||||
fromImage: buildImageFullURIFromModel(imageModel),
|
||||
fromImage,
|
||||
tag,
|
||||
repo,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -207,9 +207,9 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||
async function commitContainerAsync() {
|
||||
$scope.config.commitInProgress = true;
|
||||
const registryModel = $scope.config.RegistryModel;
|
||||
const imageConfig = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
try {
|
||||
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo: imageConfig.fromImage });
|
||||
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo, tag });
|
||||
Notifications.success('Image created', $transition$.params().id);
|
||||
$state.reload();
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import _ from 'lodash-es';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').controller('ImageController', [
|
||||
'$async',
|
||||
@@ -71,8 +70,7 @@ angular.module('portainer.docker').controller('ImageController', [
|
||||
$scope.tagImage = function () {
|
||||
const registryModel = $scope.formValues.RegistryModel;
|
||||
|
||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
|
||||
ImageService.tagImage($transition$.params().id, repo, tag)
|
||||
.then(function success() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').controller('ImportImageController', [
|
||||
'$scope',
|
||||
@@ -34,8 +33,7 @@ angular.module('portainer.docker').controller('ImportImageController', [
|
||||
async function tagImage(id) {
|
||||
const registryModel = $scope.formValues.RegistryModel;
|
||||
if (registryModel.Image) {
|
||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
try {
|
||||
await ImageService.tagImage(id, repo, tag);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,274 +1,281 @@
|
||||
<page-header title="'Service details'" breadcrumbs="[{label:'Services', link:'docker.services'}, service.Name]" reload="true"> </page-header>
|
||||
|
||||
<div class="row">
|
||||
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div class="alert alert-info" role="alert" id="service-update-alert">
|
||||
<p>This service is being updated. Editing this service is currently disabled.</p>
|
||||
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
|
||||
<div ng-if="!isLoading">
|
||||
<div class="row">
|
||||
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
|
||||
<div class="alert alert-info" role="alert" id="service-update-alert">
|
||||
<p>This service is being updated. Editing this service is currently disabled.</p>
|
||||
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-9 col-md-9 col-xs-9">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-1/5">Name</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="service.Name"
|
||||
ng-change="updateServiceAttribute(service, 'Name')"
|
||||
ng-disabled="isUpdating"
|
||||
data-cy="docker-service-edit-name"
|
||||
/>
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td> {{ service.Id }} </td>
|
||||
</tr>
|
||||
<tr ng-if="service.CreatedAt">
|
||||
<td>Created at</td>
|
||||
<td>{{ service.CreatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.UpdatedAt">
|
||||
<td>Last updated at</td>
|
||||
<td>{{ service.UpdatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Version">
|
||||
<td>Version</td>
|
||||
<td>{{ service.Version }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scheduling mode</td>
|
||||
<td>{{ service.Mode }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Mode === 'replicated'">
|
||||
<td>Replicas</td>
|
||||
<td>
|
||||
<span ng-if="service.Mode === 'replicated'">
|
||||
<div class="row">
|
||||
<div class="col-lg-9 col-md-9 col-xs-9">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-1/5">Name</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
|
||||
<input
|
||||
class="input-sm"
|
||||
type="number"
|
||||
data-cy="docker-service-edit-replicas-input"
|
||||
ng-model="service.Replicas"
|
||||
ng-change="updateServiceAttribute(service, 'Replicas')"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="service.Name"
|
||||
ng-change="updateServiceAttribute(service, 'Name')"
|
||||
ng-disabled="isUpdating"
|
||||
data-cy="docker-service-edit-name"
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td>{{ service.Image }}</td>
|
||||
</tr>
|
||||
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||
<td>
|
||||
<div class="inline-flex items-center">
|
||||
<div> Service webhook </div>
|
||||
<portainer-tooltip
|
||||
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap items-center">
|
||||
<por-switch-field label-class="'!mr-0'" checked="WebhookExists" disabled="disabledWebhookButton(WebhookExists)" on-change="(onWebhookChange)"></por-switch-field>
|
||||
<span ng-if="webhookURL">
|
||||
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
|
||||
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
|
||||
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
|
||||
Copy link
|
||||
</button>
|
||||
<span>
|
||||
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
||||
</span>
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td> {{ service.Id }} </td>
|
||||
</tr>
|
||||
<tr ng-if="service.CreatedAt">
|
||||
<td>Created at</td>
|
||||
<td>{{ service.CreatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.UpdatedAt">
|
||||
<td>Last updated at</td>
|
||||
<td>{{ service.UpdatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Version">
|
||||
<td>Version</td>
|
||||
<td>{{ service.Version }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scheduling mode</td>
|
||||
<td>{{ service.Mode }}</td>
|
||||
</tr>
|
||||
<tr ng-if="service.Mode === 'replicated'">
|
||||
<td>Replicas</td>
|
||||
<td>
|
||||
<span ng-if="service.Mode === 'replicated'">
|
||||
<input
|
||||
class="input-sm"
|
||||
type="number"
|
||||
data-cy="docker-service-edit-replicas-input"
|
||||
ng-model="service.Replicas"
|
||||
ng-change="updateServiceAttribute(service, 'Replicas')"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
|
||||
<td colspan="2">
|
||||
<p class="small text-muted" authorization="DockerServiceUpdate">
|
||||
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
|
||||
><div class="flex flex-wrap gap-x-2 gap-y-1">
|
||||
<a
|
||||
authorization="DockerServiceLogs"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="btn btn-primary btn-sm"
|
||||
type="button"
|
||||
ui-sref="docker.services.service.logs({id: service.Id})"
|
||||
>
|
||||
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
|
||||
>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.updateInProgress || isUpdating"
|
||||
ng-click="forceUpdateService(service)"
|
||||
button-spinner="state.updateInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.updateInProgress" class="vertical-center">
|
||||
<pr-icon icon="'refresh-cw'"></pr-icon>
|
||||
Update the service</span
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td>{{ service.Image }}</td>
|
||||
</tr>
|
||||
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||
<td>
|
||||
<div class="inline-flex items-center">
|
||||
<div> Service webhook </div>
|
||||
<portainer-tooltip
|
||||
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
|
||||
>
|
||||
<span ng-show="state.updateInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.rollbackInProgress || isUpdating"
|
||||
ng-click="rollbackService(service)"
|
||||
button-spinner="state.rollbackInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.rollbackInProgress" class="vertical-center">
|
||||
<pr-icon icon="'rotate-ccw'"></pr-icon>
|
||||
Rollback the service</span
|
||||
</portainer-tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-wrap items-center">
|
||||
<por-switch-field
|
||||
label-class="'!mr-0'"
|
||||
checked="WebhookExists"
|
||||
disabled="disabledWebhookButton(WebhookExists)"
|
||||
on-change="(onWebhookChange)"
|
||||
></por-switch-field>
|
||||
<span ng-if="webhookURL">
|
||||
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
|
||||
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
|
||||
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
|
||||
Copy link
|
||||
</button>
|
||||
<span>
|
||||
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
|
||||
<td colspan="2">
|
||||
<p class="small text-muted" authorization="DockerServiceUpdate">
|
||||
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
|
||||
><div class="flex flex-wrap gap-x-2 gap-y-1">
|
||||
<a
|
||||
authorization="DockerServiceLogs"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="btn btn-primary btn-sm"
|
||||
type="button"
|
||||
ui-sref="docker.services.service.logs({id: service.Id})"
|
||||
>
|
||||
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceDelete"
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm !ml-0"
|
||||
ng-disabled="state.deletionInProgress || isUpdating"
|
||||
ng-click="removeService()"
|
||||
button-spinner="state.deletionInProgress"
|
||||
>
|
||||
<span ng-hide="state.deletionInProgress" class="vertical-center">
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
Delete the service</span
|
||||
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
|
||||
>
|
||||
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||
<p class="small text-muted">
|
||||
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
|
||||
</p>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
|
||||
>Apply changes</button
|
||||
>
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
|
||||
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
|
||||
</ul>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.updateInProgress || isUpdating"
|
||||
ng-click="forceUpdateService(service)"
|
||||
button-spinner="state.updateInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.updateInProgress" class="vertical-center">
|
||||
<pr-icon icon="'refresh-cw'"></pr-icon>
|
||||
Update the service</span
|
||||
>
|
||||
<span ng-show="state.updateInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceUpdate"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.rollbackInProgress || isUpdating"
|
||||
ng-click="rollbackService(service)"
|
||||
button-spinner="state.rollbackInProgress"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||
>
|
||||
<span ng-hide="state.rollbackInProgress" class="vertical-center">
|
||||
<pr-icon icon="'rotate-ccw'"></pr-icon>
|
||||
Rollback the service</span
|
||||
>
|
||||
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
authorization="DockerServiceDelete"
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm !ml-0"
|
||||
ng-disabled="state.deletionInProgress || isUpdating"
|
||||
ng-click="removeService()"
|
||||
button-spinner="state.deletionInProgress"
|
||||
>
|
||||
<span ng-hide="state.deletionInProgress" class="vertical-center">
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
Delete the service</span
|
||||
>
|
||||
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||
<p class="small text-muted">
|
||||
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
|
||||
</p>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
|
||||
>Apply changes</button
|
||||
>
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
|
||||
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-3 col-xs-3">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
||||
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
||||
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
||||
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
||||
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
||||
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
||||
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
||||
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
||||
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
||||
</ul>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-3 col-xs-3">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
||||
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
||||
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
||||
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
||||
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
||||
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
||||
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
||||
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
||||
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
||||
</ul>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<!-- access-control-panel -->
|
||||
<access-control-panel
|
||||
ng-if="service"
|
||||
resource-id="service.Id"
|
||||
resource-control="service.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
environment-id="endpoint.Id"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="container-specs">Container specification</h3>
|
||||
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
||||
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
||||
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
||||
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
||||
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-network-specs">Networks & ports</h3>
|
||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||
|
||||
<docker-service-ports-mapping-field
|
||||
id="service-published-ports"
|
||||
class="block padding-top"
|
||||
values="formValues.ports"
|
||||
on-change="(onChangePorts)"
|
||||
has-changes="hasChanges(service, ['Ports'])"
|
||||
on-reset="(onResetPorts)"
|
||||
on-submit="(onSubmit)"
|
||||
></docker-service-ports-mapping-field>
|
||||
|
||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-specs">Service specification</h3>
|
||||
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
||||
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
||||
<div
|
||||
id="service-placement-preferences"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="padding-top"
|
||||
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
||||
></div>
|
||||
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
||||
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
||||
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
||||
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
||||
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
||||
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<access-control-panel
|
||||
ng-if="service"
|
||||
resource-id="service.Id"
|
||||
resource-control="service.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
environment-id="endpoint.Id"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="container-specs">Container specification</h3>
|
||||
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
||||
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
||||
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
||||
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
||||
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-network-specs">Networks & ports</h3>
|
||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||
|
||||
<docker-service-ports-mapping-field
|
||||
id="service-published-ports"
|
||||
class="block padding-top"
|
||||
values="formValues.ports"
|
||||
on-change="(onChangePorts)"
|
||||
has-changes="hasChanges(service, ['Ports'])"
|
||||
on-reset="(onResetPorts)"
|
||||
on-submit="(onSubmit)"
|
||||
></docker-service-ports-mapping-field>
|
||||
|
||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-specs">Service specification</h3>
|
||||
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
||||
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
||||
<div
|
||||
id="service-placement-preferences"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="padding-top"
|
||||
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
||||
></div>
|
||||
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
||||
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
||||
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
||||
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
||||
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
||||
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
||||
|
||||
@@ -731,6 +731,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$scope.isLoading = true;
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
||||
|
||||
@@ -855,6 +856,9 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
$scope.secrets = [];
|
||||
$scope.configs = [];
|
||||
Notifications.error('Failure', err, 'Unable to retrieve service details');
|
||||
})
|
||||
.finally(() => {
|
||||
$scope.isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,37 +31,31 @@
|
||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
||||
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="small text-muted mb-2"
|
||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
||||
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
||||
>
|
||||
<div class="relative flex w-fit gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3 mt-2">
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-lightbulb h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6"
|
||||
>
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
|
||||
<path d="M9 18h6"></path>
|
||||
<path d="M10 22h4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="align-middle text-[0.9em] font-medium pr-10 mb-2">Disclaimer</p>
|
||||
<div class="small">
|
||||
At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.<br />
|
||||
If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
|
||||
<a href="https://bit.ly/3WVkayl" target="_blank" rel="noopener noreferrer">please get in touch</a>.
|
||||
</div>
|
||||
<div class="relative flex w-fit gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3 mt-2">
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-lightbulb h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6"
|
||||
>
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
|
||||
<path d="M9 18h6"></path>
|
||||
<path d="M10 22h4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="align-middle text-[0.9em] font-medium pr-10 mb-2">Disclaimer</p>
|
||||
<div class="small">
|
||||
At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.<br />
|
||||
If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
|
||||
<a href="https://bit.ly/3WVkayl" target="_blank" rel="noopener noreferrer">please get in touch</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,7 +69,7 @@
|
||||
on-select="($ctrl.selectAction)"
|
||||
>
|
||||
</helm-templates-list-item>
|
||||
<div ng-if="!allCharts.length" class="text-muted small mt-4"> No Helm charts found </div>
|
||||
<div ng-if="!$ctrl.loading && !allCharts.length && $ctrl.charts.length !== 0" class="text-muted small mt-4"> No Helm charts found </div>
|
||||
<div ng-if="$ctrl.loading" class="text-muted text-center">
|
||||
Loading...
|
||||
<div class="text-muted text-center"> Initial download of Helm charts can take a few minutes </div>
|
||||
|
||||
@@ -22,6 +22,8 @@ import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
|
||||
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
|
||||
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
|
||||
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
||||
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
|
||||
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.kubernetes.react.views', [])
|
||||
@@ -78,6 +80,14 @@ export const viewsModule = angular
|
||||
[]
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'kubernetesHelmApplicationView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesClusterView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesConfigureView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
|
||||
|
||||
@@ -3,7 +3,6 @@ import _ from 'lodash-es';
|
||||
import angular from 'angular';
|
||||
import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool';
|
||||
import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper';
|
||||
import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||
|
||||
/* @ngInject */
|
||||
export function KubernetesResourcePoolService(
|
||||
@@ -12,8 +11,7 @@ export function KubernetesResourcePoolService(
|
||||
KubernetesNamespaceService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesIngressService,
|
||||
KubernetesPortainerNamespaces,
|
||||
EndpointProvider
|
||||
KubernetesPortainerNamespaces
|
||||
) {
|
||||
return {
|
||||
get,
|
||||
@@ -39,14 +37,9 @@ export function KubernetesResourcePoolService(
|
||||
|
||||
// getting the quota for all namespaces is costly by default, so disable getting it by default
|
||||
async function getAll({ getQuota = false }) {
|
||||
const namespaces = await getNamespaces(EndpointProvider.endpointID());
|
||||
// there is a lot of downstream logic using the angular namespace type with a '.Status' field (not '.Status.phase'), so format the status here to match this logic
|
||||
const namespacesFormattedStatus = namespaces.map((namespace) => ({
|
||||
...namespace,
|
||||
Status: namespace.Status.phase,
|
||||
}));
|
||||
const namespaces = await KubernetesNamespaceService.get();
|
||||
const pools = await Promise.all(
|
||||
_.map(namespacesFormattedStatus, async (namespace) => {
|
||||
_.map(namespaces, async (namespace) => {
|
||||
const name = namespace.Name;
|
||||
const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace);
|
||||
if (getQuota) {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import PortainerError from 'Portainer/error';
|
||||
|
||||
export default class KubernetesHelmApplicationController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, Notifications, HelmService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.Authentication = Authentication;
|
||||
this.Notifications = Notifications;
|
||||
this.HelmService = HelmService;
|
||||
}
|
||||
|
||||
/**
|
||||
* APPLICATION
|
||||
*/
|
||||
async getHelmApplication() {
|
||||
try {
|
||||
this.state.dataLoading = true;
|
||||
const releases = await this.HelmService.listReleases(this.endpoint.Id, { filter: `^${this.state.params.name}$`, namespace: this.state.params.namespace });
|
||||
if (releases.length > 0) {
|
||||
this.state.release = releases[0];
|
||||
} else {
|
||||
throw new PortainerError(`Release ${this.state.params.name} not found`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm application details');
|
||||
} finally {
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
this.state = {
|
||||
dataLoading: true,
|
||||
viewReady: false,
|
||||
params: {
|
||||
name: this.$state.params.name,
|
||||
namespace: this.$state.params.namespace,
|
||||
},
|
||||
release: {
|
||||
name: undefined,
|
||||
chart: undefined,
|
||||
app_version: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
await this.getHelmApplication();
|
||||
this.state.viewReady = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.release-table tr {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: 1fr 4fr;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<page-header
|
||||
ng-if="$ctrl.state.viewReady"
|
||||
title="'Helm details'"
|
||||
breadcrumbs="[{label:'Applications', link:'kubernetes.applications'}, $ctrl.state.params.name]"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="$ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 p-5">
|
||||
<div class="toolBarTitle vertical-center">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'svg-helm'"></pr-icon>
|
||||
</div>
|
||||
|
||||
Release
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<table class="table">
|
||||
<tbody class="release-table">
|
||||
<tr>
|
||||
<td class="vertical-center">Name</td>
|
||||
<td class="vertical-center !p-2" data-cy="k8sAppDetail-appName">
|
||||
{{ $ctrl.state.release.name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="vertical-center">Chart</td>
|
||||
<td class="vertical-center !p-2">
|
||||
{{ $ctrl.state.release.chart }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="vertical-center">App version</td>
|
||||
<td class="vertical-center !p-2">
|
||||
{{ $ctrl.state.release.app_version }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import controller from './helm.controller';
|
||||
import './helm.css';
|
||||
|
||||
angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
|
||||
templateUrl: './helm.html',
|
||||
controller,
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
<page-header ng-if="ctrl.state.viewReady" title="'Cluster'" breadcrumbs="['Cluster information']" reload="true"></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row" ng-if="ctrl.isAdmin">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<!-- resource-reservation -->
|
||||
<form class="form-horizontal" ng-if="ctrl.resourceReservation">
|
||||
<kubernetes-resource-reservation
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
||||
cpu-reservation="ctrl.resourceReservation.CPU"
|
||||
cpu-limit="ctrl.CPULimit"
|
||||
memory-reservation="ctrl.resourceReservation.Memory"
|
||||
memory-limit="ctrl.MemoryLimit"
|
||||
display-usage="ctrl.hasResourceUsageAccess()"
|
||||
cpu-usage="ctrl.resourceUsage.CPU"
|
||||
memory-usage="ctrl.resourceUsage.Memory"
|
||||
>
|
||||
</kubernetes-resource-reservation>
|
||||
</form>
|
||||
<!-- !resource-reservation -->
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<kube-nodes-datatable></kube-nodes-datatable>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
angular.module('portainer.kubernetes').component('kubernetesClusterView', {
|
||||
templateUrl: './cluster.html',
|
||||
controller: 'KubernetesClusterController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
||||
import { getMetricsForAllNodes, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts';
|
||||
|
||||
class KubernetesClusterController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Notifications, LocalStorage, Authentication, KubernetesNodeService, KubernetesApplicationService, KubernetesEndpointService, EndpointService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.Authentication = Authentication;
|
||||
this.Notifications = Notifications;
|
||||
this.LocalStorage = LocalStorage;
|
||||
this.KubernetesNodeService = KubernetesNodeService;
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
||||
this.EndpointService = EndpointService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getNodes = this.getNodes.bind(this);
|
||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
||||
this.hasResourceUsageAccess = this.hasResourceUsageAccess.bind(this);
|
||||
}
|
||||
|
||||
async getEndpointsAsync() {
|
||||
try {
|
||||
const endpoints = await this.KubernetesEndpointService.get();
|
||||
const systemEndpoints = _.filter(endpoints, { Namespace: 'kube-system' });
|
||||
this.systemEndpoints = _.filter(systemEndpoints, (ep) => ep.HolderIdentity);
|
||||
|
||||
const kubernetesEndpoint = _.find(endpoints, { Name: 'kubernetes' });
|
||||
if (kubernetesEndpoint && kubernetesEndpoint.Subsets) {
|
||||
const ips = _.flatten(_.map(kubernetesEndpoint.Subsets, 'Ips'));
|
||||
_.forEach(this.nodes, (node) => {
|
||||
node.Api = _.includes(ips, node.IPAddress);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve environments');
|
||||
}
|
||||
}
|
||||
|
||||
getEndpoints() {
|
||||
return this.$async(this.getEndpointsAsync);
|
||||
}
|
||||
|
||||
async getNodesAsync() {
|
||||
try {
|
||||
const nodes = await this.KubernetesNodeService.get();
|
||||
_.forEach(nodes, (node) => (node.Memory = filesizeParser(node.Memory)));
|
||||
this.nodes = nodes;
|
||||
this.CPULimit = _.reduce(this.nodes, (acc, node) => node.CPU + acc, 0);
|
||||
this.CPULimit = Math.round(this.CPULimit * 10000) / 10000;
|
||||
this.MemoryLimit = _.reduce(this.nodes, (acc, node) => KubernetesResourceReservationHelper.megaBytesValue(node.Memory) + acc, 0);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve nodes');
|
||||
}
|
||||
}
|
||||
|
||||
getNodes() {
|
||||
return this.$async(this.getNodesAsync);
|
||||
}
|
||||
|
||||
async getApplicationsAsync() {
|
||||
try {
|
||||
this.state.applicationsLoading = true;
|
||||
|
||||
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
|
||||
this.resourceReservation = new KubernetesResourceReservation();
|
||||
|
||||
// Using same rounding method as CPULimit in getNodesAsync for consistency
|
||||
this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest * 10000) / 10000;
|
||||
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest);
|
||||
|
||||
if (this.hasResourceUsageAccess()) {
|
||||
await this.getResourceUsage(this.endpoint.Id);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
||||
} finally {
|
||||
this.state.applicationsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getApplications() {
|
||||
return this.$async(this.getApplicationsAsync);
|
||||
}
|
||||
|
||||
async getResourceUsage(endpointId) {
|
||||
try {
|
||||
const nodeMetrics = await getMetricsForAllNodes(endpointId);
|
||||
const resourceUsageList = nodeMetrics.items.map((i) => i.usage);
|
||||
const clusterResourceUsage = resourceUsageList.reduce((total, u) => {
|
||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
||||
return total;
|
||||
}, new KubernetesResourceReservation());
|
||||
this.resourceUsage = clusterResourceUsage;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve cluster resource usage');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resource usage stats can be displayed
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasResourceUsageAccess() {
|
||||
return this.isAdmin && this.state.useServerMetrics;
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
const useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
||||
|
||||
this.state = {
|
||||
applicationsLoading: true,
|
||||
viewReady: false,
|
||||
useServerMetrics,
|
||||
};
|
||||
|
||||
await this.getNodes();
|
||||
if (this.isAdmin) {
|
||||
await Promise.allSettled([this.getEndpoints(), this.getApplicationsAsync()]);
|
||||
}
|
||||
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesClusterController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesClusterController', KubernetesClusterController);
|
||||
@@ -6,13 +6,13 @@ import PortainerError from '@/portainer/error';
|
||||
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
|
||||
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import _ from 'lodash';
|
||||
import { QueryObserverResult } from '@tanstack/react-query';
|
||||
|
||||
import { Team } from '@/react/portainer/users/teams/types';
|
||||
import { Role, User, UserId } from '@/portainer/users/types';
|
||||
@@ -134,3 +135,38 @@ export function createMockEnvironment(): Environment {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockQueryResult<TData, TError = unknown>(
|
||||
data: TData,
|
||||
overrides?: Partial<QueryObserverResult<TData, TError>>
|
||||
) {
|
||||
const defaultResult = {
|
||||
data,
|
||||
dataUpdatedAt: 0,
|
||||
error: null,
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
errorUpdateCount: 0,
|
||||
failureReason: null,
|
||||
isError: false,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isFetching: false,
|
||||
isInitialLoading: false,
|
||||
isLoading: false,
|
||||
isLoadingError: false,
|
||||
isPaused: false,
|
||||
isPlaceholderData: false,
|
||||
isPreviousData: false,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
isSuccess: true,
|
||||
refetch: async () => defaultResult,
|
||||
remove: () => {},
|
||||
status: 'success',
|
||||
fetchStatus: 'idle',
|
||||
};
|
||||
|
||||
return { ...defaultResult, ...overrides };
|
||||
}
|
||||
|
||||
@@ -155,9 +155,6 @@ describe('Datatable', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
const selectAllCheckbox: HTMLInputElement =
|
||||
screen.getByLabelText('Select all rows');
|
||||
expect(selectAllCheckbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('selects/deselects only page rows when select all is clicked', () => {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { ColumnDef, Row, Table } from '@tanstack/react-table';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
function allRowsSelected<T>(table: Table<T>) {
|
||||
const { rows } = table.getCoreRowModel();
|
||||
return rows.length > 0 && rows.every((row) => row.getIsSelected());
|
||||
return table.getCoreRowModel().rows.every((row) => row.getIsSelected());
|
||||
}
|
||||
|
||||
function someRowsSelected<T>(table: Table<T>) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
@@ -10,10 +10,11 @@ export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
|
||||
|
||||
export interface Props {
|
||||
inputId?: string;
|
||||
dataCy?: string;
|
||||
label: ReactNode;
|
||||
size?: Size;
|
||||
tooltip?: ComponentProps<typeof Tooltip>['message'];
|
||||
setTooltipHtmlMessage?: ComponentProps<typeof Tooltip>['setHtmlMessage'];
|
||||
tooltip?: ReactNode;
|
||||
setTooltipHtmlMessage?: boolean;
|
||||
children: ReactNode;
|
||||
errors?: ReactNode;
|
||||
required?: boolean;
|
||||
@@ -24,6 +25,7 @@ export interface Props {
|
||||
|
||||
export function FormControl({
|
||||
inputId,
|
||||
dataCy,
|
||||
label,
|
||||
size = 'small',
|
||||
tooltip = '',
|
||||
@@ -42,6 +44,7 @@ export function FormControl({
|
||||
'form-group',
|
||||
'after:clear-both after:table after:content-[""]' // to fix issues with float
|
||||
)}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
@@ -56,10 +59,15 @@ export function FormControl({
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div className={sizeClassChildren(size)}>
|
||||
{isLoading && <InlineLoader>{loadingText}</InlineLoader>}
|
||||
<div className={clsx('flex flex-col', sizeClassChildren(size))}>
|
||||
{isLoading && (
|
||||
// 34px height to reduce layout shift when loading is complete
|
||||
<div className="h-[34px] flex items-center">
|
||||
<InlineLoader>{loadingText}</InlineLoader>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && children}
|
||||
{errors && <FormError>{errors}</FormError>}
|
||||
{!!errors && !isLoading && <FormError>{errors}</FormError>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { CellContext, Column } from '@tanstack/react-table';
|
||||
import { useSref } from '@uirouter/react';
|
||||
|
||||
import { truncate } from '@/portainer/filters/filters';
|
||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||
@@ -7,6 +6,7 @@ import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
|
||||
|
||||
import { MultipleSelectionFilter } from '@@/datatables/Filter';
|
||||
import { UnusedBadge } from '@@/Badge/UnusedBadge';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -62,22 +62,20 @@ function FilterByUsage<TData extends { Used: boolean }>({
|
||||
}
|
||||
|
||||
function Cell({
|
||||
getValue,
|
||||
row: { original: image },
|
||||
row: { original: item },
|
||||
}: CellContext<ImagesListResponse, string>) {
|
||||
const name = getValue();
|
||||
|
||||
const linkProps = useSref('.image', {
|
||||
id: image.id,
|
||||
imageId: image.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
|
||||
{truncate(name, 40)}
|
||||
</a>
|
||||
{!image.used && <UnusedBadge />}
|
||||
</div>
|
||||
<>
|
||||
<Link
|
||||
to=".image"
|
||||
params={{ id: item.id, nodeName: item.nodeName }}
|
||||
title={item.id}
|
||||
data-cy={`image-link-${item.id}`}
|
||||
className="mr-2"
|
||||
>
|
||||
{truncate(item.id, 40)}
|
||||
</Link>
|
||||
{!item.used && <UnusedBadge />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,24 @@ describe('fullURIIntoRepoAndTag', () => {
|
||||
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
|
||||
});
|
||||
|
||||
it('splits image-repo:port/image correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag('registry.example.com:5000/my-image');
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com:5000/my-image',
|
||||
tag: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
it('splits image-repo:port/image:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'registry.example.com:5000/my-image:v1'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com:5000/my-image',
|
||||
tag: 'v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('splits registry:port/image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'registry.example.com:5000/my-image:v2.1'
|
||||
|
||||
@@ -121,9 +121,18 @@ export function fullURIIntoRepoAndTag(fullURI: string) {
|
||||
// - registry/image-repo:tag
|
||||
// - image-repo:tag
|
||||
// - registry:port/image-repo:tag
|
||||
// - localhost:5000/nginx
|
||||
// buildImageFullURIFromModel always gives a tag (defaulting to 'latest'), so the tag is always present after the last ':'
|
||||
const parts = fullURI.split(':');
|
||||
const tag = parts.pop() || 'latest';
|
||||
|
||||
// handle the case of a repo with a non standard port
|
||||
if (tag.includes('/')) {
|
||||
return {
|
||||
repo: fullURI,
|
||||
tag: 'latest',
|
||||
};
|
||||
}
|
||||
const repo = parts.join(':');
|
||||
return {
|
||||
repo,
|
||||
|
||||
@@ -57,6 +57,7 @@ export function ApplicationsDatatable({
|
||||
const applicationsQuery = useApplications(environmentId, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
namespace: tableState.namespace,
|
||||
withDependencies: true,
|
||||
});
|
||||
const ingressesQuery = useIngresses(environmentId);
|
||||
const ingresses = ingressesQuery.data ?? [];
|
||||
|
||||
@@ -38,6 +38,7 @@ export function ApplicationsStacksDatatable({
|
||||
const applicationsQuery = useApplications(environmentId, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
namespace: tableState.namespace,
|
||||
withDependencies: true,
|
||||
});
|
||||
const ingressesQuery = useIngresses(environmentId);
|
||||
const ingresses = ingressesQuery.data ?? [];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
export type GetAppsParams = {
|
||||
namespace?: string;
|
||||
nodeName?: string;
|
||||
withDependencies?: boolean;
|
||||
};
|
||||
|
||||
export const queryKeys = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { queryKeys } from './query-keys';
|
||||
type GetAppsParams = {
|
||||
namespace?: string;
|
||||
nodeName?: string;
|
||||
withDependencies?: boolean;
|
||||
};
|
||||
|
||||
type GetAppsQueryOptions = {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function isExternalApplication(application: Application) {
|
||||
|
||||
function getDeploymentRunningPods(deployment: Deployment): number {
|
||||
const availableReplicas = deployment.status?.availableReplicas ?? 0;
|
||||
const totalReplicas = deployment.spec?.replicas ?? 0;
|
||||
const totalReplicas = deployment.status?.replicas ?? 0;
|
||||
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
|
||||
return availableReplicas || totalReplicas - unavailableReplicas;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { HttpResponse } from 'msw';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { server, http } from '@/setup-tests/server';
|
||||
import {
|
||||
createMockEnvironment,
|
||||
createMockQueryResult,
|
||||
} from '@/react-tools/test-mocks';
|
||||
|
||||
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||
|
||||
const mockUseAuthorizations = vi.fn();
|
||||
const mockUseEnvironmentId = vi.fn(() => 3);
|
||||
const mockUseCurrentEnvironment = vi.fn();
|
||||
|
||||
// Set up mock implementations for hooks
|
||||
vi.mock('@/react/hooks/useUser', () => ({
|
||||
useAuthorizations: () => mockUseAuthorizations(),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useCurrentEnvironment', () => ({
|
||||
useCurrentEnvironment: () => mockUseCurrentEnvironment(),
|
||||
}));
|
||||
|
||||
function renderComponent() {
|
||||
const Wrapped = withTestQueryProvider(ClusterResourceReservation);
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
describe('ClusterResourceReservation', () => {
|
||||
beforeEach(() => {
|
||||
// Set the return values for the hooks
|
||||
mockUseAuthorizations.mockReturnValue({
|
||||
authorized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseEnvironmentId.mockReturnValue(3);
|
||||
|
||||
const mockEnvironment = createMockEnvironment();
|
||||
mockEnvironment.Kubernetes.Configuration.UseServerMetrics = true;
|
||||
mockUseCurrentEnvironment.mockReturnValue(
|
||||
createMockQueryResult(mockEnvironment)
|
||||
);
|
||||
|
||||
// Setup default mock responses
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/api/v1/nodes', () =>
|
||||
HttpResponse.json({
|
||||
items: [
|
||||
{
|
||||
status: {
|
||||
allocatable: {
|
||||
cpu: '4',
|
||||
memory: '8Gi',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('/api/kubernetes/3/metrics/nodes', () =>
|
||||
HttpResponse.json({
|
||||
items: [
|
||||
{
|
||||
usage: {
|
||||
cpu: '2',
|
||||
memory: '4Gi',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('/api/kubernetes/3/metrics/applications_resources', () =>
|
||||
HttpResponse.json({
|
||||
CpuRequest: 1000,
|
||||
MemoryRequest: '2Gi',
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should display resource limits, reservations and usage when all APIs respond successfully', async () => {
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||
'2147 / 8589 MB - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('memory-usage')).findByText(
|
||||
'4294 / 8589 MB - 50%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||
'1 / 4 - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('cpu-usage')).findByText(
|
||||
'2 / 4 - 50%'
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not display resource usage if user does not have K8sClusterNodeR authorization', async () => {
|
||||
mockUseAuthorizations.mockReturnValue({
|
||||
authorized: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Should only show reservation bars
|
||||
expect(
|
||||
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||
'2147 / 8589 MB - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||
'1 / 4 - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Usage bars should not be present
|
||||
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display resource usage if metrics server is not enabled', async () => {
|
||||
const disabledMetricsEnvironment = createMockEnvironment();
|
||||
disabledMetricsEnvironment.Kubernetes.Configuration.UseServerMetrics =
|
||||
false;
|
||||
mockUseCurrentEnvironment.mockReturnValue(
|
||||
createMockQueryResult(disabledMetricsEnvironment)
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Should only show reservation bars
|
||||
expect(
|
||||
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||
'2147 / 8589 MB - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||
'1 / 4 - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Usage bars should not be present
|
||||
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display warning if metrics server is enabled but usage query fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error())
|
||||
);
|
||||
|
||||
// Mock console.error so test logs are not polluted
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||
'2147 / 8589 MB - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('memory-usage')).findByText(
|
||||
'0 / 8589 MB - 0%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||
'1 / 4 - 25%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await within(await screen.findByTestId('cpu-usage')).findByText(
|
||||
'0 / 4 - 0%'
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Should show the warning message
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Resource usage is not currently available as Metrics Server is not responding/
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
// Restore console.error
|
||||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Widget, WidgetBody } from '@/react/components/Widget';
|
||||
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
|
||||
|
||||
import { useClusterResourceReservationData } from './useClusterResourceReservationData';
|
||||
|
||||
export function ClusterResourceReservation() {
|
||||
// Load all data required for this component
|
||||
const {
|
||||
cpuLimit,
|
||||
memoryLimit,
|
||||
isLoading,
|
||||
displayResourceUsage,
|
||||
resourceUsage,
|
||||
resourceReservation,
|
||||
displayWarning,
|
||||
} = useClusterResourceReservationData();
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<ResourceReservation
|
||||
isLoading={isLoading}
|
||||
displayResourceUsage={displayResourceUsage}
|
||||
resourceReservation={resourceReservation}
|
||||
resourceUsage={resourceUsage}
|
||||
cpuLimit={cpuLimit}
|
||||
memoryLimit={memoryLimit}
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
||||
displayWarning={displayWarning}
|
||||
warningMessage="Resource usage is not currently available as Metrics Server is not responding. If you've recently upgraded, Metrics Server may take a while to restart, so please check back shortly."
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
Normal file
33
app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { PageHeader } from '@/react/components/PageHeader';
|
||||
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
|
||||
|
||||
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||
|
||||
export function ClusterView() {
|
||||
const { data: environment } = useCurrentEnvironment();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cluster"
|
||||
breadcrumbs={[
|
||||
{ label: 'Environments', link: 'portainer.endpoints' },
|
||||
{
|
||||
label: environment?.Name || '',
|
||||
link: 'portainer.endpoints.endpoint',
|
||||
linkParams: { id: environment?.Id },
|
||||
},
|
||||
'Cluster information',
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
|
||||
<ClusterResourceReservation />
|
||||
|
||||
<div className="row">
|
||||
<NodesDatatable />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
app/react/kubernetes/cluster/ClusterView/index.ts
Normal file
1
app/react/kubernetes/cluster/ClusterView/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClusterView } from './ClusterView';
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './useClusterResourceLimitsQuery';
|
||||
export * from './useClusterResourceReservationQuery';
|
||||
export * from './useClusterResourceUsageQuery';
|
||||
@@ -0,0 +1,49 @@
|
||||
import { round, reduce } from 'lodash';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Node } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||
import { parseCpu } from '@/react/kubernetes/utils';
|
||||
import { getNodes } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||
|
||||
export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
|
||||
return useQuery(
|
||||
[environmentId, 'clusterResourceLimits'],
|
||||
async () => getNodes(environmentId),
|
||||
{
|
||||
...withGlobalError('Unable to retrieve resource limit data', 'Failure'),
|
||||
enabled: !!environmentId,
|
||||
select: aggregateResourceLimits,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes node data to calculate total CPU and memory limits for the cluster
|
||||
* and sets the state for memory limit in MB and CPU limit rounded to 3 decimal places.
|
||||
*/
|
||||
function aggregateResourceLimits(nodes: Node[]) {
|
||||
const processedNodes = nodes.map((node) => ({
|
||||
...node,
|
||||
memory: filesizeParser(node.status?.allocatable?.memory ?? ''),
|
||||
cpu: parseCpu(node.status?.allocatable?.cpu ?? ''),
|
||||
}));
|
||||
|
||||
return {
|
||||
nodes: processedNodes,
|
||||
memoryLimit: reduce(
|
||||
processedNodes,
|
||||
(acc, node) =>
|
||||
KubernetesResourceReservationHelper.megaBytesValue(node.memory || 0) +
|
||||
acc,
|
||||
0
|
||||
),
|
||||
cpuLimit: round(
|
||||
reduce(processedNodes, (acc, node) => (node.cpu || 0) + acc, 0),
|
||||
3
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Node } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics';
|
||||
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||
|
||||
export function useClusterResourceReservationQuery(
|
||||
environmentId: EnvironmentId,
|
||||
nodes: Node[]
|
||||
) {
|
||||
return useQuery(
|
||||
[environmentId, 'clusterResourceReservation'],
|
||||
() => getTotalResourcesForAllApplications(environmentId),
|
||||
{
|
||||
enabled: !!environmentId && nodes.length > 0,
|
||||
select: (data) => ({
|
||||
cpu: data.CpuRequest / 1000,
|
||||
memory: KubernetesResourceReservationHelper.megaBytesValue(
|
||||
data.MemoryRequest
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Node } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { getMetricsForAllNodes } from '@/react/kubernetes/metrics/metrics';
|
||||
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import { NodeMetrics } from '@/react/kubernetes/metrics/types';
|
||||
|
||||
export function useClusterResourceUsageQuery(
|
||||
environmentId: EnvironmentId,
|
||||
serverMetricsEnabled: boolean,
|
||||
authorized: boolean,
|
||||
nodes: Node[]
|
||||
) {
|
||||
return useQuery(
|
||||
[environmentId, 'clusterResourceUsage'],
|
||||
() => getMetricsForAllNodes(environmentId),
|
||||
{
|
||||
enabled:
|
||||
authorized &&
|
||||
serverMetricsEnabled &&
|
||||
!!environmentId &&
|
||||
nodes.length > 0,
|
||||
select: aggregateResourceUsage,
|
||||
...withGlobalError('Unable to retrieve resource usage data.', 'Failure'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateResourceUsage(data: NodeMetrics) {
|
||||
return data.items.reduce(
|
||||
(total, item) => ({
|
||||
cpu:
|
||||
total.cpu +
|
||||
KubernetesResourceReservationHelper.parseCPU(item.usage.cpu),
|
||||
memory:
|
||||
total.memory +
|
||||
KubernetesResourceReservationHelper.megaBytesValue(item.usage.memory),
|
||||
}),
|
||||
{
|
||||
cpu: 0,
|
||||
memory: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { getSafeValue } from '@/react/kubernetes/utils';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
|
||||
import {
|
||||
useClusterResourceLimitsQuery,
|
||||
useClusterResourceReservationQuery,
|
||||
useClusterResourceUsageQuery,
|
||||
} from './queries';
|
||||
|
||||
export function useClusterResourceReservationData() {
|
||||
const { data: environment } = useCurrentEnvironment();
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
// Check if server metrics is enabled
|
||||
const serverMetricsEnabled =
|
||||
environment?.Kubernetes?.Configuration?.UseServerMetrics || false;
|
||||
|
||||
// User needs to have K8sClusterNodeR authorization to view resource usage data
|
||||
const { authorized: hasK8sClusterNodeR } = useAuthorizations(
|
||||
['K8sClusterNodeR'],
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
// Get resource limits for the cluster
|
||||
const { data: resourceLimits, isLoading: isResourceLimitLoading } =
|
||||
useClusterResourceLimitsQuery(environmentId);
|
||||
|
||||
// Get resource reservation info for the cluster
|
||||
const {
|
||||
data: resourceReservation,
|
||||
isFetching: isResourceReservationLoading,
|
||||
} = useClusterResourceReservationQuery(
|
||||
environmentId,
|
||||
resourceLimits?.nodes || []
|
||||
);
|
||||
|
||||
// Get resource usage info for the cluster
|
||||
const {
|
||||
data: resourceUsage,
|
||||
isFetching: isResourceUsageLoading,
|
||||
isError: isResourceUsageError,
|
||||
} = useClusterResourceUsageQuery(
|
||||
environmentId,
|
||||
serverMetricsEnabled,
|
||||
hasK8sClusterNodeR,
|
||||
resourceLimits?.nodes || []
|
||||
);
|
||||
|
||||
return {
|
||||
memoryLimit: getSafeValue(resourceLimits?.memoryLimit || 0),
|
||||
cpuLimit: getSafeValue(resourceLimits?.cpuLimit || 0),
|
||||
displayResourceUsage: hasK8sClusterNodeR && serverMetricsEnabled,
|
||||
resourceUsage: {
|
||||
cpu: getSafeValue(resourceUsage?.cpu || 0),
|
||||
memory: getSafeValue(resourceUsage?.memory || 0),
|
||||
},
|
||||
resourceReservation: {
|
||||
cpu: getSafeValue(resourceReservation?.cpu || 0),
|
||||
memory: getSafeValue(resourceReservation?.memory || 0),
|
||||
},
|
||||
isLoading:
|
||||
isResourceLimitLoading ||
|
||||
isResourceReservationLoading ||
|
||||
isResourceUsageLoading,
|
||||
// Display warning if server metrics isn't responding but should be
|
||||
displayWarning:
|
||||
hasK8sClusterNodeR && serverMetricsEnabled && isResourceUsageError,
|
||||
};
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export function useNodeQuery(environmentId: EnvironmentId, nodeName: string) {
|
||||
}
|
||||
|
||||
// getNodes is used to get a list of nodes using the kubernetes API
|
||||
async function getNodes(environmentId: EnvironmentId) {
|
||||
export async function getNodes(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: nodeList } = await axios.get<NodeList>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
|
||||
|
||||
129
app/react/kubernetes/components/ResourceReservation.tsx
Normal file
129
app/react/kubernetes/components/ResourceReservation.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { round } from 'lodash';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { FormSectionTitle } from '@/react/components/form-components/FormSectionTitle';
|
||||
import { TextTip } from '@/react/components/Tip/TextTip';
|
||||
import { ResourceUsageItem } from '@/react/kubernetes/components/ResourceUsageItem';
|
||||
import { getPercentageString, getSafeValue } from '@/react/kubernetes/utils';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
interface ResourceMetrics {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
displayResourceUsage: boolean;
|
||||
resourceReservation: ResourceMetrics;
|
||||
resourceUsage: ResourceMetrics;
|
||||
cpuLimit: number;
|
||||
memoryLimit: number;
|
||||
description: string;
|
||||
isLoading?: boolean;
|
||||
title?: string;
|
||||
displayWarning?: boolean;
|
||||
warningMessage?: string;
|
||||
}
|
||||
|
||||
export function ResourceReservation({
|
||||
displayResourceUsage,
|
||||
resourceReservation,
|
||||
resourceUsage,
|
||||
cpuLimit,
|
||||
memoryLimit,
|
||||
description,
|
||||
title = 'Resource reservation',
|
||||
isLoading = false,
|
||||
displayWarning = false,
|
||||
warningMessage = '',
|
||||
}: Props) {
|
||||
const memoryReservationAnnotation = `${getSafeValue(
|
||||
resourceReservation.memory
|
||||
)} / ${memoryLimit} MB ${getPercentageString(
|
||||
resourceReservation.memory,
|
||||
memoryLimit
|
||||
)}`;
|
||||
|
||||
const memoryUsageAnnotation = `${getSafeValue(
|
||||
resourceUsage.memory
|
||||
)} / ${memoryLimit} MB ${getPercentageString(
|
||||
resourceUsage.memory,
|
||||
memoryLimit
|
||||
)}`;
|
||||
|
||||
const cpuReservationAnnotation = `${round(
|
||||
getSafeValue(resourceReservation.cpu),
|
||||
2
|
||||
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
|
||||
resourceReservation.cpu,
|
||||
cpuLimit
|
||||
)}`;
|
||||
|
||||
const cpuUsageAnnotation = `${round(
|
||||
getSafeValue(resourceUsage.cpu),
|
||||
2
|
||||
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
|
||||
resourceUsage.cpu,
|
||||
cpuLimit
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>{title}</FormSectionTitle>
|
||||
<TextTip color="blue" className="mb-2">
|
||||
{description}
|
||||
</TextTip>
|
||||
<div className="form-horizontal">
|
||||
{memoryLimit > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={resourceReservation.memory}
|
||||
total={memoryLimit}
|
||||
label="Memory reservation"
|
||||
annotation={memoryReservationAnnotation}
|
||||
isLoading={isLoading}
|
||||
dataCy="memory-reservation"
|
||||
/>
|
||||
)}
|
||||
{displayResourceUsage && memoryLimit > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={resourceUsage.memory}
|
||||
total={memoryLimit}
|
||||
label="Memory usage"
|
||||
annotation={memoryUsageAnnotation}
|
||||
isLoading={isLoading}
|
||||
dataCy="memory-usage"
|
||||
/>
|
||||
)}
|
||||
{cpuLimit > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={resourceReservation.cpu}
|
||||
total={cpuLimit}
|
||||
label="CPU reservation"
|
||||
annotation={cpuReservationAnnotation}
|
||||
isLoading={isLoading}
|
||||
dataCy="cpu-reservation"
|
||||
/>
|
||||
)}
|
||||
{displayResourceUsage && cpuLimit > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={resourceUsage.cpu}
|
||||
total={cpuLimit}
|
||||
label="CPU usage"
|
||||
annotation={cpuUsageAnnotation}
|
||||
isLoading={isLoading}
|
||||
dataCy="cpu-usage"
|
||||
/>
|
||||
)}
|
||||
{displayWarning && (
|
||||
<div className="form-group">
|
||||
<span className="col-sm-12 text-warning small vertical-center">
|
||||
<Icon icon={AlertTriangle} mode="warning" />
|
||||
{warningMessage}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ProgressBar } from '@@/ProgressBar';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { ProgressBar } from '@@/ProgressBar';
|
||||
|
||||
interface ResourceUsageItemProps {
|
||||
value: number;
|
||||
total: number;
|
||||
annotation?: React.ReactNode;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
dataCy?: string;
|
||||
}
|
||||
|
||||
export function ResourceUsageItem({
|
||||
@@ -13,9 +15,16 @@ export function ResourceUsageItem({
|
||||
total,
|
||||
annotation,
|
||||
label,
|
||||
isLoading = false,
|
||||
dataCy,
|
||||
}: ResourceUsageItemProps) {
|
||||
return (
|
||||
<FormControl label={label}>
|
||||
<FormControl
|
||||
label={label}
|
||||
isLoading={isLoading}
|
||||
className={isLoading ? 'mb-1.5' : ''}
|
||||
dataCy={dataCy}
|
||||
>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<ProgressBar
|
||||
steps={[
|
||||
@@ -0,0 +1,119 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { HttpResponse } from 'msw';
|
||||
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { server, http } from '@/setup-tests/server';
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
|
||||
import { HelmApplicationView } from './HelmApplicationView';
|
||||
|
||||
// Mock the necessary hooks and dependencies
|
||||
const mockUseCurrentStateAndParams = vi.fn();
|
||||
const mockUseEnvironmentId = vi.fn();
|
||||
|
||||
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||
}));
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withUserProvider(withTestRouter(HelmApplicationView), user)
|
||||
);
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
describe('HelmApplicationView', () => {
|
||||
beforeEach(() => {
|
||||
// Set up default mock values
|
||||
mockUseEnvironmentId.mockReturnValue(3);
|
||||
mockUseCurrentStateAndParams.mockReturnValue({
|
||||
params: {
|
||||
name: 'test-release',
|
||||
namespace: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
// Set up default mock API responses
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm', () =>
|
||||
HttpResponse.json([
|
||||
{
|
||||
name: 'test-release',
|
||||
chart: 'test-chart-1.0.0',
|
||||
app_version: '1.0.0',
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should display helm release details when data is loaded', async () => {
|
||||
renderComponent();
|
||||
|
||||
// Check for the page header
|
||||
expect(await screen.findByText('Helm details')).toBeInTheDocument();
|
||||
|
||||
// Check for the release details
|
||||
expect(screen.getByText('Release')).toBeInTheDocument();
|
||||
|
||||
// Check for the table content
|
||||
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chart')).toBeInTheDocument();
|
||||
expect(screen.getByText('App version')).toBeInTheDocument();
|
||||
|
||||
// Check for the actual values
|
||||
expect(screen.getByTestId('k8sAppDetail-appName')).toHaveTextContent(
|
||||
'test-release'
|
||||
);
|
||||
expect(screen.getByText('test-chart-1.0.0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display error message when API request fails', async () => {
|
||||
// Mock API failure
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error())
|
||||
);
|
||||
|
||||
// Mock console.error to prevent test output pollution
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for the error message to appear
|
||||
expect(
|
||||
await screen.findByText('Failed to load Helm application details')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Restore console.error
|
||||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
|
||||
it('should display error message when release is not found', async () => {
|
||||
// Mock empty response (no releases found)
|
||||
server.use(
|
||||
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([]))
|
||||
);
|
||||
|
||||
// Mock console.error to prevent test output pollution
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for the error message to appear
|
||||
expect(
|
||||
await screen.findByText('Failed to load Helm application details')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Restore console.error
|
||||
vi.spyOn(console, 'error').mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { PageHeader } from '@/react/components/PageHeader';
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@/react/components/Widget';
|
||||
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { ViewLoading } from '@@/ViewLoading';
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { useHelmRelease } from './queries/useHelmRelease';
|
||||
|
||||
export function HelmApplicationView() {
|
||||
const { params } = useCurrentStateAndParams();
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const name = params.name as string;
|
||||
const namespace = params.namespace as string;
|
||||
|
||||
const {
|
||||
data: release,
|
||||
isLoading,
|
||||
error,
|
||||
} = useHelmRelease(environmentId, name, namespace);
|
||||
|
||||
if (isLoading) {
|
||||
return <ViewLoading />;
|
||||
}
|
||||
|
||||
if (error || !release) {
|
||||
return (
|
||||
<Alert color="error" title="Failed to load Helm application details" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Helm details"
|
||||
breadcrumbs={[
|
||||
{ label: 'Applications', link: 'kubernetes.applications' },
|
||||
name,
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetTitle icon={helm} title="Release" />
|
||||
<WidgetBody>
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="!border-none w-40">Name</td>
|
||||
<td
|
||||
className="!border-none min-w-[140px]"
|
||||
data-cy="k8sAppDetail-appName"
|
||||
>
|
||||
{release.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="!border-t">Chart</td>
|
||||
<td className="!border-t">{release.chart}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>App version</td>
|
||||
<td>{release.app_version}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
app/react/kubernetes/helm/HelmApplicationView/index.ts
Normal file
1
app/react/kubernetes/helm/HelmApplicationView/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { HelmApplicationView } from './HelmApplicationView';
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import PortainerError from 'Portainer/error';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
interface HelmRelease {
|
||||
name: string;
|
||||
chart: string;
|
||||
app_version: string;
|
||||
}
|
||||
/**
|
||||
* List all helm releases based on passed in options
|
||||
* @param environmentId - Environment ID
|
||||
* @param options - Options for filtering releases
|
||||
* @returns List of helm releases
|
||||
*/
|
||||
export async function listReleases(
|
||||
environmentId: EnvironmentId,
|
||||
options: {
|
||||
namespace?: string;
|
||||
filter?: string;
|
||||
selector?: string;
|
||||
output?: string;
|
||||
} = {}
|
||||
): Promise<HelmRelease[]> {
|
||||
try {
|
||||
const { namespace, filter, selector, output } = options;
|
||||
const url = `endpoints/${environmentId}/kubernetes/helm`;
|
||||
const { data } = await axios.get<HelmRelease[]>(url, {
|
||||
params: { namespace, filter, selector, output },
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve release list');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook to fetch a specific Helm release
|
||||
*/
|
||||
export function useHelmRelease(
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
namespace: string
|
||||
) {
|
||||
return useQuery(
|
||||
[environmentId, 'helm', namespace, name],
|
||||
() => getHelmRelease(environmentId, name, namespace),
|
||||
{
|
||||
enabled: !!environmentId,
|
||||
...withGlobalError('Unable to retrieve helm application details'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific Helm release
|
||||
*/
|
||||
async function getHelmRelease(
|
||||
environmentId: EnvironmentId,
|
||||
name: string,
|
||||
namespace: string
|
||||
): Promise<HelmRelease> {
|
||||
try {
|
||||
const releases = await listReleases(environmentId, {
|
||||
filter: `^${name}$`,
|
||||
namespace,
|
||||
});
|
||||
|
||||
if (releases.length > 0) {
|
||||
return releases[0];
|
||||
}
|
||||
|
||||
throw new PortainerError(`Release ${name} not found`);
|
||||
} catch (err) {
|
||||
throw new PortainerError(
|
||||
'Unable to retrieve helm application details',
|
||||
err as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,8 @@ export type Usage = {
|
||||
};
|
||||
|
||||
export type ApplicationResource = {
|
||||
cpuRequest: number;
|
||||
cpuLimit: number;
|
||||
memoryRequest: number;
|
||||
memoryLimit: number;
|
||||
CpuRequest: number;
|
||||
CpuLimit: number;
|
||||
MemoryRequest: number;
|
||||
MemoryLimit: number;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
|
||||
const applicationsQuery = useApplications(environmentId, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
namespace,
|
||||
withDependencies: true,
|
||||
});
|
||||
const applications = applicationsQuery.data ?? [];
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
import { useNamespaceResourceReservationData } from './useNamespaceResourceReservationData';
|
||||
|
||||
interface Props {
|
||||
namespaceName: string;
|
||||
environmentId: number;
|
||||
resourceQuotaValues: ResourceQuotaFormValues;
|
||||
}
|
||||
|
||||
export function NamespaceResourceReservation({
|
||||
environmentId,
|
||||
namespaceName,
|
||||
resourceQuotaValues,
|
||||
}: Props) {
|
||||
const {
|
||||
cpuLimit,
|
||||
memoryLimit,
|
||||
displayResourceUsage,
|
||||
resourceUsage,
|
||||
resourceReservation,
|
||||
isLoading,
|
||||
} = useNamespaceResourceReservationData(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
resourceQuotaValues
|
||||
);
|
||||
|
||||
if (!resourceQuotaValues.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourceReservation
|
||||
displayResourceUsage={displayResourceUsage}
|
||||
resourceReservation={resourceReservation}
|
||||
resourceUsage={resourceUsage}
|
||||
cpuLimit={cpuLimit}
|
||||
memoryLimit={memoryLimit}
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,8 @@ import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||
|
||||
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
|
||||
|
||||
import { ResourceReservationUsage } from './ResourceReservationUsage';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
import { NamespaceResourceReservation } from './NamespaceResourceReservation';
|
||||
|
||||
interface Props {
|
||||
values: ResourceQuotaFormValues;
|
||||
@@ -128,7 +128,7 @@ export function ResourceQuotaFormSection({
|
||||
</div>
|
||||
)}
|
||||
{namespaceName && isEdit && (
|
||||
<ResourceReservationUsage
|
||||
<NamespaceResourceReservation
|
||||
namespaceName={namespaceName}
|
||||
environmentId={environmentId}
|
||||
resourceQuotaValues={values}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { round } from 'lodash';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
|
||||
import { megaBytesValue, parseCPU } from '../../../resourceQuotaUtils';
|
||||
import { ResourceUsageItem } from '../../ResourceUsageItem';
|
||||
|
||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function ResourceReservationUsage({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
resourceQuotaValues,
|
||||
}: {
|
||||
namespaceName: string;
|
||||
environmentId: EnvironmentId;
|
||||
resourceQuotaValues: ResourceQuotaFormValues;
|
||||
}) {
|
||||
const namespaceMetricsQuery = useMetricsForNamespace(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
{
|
||||
select: aggregatePodUsage,
|
||||
}
|
||||
);
|
||||
const usedResourceQuotaQuery = useResourceQuotaUsed(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
const { data: namespaceMetrics } = namespaceMetricsQuery;
|
||||
const { data: usedResourceQuota } = usedResourceQuotaQuery;
|
||||
|
||||
const memoryQuota = Number(resourceQuotaValues.memory) ?? 0;
|
||||
const cpuQuota = Number(resourceQuotaValues.cpu) ?? 0;
|
||||
|
||||
if (!resourceQuotaValues.enabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>Resource reservation</FormSectionTitle>
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Resource reservation represents the total amount of resource assigned to
|
||||
all the applications deployed inside this namespace.
|
||||
</TextTip>
|
||||
{!!usedResourceQuota && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory reservation"
|
||||
annotation={`${usedResourceQuota.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(usedResourceQuota.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory used"
|
||||
annotation={`${namespaceMetrics.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(namespaceMetrics.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!usedResourceQuota && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU reservation"
|
||||
annotation={`${
|
||||
usedResourceQuota.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
usedResourceQuota.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU used"
|
||||
annotation={`${
|
||||
namespaceMetrics.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
namespaceMetrics.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getSafeValue(value: number | string) {
|
||||
const valueNumber = Number(value);
|
||||
if (Number.isNaN(valueNumber)) {
|
||||
return 0;
|
||||
}
|
||||
return valueNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the percentage of the value over the total.
|
||||
* @param value - The value to calculate the percentage for.
|
||||
* @param total - The total value to compare the percentage to.
|
||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||
*/
|
||||
function getPercentageString(value: number, total?: number | string) {
|
||||
const totalNumber = Number(total);
|
||||
if (
|
||||
totalNumber === 0 ||
|
||||
total === undefined ||
|
||||
total === '' ||
|
||||
Number.isNaN(totalNumber)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (value > totalNumber) {
|
||||
return '- Exceeded';
|
||||
}
|
||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the resource usage of all the containers in the namespace.
|
||||
* @param podMetricsList - List of pod metrics
|
||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||
i.containers.map((c) => c.usage)
|
||||
);
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||
(total, usage) => ({
|
||||
cpu: total.cpu + parseCPU(usage.cpu),
|
||||
memory: total.memory + megaBytesValue(usage.memory),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 }
|
||||
);
|
||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||
return namespaceResourceUsage;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { round } from 'lodash';
|
||||
|
||||
import { getSafeValue } from '@/react/kubernetes/utils';
|
||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||
import {
|
||||
megaBytesValue,
|
||||
parseCPU,
|
||||
} from '@/react/kubernetes/namespaces/resourceQuotaUtils';
|
||||
|
||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function useNamespaceResourceReservationData(
|
||||
environmentId: number,
|
||||
namespaceName: string,
|
||||
resourceQuotaValues: ResourceQuotaFormValues
|
||||
) {
|
||||
const { data: quota, isLoading: isQuotaLoading } = useResourceQuotaUsed(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
const { data: metrics, isLoading: isMetricsLoading } = useMetricsForNamespace(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
{
|
||||
select: aggregatePodUsage,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
cpuLimit: Number(resourceQuotaValues.cpu) || 0,
|
||||
memoryLimit: Number(resourceQuotaValues.memory) || 0,
|
||||
displayResourceUsage: !!metrics,
|
||||
resourceReservation: {
|
||||
cpu: getSafeValue(quota?.cpu || 0),
|
||||
memory: getSafeValue(quota?.memory || 0),
|
||||
},
|
||||
resourceUsage: {
|
||||
cpu: getSafeValue(metrics?.cpu || 0),
|
||||
memory: getSafeValue(metrics?.memory || 0),
|
||||
},
|
||||
isLoading: isQuotaLoading || isMetricsLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the resource usage of all the containers in the namespace.
|
||||
* @param podMetricsList - List of pod metrics
|
||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||
i.containers.map((c) => c.usage)
|
||||
);
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||
(total, usage) => ({
|
||||
cpu: total.cpu + parseCPU(usage.cpu),
|
||||
memory: total.memory + megaBytesValue(usage.memory),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 }
|
||||
);
|
||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||
return namespaceResourceUsage;
|
||||
}
|
||||
@@ -20,3 +20,38 @@ export function prepareAnnotations(annotations?: Annotation[]) {
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the safe value of the given number or string.
|
||||
* @param value - The value to get the safe value for.
|
||||
* @returns The safe value of the given number or string.
|
||||
*/
|
||||
export function getSafeValue(value: number | string) {
|
||||
const valueNumber = Number(value);
|
||||
if (Number.isNaN(valueNumber)) {
|
||||
return 0;
|
||||
}
|
||||
return valueNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the percentage of the value over the total.
|
||||
* @param value - The value to calculate the percentage for.
|
||||
* @param total - The total value to compare the percentage to.
|
||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||
*/
|
||||
export function getPercentageString(value: number, total?: number | string) {
|
||||
const totalNumber = Number(total);
|
||||
if (
|
||||
totalNumber === 0 ||
|
||||
total === undefined ||
|
||||
total === '' ||
|
||||
Number.isNaN(totalNumber)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (value > totalNumber) {
|
||||
return '- Exceeded';
|
||||
}
|
||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"docker": "v27.5.1",
|
||||
"helm": "v3.17.3",
|
||||
"kubectl": "v1.32.2",
|
||||
"mingit": "2.49.0.1"
|
||||
"helm": "v3.17.0",
|
||||
"kubectl": "v1.32.1",
|
||||
"mingit": "2.48.1.1"
|
||||
}
|
||||
|
||||
22
go.mod
22
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/portainer/portainer
|
||||
|
||||
go 1.23.10
|
||||
go 1.23.5
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
@@ -25,12 +25,11 @@ require (
|
||||
github.com/go-ldap/ldap/v3 v3.4.1
|
||||
github.com/go-playground/validator/v10 v10.12.0
|
||||
github.com/gofrs/uuid v4.2.0+incompatible
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/gorilla/csrf v1.7.3
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hashicorp/go-version v1.7.0
|
||||
github.com/hashicorp/golang-lru v0.5.4
|
||||
github.com/joho/godotenv v1.4.0
|
||||
github.com/jpillora/chisel v1.10.0
|
||||
@@ -48,11 +47,11 @@ require (
|
||||
github.com/urfave/negroni v1.0.0
|
||||
github.com/viney-shih/go-lock v1.1.1
|
||||
go.etcd.io/bbolt v1.3.11
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
|
||||
golang.org/x/mod v0.21.0
|
||||
golang.org/x/oauth2 v0.27.0
|
||||
golang.org/x/sync v0.12.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/sync v0.10.0
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/api v0.29.2
|
||||
@@ -148,6 +147,7 @@ require (
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/in-toto/in-toto-golang v0.9.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
@@ -242,10 +242,10 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.25.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/term v0.30.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/term v0.27.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.6.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user