Compare commits
28 Commits
release/2.
...
2.27.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbeccc4e1e | ||
|
|
7fd5b96130 | ||
|
|
ee6d33365e | ||
|
|
e115055a1b | ||
|
|
384cb53c64 | ||
|
|
4240cbf029 | ||
|
|
eb28dd4f4e | ||
|
|
78127f8f3d | ||
|
|
c474322889 | ||
|
|
83527da1a8 | ||
|
|
7c8bef84b1 | ||
|
|
5b3dba130b | ||
|
|
4039c3a693 | ||
|
|
b1dceb15e4 | ||
|
|
2feaacddb9 | ||
|
|
65e0344975 | ||
|
|
915beecce3 | ||
|
|
fbabeb098f | ||
|
|
d5981a4be9 | ||
|
|
b0de6d41b7 | ||
|
|
3898b9e09e | ||
|
|
c0a4a9ab5c | ||
|
|
b9a68e9f31 | ||
|
|
52afa6cf67 | ||
|
|
1abb77aea5 | ||
|
|
ab824da5d7 | ||
|
|
ded33a33a0 | ||
|
|
4bd9569e63 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -95,6 +95,8 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.27.1'
|
||||
- '2.27.0'
|
||||
- '2.26.1'
|
||||
- '2.26.0'
|
||||
- '2.25.1'
|
||||
|
||||
@@ -60,6 +60,7 @@ 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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,20 +4,21 @@
|
||||
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"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
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"
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -575,6 +575,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
AdminCreationDone: adminCreationDone,
|
||||
PendingActionsService: pendingActionsService,
|
||||
PlatformService: platformService,
|
||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ 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,6 +244,32 @@ 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,6 +6,7 @@ import (
|
||||
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -31,6 +32,33 @@ 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,6 +9,7 @@ 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
|
||||
@@ -42,6 +43,19 @@ 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,6 +28,12 @@ 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)
|
||||
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc2",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.8",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.0-rc2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.8\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -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), 0744); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := os.Create(dst)
|
||||
|
||||
@@ -841,11 +841,11 @@ func (service *Service) GetDefaultSSLCertsPath() (string, string) {
|
||||
}
|
||||
|
||||
func defaultMTLSCertPathUnderFileStore() (string, string, string) {
|
||||
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
|
||||
caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename)
|
||||
certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
|
||||
keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename)
|
||||
|
||||
return certPath, caCertPath, keyPath
|
||||
return caCertPath, certPath, keyPath
|
||||
}
|
||||
|
||||
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
|
||||
@@ -1014,26 +1014,45 @@ func CreateFile(path string, r io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (service *Service) StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error) {
|
||||
certPath, caCertPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||
func (service *Service) StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) {
|
||||
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||
|
||||
r := bytes.NewReader(cert)
|
||||
err := service.createFileInStore(certPath, r)
|
||||
if err != nil {
|
||||
r := bytes.NewReader(caCert)
|
||||
if err := service.createFileInStore(caCertPath, r); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
r = bytes.NewReader(caCert)
|
||||
err = service.createFileInStore(caCertPath, r)
|
||||
if err != nil {
|
||||
r = bytes.NewReader(cert)
|
||||
if err := service.createFileInStore(certPath, r); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
r = bytes.NewReader(key)
|
||||
err = service.createFileInStore(keyPath, r)
|
||||
if err != nil {
|
||||
if err := service.createFileInStore(keyPath, r); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
return service.wrapFileStore(certPath), service.wrapFileStore(caCertPath), service.wrapFileStore(keyPath), nil
|
||||
return service.wrapFileStore(caCertPath), service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil
|
||||
}
|
||||
|
||||
func (service *Service) GetMTLSCertificates() (string, string, string, error) {
|
||||
caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
|
||||
|
||||
caCertPath = service.wrapFileStore(caCertPath)
|
||||
certPath = service.wrapFileStore(certPath)
|
||||
keyPath = service.wrapFileStore(keyPath)
|
||||
|
||||
paths := [...]string{caCertPath, certPath, keyPath}
|
||||
for _, path := range paths {
|
||||
exists, err := service.FileExists(path)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", "", "", fmt.Errorf("file %s does not exist", path)
|
||||
}
|
||||
}
|
||||
|
||||
return caCertPath, certPath, keyPath, nil
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ 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(r, registries, endpoint, user, securityContext.UserMemberships)
|
||||
registries, handleError := handler.filterRegistriesByAccess(tx, 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(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
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) {
|
||||
if !endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||
return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil
|
||||
}
|
||||
|
||||
return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships)
|
||||
return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships)
|
||||
}
|
||||
|
||||
func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
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) {
|
||||
namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
isAdmin, err := security.IsAdmin(r)
|
||||
if err != nil {
|
||||
@@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, regi
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user)
|
||||
return handler.filterKubernetesRegistriesByUserRole(tx, 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(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
|
||||
func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, 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(r *http.Request, re
|
||||
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
userNamespaces, err := handler.userNamespaces(endpoint, user)
|
||||
userNamespaces, err := handler.userNamespaces(tx, endpoint, user)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("unable to retrieve user namespaces", err)
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re
|
||||
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
|
||||
}
|
||||
|
||||
func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
|
||||
func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, 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(endpoint *portainer.Endpoint, user *porta
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,19 +26,20 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.26.0
|
||||
// @version 2.27.8
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param namespace query string true "Namespace name"
|
||||
// @param nodeName query string true "Node name"
|
||||
// @param withDependencies query boolean false "Include dependencies in the response"
|
||||
// @success 200 {array} models.K8sApplication "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||
@@ -117,12 +116,6 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
|
||||
return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err)
|
||||
}
|
||||
|
||||
withDependencies, err := request.RetrieveBooleanQueryParameter(r, "withDependencies", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the withDependencies query parameter")
|
||||
return nil, httperror.BadRequest("Unable to parse the withDependencies query parameter", err)
|
||||
}
|
||||
|
||||
nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter")
|
||||
@@ -135,7 +128,7 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
|
||||
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
|
||||
}
|
||||
|
||||
applications, err := cli.GetApplications(namespace, nodeName, withDependencies)
|
||||
applications, err := cli.GetApplications(namespace, nodeName)
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications")
|
||||
|
||||
36
api/http/middlewares/plaintext_http_request.go
Normal file
36
api/http/middlewares/plaintext_http_request.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
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 xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
|
||||
req = csrf.PlaintextHTTPRequest(r)
|
||||
}
|
||||
|
||||
h.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func PlaintextHTTPRequest(next http.Handler) http.Handler {
|
||||
return &plainTextHTTPRequestHandler{next: next}
|
||||
}
|
||||
@@ -7,12 +7,31 @@ 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
|
||||
director := func(req *http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
|
||||
@@ -26,8 +45,14 @@ func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP
|
||||
// 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
|
||||
|
||||
190
api/http/proxy/factory/reverse_proxy_test.go
Normal file
190
api/http/proxy/factory/reverse_proxy_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
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,8 +243,7 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler,
|
||||
return
|
||||
}
|
||||
|
||||
_, err = bouncer.dataStore.User().Read(tokenData.ID)
|
||||
if bouncer.dataStore.IsErrObjectNotFound(err) {
|
||||
if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
|
||||
return
|
||||
} else if err != nil {
|
||||
@@ -322,9 +321,8 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := bouncer.dataStore.User().Read(token.ID)
|
||||
if user == nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
|
||||
if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ type Server struct {
|
||||
AdminCreationDone chan struct{}
|
||||
PendingActionsService *pendingactions.PendingActionsService
|
||||
PlatformService platform.Service
|
||||
PullLimitCheckDisabled bool
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
@@ -181,6 +182,7 @@ 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)
|
||||
|
||||
@@ -347,7 +349,7 @@ func (server *Server) Start() error {
|
||||
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
|
||||
httpServer := &http.Server{
|
||||
Addr: server.BindAddress,
|
||||
Handler: handler,
|
||||
Handler: middlewares.PlaintextHTTPRequest(handler),
|
||||
ErrorLog: errorLogger,
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ 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 {
|
||||
@@ -186,6 +187,9 @@ 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 {
|
||||
@@ -426,6 +430,10 @@ 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,45 +12,58 @@ import (
|
||||
labels "k8s.io/apimachinery/pkg/labels"
|
||||
)
|
||||
|
||||
// PortainerApplicationResources contains collections of various Kubernetes resources
|
||||
// associated with a Portainer application.
|
||||
type PortainerApplicationResources struct {
|
||||
Pods []corev1.Pod
|
||||
ReplicaSets []appsv1.ReplicaSet
|
||||
Deployments []appsv1.Deployment
|
||||
StatefulSets []appsv1.StatefulSet
|
||||
DaemonSets []appsv1.DaemonSet
|
||||
Services []corev1.Service
|
||||
HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler
|
||||
}
|
||||
|
||||
// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster
|
||||
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
|
||||
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
|
||||
func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||
if kcl.IsKubeAdmin {
|
||||
return kcl.fetchApplications(namespace, nodeName, withDependencies)
|
||||
return kcl.fetchApplications(namespace, nodeName)
|
||||
}
|
||||
|
||||
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies)
|
||||
return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
|
||||
}
|
||||
|
||||
// fetchApplications fetches the applications in the namespaces the user has access to.
|
||||
// This function is called when the user is an admin.
|
||||
func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||
podListOptions := metav1.ListOptions{}
|
||||
if nodeName != "" {
|
||||
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
||||
}
|
||||
if !withDependencies {
|
||||
// TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
|
||||
pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
|
||||
}
|
||||
|
||||
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
||||
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(applications, unhealthyApplications...), nil
|
||||
}
|
||||
|
||||
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
|
||||
// This function is called when the user is not an admin.
|
||||
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
|
||||
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
@@ -62,28 +75,24 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
|
||||
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
|
||||
}
|
||||
|
||||
if !withDependencies {
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
|
||||
applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
|
||||
results := make([]models.K8sApplication, 0)
|
||||
for _, application := range applications {
|
||||
for _, application := range append(applications, unhealthyApplications...) {
|
||||
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
|
||||
results = append(results, application)
|
||||
}
|
||||
@@ -93,11 +102,11 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
|
||||
}
|
||||
|
||||
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
|
||||
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
|
||||
applications := []models.K8sApplication{}
|
||||
processedOwners := make(map[string]struct{})
|
||||
|
||||
for _, pod := range pods {
|
||||
for _, pod := range portainerApplicationResources.Pods {
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
ownerUID := string(pod.OwnerReferences[0].UID)
|
||||
if _, exists := processedOwners[ownerUID]; exists {
|
||||
@@ -106,7 +115,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
|
||||
processedOwners[ownerUID] = struct{}{}
|
||||
}
|
||||
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
|
||||
application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -151,7 +160,9 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,7 +179,9 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -181,12 +194,12 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
|
||||
}
|
||||
|
||||
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
|
||||
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
|
||||
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
|
||||
if isReplicaSetOwner(pod) {
|
||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
||||
updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
|
||||
}
|
||||
|
||||
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
|
||||
application := createApplicationFromPod(&pod, portainerApplicationResources)
|
||||
if application.ID == "" && application.Name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -203,9 +216,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
|
||||
return &application, nil
|
||||
}
|
||||
|
||||
// createApplication creates a K8sApplication object from a pod
|
||||
// createApplicationFromPod creates a K8sApplication object from a pod
|
||||
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
|
||||
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
|
||||
func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
|
||||
kind := "Pod"
|
||||
name := pod.Name
|
||||
|
||||
@@ -221,120 +234,172 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
|
||||
|
||||
switch kind {
|
||||
case "Deployment":
|
||||
for _, deployment := range deployments {
|
||||
for _, deployment := range portainerApplicationResources.Deployments {
|
||||
if deployment.Name == name && deployment.Namespace == pod.Namespace {
|
||||
application.ApplicationType = "Deployment"
|
||||
application.Kind = "Deployment"
|
||||
application.ID = string(deployment.UID)
|
||||
application.ResourcePool = deployment.Namespace
|
||||
application.Name = name
|
||||
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = deployment.Labels
|
||||
application.MatchLabels = deployment.Spec.Selector.MatchLabels
|
||||
application.CreationDate = deployment.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(deployment.Status.Replicas)
|
||||
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: deployment.Labels,
|
||||
}
|
||||
|
||||
populateApplicationFromDeployment(&application, deployment)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "StatefulSet":
|
||||
for _, statefulSet := range statefulSets {
|
||||
for _, statefulSet := range portainerApplicationResources.StatefulSets {
|
||||
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
|
||||
application.Kind = "StatefulSet"
|
||||
application.ApplicationType = "StatefulSet"
|
||||
application.ID = string(statefulSet.UID)
|
||||
application.ResourcePool = statefulSet.Namespace
|
||||
application.Name = name
|
||||
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = statefulSet.Labels
|
||||
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = statefulSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(statefulSet.Status.Replicas)
|
||||
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: statefulSet.Labels,
|
||||
}
|
||||
|
||||
populateApplicationFromStatefulSet(&application, statefulSet)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "DaemonSet":
|
||||
for _, daemonSet := range daemonSets {
|
||||
for _, daemonSet := range portainerApplicationResources.DaemonSets {
|
||||
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
|
||||
application.Kind = "DaemonSet"
|
||||
application.ApplicationType = "DaemonSet"
|
||||
application.ID = string(daemonSet.UID)
|
||||
application.ResourcePool = daemonSet.Namespace
|
||||
application.Name = name
|
||||
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = daemonSet.Labels
|
||||
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = daemonSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
|
||||
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
||||
application.DeploymentType = "Global"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: daemonSet.Labels,
|
||||
}
|
||||
|
||||
populateApplicationFromDaemonSet(&application, daemonSet)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "Pod":
|
||||
runningPodsCount := 1
|
||||
if pod.Status.Phase != corev1.PodRunning {
|
||||
runningPodsCount = 0
|
||||
}
|
||||
|
||||
application.ApplicationType = "Pod"
|
||||
application.Kind = "Pod"
|
||||
application.ID = string(pod.UID)
|
||||
application.ResourcePool = pod.Namespace
|
||||
application.Name = pod.Name
|
||||
application.Image = pod.Spec.Containers[0].Image
|
||||
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = pod.Labels
|
||||
application.MatchLabels = pod.Labels
|
||||
application.CreationDate = pod.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 1
|
||||
application.RunningPodsCount = runningPodsCount
|
||||
application.DeploymentType = string(pod.Status.Phase)
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: pod.Labels,
|
||||
}
|
||||
populateApplicationFromPod(&application, *pod)
|
||||
}
|
||||
|
||||
if application.ID != "" && application.Name != "" && len(services) > 0 {
|
||||
updateApplicationWithService(&application, services)
|
||||
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 {
|
||||
updateApplicationWithService(&application, portainerApplicationResources.Services)
|
||||
}
|
||||
|
||||
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
|
||||
if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
|
||||
}
|
||||
|
||||
return application
|
||||
}
|
||||
|
||||
// createApplicationFromDeployment creates a K8sApplication from a Deployment
|
||||
func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication {
|
||||
var app models.K8sApplication
|
||||
populateApplicationFromDeployment(&app, deployment)
|
||||
return app
|
||||
}
|
||||
|
||||
// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet
|
||||
func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication {
|
||||
var app models.K8sApplication
|
||||
populateApplicationFromStatefulSet(&app, statefulSet)
|
||||
return app
|
||||
}
|
||||
|
||||
// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet
|
||||
func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication {
|
||||
var app models.K8sApplication
|
||||
populateApplicationFromDaemonSet(&app, daemonSet)
|
||||
return app
|
||||
}
|
||||
|
||||
func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) {
|
||||
application.ApplicationType = "Deployment"
|
||||
application.Kind = "Deployment"
|
||||
application.ID = string(deployment.UID)
|
||||
application.ResourcePool = deployment.Namespace
|
||||
application.Name = deployment.Name
|
||||
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = deployment.Labels
|
||||
application.MatchLabels = deployment.Spec.Selector.MatchLabels
|
||||
application.CreationDate = deployment.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 0
|
||||
if deployment.Spec.Replicas != nil {
|
||||
application.TotalPodsCount = int(*deployment.Spec.Replicas)
|
||||
}
|
||||
application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: deployment.Labels,
|
||||
}
|
||||
|
||||
// If the deployment has containers, use the first container's image
|
||||
if len(deployment.Spec.Template.Spec.Containers) > 0 {
|
||||
application.Image = deployment.Spec.Template.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) {
|
||||
application.Kind = "StatefulSet"
|
||||
application.ApplicationType = "StatefulSet"
|
||||
application.ID = string(statefulSet.UID)
|
||||
application.ResourcePool = statefulSet.Namespace
|
||||
application.Name = statefulSet.Name
|
||||
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = statefulSet.Labels
|
||||
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = statefulSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 0
|
||||
if statefulSet.Spec.Replicas != nil {
|
||||
application.TotalPodsCount = int(*statefulSet.Spec.Replicas)
|
||||
}
|
||||
application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
|
||||
application.DeploymentType = "Replicated"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: statefulSet.Labels,
|
||||
}
|
||||
|
||||
// If the statefulSet has containers, use the first container's image
|
||||
if len(statefulSet.Spec.Template.Spec.Containers) > 0 {
|
||||
application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) {
|
||||
application.Kind = "DaemonSet"
|
||||
application.ApplicationType = "DaemonSet"
|
||||
application.ID = string(daemonSet.UID)
|
||||
application.ResourcePool = daemonSet.Namespace
|
||||
application.Name = daemonSet.Name
|
||||
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = daemonSet.Labels
|
||||
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = daemonSet.CreationTimestamp.Time
|
||||
application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
|
||||
application.RunningPodsCount = int(daemonSet.Status.NumberReady)
|
||||
application.DeploymentType = "Global"
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: daemonSet.Labels,
|
||||
}
|
||||
|
||||
if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
|
||||
application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
func populateApplicationFromPod(application *models.K8sApplication, pod corev1.Pod) {
|
||||
runningPodsCount := 1
|
||||
if pod.Status.Phase != corev1.PodRunning {
|
||||
runningPodsCount = 0
|
||||
}
|
||||
|
||||
application.ApplicationType = "Pod"
|
||||
application.Kind = "Pod"
|
||||
application.ID = string(pod.UID)
|
||||
application.ResourcePool = pod.Namespace
|
||||
application.Name = pod.Name
|
||||
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.Labels = pod.Labels
|
||||
application.MatchLabels = pod.Labels
|
||||
application.CreationDate = pod.CreationTimestamp.Time
|
||||
application.TotalPodsCount = 1
|
||||
application.RunningPodsCount = runningPodsCount
|
||||
application.DeploymentType = string(pod.Status.Phase)
|
||||
application.Metadata = &models.Metadata{
|
||||
Labels: pod.Labels,
|
||||
}
|
||||
|
||||
// If the pod has containers, use the first container's image
|
||||
if len(pod.Spec.Containers) > 0 {
|
||||
application.Image = pod.Spec.Containers[0].Image
|
||||
}
|
||||
}
|
||||
|
||||
// updateApplicationWithService updates the application with the services that match the application's selector match labels
|
||||
// and are in the same namespace as the application
|
||||
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
|
||||
@@ -410,7 +475,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -436,7 +503,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -454,3 +523,84 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
|
||||
|
||||
return configurationOwners, nil
|
||||
}
|
||||
|
||||
// fetchUnhealthyApplications fetches applications that failed to schedule any pods
|
||||
// due to issues like missing resource limits or other scheduling constraints
|
||||
func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) {
|
||||
var unhealthyApplications []models.K8sApplication
|
||||
|
||||
// Process Deployments
|
||||
for _, deployment := range resources.Deployments {
|
||||
if hasNoScheduledPods(deployment) {
|
||||
app := createApplicationFromDeployment(deployment)
|
||||
addRelatedResourcesToApplication(&app, resources)
|
||||
unhealthyApplications = append(unhealthyApplications, app)
|
||||
}
|
||||
}
|
||||
|
||||
// Process StatefulSets
|
||||
for _, statefulSet := range resources.StatefulSets {
|
||||
if hasNoScheduledPods(statefulSet) {
|
||||
app := createApplicationFromStatefulSet(statefulSet)
|
||||
addRelatedResourcesToApplication(&app, resources)
|
||||
unhealthyApplications = append(unhealthyApplications, app)
|
||||
}
|
||||
}
|
||||
|
||||
// Process DaemonSets
|
||||
for _, daemonSet := range resources.DaemonSets {
|
||||
if hasNoScheduledPods(daemonSet) {
|
||||
app := createApplicationFromDaemonSet(daemonSet)
|
||||
addRelatedResourcesToApplication(&app, resources)
|
||||
unhealthyApplications = append(unhealthyApplications, app)
|
||||
}
|
||||
}
|
||||
|
||||
return unhealthyApplications, nil
|
||||
}
|
||||
|
||||
// addRelatedResourcesToApplication adds Services and HPA information to the application
|
||||
func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) {
|
||||
if app.ID == "" || app.Name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources.Services) > 0 {
|
||||
updateApplicationWithService(app, resources.Services)
|
||||
}
|
||||
|
||||
if len(resources.HorizontalPodAutoscalers) > 0 {
|
||||
updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers)
|
||||
}
|
||||
}
|
||||
|
||||
// hasNoScheduledPods checks if a workload has completely failed to schedule any pods
|
||||
// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running
|
||||
// if any pods exist at all (even if not ready), it returns false
|
||||
func hasNoScheduledPods(obj interface{}) bool {
|
||||
switch resource := obj.(type) {
|
||||
case appsv1.Deployment:
|
||||
if resource.Status.Replicas > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0
|
||||
|
||||
case appsv1.StatefulSet:
|
||||
if resource.Status.Replicas > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0
|
||||
|
||||
case appsv1.DaemonSet:
|
||||
if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
461
api/kubernetes/cli/applications_test.go
Normal file
461
api/kubernetes/cli/applications_test.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// Helper functions to create test resources
|
||||
func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
|
||||
return &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("deploy-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "nginx:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.DeploymentStatus{
|
||||
Replicas: replicas,
|
||||
ReadyReplicas: replicas,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
|
||||
return &appsv1.ReplicaSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("rs-" + name),
|
||||
OwnerReferences: []metav1.OwnerReference{
|
||||
{
|
||||
Kind: "Deployment",
|
||||
Name: deploymentName,
|
||||
UID: types.UID("deploy-" + deploymentName),
|
||||
},
|
||||
},
|
||||
},
|
||||
Spec: appsv1.ReplicaSetSpec{
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": deploymentName,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
|
||||
return &appsv1.StatefulSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("sts-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.StatefulSetSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "redis:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.StatefulSetStatus{
|
||||
Replicas: replicas,
|
||||
ReadyReplicas: replicas,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
|
||||
return &appsv1.DaemonSet{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("ds-" + name),
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: appsv1.DaemonSetSpec{
|
||||
Selector: &metav1.LabelSelector{
|
||||
MatchLabels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"app": name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: name,
|
||||
Image: "fluentd:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: appsv1.DaemonSetStatus{
|
||||
DesiredNumberScheduled: 2,
|
||||
NumberReady: 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
|
||||
phase := corev1.PodPending
|
||||
if isRunning {
|
||||
phase = corev1.PodRunning
|
||||
}
|
||||
|
||||
var ownerReferences []metav1.OwnerReference
|
||||
if ownerKind != "" && ownerName != "" {
|
||||
ownerReferences = []metav1.OwnerReference{
|
||||
{
|
||||
Kind: ownerKind,
|
||||
Name: ownerName,
|
||||
UID: types.UID(ownerKind + "-" + ownerName),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("pod-" + name),
|
||||
OwnerReferences: ownerReferences,
|
||||
Labels: map[string]string{
|
||||
"app": ownerName,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "container-" + name,
|
||||
Image: "busybox:latest",
|
||||
Resources: corev1.ResourceRequirements{
|
||||
Limits: corev1.ResourceList{},
|
||||
Requests: corev1.ResourceList{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1.PodStatus{
|
||||
Phase: phase,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
UID: types.UID("svc-" + name),
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: selector,
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetApplications(t *testing.T) {
|
||||
t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup the test namespace
|
||||
namespace := "test-namespace"
|
||||
defaultNamespace := "default"
|
||||
|
||||
// Create resources in the test namespace
|
||||
// 1. Deployment with pods
|
||||
deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
|
||||
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
|
||||
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 2. Deployment without pods (scaled to 0)
|
||||
deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
|
||||
_, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 3. StatefulSet with pods
|
||||
stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 4. StatefulSet without pods
|
||||
stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 5. DaemonSet with pods
|
||||
dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
|
||||
_, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 6. Naked Pod (no owner reference)
|
||||
nakedPod := createTestPod("naked-pod", namespace, "", "", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 7. Resources in another namespace
|
||||
deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
|
||||
_, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
|
||||
_, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// 8. Add a service (dependency)
|
||||
service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
|
||||
_, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient with admin privileges
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: true,
|
||||
}
|
||||
|
||||
// Test cases
|
||||
|
||||
// 1. All resources, no filtering
|
||||
t.Run("All resources with dependencies", func(t *testing.T) {
|
||||
apps, err := kubeClient.GetApplications("", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
|
||||
// Note: Each controller with pods should count once, not per pod
|
||||
assert.Equal(t, 7, len(apps))
|
||||
|
||||
// Verify one of the deployments has services attached
|
||||
appsWithServices := []models.K8sApplication{}
|
||||
for _, app := range apps {
|
||||
if len(app.Services) > 0 {
|
||||
appsWithServices = append(appsWithServices, app)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, len(appsWithServices))
|
||||
assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
|
||||
})
|
||||
|
||||
// 2. Filter by namespace
|
||||
t.Run("Filter by namespace", func(t *testing.T) {
|
||||
apps, err := kubeClient.GetApplications(namespace, "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 6 resources in the test namespace
|
||||
assert.Equal(t, 6, len(apps))
|
||||
|
||||
// Verify resources from other namespaces are not included
|
||||
for _, app := range apps {
|
||||
assert.Equal(t, namespace, app.ResourcePool)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup the test namespaces
|
||||
namespace1 := "allowed-ns"
|
||||
namespace2 := "restricted-ns"
|
||||
|
||||
// Create resources in the allowed namespace
|
||||
sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
|
||||
_, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Add a StatefulSet without pods in the allowed namespace
|
||||
stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create resources in the restricted namespace
|
||||
sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
|
||||
_, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
|
||||
_, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient with non-admin privileges (only allowed namespace1)
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: false,
|
||||
NonAdminNamespaces: []string{namespace1},
|
||||
}
|
||||
|
||||
// Test that only resources from allowed namespace are returned
|
||||
apps, err := kubeClient.GetApplications("", "")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
|
||||
assert.Equal(t, 2, len(apps))
|
||||
|
||||
// Verify resources are from the allowed namespace
|
||||
for _, app := range apps {
|
||||
assert.Equal(t, namespace1, app.ResourcePool)
|
||||
assert.Equal(t, "StatefulSet", app.Kind)
|
||||
}
|
||||
|
||||
// Verify names of returned resources
|
||||
stsNames := make(map[string]bool)
|
||||
for _, app := range apps {
|
||||
stsNames[app.Name] = true
|
||||
}
|
||||
|
||||
assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
|
||||
assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
|
||||
})
|
||||
|
||||
t.Run("Filter by node name", func(t *testing.T) {
|
||||
// Create a fake K8s client
|
||||
fakeClient := fake.NewSimpleClientset()
|
||||
|
||||
// Setup test namespace
|
||||
namespace := "node-filter-ns"
|
||||
nodeName := "worker-node-1"
|
||||
|
||||
// Create a deployment with pods on specific node
|
||||
deploy := createTestDeployment("node-deploy", namespace, 2)
|
||||
_, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create ReplicaSet for the deployment
|
||||
rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
|
||||
_, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create 2 pods, one on the specified node, one on a different node
|
||||
pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||
pod1.Spec.NodeName = nodeName
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
|
||||
pod2.Spec.NodeName = "worker-node-2"
|
||||
_, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create the KubeClient
|
||||
kubeClient := &KubeClient{
|
||||
cli: fakeClient,
|
||||
instanceID: "test-instance",
|
||||
IsKubeAdmin: true,
|
||||
}
|
||||
|
||||
// Test filtering by node name
|
||||
apps, err := kubeClient.GetApplications(namespace, nodeName)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// We expect to find only the pod on the specified node
|
||||
assert.Equal(t, 1, len(apps))
|
||||
if len(apps) > 0 {
|
||||
assert.Equal(t, "node-deploy", apps[0].Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -87,6 +87,14 @@ 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 volumes for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
return nil, nil
|
||||
@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
|
||||
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
|
||||
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
|
||||
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8s
|
||||
for index, configMap := range configMaps {
|
||||
updatedConfigMap := configMap
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, replicaSets)
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -110,7 +109,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
|
||||
},
|
||||
}
|
||||
|
||||
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
|
||||
shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error creating shell pod")
|
||||
}
|
||||
@@ -158,7 +157,7 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
|
||||
pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,70 +171,67 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces
|
||||
func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false)
|
||||
}
|
||||
|
||||
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
|
||||
// this is required for the applications list view
|
||||
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) (PortainerApplicationResources, error) {
|
||||
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
|
||||
}
|
||||
|
||||
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
|
||||
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
|
||||
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) {
|
||||
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, nil
|
||||
return PortainerApplicationResources{}, nil
|
||||
}
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
|
||||
}
|
||||
|
||||
// if replicaSet owner reference exists, fetch the replica sets
|
||||
// this also means that the deployments will be fetched because deployments own replica sets
|
||||
replicaSets := &appsv1.ReplicaSetList{}
|
||||
deployments := &appsv1.DeploymentList{}
|
||||
if containsReplicaSetOwnerReference(pods) {
|
||||
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
|
||||
}
|
||||
|
||||
deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources := PortainerApplicationResources{
|
||||
Pods: pods.Items,
|
||||
}
|
||||
|
||||
statefulSets := &appsv1.StatefulSetList{}
|
||||
if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
|
||||
statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.ReplicaSets = replicaSets.Items
|
||||
|
||||
deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.Deployments = deployments.Items
|
||||
|
||||
if includeStatefulSets {
|
||||
statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.StatefulSets = statefulSets.Items
|
||||
}
|
||||
|
||||
daemonSets := &appsv1.DaemonSetList{}
|
||||
if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
|
||||
daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if includeDaemonSets {
|
||||
daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.DaemonSets = daemonSets.Items
|
||||
}
|
||||
|
||||
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.Services = services.Items
|
||||
|
||||
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
||||
return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
|
||||
}
|
||||
portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
|
||||
|
||||
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
|
||||
return portainerApplicationResources, nil
|
||||
}
|
||||
|
||||
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
|
||||
@@ -137,7 +136,7 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
|
||||
role, err := client.Get(context.Background(), name, v1.GetOptions{})
|
||||
role, err := client.Get(context.Background(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
corev1 "k8s.io/api/rbac/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
|
||||
@@ -98,7 +96,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
|
||||
func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
return client.Get(context.Background(), name, metav1.GetOptions{})
|
||||
}
|
||||
@@ -111,7 +109,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().RoleBindings(namespace)
|
||||
|
||||
roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
|
||||
roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
continue
|
||||
@@ -125,7 +123,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error)
|
||||
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
|
||||
// This function is called when the user is not an admin.
|
||||
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
|
||||
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
return nil, nil
|
||||
@@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
|
||||
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
|
||||
updatedSecrets := make([]models.K8sSecret, len(secrets))
|
||||
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret
|
||||
for index, secret := range secrets {
|
||||
updatedSecret := secret
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets)
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
|
||||
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
|
||||
if containsServiceWithSelector(services) {
|
||||
updatedServices := make([]models.K8sServiceInfo, len(services))
|
||||
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
}
|
||||
@@ -182,7 +182,7 @@ func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServ
|
||||
for index, service := range services {
|
||||
updatedService := service
|
||||
|
||||
application, err := kcl.GetApplicationFromServiceSelector(pods, service, replicaSets)
|
||||
application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -92,7 +91,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
|
||||
|
||||
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
|
||||
var errors []error
|
||||
for namespace := range reqs {
|
||||
for _, serviceName := range reqs[namespace] {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/rs/zerolog/log"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
autoscalingv2 "k8s.io/api/autoscaling/v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
storagev1 "k8s.io/api/storage/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -265,7 +264,12 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
|
||||
if pod.Spec.Volumes != nil {
|
||||
for _, podVolume := range pod.Spec.Volumes {
|
||||
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
|
||||
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, false)
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSetItems,
|
||||
Deployments: deploymentItems,
|
||||
StatefulSets: statefulSetItems,
|
||||
DaemonSets: daemonSetItems,
|
||||
}, false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to convert pod to application")
|
||||
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)
|
||||
|
||||
@@ -134,6 +134,7 @@ type (
|
||||
LogLevel *string
|
||||
LogMode *string
|
||||
KubectlShellImage *string
|
||||
PullLimitCheckDisabled *bool
|
||||
}
|
||||
|
||||
// CustomTemplateVariableDefinition
|
||||
@@ -1491,7 +1492,8 @@ type (
|
||||
StoreSSLCertPair(cert, key []byte) (string, string, error)
|
||||
CopySSLCertPair(certPath, keyPath string) (string, string, error)
|
||||
CopySSLCACert(caCertPath string) (string, error)
|
||||
StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error)
|
||||
StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error)
|
||||
GetMTLSCertificates() (string, string, string, error)
|
||||
GetDefaultChiselPrivateKeyPath() string
|
||||
StoreChiselPrivateKey(privateKey []byte) error
|
||||
}
|
||||
@@ -1543,7 +1545,7 @@ type (
|
||||
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
|
||||
GetSecrets(namespace string) ([]models.K8sSecret, error)
|
||||
GetIngressControllers() (models.K8sIngressControllers, error)
|
||||
GetApplications(namespace, nodename string, withDependencies bool) ([]models.K8sApplication, error)
|
||||
GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
|
||||
GetMetrics() (models.K8sMetrics, error)
|
||||
GetStorage() ([]KubernetesStorageClassConfig, error)
|
||||
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
|
||||
@@ -1636,7 +1638,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.27.0-rc2"
|
||||
APIVersion = "2.27.8"
|
||||
// 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
|
||||
@@ -1688,6 +1690,8 @@ 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"
|
||||
)
|
||||
|
||||
// List of supported features
|
||||
|
||||
@@ -3,6 +3,7 @@ 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(
|
||||
@@ -11,7 +12,8 @@ export function KubernetesResourcePoolService(
|
||||
KubernetesNamespaceService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesIngressService,
|
||||
KubernetesPortainerNamespaces
|
||||
KubernetesPortainerNamespaces,
|
||||
EndpointProvider
|
||||
) {
|
||||
return {
|
||||
get,
|
||||
@@ -37,9 +39,14 @@ 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 KubernetesNamespaceService.get();
|
||||
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 pools = await Promise.all(
|
||||
_.map(namespaces, async (namespace) => {
|
||||
_.map(namespacesFormattedStatus, async (namespace) => {
|
||||
const name = namespace.Name;
|
||||
const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace);
|
||||
if (getQuota) {
|
||||
|
||||
@@ -71,7 +71,9 @@ class KubernetesClusterController {
|
||||
|
||||
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
|
||||
this.resourceReservation = new KubernetesResourceReservation();
|
||||
this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest / 1000);
|
||||
|
||||
// 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()) {
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
||||
return [1, -1].includes(Number(val)) ? word : plural;
|
||||
}
|
||||
|
||||
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
||||
return `${value} ${pluralize(value, word, plural)}`;
|
||||
}
|
||||
// Re-exporting so we don't have to update one meeeeellion files that are already importing these
|
||||
// functions from here.
|
||||
export {
|
||||
pluralize,
|
||||
addPlural,
|
||||
grammaticallyJoin,
|
||||
} from '@/react/common/string-utils';
|
||||
|
||||
27
app/react/common/string-utils.ts
Normal file
27
app/react/common/string-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function capitalize(s: string) {
|
||||
return s.slice(0, 1).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
||||
return [1, -1].includes(Number(val)) ? word : plural;
|
||||
}
|
||||
|
||||
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
||||
return `${value} ${pluralize(value, word, plural)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins an array of strings into a grammatically correct sentence.
|
||||
*/
|
||||
export function grammaticallyJoin(
|
||||
values: string[],
|
||||
separator = ', ',
|
||||
lastSeparator = ' and '
|
||||
) {
|
||||
if (values.length === 0) return '';
|
||||
if (values.length === 1) return values[0];
|
||||
|
||||
const allButLast = values.slice(0, -1);
|
||||
const last = values[values.length - 1];
|
||||
return `${allButLast.join(separator)}${lastSeparator}${last}`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createColumnHelper,
|
||||
@@ -154,6 +155,9 @@ 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', () => {
|
||||
@@ -170,7 +174,7 @@ describe('Datatable', () => {
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
// Check if all rows on the page are selected
|
||||
expect(screen.getByText('2 item(s) selected')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 items selected')).toBeInTheDocument();
|
||||
|
||||
// Deselect
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
@@ -192,13 +196,44 @@ describe('Datatable', () => {
|
||||
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||
|
||||
// Check if all rows on the page are selected
|
||||
expect(screen.getByText('3 item(s) selected')).toBeInTheDocument();
|
||||
expect(screen.getByText('3 items selected')).toBeInTheDocument();
|
||||
|
||||
// Deselect
|
||||
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
||||
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
||||
});
|
||||
|
||||
it('shows indeterminate state and correct footer text when hidden rows are selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DatatableWithStore
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
data-cy="test-table"
|
||||
title="Test table with search"
|
||||
/>
|
||||
);
|
||||
|
||||
// Select Jane
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(checkboxes[2]); // Select the second row
|
||||
|
||||
// Search for John (will hide selected Jane)
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
await user.type(searchInput, 'John');
|
||||
|
||||
// Check if the footer text is correct
|
||||
expect(
|
||||
await screen.findByText('1 item selected (1 hidden by filters)')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check if the checkbox is indeterminate
|
||||
const selectAllCheckbox: HTMLInputElement =
|
||||
screen.getByLabelText('Select all rows');
|
||||
expect(selectAllCheckbox.indeterminate).toBe(true);
|
||||
expect(selectAllCheckbox.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Test the defaultGlobalFilterFn used in searches
|
||||
|
||||
@@ -171,6 +171,14 @@ export function Datatable<D extends DefaultType>({
|
||||
|
||||
const selectedRowModel = tableInstance.getSelectedRowModel();
|
||||
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
||||
const filteredItems = tableInstance
|
||||
.getFilteredRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
|
||||
const hiddenSelectedItems = useMemo(
|
||||
() => _.difference(selectedItems, filteredItems),
|
||||
[selectedItems, filteredItems]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Container noWidget={noWidget} aria-label={title}>
|
||||
@@ -203,6 +211,7 @@ export function Datatable<D extends DefaultType>({
|
||||
pageSize={tableState.pagination.pageSize}
|
||||
pageCount={tableInstance.getPageCount()}
|
||||
totalSelected={selectedItems.length}
|
||||
totalHiddenSelected={hiddenSelectedItems.length}
|
||||
/>
|
||||
</Table.Container>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SelectedRowsCount } from './SelectedRowsCount';
|
||||
|
||||
interface Props {
|
||||
totalSelected: number;
|
||||
totalHiddenSelected: number;
|
||||
pageSize: number;
|
||||
page: number;
|
||||
onPageChange(page: number): void;
|
||||
@@ -14,6 +15,7 @@ interface Props {
|
||||
|
||||
export function DatatableFooter({
|
||||
totalSelected,
|
||||
totalHiddenSelected,
|
||||
pageSize,
|
||||
page,
|
||||
onPageChange,
|
||||
@@ -22,7 +24,7 @@ export function DatatableFooter({
|
||||
}: Props) {
|
||||
return (
|
||||
<Table.Footer>
|
||||
<SelectedRowsCount value={totalSelected} />
|
||||
<SelectedRowsCount value={totalSelected} hidden={totalHiddenSelected} />
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { addPlural } from '@/react/common/string-utils';
|
||||
|
||||
interface SelectedRowsCountProps {
|
||||
value: number;
|
||||
hidden: number;
|
||||
}
|
||||
|
||||
export function SelectedRowsCount({ value }: SelectedRowsCountProps) {
|
||||
export function SelectedRowsCount({ value, hidden }: SelectedRowsCountProps) {
|
||||
return value !== 0 ? (
|
||||
<div className="infoBar">{value} item(s) selected</div>
|
||||
<div className="infoBar">
|
||||
{addPlural(value, 'item')} selected
|
||||
{hidden !== 0 && ` (${hidden} hidden by filters)`}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { ColumnDef, Row } from '@tanstack/react-table';
|
||||
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());
|
||||
}
|
||||
|
||||
function someRowsSelected<T>(table: Table<T>) {
|
||||
return table.getCoreRowModel().rows.some((row) => row.getIsSelected());
|
||||
}
|
||||
|
||||
function somePageRowsSelected<T>(table: Table<T>) {
|
||||
return table.getRowModel().rows.some((row) => row.getIsSelected());
|
||||
}
|
||||
|
||||
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||
let lastSelectedId = '';
|
||||
|
||||
@@ -11,15 +24,15 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
data-cy={`select-all-checkbox-${dataCy}`}
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
checked={allRowsSelected(table)}
|
||||
indeterminate={!allRowsSelected(table) && someRowsSelected(table)}
|
||||
onChange={(e) => {
|
||||
// Select all rows if shift key is held down, otherwise only page rows
|
||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
|
||||
table.getToggleAllRowsSelectedHandler()(e);
|
||||
table.toggleAllRowsSelected();
|
||||
return;
|
||||
}
|
||||
table.getToggleAllPageRowsSelectedHandler()(e);
|
||||
table.toggleAllPageRowsSelected(!somePageRowsSelected(table));
|
||||
}}
|
||||
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -42,6 +42,8 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||
resolvedRef = defaultRef;
|
||||
}
|
||||
|
||||
// Need to check this on every render as the browser will always set the element's
|
||||
// indeterminate state to false when the checkbox is clicked, even if the indeterminate prop hasn't changed
|
||||
useEffect(() => {
|
||||
if (resolvedRef === null || resolvedRef.current === null) {
|
||||
return;
|
||||
@@ -50,7 +52,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
||||
if (typeof indeterminate !== 'undefined') {
|
||||
resolvedRef.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [resolvedRef, indeterminate]);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="md-checkbox flex items-center" title={title || label}>
|
||||
|
||||
@@ -57,7 +57,6 @@ 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,7 +38,6 @@ 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,7 +3,6 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
export type GetAppsParams = {
|
||||
namespace?: string;
|
||||
nodeName?: string;
|
||||
withDependencies?: boolean;
|
||||
};
|
||||
|
||||
export const queryKeys = {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.status?.replicas ?? 0;
|
||||
const totalReplicas = deployment.spec?.replicas ?? 0;
|
||||
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
|
||||
return availableReplicas || totalReplicas - unavailableReplicas;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
|
||||
const applicationsQuery = useApplications(environmentId, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
namespace,
|
||||
withDependencies: true,
|
||||
});
|
||||
const applications = applicationsQuery.data ?? [];
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ export function AppTemplatesList({
|
||||
pageSize={listState.pageSize}
|
||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||
totalSelected={0}
|
||||
totalHiddenSelected={0}
|
||||
/>
|
||||
</Table.Container>
|
||||
);
|
||||
|
||||
@@ -86,6 +86,7 @@ export function CustomTemplatesList({
|
||||
pageSize={listState.pageSize}
|
||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||
totalSelected={0}
|
||||
totalHiddenSelected={0}
|
||||
/>
|
||||
</Table.Container>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"docker": "v27.5.1",
|
||||
"helm": "v3.17.0",
|
||||
"kubectl": "v1.32.1",
|
||||
"mingit": "2.47.0.1"
|
||||
"helm": "v3.17.3",
|
||||
"kubectl": "v1.32.2",
|
||||
"mingit": "2.49.0.1"
|
||||
}
|
||||
|
||||
22
go.mod
22
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/portainer/portainer
|
||||
|
||||
go 1.23.5
|
||||
go 1.23.10
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver v1.5.0
|
||||
@@ -25,11 +25,12 @@ 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.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/csrf v1.7.3
|
||||
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
|
||||
@@ -47,11 +48,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.31.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
|
||||
golang.org/x/mod v0.21.0
|
||||
golang.org/x/oauth2 v0.23.0
|
||||
golang.org/x/sync v0.10.0
|
||||
golang.org/x/oauth2 v0.27.0
|
||||
golang.org/x/sync v0.12.0
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/api v0.29.2
|
||||
@@ -147,7 +148,6 @@ 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.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/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/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
|
||||
|
||||
36
go.sum
36
go.sum
@@ -280,8 +280,8 @@ github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
@@ -311,8 +311,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
@@ -695,8 +695,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP
|
||||
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -715,10 +715,10 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -727,8 +727,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -755,20 +755,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.26.0",
|
||||
"version": "2.27.8",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -87,7 +88,7 @@ func withComposeService(
|
||||
return composeFn(composeService, nil)
|
||||
}
|
||||
|
||||
env, err := parseEnvironment(options)
|
||||
env, err := parseEnvironment(options, filePaths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -97,6 +98,11 @@ func withComposeService(
|
||||
WorkingDir: filepath.Dir(filePaths[0]),
|
||||
}
|
||||
|
||||
if options.ProjectDir != "" {
|
||||
// When relative paths are used in the compose file, the project directory is used as the base path
|
||||
configDetails.WorkingDir = options.ProjectDir
|
||||
}
|
||||
|
||||
for _, p := range filePaths {
|
||||
configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p})
|
||||
}
|
||||
@@ -326,7 +332,7 @@ func addServiceLabels(project *types.Project, oneOff bool, edgeStackID portainer
|
||||
}
|
||||
}
|
||||
|
||||
func parseEnvironment(options libstack.Options) (map[string]string, error) {
|
||||
func parseEnvironment(options libstack.Options, filePaths []string) (map[string]string, error) {
|
||||
env := make(map[string]string)
|
||||
|
||||
for _, envLine := range options.Env {
|
||||
@@ -339,7 +345,22 @@ func parseEnvironment(options libstack.Options) (map[string]string, error) {
|
||||
}
|
||||
|
||||
if options.EnvFilePath == "" {
|
||||
return env, nil
|
||||
if len(filePaths) == 0 {
|
||||
return env, nil
|
||||
}
|
||||
|
||||
defaultDotEnv := filepath.Join(filepath.Dir(filePaths[0]), ".env")
|
||||
s, err := os.Stat(defaultDotEnv)
|
||||
if os.IsNotExist(err) {
|
||||
return env, nil
|
||||
}
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
if s.IsDir() {
|
||||
return env, nil
|
||||
}
|
||||
options.EnvFilePath = defaultDotEnv
|
||||
}
|
||||
|
||||
e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
|
||||
|
||||
Reference in New Issue
Block a user