Compare commits

..

51 Commits

Author SHA1 Message Date
James Player c4ca049ab8 Bump version to v2.28.1 (#545) 2025-03-20 14:12:55 +13:00
Oscar Zhou 80cc44a17b fix(k8s/config): force insecure-skip-tls-verify option for internal use [BE-11706] (#538) 2025-03-20 08:49:35 +13:00
James Player a67b917bdd Bump version to 2.28.0 (#523) 2025-03-17 16:00:33 +13:00
Steven Kang 2791bd123c fix: cve-2025-22869 develop (#511) 2025-03-17 12:24:39 +13:00
andres-portainer e1f9b69cd5 feat(edgestack): improve the structure to make JSON operations faster BE-11668 (#475) 2025-03-15 10:10:17 -03:00
andres-portainer 2c05496962 feat(edgeconfigs): parse .env config files for interpolation BE-11673 (#514) 2025-03-15 10:09:22 -03:00
Oscar Zhou 66bcf9223a fix(k8s/config): avoid hardcoded "insecure-skip-tls-verify" in kubeconfig [BE-11651] (#500) 2025-03-14 11:20:41 +13:00
James Player 993f69db37 chore(app): Migrate helm templates list to react (#492) 2025-03-14 10:37:14 +13:00
Ali 58317edb6d fix(namespaces): only show namespaces with access [r8s-251] (#501) 2025-03-14 07:57:06 +13:00
Steven Kang 417891675d fix: ensure no non-admin users have access to system namespaces (#499) 2025-03-13 16:43:56 +13:00
Steven Kang 8b7aef883a fix: display unscheduled applications (#496)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-03-13 14:13:18 +13:00
Ali b5961d79f8 refactor(helm): helm binary to sdk refactor [r8s-229] (#463)
Co-authored-by: stevensbkang <skan070@gmail.com>
2025-03-13 12:20:16 +13:00
LP B 0d25f3f430 fix(app): restore gitops update options (#419) 2025-03-12 14:00:31 +01:00
Steven Kang 798fa2396a feat: kubernets service - display external hostname (#486) 2025-03-12 22:34:00 +13:00
James Player 28b222fffa fix(app): Make sure empty tables don't have select all rows checkbox checked (#489) 2025-03-12 10:34:07 +13:00
James Player b57855f20d fix(app): datatable global checkbox doesn't reflect the selected state (#470) 2025-03-10 09:21:20 +13:00
Cara Ryan 438b1f9815 fix(helm): Remove duplicate helm instructions in CE [BE-11670] (#482) 2025-03-06 09:35:31 +13:00
LP B 2bccb3589e fix(app/images): nodeName on images list links (#484) 2025-03-05 16:04:16 +01:00
James Player 52bb06eb7b chore(helm): Convert helm details view to react (#476) 2025-03-03 11:29:58 +13:00
Malcolm Lockyer 8e6d0e7d42 perf(endpointrelation): Part 2 of fixing endpointrelation perf [be-11616] (#471) 2025-02-28 14:41:54 +13:00
Steven Kang 5526fd8296 chore: bump 2.27.1 - develop (#468) 2025-02-27 11:02:25 +13:00
Anthony Lapenna a554a8c49f api: remove server-ce swagger.json (#467) 2025-02-26 16:10:02 +13:00
James Player 7759d762ab chore(react): Convert cluster details to react CE (#466) 2025-02-26 14:13:50 +13:00
Oscar Zhou dd98097897 fix(libstack): miss to read default .env file [BE-11638] (#458) 2025-02-26 13:00:25 +13:00
Steven Kang cc73b7831f fix: cve-2024-50338 - develop (#461) 2025-02-25 12:55:44 +13:00
James Carppe 9c243cc8dd Update bug report template for 2.27.0 (#450) 2025-02-20 13:38:26 +13:00
Oscar Zhou 5d568a3f32 fix(edge): edge stack pending when yaml file is under same root folder of edge configs [BE-11620] (#447) 2025-02-20 12:09:26 +13:00
Steven Kang 1b83542d41 chore: bump version to 2.27.0 - develop (#445) 2025-02-20 09:42:52 +13:00
LP B cf95d91db3 fix(swarm): keep swarm stack stop command attached (#444) 2025-02-19 19:25:28 +01:00
Viktor Pettersson 41c1d88615 fix(edge): configure persisted mTLS certificates on start-up [BE-11622] (#437)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
2025-02-19 14:46:39 +13:00
Steven Kang df8673ba40 version: bump version to 2.27.0-rc3 - develop (#426) 2025-02-14 08:39:02 +13:00
andres-portainer 96b1869a0c fix(swarm): fix the Host field when listing images BE-10827 (#352)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2025-02-12 00:47:45 +01:00
Oscar Zhou e45b852c09 fix(platform): remove error log when local env is not found [BE-11353] (#364) 2025-02-12 09:23:52 +13:00
Steven Kang 2d3e5c3499 workaround: leave the globally set helm repo to empty and add disclaimer - develop (#409) 2025-02-11 15:36:29 +13:00
Oscar Zhou b25bf1e341 fix(podman): missing filter in homepage [BE-11502] (#404) 2025-02-10 21:08:27 +13:00
Oscar Zhou 4bb80d3e3a fix(setting): failed to persist edge computer setting [BE-11403] (#395) 2025-02-10 21:05:15 +13:00
Steven Kang 03575186a7 remove deprecated api endpoints - develop [BE-11510] (#399) 2025-02-10 10:46:36 +13:00
Steven Kang 935c7dd496 feat: improve diagnostics stability - develop (#355) 2025-02-10 10:45:47 +13:00
Steven Kang 1b2dc6a133 version: bump version to 2.27.0-rc2 - develop (#402) 2025-02-07 14:47:49 +13:00
Steven Kang d4e2b2188e chore: bump go version to 1.23.5 develop (#392) 2025-02-07 08:48:19 +13:00
viktigpetterr 9658f757c2 fix(endpoints): use the post method for batch delete API operations [BE-11573] (#394) 2025-02-06 18:14:43 +01:00
Ali 371e84d9a5 fix(podman): create new image from a container in podman [r8s-90] (#347) 2025-02-05 20:22:33 +13:00
Steven Kang 5423a2f1b9 security: cve-2025-21613 develop (#390) 2025-02-05 15:56:30 +13:00
Oscar Zhou 7001f8e088 fix(edge): check all endpoint_relation db query logic [BE-11602] (#378) 2025-02-05 15:20:20 +13:00
Steven Kang 678cd54553 security: cve-2024-45338 develop (#386) 2025-02-05 15:03:39 +13:00
Oscar Zhou bc19d6592f fix(libstack): cannot open std edge stack log page [BE-11603] (#384) 2025-02-05 12:17:51 +13:00
James Player 5af0859f67 fix(datatables): "Select all" should select only elements of the current page (#376) 2025-02-04 15:34:33 +13:00
Oscar Zhou 379711951c fix(edgegroup): failed to associate env to static edge group [BE-11599] (#368) 2025-02-04 09:41:24 +13:00
LP B a50a9c5617 fix(app/edge): edge stacks webhooks cannot be disabled once created (#372) 2025-02-03 20:50:24 +01:00
LP B c0d30a455f fix(api/edge): backend panic on edge stack removal (#371) 2025-02-03 20:25:25 +01:00
LP B 9a3f6b21d2 feat(app/service-details): hide view while loading data (#348) 2025-02-03 14:20:35 +01:00
153 changed files with 4747 additions and 8494 deletions
+7 -11
View File
@@ -2,18 +2,17 @@ name: Bug Report
description: Create a report to help us improve.
labels: kind/bug,bug/need-confirmation
body:
- type: markdown
attributes:
value: |
# Welcome!
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
- type: checkboxes
@@ -45,7 +44,7 @@ body:
- type: textarea
attributes:
label: Problem Description
description: A clear and concise description of what the bug is.
description: A clear and concise description of what the bug is.
validations:
required: true
@@ -71,7 +70,7 @@ body:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
4. See error
validations:
required: true
@@ -95,6 +94,7 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.28.0'
- '2.27.1'
- '2.27.0'
- '2.26.1'
@@ -121,10 +121,6 @@ body:
- '2.19.2'
- '2.19.1'
- '2.19.0'
- '2.18.4'
- '2.18.3'
- '2.18.2'
- '2.18.1'
validations:
required: true
@@ -162,7 +158,7 @@ body:
- type: input
attributes:
label: Browser
description: |
description: |
Enter your browser and version. Example: Google Chrome 114.0
validations:
required: false
-1
View File
@@ -60,7 +60,6 @@ func CLIFlags() *portainer.CLIFlags {
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
}
}
+16 -17
View File
@@ -4,21 +4,20 @@
package cli
const (
defaultBindAddress = ":9000"
defaultHTTPSBindAddress = ":9443"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "./"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultHTTPDisabled = "false"
defaultHTTPEnabled = "false"
defaultSSL = "false"
defaultBaseURL = "/"
defaultSecretKeyName = "portainer"
defaultPullLimitCheckDisabled = "false"
defaultBindAddress = ":9000"
defaultHTTPSBindAddress = ":9443"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "./"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultHTTPDisabled = "false"
defaultHTTPEnabled = "false"
defaultSSL = "false"
defaultBaseURL = "/"
defaultSecretKeyName = "portainer"
)
+17 -18
View File
@@ -1,22 +1,21 @@
package cli
const (
defaultBindAddress = ":9000"
defaultHTTPSBindAddress = ":9443"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "./"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
defaultHTTPDisabled = "false"
defaultHTTPEnabled = "false"
defaultSSL = "false"
defaultSnapshotInterval = "5m"
defaultBaseURL = "/"
defaultSecretKeyName = "portainer"
defaultPullLimitCheckDisabled = "false"
defaultBindAddress = ":9000"
defaultHTTPSBindAddress = ":9443"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "./"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
defaultHTTPDisabled = "false"
defaultHTTPEnabled = "false"
defaultSSL = "false"
defaultSnapshotInterval = "5m"
defaultBaseURL = "/"
defaultSecretKeyName = "portainer"
)
+4 -4
View File
@@ -49,6 +49,7 @@ import (
"github.com/portainer/portainer/pkg/build"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/gofrs/uuid"
@@ -169,8 +170,8 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
}
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
return libhelm.NewHelmPackageManager()
}
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
@@ -437,7 +438,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
helmPackageManager, err := initHelmPackageManager()
if err != nil {
log.Fatal().Err(err).Msg("failed initializing helm package manager")
}
@@ -575,7 +576,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
AdminCreationDone: adminCreationDone,
PendingActionsService: pendingActionsService,
PlatformService: platformService,
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
}
}
-2
View File
@@ -6,10 +6,8 @@ import (
type ReadTransaction interface {
GetObject(bucketName string, key []byte, object any) error
GetRawBytes(bucketName string, key []byte) ([]byte, error)
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
KeyExists(bucketName string, key []byte) (bool, error)
}
type Transaction interface {
-26
View File
@@ -244,32 +244,6 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
})
}
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
var value []byte
err := connection.ViewTx(func(tx portainer.Transaction) error {
var err error
value, err = tx.GetRawBytes(bucketName, key)
return err
})
return value, err
}
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
var exists bool
err := connection.ViewTx(func(tx portainer.Transaction) error {
var err error
exists, err = tx.KeyExists(bucketName, key)
return err
})
return exists, err
}
func (connection *DbConnection) getEncryptionKey() []byte {
if !connection.isEncrypted {
return nil
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
const (
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://kubernetes.github.io/ingress-nginx","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)
-28
View File
@@ -6,7 +6,6 @@ import (
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
bolt "go.etcd.io/bbolt"
)
@@ -32,33 +31,6 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
return tx.conn.UnmarshalObject(value, object)
}
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
bucket := tx.tx.Bucket([]byte(bucketName))
value := bucket.Get(key)
if value == nil {
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
}
if tx.conn.getEncryptionKey() != nil {
var err error
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
return value, errors.Wrap(err, "Failed decrypting object")
}
}
return value, nil
}
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
bucket := tx.tx.Bucket([]byte(bucketName))
value := bucket.Get(key)
return value != nil, nil
}
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
data, err := tx.conn.MarshalObject(object)
if err != nil {
-14
View File
@@ -9,7 +9,6 @@ import (
type BaseCRUD[T any, I constraints.Integer] interface {
Create(element *T) error
Read(ID I) (*T, error)
Exists(ID I) (bool, error)
ReadAll() ([]T, error)
Update(ID I, element *T) error
Delete(ID I) error
@@ -43,19 +42,6 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
})
}
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
var exists bool
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
exists, err = service.Tx(tx).Exists(ID)
return err
})
return exists, err
}
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
var collection = make([]T, 0)
-6
View File
@@ -28,12 +28,6 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
return &element, nil
}
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
identifier := service.Connection.ConvertToKey(int(ID))
return service.Tx.KeyExists(service.Bucket, identifier)
}
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
var collection = make([]T, 0)
@@ -22,6 +22,8 @@ type Service struct {
mu sync.Mutex
}
var _ dataservices.EndpointRelationService = &Service{}
func (service *Service) BucketName() string {
return BucketName
}
@@ -109,6 +111,18 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
return nil
}
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error {
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
})
}
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error {
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
})
}
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
deletedRelation, _ := service.EndpointRelation(endpointID)
+54
View File
@@ -13,6 +13,8 @@ type ServiceTx struct {
tx portainer.Transaction
}
var _ dataservices.EndpointRelationService = &ServiceTx{}
func (service ServiceTx) BucketName() string {
return BucketName
}
@@ -74,6 +76,58 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
return nil
}
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
for _, endpointID := range endpointIDs {
rel, err := service.EndpointRelation(endpointID)
if err != nil {
return err
}
rel.EdgeStacks[edgeStackID] = true
identifier := service.service.connection.ConvertToKey(int(endpointID))
err = service.tx.UpdateObject(BucketName, identifier, rel)
cache.Del(endpointID)
if err != nil {
return err
}
}
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments += len(endpointIDs)
}); err != nil {
log.Error().Err(err).Msg("could not update the number of deployments")
}
return nil
}
func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
for _, endpointID := range endpointIDs {
rel, err := service.EndpointRelation(endpointID)
if err != nil {
return err
}
delete(rel.EdgeStacks, edgeStackID)
identifier := service.service.connection.ConvertToKey(int(endpointID))
err = service.tx.UpdateObject(BucketName, identifier, rel)
cache.Del(endpointID)
if err != nil {
return err
}
}
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments -= len(endpointIDs)
}); err != nil {
log.Error().Err(err).Msg("could not update the number of deployments")
}
return nil
}
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
deletedRelation, _ := service.EndpointRelation(endpointID)
+2
View File
@@ -115,6 +115,8 @@ type (
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
Create(endpointRelation *portainer.EndpointRelation) error
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
BucketName() string
}
@@ -94,6 +94,10 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
continue
}
if environmentStatus.Details == nil {
continue
}
statusArray := []portainer.EdgeStackDeploymentStatus{}
if environmentStatus.Details.Pending {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
@@ -75,6 +75,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
for _, edgeStack := range edgeStacks {
for endpointId, status := range edgeStack.Status {
if status.Details == nil {
status.Details = &portainer.EdgeStackStatusDetails{}
}
switch status.Type {
case portainer.EdgeStackStatusPending:
status.Details.Pending = true
@@ -93,10 +97,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
edgeStack.Status[endpointId] = status
}
err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
if err != nil {
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
return err
}
}
return nil
}
@@ -605,12 +605,12 @@
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
"HelmRepositoryURL": "",
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.27.8",
"KubectlShellImage": "portainer/kubectl-shell:2.28.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.27.8\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.28.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -1
View File
@@ -127,7 +127,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
return err
}
args = append(args, "stack", "rm", stack.Name)
args = append(args, "stack", "rm", "--detach=false", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
+1 -1
View File
@@ -68,7 +68,7 @@ func copyFile(src, dst string) error {
defer from.Close()
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
return err
}
to, err := os.Create(dst)
+32 -33
View File
@@ -15,15 +15,19 @@ type MultiFilterArgs []struct {
}
// MultiFilterDirForPerDevConfigs filers the given dirEntries with multiple filter args, returns the merged entries for the given device
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) []DirEntry {
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
var envFiles []string
for _, multiFilterArg := range multiFilterArgs {
tmp := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
filteredDirEntries = append(filteredDirEntries, tmp...)
envFiles = append(envFiles, efs...)
}
return deduplicate(filteredDirEntries)
return deduplicate(filteredDirEntries), envFiles
}
func deduplicate(dirEntries []DirEntry) []DirEntry {
@@ -32,8 +36,7 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
marks := make(map[string]struct{})
for _, dirEntry := range dirEntries {
_, ok := marks[dirEntry.Name]
if !ok {
if _, ok := marks[dirEntry.Name]; !ok {
marks[dirEntry.Name] = struct{}{}
deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry)
}
@@ -44,34 +47,33 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
// For given configPath A/B/C, return entries:
// 1. all entries outside of dir A
// 2. dir entries A, A/B, A/B/C
// 3. For filterType file:
// 1. all entries outside of dir A/B/C
// 2. For filterType file:
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
// 4. For filterType dir:
// 3. For filterType dir:
// dir entry: A/B/C/<deviceName>
// all entries: A/B/C/<deviceName>/*
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
var envFiles []string
for _, dirEntry := range dirEntries {
if shouldIncludeEntry(dirEntry, deviceName, configPath, filterType) {
filteredDirEntries = append(filteredDirEntries, dirEntry)
if shouldParseEnvVars(dirEntry, deviceName, configPath, filterType) {
envFiles = append(envFiles, dirEntry.Name)
}
}
}
return filteredDirEntries
return filteredDirEntries, envFiles
}
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
// Include all entries outside of dir A
if !isInConfigRootDir(dirEntry, configPath) {
return true
}
// Include dir entries A, A/B, A/B/C
if isParentDir(dirEntry, configPath) {
if !isInConfigDir(dirEntry, configPath) {
return true
}
@@ -90,21 +92,9 @@ func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filter
return false
}
func isInConfigRootDir(dirEntry DirEntry, configPath string) bool {
// get the first element of the configPath
rootDir := strings.Split(configPath, string(os.PathSeparator))[0]
// return true if entry name starts with "A/"
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(rootDir))
}
func isParentDir(dirEntry DirEntry, configPath string) bool {
if dirEntry.IsFile {
return false
}
// return true for dir entries A, A/B, A/B/C
return strings.HasPrefix(appendTailSeparator(configPath), appendTailSeparator(dirEntry.Name))
func isInConfigDir(dirEntry DirEntry, configPath string) bool {
// return true if entry name starts with "A/B"
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
}
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
@@ -138,6 +128,15 @@ func shouldIncludeDir(dirEntry DirEntry, deviceName, configPath string) bool {
return strings.HasPrefix(dirEntry.Name, filterPrefix)
}
func shouldParseEnvVars(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
if !dirEntry.IsFile {
return false
}
return isInConfigDir(dirEntry, configPath) &&
filepath.Base(dirEntry.Name) == deviceName+".env"
}
func appendTailSeparator(path string) string {
return fmt.Sprintf("%s%c", path, os.PathSeparator)
}
@@ -4,14 +4,17 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
type args struct {
dirEntries []DirEntry
configPath string
multiFilterArgs MultiFilterArgs
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
t.Helper()
dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
require.Equal(t, wantDirEntries, dirEntries)
}
baseDirEntries := []DirEntry{
@@ -26,67 +29,94 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
{"configs/folder2/config2", "", true, 420},
}
tests := []struct {
name string
args args
want []DirEntry
}{
{
name: "filter file1",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
// Filter file1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
)
// Filter folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and folder1
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
)
// Filter file1 and file2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"file2", portainer.PerDevConfigsTypeFile},
},
{
name: "filter folder1",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
},
{
name: "filter file1 and folder1",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
},
{
name: "filter file1 and file2",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{
{"file1", portainer.PerDevConfigsTypeFile},
{"file2", portainer.PerDevConfigsTypeFile},
},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
},
{
name: "filter folder1 and folder2",
args: args{
baseDirEntries,
"configs",
MultiFilterArgs{
{"folder1", portainer.PerDevConfigsTypeDir},
{"folder2", portainer.PerDevConfigsTypeDir},
},
},
want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
)
// Filter folder1 and folder2
f(
baseDirEntries,
"configs",
MultiFilterArgs{
{"folder1", portainer.PerDevConfigsTypeDir},
{"folder2", portainer.PerDevConfigsTypeDir},
},
[]DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
)
}
func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
t.Helper()
_, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
require.Equal(t, wantEnvFiles, envFiles)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, MultiFilterDirForPerDevConfigs(tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs), "MultiFilterDirForPerDevConfigs(%v, %v, %v)", tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs)
})
baseDirEntries := []DirEntry{
{".env", "", true, 420},
{"docker-compose.yaml", "", true, 420},
{"configs", "", false, 420},
{"configs/edge-id/edge-id.env", "", true, 420},
}
f(
baseDirEntries,
"configs",
MultiFilterArgs{{"edge-id", portainer.PerDevConfigsTypeDir}},
[]string{"configs/edge-id/edge-id.env"},
)
}
func TestIsInConfigDir(t *testing.T) {
f := func(dirEntry DirEntry, configPath string, expect bool) {
t.Helper()
actual := isInConfigDir(dirEntry, configPath)
assert.Equal(t, expect, actual)
}
f(DirEntry{Name: "edge-configs"}, "edge-configs", false)
f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false)
f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true)
f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false)
f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false)
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
}
+10 -48
View File
@@ -138,57 +138,19 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
}
oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
endpointsToRemove := set.Set[portainer.EndpointID]{}
for endpointID := range oldRelatedSet {
if !newRelatedSet[endpointID] {
endpointsToRemove[endpointID] = true
}
relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
if len(relatedEnvironmentsToRemove) > 0 {
tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
}
for endpointID := range endpointsToRemove {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
continue
}
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
delete(relation.EdgeStacks, edgeStackID)
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
if len(relatedEnvironmentsToAdd) > 0 {
tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID)
}
endpointsToAdd := set.Set[portainer.EndpointID]{}
for endpointID := range newRelatedSet {
if !oldRelatedSet[endpointID] {
endpointsToAdd[endpointID] = true
}
}
for endpointID := range endpointsToAdd {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil && !tx.IsErrObjectNotFound(err) {
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
if relation == nil {
relation = &portainer.EndpointRelation{
EndpointID: endpointID,
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
}
relation.EdgeStacks[edgeStackID] = true
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}
return newRelatedEnvironmentIDs, endpointsToAdd, nil
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
}
@@ -80,13 +80,6 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
}
}
if handler.PullLimitCheckDisabled {
return response.JSON(w, &dockerhubStatusResponse{
Limit: 10,
Remaining: 10,
})
}
httpClient := client.NewHTTPClient()
token, err := getDockerHubToken(httpClient, registry)
if err != nil {
@@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err)
}
registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships)
registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships)
if handleError != nil {
return nil, handleError
}
@@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
return registries, err
}
func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
if !endpointutils.IsKubernetesEndpoint(endpoint) {
return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil
}
return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships)
return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships)
}
func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true)
isAdmin, err := security.IsAdmin(r)
if err != nil {
@@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataS
return registries, nil
}
return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user)
return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user)
}
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
@@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc
return false
}
func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if errors.Is(err, security.ErrAuthorizationRequired) {
return nil, httperror.Forbidden("User is not authorized", err)
@@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
userNamespaces, err := handler.userNamespaces(tx, endpoint, user)
userNamespaces, err := handler.userNamespaces(endpoint, user)
if err != nil {
return nil, httperror.InternalServerError("unable to retrieve user namespaces", err)
}
@@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.Dat
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
}
func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return nil, err
@@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *po
return nil, err
}
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return nil, err
}
+13 -14
View File
@@ -26,20 +26,19 @@ func hideFields(endpoint *portainer.Endpoint) {
// Handler is the HTTP handler used to handle environment(endpoint) operations.
type Handler struct {
*mux.Router
requestBouncer security.BouncerService
DataStore dataservices.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
ReverseTunnelService portainer.ReverseTunnelService
SnapshotService portainer.SnapshotService
K8sClientFactory *cli.ClientFactory
ComposeStackManager portainer.ComposeStackManager
AuthorizationService *authorization.Service
DockerClientFactory *dockerclient.ClientFactory
BindAddress string
BindAddressHTTPS string
PendingActionsService *pendingactions.PendingActionsService
PullLimitCheckDisabled bool
requestBouncer security.BouncerService
DataStore dataservices.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
ReverseTunnelService portainer.ReverseTunnelService
SnapshotService portainer.SnapshotService
K8sClientFactory *cli.ClientFactory
ComposeStackManager portainer.ComposeStackManager
AuthorizationService *authorization.Service
DockerClientFactory *dockerclient.ClientFactory
BindAddress string
BindAddressHTTPS string
PendingActionsService *pendingactions.PendingActionsService
}
// NewHandler creates a handler to manage environment(endpoint) operations.
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.27.8
// @version 2.28.1
// @description.markdown api-description.md
// @termsOfService
+9 -5
View File
@@ -1,6 +1,7 @@
package helm
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -8,8 +9,8 @@ import (
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libhelm/options"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
@@ -23,11 +24,11 @@ type Handler struct {
jwtService portainer.JWTService
kubeClusterAccessService kubernetes.KubeClusterAccessService
kubernetesDeployer portainer.KubernetesDeployer
helmPackageManager libhelm.HelmPackageManager
helmPackageManager libhelmtypes.HelmPackageManager
}
// NewHandler creates a handler to manage endpoint group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelmtypes.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
@@ -57,7 +58,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
}
// NewTemplateHandler creates a template handler to manage environment(endpoint) group operations.
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelm.HelmPackageManager) *Handler {
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelmtypes.HelmPackageManager) *Handler {
h := &Handler{
Router: mux.NewRouter(),
helmPackageManager: helmPackageManager,
@@ -78,7 +79,7 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
// getHelmClusterAccess obtains the core k8s cluster access details from request.
// The cluster access includes the cluster server url, the user's bearer token and the tls certificate.
// The cluster access is passed in as kube config CLI params to helm binary.
// The cluster access is passed in as kube config CLI params to helm.
func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
@@ -107,6 +108,9 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
return &options.KubernetesClusterAccess{
ClusterName: fmt.Sprintf("%s-%s", "portainer-cluster", endpoint.Name),
ContextName: fmt.Sprintf("%s-%s", "portainer-ctx", endpoint.Name),
UserName: fmt.Sprintf("%s-%s", "portainer-sa-user", tokenData.Username),
ClusterServerURL: kubeConfigInternal.ClusterServerURL,
CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile,
AuthToken: bearerToken,
+2 -2
View File
@@ -13,8 +13,8 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
@@ -34,7 +34,7 @@ func Test_helmDelete(t *testing.T) {
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
+5 -9
View File
@@ -99,15 +99,11 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
}
installOpts := options.InstallOptions{
Name: p.Name,
Chart: p.Chart,
Namespace: p.Namespace,
Repo: p.Repo,
KubernetesClusterAccess: &options.KubernetesClusterAccess{
ClusterServerURL: clusterAccess.ClusterServerURL,
CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile,
AuthToken: clusterAccess.AuthToken,
},
Name: p.Name,
Chart: p.Chart,
Namespace: p.Namespace,
Repo: p.Repo,
KubernetesClusterAccess: clusterAccess,
}
if p.Values != "" {
+3 -3
View File
@@ -15,9 +15,9 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -38,14 +38,14 @@ func Test_helmInstall(t *testing.T) {
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
is.NotNil(h, "Handler should not fail")
// Install a single chart. We expect to get these values back
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"}
optdata, err := json.Marshal(options)
is.NoError(err)
+2 -2
View File
@@ -14,9 +14,9 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -37,7 +37,7 @@ func Test_helmList(t *testing.T) {
is.NoError(err, "Error initialising jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
@@ -8,19 +8,19 @@ import (
"testing"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_helmRepoSearch(t *testing.T) {
is := assert.New(t)
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
assert.NotNil(t, h, "Handler should not fail")
repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
for _, repo := range repos {
t.Run(repo, func(t *testing.T) {
+3 -3
View File
@@ -9,14 +9,14 @@ import (
"testing"
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_helmShow(t *testing.T) {
is := assert.New(t)
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
helmPackageManager := test.NewMockHelmPackageManager()
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
is.NotNil(h, "Handler should not fail")
@@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
t.Run(cmd, func(t *testing.T) {
is.NotNil(h, "Handler should not fail")
repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami")
chart := "nginx"
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
rr := httptest.NewRecorder()
+78 -1
View File
@@ -1,9 +1,14 @@
package kubernetes
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
@@ -162,11 +167,48 @@ func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenD
func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint, isInternal bool) clientV1.NamedCluster {
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(r.Host, endpoint.ID, isInternal)
if isInternal {
return clientV1.NamedCluster{
Name: buildClusterName(endpoint.Name),
Cluster: clientV1.Cluster{
Server: kubeConfigInternal.ClusterServerURL,
InsecureSkipTLSVerify: true,
},
}
}
selfSignedCert := false
serverUrl, err := url.Parse(kubeConfigInternal.ClusterServerURL)
if err != nil {
log.Warn().Err(err).Msg("Failed to parse server URL")
}
if strings.EqualFold(serverUrl.Scheme, "https") {
var certPem []byte
var err error
if kubeConfigInternal.CertificateAuthorityData != "" {
certPem = []byte(kubeConfigInternal.CertificateAuthorityData)
} else if kubeConfigInternal.CertificateAuthorityFile != "" {
certPem, err = os.ReadFile(kubeConfigInternal.CertificateAuthorityFile)
if err != nil {
log.Warn().Err(err).Msg("Failed to open certificate file")
}
}
if certPem != nil {
selfSignedCert, err = IsSelfSignedCertificate(certPem)
if err != nil {
log.Warn().Err(err).Msg("Failed to verify if certificate is self-signed")
}
}
}
return clientV1.NamedCluster{
Name: buildClusterName(endpoint.Name),
Cluster: clientV1.Cluster{
Server: kubeConfigInternal.ClusterServerURL,
InsecureSkipTLSVerify: true,
InsecureSkipTLSVerify: selfSignedCert,
},
}
}
@@ -215,3 +257,38 @@ func writeFileContent(w http.ResponseWriter, r *http.Request, endpoints []portai
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase))
return response.JSON(w, config)
}
func IsSelfSignedCertificate(certPem []byte) (bool, error) {
if certPem == nil {
return false, errors.New("certificate data is empty")
}
if !strings.Contains(string(certPem), "BEGIN CERTIFICATE") {
certPem = []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", string(certPem)))
}
block, _ := pem.Decode(certPem)
if block == nil {
return false, errors.New("failed to decode certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false, err
}
if cert.Issuer.String() != cert.Subject.String() {
return false, nil
}
roots := x509.NewCertPool()
roots.AddCert(cert)
opts := x509.VerifyOptions{
Roots: roots,
CurrentTime: cert.NotBefore,
}
_, err = cert.Verify(opts)
return err == nil, err
}
+186
View File
@@ -0,0 +1,186 @@
package kubernetes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsSelfSignedCertificate(t *testing.T) {
tc := []struct {
name string
cert string
expected bool
}{
{
name: "portainer self-signed",
cert: `-----BEGIN CERTIFICATE-----
MIIBUTCB+KADAgECAhBB7psNiJlJd/nRCCKUPVenMAoGCCqGSM49BAMCMAAwHhcN
MjUwMzEzMDQwODI0WhcNMzAwMzEzMDQwODI0WjAAMFkwEwYHKoZIzj0CAQYIKoZI
zj0DAQcDQgAESdGCaXq0r1GDxF89yKjjLeCIixiPDdXAg+lw4NqAWeJq2AOo+8IH
vcCq9bSlYlezK8RzTsbf9Z1m5jRqUEbSjqNUMFIwDgYDVR0PAQH/BAQDAgWgMBMG
A1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0RAQH/BBMwEYIJ
bG9jYWxob3N0hwQAAAAAMAoGCCqGSM49BAMCA0gAMEUCIApLliukFaCZHbc/2pkH
0VDY+fBMb12jhmVpgKh1Cqg9AiEAwFrMQLUkzATUpiHuukdUg5VsUiMIkWTPLglz
E4+1dRc=
-----END CERTIFICATE-----
`,
expected: true,
},
{
name: "portainer self-signed without header",
cert: `MIIBUzCB+aADAgECAhEAjsskPzuCS5BeHjXGwYqc2jAKBggqhkjOPQQDAjAAMB4XDTI1MDMxMzA0MzQyNloXDTMwMDMxMzA0MzQyNlowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABITD+dNDLYQbLYDE3UMlTzD61OYRSVkVZspdp1MvZITIG4VOxtfQUqcW3P7OHQdoi52GIQ/GM6iDgxwB1BOyi3mjVDBSMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdEQEB/wQTMBGCCWxvY2FsaG9zdIcEAAAAADAKBggqhkjOPQQDAgNJADBGAiEA8SmyeYLhrnrNLAFcxZp0dk6nMN70XVAfqGnbK/s8NR8CIQDgQdqhfge8QvN2TsH4gg98a9VHDv+RlcOlJ80SS+G/Ww==`,
expected: true,
},
{
name: "custom certificate generated by openssl",
cert: `-----BEGIN CERTIFICATE-----
MIIB9TCCAZugAwIBAgIULTkNYfYHiqfOiX7mKOIGxRefx/YwCgYIKoZIzj0EAwIw
SDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp
c2NvMRQwEgYDVQQDEwtleGFtcGxlLm5ldDAeFw0yNTAyMjgwNjI3MDBaFw0zNTAy
MjYwNjI3MDBaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT3WlLvbGw7wPkQ
3LuHFJEaNrDv3n359JMV1CkjQi3U37u0fJrjd+8o7TxPBYgt9HDD9vsURhy41DNo
g71F2AIto4GqMIGnMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
AQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU+nMxx/VCE9fzrlHI
FX9mF5SRPrkwHwYDVR0jBBgwFoAUOlUIToGwnBOqzZ1dBfOvdKbwNaAwKAYDVR0R
AQH/BB4wHIIaZWRnZS4xNzIuMTcuMjIxLjIwOC5uaXAuaW8wCgYIKoZIzj0EAwID
SAAwRQIgeYrkjY0z/ypMKXZbvbMi8qOK44qoISKkSErBUCBLuwoCIQDRaJA9r931
utpXXnysVGecVXHHKOOl1YhWglmuPvcZhw==
-----END CERTIFICATE-----`,
expected: false,
},
{
name: "google.com certificate",
cert: `-----BEGIN CERTIFICATE-----
MIIOITCCDQmgAwIBAgIQKS0IQxknY8USDjt3IYchljANBgkqhkiG9w0BAQsFADA7
MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww
CgYDVQQDEwNXUjIwHhcNMjUwMjI2MTUzMjU1WhcNMjUwNTIxMTUzMjU0WjAXMRUw
EwYDVQQDDAwqLmdvb2dsZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARx
nMOmIG3BuO7my/BbF/rGPAMH/JbxBDufbYFQHV+6l5pF5sdT/Zov3X+qsR3IYFl7
F2a0gAUmK1Bq7//zTb3uo4IMDjCCDAowDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM
MAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFN+aEjBz3PaUtelz
3g9rVTkGRgU0MB8GA1UdIwQYMBaAFN4bHu15FdQ+NyTDIbvsNDltQrIwMFgGCCsG
AQUFBwEBBEwwSjAhBggrBgEFBQcwAYYVaHR0cDovL28ucGtpLmdvb2cvd3IyMCUG
CCsGAQUFBzAChhlodHRwOi8vaS5wa2kuZ29vZy93cjIuY3J0MIIJ5AYDVR0RBIIJ
2zCCCdeCDCouZ29vZ2xlLmNvbYIWKi5hcHBlbmdpbmUuZ29vZ2xlLmNvbYIJKi5i
ZG4uZGV2ghUqLm9yaWdpbi10ZXN0LmJkbi5kZXaCEiouY2xvdWQuZ29vZ2xlLmNv
bYIYKi5jcm93ZHNvdXJjZS5nb29nbGUuY29tghgqLmRhdGFjb21wdXRlLmdvb2ds
ZS5jb22CCyouZ29vZ2xlLmNhggsqLmdvb2dsZS5jbIIOKi5nb29nbGUuY28uaW6C
DiouZ29vZ2xlLmNvLmpwgg4qLmdvb2dsZS5jby51a4IPKi5nb29nbGUuY29tLmFy
gg8qLmdvb2dsZS5jb20uYXWCDyouZ29vZ2xlLmNvbS5icoIPKi5nb29nbGUuY29t
LmNvgg8qLmdvb2dsZS5jb20ubXiCDyouZ29vZ2xlLmNvbS50coIPKi5nb29nbGUu
Y29tLnZuggsqLmdvb2dsZS5kZYILKi5nb29nbGUuZXOCCyouZ29vZ2xlLmZyggsq
Lmdvb2dsZS5odYILKi5nb29nbGUuaXSCCyouZ29vZ2xlLm5sggsqLmdvb2dsZS5w
bIILKi5nb29nbGUucHSCDyouZ29vZ2xlYXBpcy5jboIRKi5nb29nbGV2aWRlby5j
b22CDCouZ3N0YXRpYy5jboIQKi5nc3RhdGljLWNuLmNvbYIPZ29vZ2xlY25hcHBz
LmNughEqLmdvb2dsZWNuYXBwcy5jboIRZ29vZ2xlYXBwcy1jbi5jb22CEyouZ29v
Z2xlYXBwcy1jbi5jb22CDGdrZWNuYXBwcy5jboIOKi5na2VjbmFwcHMuY26CEmdv
b2dsZWRvd25sb2Fkcy5jboIUKi5nb29nbGVkb3dubG9hZHMuY26CEHJlY2FwdGNo
YS5uZXQuY26CEioucmVjYXB0Y2hhLm5ldC5jboIQcmVjYXB0Y2hhLWNuLm5ldIIS
Ki5yZWNhcHRjaGEtY24ubmV0ggt3aWRldmluZS5jboINKi53aWRldmluZS5jboIR
YW1wcHJvamVjdC5vcmcuY26CEyouYW1wcHJvamVjdC5vcmcuY26CEWFtcHByb2pl
Y3QubmV0LmNughMqLmFtcHByb2plY3QubmV0LmNughdnb29nbGUtYW5hbHl0aWNz
LWNuLmNvbYIZKi5nb29nbGUtYW5hbHl0aWNzLWNuLmNvbYIXZ29vZ2xlYWRzZXJ2
aWNlcy1jbi5jb22CGSouZ29vZ2xlYWRzZXJ2aWNlcy1jbi5jb22CEWdvb2dsZXZh
ZHMtY24uY29tghMqLmdvb2dsZXZhZHMtY24uY29tghFnb29nbGVhcGlzLWNuLmNv
bYITKi5nb29nbGVhcGlzLWNuLmNvbYIVZ29vZ2xlb3B0aW1pemUtY24uY29tghcq
Lmdvb2dsZW9wdGltaXplLWNuLmNvbYISZG91YmxlY2xpY2stY24ubmV0ghQqLmRv
dWJsZWNsaWNrLWNuLm5ldIIYKi5mbHMuZG91YmxlY2xpY2stY24ubmV0ghYqLmcu
ZG91YmxlY2xpY2stY24ubmV0gg5kb3VibGVjbGljay5jboIQKi5kb3VibGVjbGlj
ay5jboIUKi5mbHMuZG91YmxlY2xpY2suY26CEiouZy5kb3VibGVjbGljay5jboIR
ZGFydHNlYXJjaC1jbi5uZXSCEyouZGFydHNlYXJjaC1jbi5uZXSCHWdvb2dsZXRy
YXZlbGFkc2VydmljZXMtY24uY29tgh8qLmdvb2dsZXRyYXZlbGFkc2VydmljZXMt
Y24uY29tghhnb29nbGV0YWdzZXJ2aWNlcy1jbi5jb22CGiouZ29vZ2xldGFnc2Vy
dmljZXMtY24uY29tghdnb29nbGV0YWdtYW5hZ2VyLWNuLmNvbYIZKi5nb29nbGV0
YWdtYW5hZ2VyLWNuLmNvbYIYZ29vZ2xlc3luZGljYXRpb24tY24uY29tghoqLmdv
b2dsZXN5bmRpY2F0aW9uLWNuLmNvbYIkKi5zYWZlZnJhbWUuZ29vZ2xlc3luZGlj
YXRpb24tY24uY29tghZhcHAtbWVhc3VyZW1lbnQtY24uY29tghgqLmFwcC1tZWFz
dXJlbWVudC1jbi5jb22CC2d2dDEtY24uY29tgg0qLmd2dDEtY24uY29tggtndnQy
LWNuLmNvbYINKi5ndnQyLWNuLmNvbYILMm1kbi1jbi5uZXSCDSouMm1kbi1jbi5u
ZXSCFGdvb2dsZWZsaWdodHMtY24ubmV0ghYqLmdvb2dsZWZsaWdodHMtY24ubmV0
ggxhZG1vYi1jbi5jb22CDiouYWRtb2ItY24uY29tghRnb29nbGVzYW5kYm94LWNu
LmNvbYIWKi5nb29nbGVzYW5kYm94LWNuLmNvbYIeKi5zYWZlbnVwLmdvb2dsZXNh
bmRib3gtY24uY29tgg0qLmdzdGF0aWMuY29tghQqLm1ldHJpYy5nc3RhdGljLmNv
bYIKKi5ndnQxLmNvbYIRKi5nY3BjZG4uZ3Z0MS5jb22CCiouZ3Z0Mi5jb22CDiou
Z2NwLmd2dDIuY29tghAqLnVybC5nb29nbGUuY29tghYqLnlvdXR1YmUtbm9jb29r
aWUuY29tggsqLnl0aW1nLmNvbYILYW5kcm9pZC5jb22CDSouYW5kcm9pZC5jb22C
EyouZmxhc2guYW5kcm9pZC5jb22CBGcuY26CBiouZy5jboIEZy5jb4IGKi5nLmNv
ggZnb28uZ2yCCnd3dy5nb28uZ2yCFGdvb2dsZS1hbmFseXRpY3MuY29tghYqLmdv
b2dsZS1hbmFseXRpY3MuY29tggpnb29nbGUuY29tghJnb29nbGVjb21tZXJjZS5j
b22CFCouZ29vZ2xlY29tbWVyY2UuY29tgghnZ3BodC5jboIKKi5nZ3BodC5jboIK
dXJjaGluLmNvbYIMKi51cmNoaW4uY29tggh5b3V0dS5iZYILeW91dHViZS5jb22C
DSoueW91dHViZS5jb22CEW11c2ljLnlvdXR1YmUuY29tghMqLm11c2ljLnlvdXR1
YmUuY29tghR5b3V0dWJlZWR1Y2F0aW9uLmNvbYIWKi55b3V0dWJlZWR1Y2F0aW9u
LmNvbYIPeW91dHViZWtpZHMuY29tghEqLnlvdXR1YmVraWRzLmNvbYIFeXQuYmWC
ByoueXQuYmWCGmFuZHJvaWQuY2xpZW50cy5nb29nbGUuY29tghMqLmFuZHJvaWQu
Z29vZ2xlLmNughIqLmNocm9tZS5nb29nbGUuY26CFiouZGV2ZWxvcGVycy5nb29n
bGUuY26CFSouYWlzdHVkaW8uZ29vZ2xlLmNvbTATBgNVHSAEDDAKMAgGBmeBDAEC
ATA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vYy5wa2kuZ29vZy93cjIvb0JGWVlh
aHpnVkkuY3JsMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHcAzxFW7tUufK/zh1vZ
aS6b6RpxZ0qwF+ysAdJbd87MOwgAAAGVQxqxaQAABAMASDBGAiEAk6r74vfyJIaa
hYTWqNRsjl/RpCWq/wyzzMi21zgGmfkCIQCZafyS/fl0tiutICL9aOSnDBRfPYqd
CeNqKOy11EjvigB1AN6FgddQJHxrzcuvVjfF54HGTORu1hdjn480pybJ4r03AAAB
lUMasUkAAAQDAEYwRAIgYfG2iyRnmn8MI86RFDxOQW1/IOBAjQxNfIQ8toZlZkoC
IA1BHw7cqmlTP7Ks+ebX6hGfNlVsgTQS8iYyKL5/BSvTMA0GCSqGSIb3DQEBCwUA
A4IBAQAYSNtoW72rqhPfjV5Ug1ENbbimfqmqiJS4JdzaEFRpftzachTuvx8relaY
+7FAz5y4YULu9LGNjpBRYW8yW9pgfWyc53CCHSkDODguUOMCRo3hdglxZ2d5pJ/8
TQY4zRBd8OHzOAx2kH6jLEj9I0nDie3vowSYm7FCBRLjzfForRNQWmzPu+5hS3De
QM0R2jWpmPcG3ffQ5qQwnAQnP9HCK9oEZ5cFqLvOQWfttj/rzKOz856iSEoRpf8S
wVFRu3Uv2TXQ6UYF2cDfiWCe6/mO35CIynC6FVkunze/Q/2rtaCDttLRYZcLllj8
PSl7nmLhtqDlO7da/S34BFiyyRjN
-----END CERTIFICATE-----
`,
expected: false,
},
{
name: "let's encrypt certificate",
cert: `-----BEGIN CERTIFICATE-----
MIIGMjCCBRqgAwIBAgISBVHH05rEMkaCuDQvABDjiam0MA0GCSqGSIb3DQEBCwUA
MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD
EwNSMTAwHhcNMjUwMzEzMDIyMzE2WhcNMjUwNjExMDIyMzE1WjAkMSIwIAYDVQQD
Exlvei1kZW1vLnBvcnRhaW5lcmNsb3VkLmlvMIICIjANBgkqhkiG9w0BAQEFAAOC
Ag8AMIICCgKCAgEAwNCcr9azSaldEwgL54bQScuWBnmw3FMHgEATxDVp2MEawQkV
I3VScUcJWBnlHlb7TUanRC/c/vJGbzc+KDuCRTZ2/Ob2yQ9G5mZjGttBAnBSQPpV
arEEBFCClhVBn4LhLNmIsCjCy25+m0HY/dwWbKjTMT/KxpTa3L3mdmIFa7XNs6W2
vEZGwYM+2JPMJ9DwemVrrrvRqd5vLWTZcWvWJQ7HMfw3PoELpeqyycmxDqd9PCMz
yMp8q3UwLDur3+KfDXGtGOoubxcOuJrpemOe8JeM5cEYEhvOy8D16zmWwWYDT19D
ElFfUbM0GGITpJ41Qie03DvmI0hDYDqTEZfKza967VsvD7K9bFgLHmHdv7gLNutB
FConpziNqslapWwQ5j7bKircxKjRQVkOiXH48m2IUzylqWgJPVMvHukRu0YVnvbt
Q53xNVZQEbjvZmIuz8jqo22Y/1Jr7Plnb1lUvvDznA58MHT0KA4LSZwk9tvMJJCw
vh7AoWB6/Jnl8QVnApOdCa6M/An128rBwgrCmp0wSvhMecTkWC8/gsah0Q5wKFL3
ziBth728Qy8RlNghRUw88e/y4pdGHN8egjK1NpdgsvTFdRNQ8qwu0lx9pO3b6TNQ
qDG5pirXjS/DhPYvZtJRDK6SMTHJNm+0NGdWB8qpNssFrU6u2cRl0533LtECAwEA
AaOCAk0wggJJMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUiQi/3pZamfPxRGPI8DTZ
tej1494wHwYDVR0jBBgwFoAUu7zDR6XkvKnGw6RyDBCNojXhyOgwVwYIKwYBBQUH
AQEESzBJMCIGCCsGAQUFBzABhhZodHRwOi8vcjEwLm8ubGVuY3Iub3JnMCMGCCsG
AQUFBzAChhdodHRwOi8vcjEwLmkubGVuY3Iub3JnLzAkBgNVHREEHTAbghlvei1k
ZW1vLnBvcnRhaW5lcmNsb3VkLmlvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC4GA1Ud
HwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTAuYy5sZW5jci5vcmcvNTMuY3JsMIIBBAYK
KwYBBAHWeQIEAgSB9QSB8gDwAHcAzPsPaoVxCWX+lZtTzumyfCLphVwNl422qX5U
wP5MDbAAAAGVjYW7/QAABAMASDBGAiEA8CjMOIj7wqQ60BX22A5pDkA23IxZPzwV
1MF5+VSgdqgCIQCZhry5AK2VyZX/cIODEl6eHBCUWS4vHB+J8RxeclKCpAB1AKLj
CuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABlY2Fu/QAAAQDAEYwRAIg
bwjJgZJew/1LoL9yzDD1P4Xkd8ezFucxfU3AzlV1XEYCIH5RPyW1HP9GSr+aAx+I
o3inVl1NagJFYiApAPvFmIEgMA0GCSqGSIb3DQEBCwUAA4IBAQATJWi1sJSBstO+
hyH7DsrAtDhiQTOWzUZezBlgCn8hfmA3nX5uKsHyxPPPEQ/GFYOltRD/+34X9kFF
YNzUjJOP0bGk45I1JbspxRRvtbDpk0+dj2VE2toM8vLRDz3+DB4YB2lFofYlex++
16xFzOIE+ZW41qBs3G8InsyHADsaFY2CQ9re/kZvenptU/ax1U2a21JJ3TT2DmXW
AHZYQ5/whVIowsebw1e28I12VhLl2BKn7v4MpCn3GUzBBQAEbJ6TIjHtFKWWnVfH
FisaUX6N4hMzGZVJOsbH4QVBGuNwUshHiD8MSpbans2w+T4bCe11XayerqxFhTao
w/pjiPVy
-----END CERTIFICATE-----
`,
expected: false,
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
actual, err := IsSelfSignedCertificate([]byte(tt.cert))
assert.NoError(t, err)
assert.Equal(t, tt.expected, actual)
})
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
// Whether telemetry is enabled
EnableTelemetry *bool `example:"false"`
// Helm repository URL
HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
// Kubectl Shell Image
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
@@ -1,36 +0,0 @@
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}
}
+2 -2
View File
@@ -35,8 +35,8 @@ type (
}
K8sServiceIngress struct {
IP string `json:"IP"`
Host string `json:"Host"`
IP string `json:"IP"`
Hostname string `json:"Hostname"`
}
// K8sServiceDeleteRequests is a mapping of namespace names to a slice of
+2 -27
View File
@@ -7,31 +7,12 @@ import (
"strings"
)
// Note that we discard any non-canonical headers by design
var allowedHeaders = map[string]struct{}{
"Accept": {},
"Accept-Encoding": {},
"Accept-Language": {},
"Cache-Control": {},
"Content-Length": {},
"Content-Type": {},
"Private-Token": {},
"User-Agent": {},
"X-Portaineragent-Target": {},
"X-Portainer-Volumename": {},
"X-Registry-Auth": {},
}
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
return &httputil.ReverseProxy{Director: createDirector(target)}
}
func createDirector(target *url.URL) func(*http.Request) {
targetQuery := target.RawQuery
return func(req *http.Request) {
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
@@ -45,14 +26,8 @@ func createDirector(target *url.URL) func(*http.Request) {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
for k := range req.Header {
if _, ok := allowedHeaders[k]; !ok {
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
delete(req.Header, k)
}
}
}
return &httputil.ReverseProxy{Director: director}
}
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
@@ -1,190 +0,0 @@
package factory
import (
"net/http"
"net/url"
"testing"
"github.com/google/go-cmp/cmp"
portainer "github.com/portainer/portainer/api"
)
func Test_createDirector(t *testing.T) {
testCases := []struct {
name string
target *url.URL
req *http.Request
expectedReq *http.Request
}{
{
name: "base case",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
true,
),
},
{
name: "no User-Agent",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
true,
),
},
{
name: "Sensitive Headers",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Authorization": "secret",
"Proxy-Authorization": "secret",
"Cookie": "secret",
"X-Csrf-Token": "secret",
"X-Api-Key": "secret",
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Portaineragent-Target": "test-agent-1",
"X-Portainer-Volumename": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
},
{
name: "Non canonical Headers",
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
req: createRequest(
t,
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
portainer.PortainerAgentTargetHeader: "test-agent-1",
"X-Portainer-VolumeName": "test-volume-1",
"X-Registry-Auth": "test-registry-auth",
},
false,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{
"Accept": "application/json",
"Accept-Encoding": "gzip",
"Accept-Language": "en-GB",
"Cache-Control": "None",
"Content-Length": "100",
"Content-Type": "application/json",
"Private-Token": "test-private-token",
"User-Agent": "test-user-agent",
"X-Registry-Auth": "test-registry-auth",
},
true,
),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
director := createDirector(tc.target)
director(tc.req)
if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
t.Fatalf("requests are different: \n%s", diff)
}
})
}
}
func createURL(t *testing.T, urlString string) *url.URL {
parsedURL, err := url.Parse(urlString)
if err != nil {
t.Fatalf("Failed to create url: %s", err)
}
return parsedURL
}
func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("Failed to create http request: %s", err)
} else {
for k, v := range headers {
if canonicalHeaders {
req.Header.Add(k, v)
} else {
req.Header[k] = []string{v}
}
}
}
return req
}
func compareRequests(a, b *http.Request) bool {
methodEqual := a.Method == b.Method
urlEqual := cmp.Diff(a.URL, b.URL) == ""
hostEqual := a.Host == b.Host
protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor
headersEqual := cmp.Diff(a.Header, b.Header) == ""
return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual
}
+5 -3
View File
@@ -243,7 +243,8 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler,
return
}
if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok {
_, err = bouncer.dataStore.User().Read(tokenData.ID)
if bouncer.dataStore.IsErrObjectNotFound(err) {
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
return
} else if err != nil {
@@ -321,8 +322,9 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
return
}
if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok {
httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized)
user, _ := bouncer.dataStore.User().Read(token.ID)
if user == nil {
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
return
}
+3 -5
View File
@@ -67,7 +67,7 @@ import (
"github.com/portainer/portainer/api/platform"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/rs/zerolog/log"
)
@@ -103,7 +103,7 @@ type Server struct {
DockerClientFactory *dockerclient.ClientFactory
KubernetesClientFactory *cli.ClientFactory
KubernetesDeployer portainer.KubernetesDeployer
HelmPackageManager libhelm.HelmPackageManager
HelmPackageManager libhelmtypes.HelmPackageManager
Scheduler *scheduler.Scheduler
ShutdownCtx context.Context
ShutdownTrigger context.CancelFunc
@@ -112,7 +112,6 @@ type Server struct {
AdminCreationDone chan struct{}
PendingActionsService *pendingactions.PendingActionsService
PlatformService platform.Service
PullLimitCheckDisabled bool
}
// Start starts the HTTP server
@@ -182,7 +181,6 @@ func (server *Server) Start() error {
endpointHandler.BindAddress = server.BindAddress
endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS
endpointHandler.PendingActionsService = server.PendingActionsService
endpointHandler.PullLimitCheckDisabled = server.PullLimitCheckDisabled
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer, server.DataStore, server.FileService, server.ReverseTunnelService)
@@ -349,7 +347,7 @@ func (server *Server) Start() error {
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
httpServer := &http.Server{
Addr: server.BindAddress,
Handler: middlewares.PlaintextHTTPRequest(handler),
Handler: handler,
ErrorLog: errorLogger,
}
+6 -21
View File
@@ -11,7 +11,6 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/edge"
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
"github.com/rs/zerolog/log"
"github.com/pkg/errors"
)
@@ -100,12 +99,15 @@ func (service *Service) PersistEdgeStack(
stack.ManifestPath = manifestPath
stack.ProjectPath = projectPath
stack.EntryPoint = composePath
stack.NumDeployments = len(relatedEndpointIds)
if err := tx.EdgeStack().Create(stack.ID, stack); err != nil {
return nil, err
}
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
}
if err := service.updateEndpointRelations(tx, stack.ID, relatedEndpointIds); err != nil {
return nil, fmt.Errorf("unable to update endpoint relations: %w", err)
}
@@ -148,25 +150,8 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
}
for _, endpointID := range relatedEndpointIds {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
log.Warn().
Int("endpoint_id", int(endpointID)).
Msg("Unable to find endpoint relation in database, skipping")
continue
}
return errors.WithMessage(err, "Unable to find environment relation in database")
}
delete(relation.EdgeStacks, edgeStackID)
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return errors.WithMessage(err, "Unable to persist environment relation in database")
}
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEndpointIds, edgeStackID); err != nil {
return errors.WithMessage(err, "unable to remove environment relation in database")
}
if err := tx.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil {
+26 -8
View File
@@ -9,6 +9,8 @@ import (
"github.com/portainer/portainer/api/dataservices/errors"
)
var _ dataservices.DataStore = &testDatastore{}
type testDatastore struct {
customTemplate dataservices.CustomTemplateService
edgeGroup dataservices.EdgeGroupService
@@ -151,7 +153,6 @@ func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User
func (s *stubUserService) Create(user *portainer.User) error { return nil }
func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
// WithUsers testDatastore option that will instruct testDatastore to return provided users
func WithUsers(us []portainer.User) datastoreOption {
@@ -187,9 +188,6 @@ func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFun
}
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
return false, nil
}
// WithEdgeJobs option will instruct testDatastore to return provided jobs
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
@@ -231,6 +229,30 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
return nil
}
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
for _, endpointID := range endpointIDs {
for i, r := range s.relations {
if r.EndpointID == endpointID {
s.relations[i].EdgeStacks[edgeStackID] = true
}
}
}
return nil
}
func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
for _, endpointID := range endpointIDs {
for i, r := range s.relations {
if r.EndpointID == endpointID {
delete(s.relations[i].EdgeStacks, edgeStackID)
}
}
}
return nil
}
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
return nil
}
@@ -430,10 +452,6 @@ func (s *stubStacksService) GetNextIdentifier() int {
return len(s.stacks)
}
func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) {
return false, nil
}
// WithStacks option will instruct testDatastore to return provided stacks
func WithStacks(stacks []portainer.Stack) datastoreOption {
return func(d *testDatastore) {
-8
View File
@@ -87,14 +87,6 @@ func (factory *ClientFactory) ClearClientCache() {
// Remove the cached kube client so a new one can be created
func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) {
factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID)))
endpointPrefix := strconv.Itoa(int(endpointID)) + "."
for key := range factory.endpointProxyClients.Items() {
if strings.HasPrefix(key, endpointPrefix) {
factory.endpointProxyClients.Delete(key)
}
}
}
// GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
+8 -3
View File
@@ -265,9 +265,12 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
return systemLabelValue == "true"
}
systemNamespaces := defaultSystemNamespaces()
return isSystemDefaultNamespace(namespace.Name)
}
_, isSystem := systemNamespaces[namespace.Name]
func isSystemDefaultNamespace(namespace string) bool {
systemNamespaces := defaultSystemNamespaces()
_, isSystem := systemNamespaces[namespace]
return isSystem
}
@@ -390,7 +393,9 @@ func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8s
func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} {
nonAdminNamespaceSet := make(map[string]struct{}, len(kcl.NonAdminNamespaces))
for _, namespace := range kcl.NonAdminNamespaces {
nonAdminNamespaceSet[namespace] = struct{}{}
if !isSystemDefaultNamespace(namespace) {
nonAdminNamespaceSet[namespace] = struct{}{}
}
}
return nonAdminNamespaceSet
+3 -3
View File
@@ -81,8 +81,8 @@ func parseService(service corev1.Service) models.K8sServiceInfo {
ingressStatus := make([]models.K8sServiceIngress, 0)
for _, status := range service.Status.LoadBalancer.Ingress {
ingressStatus = append(ingressStatus, models.K8sServiceIngress{
IP: status.IP,
Host: status.Hostname,
IP: status.IP,
Hostname: status.Hostname,
})
}
@@ -130,7 +130,7 @@ func (kcl *KubeClient) convertToK8sService(info models.K8sServiceInfo) corev1.Se
for _, i := range info.IngressStatus {
service.Status.LoadBalancer.Ingress = append(
service.Status.LoadBalancer.Ingress,
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host},
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Hostname},
)
}
@@ -109,6 +109,7 @@ func (service *kubeClusterAccessService) GetClusterDetails(hostURL string, endpo
Str("host_URL", hostURL).
Str("HTTPS_bind_address", service.httpsBindAddr).
Str("base_URL", baseURL).
Bool("is_internal", isInternal).
Msg("kubeconfig")
clusterServerURL, err := url.JoinPath("https://", hostURL, baseURL, "/api/endpoints/", strconv.Itoa(int(endpointID)), "/kubernetes")
+15 -18
View File
@@ -134,7 +134,6 @@ type (
LogLevel *string
LogMode *string
KubectlShellImage *string
PullLimitCheckDisabled *bool
}
// CustomTemplateVariableDefinition
@@ -310,7 +309,7 @@ type (
// FileVersion is the version of the stack file, used to detect changes
FileVersion int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack
ConfigHash string `json:"ConfigHash"`
ConfigHash string `json:"ConfigHash,omitempty"`
}
// EdgeStack represents an edge stack
@@ -354,24 +353,24 @@ type (
// EE only feature
DeploymentInfo StackDeploymentInfo
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"`
// Deprecated
Details EdgeStackStatusDetails
Details *EdgeStackStatusDetails `json:"Details,omitempty"`
// Deprecated
Error string
Error string `json:"Error,omitempty"`
// Deprecated
Type EdgeStackStatusType `json:"Type"`
Type EdgeStackStatusType `json:"Type,omitempty"`
}
// EdgeStackDeploymentStatus represents an edge stack deployment status
EdgeStackDeploymentStatus struct {
Time int64
Type EdgeStackStatusType
Error string
Error string `json:"Error,omitempty"`
// EE only feature
RollbackTo *int
Version int `json:"Version,omitempty"`
RollbackTo *int `json:"RollbackTo,omitempty"`
Version int `json:"Version,omitempty"`
}
// EdgeStackStatusType represents an edge stack status type
@@ -589,7 +588,7 @@ type (
// User identifier
UserID UserID `json:"UserId" example:"1"`
// Helm repository URL
URL string `json:"URL" example:"https://kubernetes.github.io/ingress-nginx"`
URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"`
}
// QuayRegistryData represents data required for Quay registry to work
@@ -985,8 +984,8 @@ type (
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
// Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
// Helm repository URL, defaults to ""
HelmRepositoryURL string `json:"HelmRepositoryURL"`
// Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
// KubectlImage, defaults to portainer/kubectl-shell
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
@@ -1638,9 +1637,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.27.8"
APIVersion = "2.28.1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1674,8 +1673,8 @@ const (
DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
// DefaultHelmrepositoryURL set to empty string until oci support is added
DefaultHelmRepositoryURL = ""
// DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
DefaultUserSessionTimeout = "8h"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
@@ -1690,8 +1689,6 @@ const (
PortainerCacheHeader = "X-Portainer-Cache"
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
// PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check
PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED"
)
// List of supported features
-6114
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,4 +1,4 @@
import { buildImageFullURIFromModel, imageContainsURL } from '@/react/docker/images/utils';
import { buildImageFullURIFromModel, imageContainsURL, fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory);
function ImageHelperFactory() {
@@ -18,8 +18,12 @@ function ImageHelperFactory() {
* @param {PorImageRegistryModel} registry
*/
function createImageConfigForContainer(imageModel) {
const fromImage = buildImageFullURIFromModel(imageModel);
const { tag, repo } = fullURIIntoRepoAndTag(fromImage);
return {
fromImage: buildImageFullURIFromModel(imageModel),
fromImage,
tag,
repo,
};
}
@@ -207,9 +207,9 @@ angular.module('portainer.docker').controller('ContainerController', [
async function commitContainerAsync() {
$scope.config.commitInProgress = true;
const registryModel = $scope.config.RegistryModel;
const imageConfig = ImageHelper.createImageConfigForContainer(registryModel);
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
try {
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo: imageConfig.fromImage });
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo, tag });
Notifications.success('Image created', $transition$.params().id);
$state.reload();
} catch (err) {
@@ -2,7 +2,6 @@ import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImageController', [
'$async',
@@ -71,8 +70,7 @@ angular.module('portainer.docker').controller('ImageController', [
$scope.tagImage = function () {
const registryModel = $scope.formValues.RegistryModel;
const image = ImageHelper.createImageConfigForContainer(registryModel);
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
ImageService.tagImage($transition$.params().id, repo, tag)
.then(function success() {
@@ -1,5 +1,4 @@
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImportImageController', [
'$scope',
@@ -34,8 +33,7 @@ angular.module('portainer.docker').controller('ImportImageController', [
async function tagImage(id) {
const registryModel = $scope.formValues.RegistryModel;
if (registryModel.Image) {
const image = ImageHelper.createImageConfigForContainer(registryModel);
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
try {
await ImageService.tagImage(id, repo, tag);
} catch (err) {
+266 -259
View File
@@ -1,274 +1,281 @@
<page-header title="'Service details'" breadcrumbs="[{label:'Services', link:'docker.services'}, service.Name]" reload="true"> </page-header>
<div class="row">
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
<div class="alert alert-info" role="alert" id="service-update-alert">
<p>This service is being updated. Editing this service is currently disabled.</p>
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
<div ng-if="!isLoading">
<div class="row">
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
<div class="alert alert-info" role="alert" id="service-update-alert">
<p>This service is being updated. Editing this service is currently disabled.</p>
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-9 col-md-9 col-xs-9">
<rd-widget>
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td class="w-1/5">Name</td>
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
<input
type="text"
class="form-control"
ng-model="service.Name"
ng-change="updateServiceAttribute(service, 'Name')"
ng-disabled="isUpdating"
data-cy="docker-service-edit-name"
/>
</td>
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
</tr>
<tr>
<td>ID</td>
<td> {{ service.Id }} </td>
</tr>
<tr ng-if="service.CreatedAt">
<td>Created at</td>
<td>{{ service.CreatedAt | getisodate }}</td>
</tr>
<tr ng-if="service.UpdatedAt">
<td>Last updated at</td>
<td>{{ service.UpdatedAt | getisodate }}</td>
</tr>
<tr ng-if="service.Version">
<td>Version</td>
<td>{{ service.Version }}</td>
</tr>
<tr>
<td>Scheduling mode</td>
<td>{{ service.Mode }}</td>
</tr>
<tr ng-if="service.Mode === 'replicated'">
<td>Replicas</td>
<td>
<span ng-if="service.Mode === 'replicated'">
<div class="row">
<div class="col-lg-9 col-md-9 col-xs-9">
<rd-widget>
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td class="w-1/5">Name</td>
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
<input
class="input-sm"
type="number"
data-cy="docker-service-edit-replicas-input"
ng-model="service.Replicas"
ng-change="updateServiceAttribute(service, 'Replicas')"
disable-authorization="DockerServiceUpdate"
type="text"
class="form-control"
ng-model="service.Name"
ng-change="updateServiceAttribute(service, 'Name')"
ng-disabled="isUpdating"
data-cy="docker-service-edit-name"
/>
</span>
</td>
</tr>
<tr>
<td>Image</td>
<td>{{ service.Image }}</td>
</tr>
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
<td>
<div class="inline-flex items-center">
<div> Service webhook </div>
<portainer-tooltip
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
>
</portainer-tooltip>
</div>
</td>
<td>
<div class="flex flex-wrap items-center">
<por-switch-field label-class="'!mr-0'" checked="WebhookExists" disabled="disabledWebhookButton(WebhookExists)" on-change="(onWebhookChange)"></por-switch-field>
<span ng-if="webhookURL">
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
Copy link
</button>
<span>
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</td>
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
</tr>
<tr>
<td>ID</td>
<td> {{ service.Id }} </td>
</tr>
<tr ng-if="service.CreatedAt">
<td>Created at</td>
<td>{{ service.CreatedAt | getisodate }}</td>
</tr>
<tr ng-if="service.UpdatedAt">
<td>Last updated at</td>
<td>{{ service.UpdatedAt | getisodate }}</td>
</tr>
<tr ng-if="service.Version">
<td>Version</td>
<td>{{ service.Version }}</td>
</tr>
<tr>
<td>Scheduling mode</td>
<td>{{ service.Mode }}</td>
</tr>
<tr ng-if="service.Mode === 'replicated'">
<td>Replicas</td>
<td>
<span ng-if="service.Mode === 'replicated'">
<input
class="input-sm"
type="number"
data-cy="docker-service-edit-replicas-input"
ng-model="service.Replicas"
ng-change="updateServiceAttribute(service, 'Replicas')"
disable-authorization="DockerServiceUpdate"
/>
</span>
</div>
</td>
</tr>
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
<td colspan="2">
<p class="small text-muted" authorization="DockerServiceUpdate">
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
><div class="flex flex-wrap gap-x-2 gap-y-1">
<a
authorization="DockerServiceLogs"
ng-if="applicationState.endpoint.apiVersion >= 1.3"
class="btn btn-primary btn-sm"
type="button"
ui-sref="docker.services.service.logs({id: service.Id})"
>
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
>
<button
authorization="DockerServiceUpdate"
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.updateInProgress || isUpdating"
ng-click="forceUpdateService(service)"
button-spinner="state.updateInProgress"
ng-if="applicationState.endpoint.apiVersion >= 1.25"
>
<span ng-hide="state.updateInProgress" class="vertical-center">
<pr-icon icon="'refresh-cw'"></pr-icon>
Update the service</span
</td>
</tr>
<tr>
<td>Image</td>
<td>{{ service.Image }}</td>
</tr>
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
<td>
<div class="inline-flex items-center">
<div> Service webhook </div>
<portainer-tooltip
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
>
<span ng-show="state.updateInProgress">Update in progress...</span>
</button>
<button
authorization="DockerServiceUpdate"
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.rollbackInProgress || isUpdating"
ng-click="rollbackService(service)"
button-spinner="state.rollbackInProgress"
ng-if="applicationState.endpoint.apiVersion >= 1.25"
>
<span ng-hide="state.rollbackInProgress" class="vertical-center">
<pr-icon icon="'rotate-ccw'"></pr-icon>
Rollback the service</span
</portainer-tooltip>
</div>
</td>
<td>
<div class="flex flex-wrap items-center">
<por-switch-field
label-class="'!mr-0'"
checked="WebhookExists"
disabled="disabledWebhookButton(WebhookExists)"
on-change="(onWebhookChange)"
></por-switch-field>
<span ng-if="webhookURL">
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
Copy link
</button>
<span>
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</span>
</div>
</td>
</tr>
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
<td colspan="2">
<p class="small text-muted" authorization="DockerServiceUpdate">
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
><div class="flex flex-wrap gap-x-2 gap-y-1">
<a
authorization="DockerServiceLogs"
ng-if="applicationState.endpoint.apiVersion >= 1.3"
class="btn btn-primary btn-sm"
type="button"
ui-sref="docker.services.service.logs({id: service.Id})"
>
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
</button>
<button
authorization="DockerServiceDelete"
type="button"
class="btn btn-danger btn-sm !ml-0"
ng-disabled="state.deletionInProgress || isUpdating"
ng-click="removeService()"
button-spinner="state.deletionInProgress"
>
<span ng-hide="state.deletionInProgress" class="vertical-center">
<pr-icon icon="'trash-2'"></pr-icon>
Delete the service</span
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
>
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer authorization="DockerServiceUpdate">
<p class="small text-muted">
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
</p>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
>Apply changes</button
>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
<button
authorization="DockerServiceUpdate"
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.updateInProgress || isUpdating"
ng-click="forceUpdateService(service)"
button-spinner="state.updateInProgress"
ng-if="applicationState.endpoint.apiVersion >= 1.25"
>
<span ng-hide="state.updateInProgress" class="vertical-center">
<pr-icon icon="'refresh-cw'"></pr-icon>
Update the service</span
>
<span ng-show="state.updateInProgress">Update in progress...</span>
</button>
<button
authorization="DockerServiceUpdate"
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.rollbackInProgress || isUpdating"
ng-click="rollbackService(service)"
button-spinner="state.rollbackInProgress"
ng-if="applicationState.endpoint.apiVersion >= 1.25"
>
<span ng-hide="state.rollbackInProgress" class="vertical-center">
<pr-icon icon="'rotate-ccw'"></pr-icon>
Rollback the service</span
>
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
</button>
<button
authorization="DockerServiceDelete"
type="button"
class="btn btn-danger btn-sm !ml-0"
ng-disabled="state.deletionInProgress || isUpdating"
ng-click="removeService()"
button-spinner="state.deletionInProgress"
>
<span ng-hide="state.deletionInProgress" class="vertical-center">
<pr-icon icon="'trash-2'"></pr-icon>
Delete the service</span
>
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer authorization="DockerServiceUpdate">
<p class="small text-muted">
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
</p>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
>Apply changes</button
>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</rd-widget-footer>
</rd-widget>
</div>
<div class="col-lg-3 col-md-3 col-xs-3">
<rd-widget>
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
<rd-widget-body classes="no-padding">
<ul class="nav nav-pills nav-stacked">
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
<li><a href ng-click="goToItem('service-network-specs')">Network &amp; published ports</a></li>
<li><a href ng-click="goToItem('service-resources')">Resource limits &amp; reservations</a></li>
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
</ul>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="col-lg-3 col-md-3 col-xs-3">
<rd-widget>
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
<rd-widget-body classes="no-padding">
<ul class="nav nav-pills nav-stacked">
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
<li><a href ng-click="goToItem('service-network-specs')">Network &amp; published ports</a></li>
<li><a href ng-click="goToItem('service-resources')">Resource limits &amp; reservations</a></li>
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
</ul>
</rd-widget-body>
</rd-widget>
<!-- access-control-panel -->
<access-control-panel
ng-if="service"
resource-id="service.Id"
resource-control="service.ResourceControl"
resource-type="resourceType"
on-update-success="(onUpdateResourceControlSuccess)"
environment-id="endpoint.Id"
>
</access-control-panel>
<!-- !access-control-panel -->
<div class="row">
<hr />
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="container-specs">Container specification</h3>
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
</div>
</div>
<div class="row">
<hr />
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-network-specs">Networks &amp; ports</h3>
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
<docker-service-ports-mapping-field
id="service-published-ports"
class="block padding-top"
values="formValues.ports"
on-change="(onChangePorts)"
has-changes="hasChanges(service, ['Ports'])"
on-reset="(onResetPorts)"
on-submit="(onSubmit)"
></docker-service-ports-mapping-field>
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
</div>
</div>
<div class="row">
<hr />
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-specs">Service specification</h3>
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
<div
id="service-placement-preferences"
ng-if="applicationState.endpoint.apiVersion >= 1.3"
class="padding-top"
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
></div>
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
</div>
</div>
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
</div>
<!-- access-control-panel -->
<access-control-panel
ng-if="service"
resource-id="service.Id"
resource-control="service.ResourceControl"
resource-type="resourceType"
on-update-success="(onUpdateResourceControlSuccess)"
environment-id="endpoint.Id"
>
</access-control-panel>
<!-- !access-control-panel -->
<div class="row">
<hr />
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="container-specs">Container specification</h3>
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
</div>
</div>
<div class="row">
<hr />
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-network-specs">Networks &amp; ports</h3>
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
<docker-service-ports-mapping-field
id="service-published-ports"
class="block padding-top"
values="formValues.ports"
on-change="(onChangePorts)"
has-changes="hasChanges(service, ['Ports'])"
on-reset="(onResetPorts)"
on-submit="(onSubmit)"
></docker-service-ports-mapping-field>
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
</div>
</div>
<div class="row">
<hr />
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-specs">Service specification</h3>
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
<div
id="service-placement-preferences"
ng-if="applicationState.endpoint.apiVersion >= 1.3"
class="padding-top"
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
></div>
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
</div>
</div>
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
@@ -731,6 +731,7 @@ angular.module('portainer.docker').controller('ServiceController', [
};
function initView() {
$scope.isLoading = true;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
@@ -855,6 +856,9 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.secrets = [];
$scope.configs = [];
Notifications.error('Failure', err, 'Unable to retrieve service details');
})
.finally(() => {
$scope.isLoading = false;
});
}
@@ -1,9 +0,0 @@
.helm-template-item-details {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.helm-template-item-details .helm-template-item-details-sub {
width: 100%;
}
@@ -1,40 +0,0 @@
<!-- helm chart -->
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)" role="listitem">
<div class="blocklist-item-box">
<!-- helmchart-image -->
<span class="shrink-0">
<fallback-image src="$ctrl.model.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'blocklist-item-logo h-16 w-auto'" size="'3xl'"></fallback-image>
</span>
<!-- helmchart-details -->
<div class="col-sm-12 helm-template-item-details">
<!-- blocklist-item-line1 -->
<div class="blocklist-item-line">
<span>
<span class="blocklist-item-title">
{{ $ctrl.model.name }}
</span>
<span class="space-left blocklist-item-subtitle">
<span class="vertical-center">
<pr-icon icon="'svg-helm'" mode="'primary'"></pr-icon>
</span>
<span> Helm </span>
</span>
</span>
</div>
<!-- !blocklist-item-line1 -->
<span class="blocklist-item-actions" ng-transclude="actions"></span>
<!-- blocklist-item-line2 -->
<div class="blocklist-item-line helm-template-item-details-sub">
<span class="blocklist-item-desc">
{{ $ctrl.model.description }}
</span>
<span class="small text-muted" ng-if="$ctrl.model.annotations.category">
{{ $ctrl.model.annotations.category }}
</span>
</div>
<!-- !blocklist-item-line2 -->
</div>
<!-- !helmchart-details -->
</div>
<!-- !helm chart -->
</div>
@@ -1,17 +0,0 @@
import angular from 'angular';
import './helm-templates-list-item.css';
import { HelmIcon } from '../../HelmIcon';
angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
templateUrl: './helm-templates-list-item.html',
bindings: {
model: '<',
onSelect: '<',
},
transclude: {
actions: '?templateItemActions',
},
controller() {
this.fallbackIcon = HelmIcon;
},
});
@@ -1,43 +0,0 @@
export default class HelmTemplatesListController {
/* @ngInject */
constructor($async, $scope, HelmService, Notifications) {
this.$async = $async;
this.$scope = $scope;
this.HelmService = HelmService;
this.Notifications = Notifications;
this.state = {
textFilter: '',
selectedCategory: '',
categories: [],
};
this.updateCategories = this.updateCategories.bind(this);
this.onCategoryChange = this.onCategoryChange.bind(this);
}
async updateCategories() {
try {
const annotationCategories = this.charts
.map((t) => t.annotations) // get annotations
.filter((a) => a) // filter out undefined/nulls
.map((c) => c.category); // get annotation category
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
this.state.categories = availableCategories.map((cat) => ({ label: cat, value: cat }));
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm charts categories');
}
}
onCategoryChange(value) {
return this.$scope.$evalAsync(() => {
this.state.selectedCategory = value || '';
});
}
$onChanges() {
if (this.charts.length > 0) {
this.updateCategories();
}
}
}
@@ -1,85 +0,0 @@
<section class="datatable" aria-label="Helm charts">
<div class="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div class="toolBarTitle vertical-center"> {{ $ctrl.titleText }} </div>
<div class="searchBar vertical-center !mr-0">
<pr-icon icon="'search'" class="searchIcon"></pr-icon>
<input
type="text"
data-cy="helm-templates-search"
class="searchInput"
ng-model="$ctrl.state.textFilter"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
aria-label="Search input"
/>
</div>
<div class="w-1/5">
<por-select
placeholder="'Select a category'"
value="$ctrl.state.selectedCategory"
options="$ctrl.state.categories"
on-change="($ctrl.onCategoryChange)"
is-clearable="true"
bind-to-body="true"
></por-select>
</div>
</div>
<div class="w-full">
<div class="small text-muted mb-2"
>Select the Helm chart to use. Bring further Helm charts into your selection list via
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
>
<div class="w-full">
<div class="small text-muted mb-2"
>Select the Helm chart to use. Bring further Helm charts into your selection list via
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
>
<div class="relative flex w-fit gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3 mt-2">
<div class="mt-0.5 shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-lightbulb h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6"
>
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
<path d="M9 18h6"></path>
<path d="M10 22h4"></path>
</svg>
</div>
<div>
<p class="align-middle text-[0.9em] font-medium pr-10 mb-2">Disclaimer</p>
<div class="small">
At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.<br />
If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
<a href="https://bit.ly/3WVkayl" target="_blank" rel="noopener noreferrer">please get in touch</a>.
</div>
</div>
</div>
</div>
</div>
<div class="blocklist !px-0" role="list">
<helm-templates-list-item
ng-repeat="chart in allCharts = ($ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory)"
model="chart"
type-label="helm"
on-select="($ctrl.selectAction)"
>
</helm-templates-list-item>
<div ng-if="!allCharts.length" class="text-muted small mt-4"> No Helm charts found </div>
<div ng-if="$ctrl.loading" class="text-muted text-center">
Loading...
<div class="text-muted text-center"> Initial download of Helm charts can take a few minutes </div>
</div>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
</div>
</section>
@@ -1,14 +0,0 @@
import angular from 'angular';
import controller from './helm-templates-list.controller';
angular.module('portainer.kubernetes').component('helmTemplatesList', {
templateUrl: './helm-templates-list.html',
controller,
bindings: {
loading: '<',
titleText: '@',
charts: '<',
tableKey: '@',
selectAction: '<',
},
});
@@ -101,7 +101,7 @@
<div class="row" ng-if="!$ctrl.state.chart">
<div class="col-sm-12 p-0">
<helm-templates-list
title-text="Helm chart"
title-text="'Helm chart'"
charts="$ctrl.state.charts"
table-key="$ctrl.state.charts"
select-action="$ctrl.selectHelmChart"
+15
View File
@@ -58,6 +58,8 @@ import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/co
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable';
import { HelmTemplatesList } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesList';
import { HelmTemplatesListItem } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem';
import { namespacesModule } from './namespaces';
import { clusterManagementModule } from './clusterManagement';
@@ -205,6 +207,19 @@ export const ngModule = angular
'tableTitle',
'dataCy',
])
)
.component(
'helmTemplatesList',
r2a(withUIRouter(withCurrentUser(HelmTemplatesList)), [
'loading',
'titleText',
'charts',
'selectAction',
])
)
.component(
'helmTemplatesListItem',
r2a(HelmTemplatesListItem, ['model', 'onSelect', 'actions'])
);
export const componentsModule = ngModule.name;
+10
View File
@@ -22,6 +22,8 @@ import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -78,6 +80,14 @@ export const viewsModule = angular
[]
)
)
.component(
'kubernetesHelmApplicationView',
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
)
.component(
'kubernetesClusterView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
)
.component(
'kubernetesConfigureView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
@@ -1,52 +0,0 @@
import PortainerError from 'Portainer/error';
export default class KubernetesHelmApplicationController {
/* @ngInject */
constructor($async, $state, Authentication, Notifications, HelmService) {
this.$async = $async;
this.$state = $state;
this.Authentication = Authentication;
this.Notifications = Notifications;
this.HelmService = HelmService;
}
/**
* APPLICATION
*/
async getHelmApplication() {
try {
this.state.dataLoading = true;
const releases = await this.HelmService.listReleases(this.endpoint.Id, { filter: `^${this.state.params.name}$`, namespace: this.state.params.namespace });
if (releases.length > 0) {
this.state.release = releases[0];
} else {
throw new PortainerError(`Release ${this.state.params.name} not found`);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve helm application details');
} finally {
this.state.dataLoading = false;
}
}
$onInit() {
return this.$async(async () => {
this.state = {
dataLoading: true,
viewReady: false,
params: {
name: this.$state.params.name,
namespace: this.$state.params.namespace,
},
release: {
name: undefined,
chart: undefined,
app_version: undefined,
},
};
await this.getHelmApplication();
this.state.viewReady = true;
});
}
}
@@ -1,5 +0,0 @@
.release-table tr {
display: grid;
grid-auto-flow: column;
grid-template-columns: 1fr 4fr;
}
@@ -1,50 +0,0 @@
<page-header
ng-if="$ctrl.state.viewReady"
title="'Helm details'"
breadcrumbs="[{label:'Applications', link:'kubernetes.applications'}, $ctrl.state.params.name]"
reload="true"
></page-header>
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="$ctrl.state.viewReady">
<div class="row">
<div class="col-sm-12">
<rd-widget>
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 p-5">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="'svg-helm'"></pr-icon>
</div>
Release
</div>
</div>
<rd-widget-body>
<table class="table">
<tbody class="release-table">
<tr>
<td class="vertical-center">Name</td>
<td class="vertical-center !p-2" data-cy="k8sAppDetail-appName">
{{ $ctrl.state.release.name }}
</td>
</tr>
<tr>
<td class="vertical-center">Chart</td>
<td class="vertical-center !p-2">
{{ $ctrl.state.release.chart }}
</td>
</tr>
<tr>
<td class="vertical-center">App version</td>
<td class="vertical-center !p-2">
{{ $ctrl.state.release.app_version }}
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
@@ -1,11 +0,0 @@
import angular from 'angular';
import controller from './helm.controller';
import './helm.css';
angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
templateUrl: './helm.html',
controller,
bindings: {
endpoint: '<',
},
});
-33
View File
@@ -1,33 +0,0 @@
<page-header ng-if="ctrl.state.viewReady" title="'Cluster'" breadcrumbs="['Cluster information']" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div class="row" ng-if="ctrl.isAdmin">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<!-- resource-reservation -->
<form class="form-horizontal" ng-if="ctrl.resourceReservation">
<kubernetes-resource-reservation
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
cpu-reservation="ctrl.resourceReservation.CPU"
cpu-limit="ctrl.CPULimit"
memory-reservation="ctrl.resourceReservation.Memory"
memory-limit="ctrl.MemoryLimit"
display-usage="ctrl.hasResourceUsageAccess()"
cpu-usage="ctrl.resourceUsage.CPU"
memory-usage="ctrl.resourceUsage.Memory"
>
</kubernetes-resource-reservation>
</form>
<!-- !resource-reservation -->
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<kube-nodes-datatable></kube-nodes-datatable>
</div>
</div>
-8
View File
@@ -1,8 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesClusterView', {
templateUrl: './cluster.html',
controller: 'KubernetesClusterController',
controllerAs: 'ctrl',
bindings: {
endpoint: '<',
},
});
@@ -1,141 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import filesizeParser from 'filesize-parser';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
import { getMetricsForAllNodes, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts';
class KubernetesClusterController {
/* @ngInject */
constructor($async, $state, Notifications, LocalStorage, Authentication, KubernetesNodeService, KubernetesApplicationService, KubernetesEndpointService, EndpointService) {
this.$async = $async;
this.$state = $state;
this.Authentication = Authentication;
this.Notifications = Notifications;
this.LocalStorage = LocalStorage;
this.KubernetesNodeService = KubernetesNodeService;
this.KubernetesApplicationService = KubernetesApplicationService;
this.KubernetesEndpointService = KubernetesEndpointService;
this.EndpointService = EndpointService;
this.onInit = this.onInit.bind(this);
this.getNodes = this.getNodes.bind(this);
this.getNodesAsync = this.getNodesAsync.bind(this);
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
this.hasResourceUsageAccess = this.hasResourceUsageAccess.bind(this);
}
async getEndpointsAsync() {
try {
const endpoints = await this.KubernetesEndpointService.get();
const systemEndpoints = _.filter(endpoints, { Namespace: 'kube-system' });
this.systemEndpoints = _.filter(systemEndpoints, (ep) => ep.HolderIdentity);
const kubernetesEndpoint = _.find(endpoints, { Name: 'kubernetes' });
if (kubernetesEndpoint && kubernetesEndpoint.Subsets) {
const ips = _.flatten(_.map(kubernetesEndpoint.Subsets, 'Ips'));
_.forEach(this.nodes, (node) => {
node.Api = _.includes(ips, node.IPAddress);
});
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve environments');
}
}
getEndpoints() {
return this.$async(this.getEndpointsAsync);
}
async getNodesAsync() {
try {
const nodes = await this.KubernetesNodeService.get();
_.forEach(nodes, (node) => (node.Memory = filesizeParser(node.Memory)));
this.nodes = nodes;
this.CPULimit = _.reduce(this.nodes, (acc, node) => node.CPU + acc, 0);
this.CPULimit = Math.round(this.CPULimit * 10000) / 10000;
this.MemoryLimit = _.reduce(this.nodes, (acc, node) => KubernetesResourceReservationHelper.megaBytesValue(node.Memory) + acc, 0);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve nodes');
}
}
getNodes() {
return this.$async(this.getNodesAsync);
}
async getApplicationsAsync() {
try {
this.state.applicationsLoading = true;
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
this.resourceReservation = new KubernetesResourceReservation();
// Using same rounding method as CPULimit in getNodesAsync for consistency
this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest * 10000) / 10000;
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest);
if (this.hasResourceUsageAccess()) {
await this.getResourceUsage(this.endpoint.Id);
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
} finally {
this.state.applicationsLoading = false;
}
}
getApplications() {
return this.$async(this.getApplicationsAsync);
}
async getResourceUsage(endpointId) {
try {
const nodeMetrics = await getMetricsForAllNodes(endpointId);
const resourceUsageList = nodeMetrics.items.map((i) => i.usage);
const clusterResourceUsage = resourceUsageList.reduce((total, u) => {
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
return total;
}, new KubernetesResourceReservation());
this.resourceUsage = clusterResourceUsage;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve cluster resource usage');
}
}
/**
* Check if resource usage stats can be displayed
* @returns {boolean}
*/
hasResourceUsageAccess() {
return this.isAdmin && this.state.useServerMetrics;
}
async onInit() {
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
this.isAdmin = this.Authentication.isAdmin();
const useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
this.state = {
applicationsLoading: true,
viewReady: false,
useServerMetrics,
};
await this.getNodes();
if (this.isAdmin) {
await Promise.allSettled([this.getEndpoints(), this.getApplicationsAsync()]);
}
this.state.viewReady = true;
}
$onInit() {
return this.$async(this.onInit);
}
}
export default KubernetesClusterController;
angular.module('portainer.kubernetes').controller('KubernetesClusterController', KubernetesClusterController);
@@ -7,7 +7,7 @@ import { gitFormRefField } from './git-form-ref-field';
export const gitFormModule = angular
.module('portainer.app.components.git-form', [])
.component('gitForm', gitForm)
.component('gitForm', gitForm) // kube deploy + docker stack create
.component('gitFormAuthFieldset', gitFormAuthFieldset)
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdate)
.component('gitFormRefField', gitFormRefField).name;
@@ -29,6 +29,7 @@ export const gitFormModule = angular
'webhookId',
'webhooksDocs',
'createdFromCustomTemplateId',
'isAutoUpdateVisible',
])
)
.component(
+36
View File
@@ -1,4 +1,5 @@
import _ from 'lodash';
import { QueryObserverResult } from '@tanstack/react-query';
import { Team } from '@/react/portainer/users/teams/types';
import { Role, User, UserId } from '@/portainer/users/types';
@@ -134,3 +135,38 @@ export function createMockEnvironment(): Environment {
},
};
}
export function createMockQueryResult<TData, TError = unknown>(
data: TData,
overrides?: Partial<QueryObserverResult<TData, TError>>
) {
const defaultResult = {
data,
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
failureCount: 0,
errorUpdateCount: 0,
failureReason: null,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isLoading: false,
isLoadingError: false,
isPaused: false,
isPlaceholderData: false,
isPreviousData: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isSuccess: true,
refetch: async () => defaultResult,
remove: () => {},
status: 'success',
fetchStatus: 'idle',
};
return { ...defaultResult, ...overrides };
}
@@ -1,4 +1,4 @@
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import { PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { Tooltip } from '@@/Tip/Tooltip';
@@ -10,10 +10,11 @@ export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
export interface Props {
inputId?: string;
dataCy?: string;
label: ReactNode;
size?: Size;
tooltip?: ComponentProps<typeof Tooltip>['message'];
setTooltipHtmlMessage?: ComponentProps<typeof Tooltip>['setHtmlMessage'];
tooltip?: ReactNode;
setTooltipHtmlMessage?: boolean;
children: ReactNode;
errors?: ReactNode;
required?: boolean;
@@ -24,6 +25,7 @@ export interface Props {
export function FormControl({
inputId,
dataCy,
label,
size = 'small',
tooltip = '',
@@ -42,6 +44,7 @@ export function FormControl({
'form-group',
'after:clear-both after:table after:content-[""]' // to fix issues with float
)}
data-cy={dataCy}
>
<label
htmlFor={inputId}
@@ -56,10 +59,15 @@ export function FormControl({
)}
</label>
<div className={sizeClassChildren(size)}>
{isLoading && <InlineLoader>{loadingText}</InlineLoader>}
<div className={clsx('flex flex-col', sizeClassChildren(size))}>
{isLoading && (
// 34px height to reduce layout shift when loading is complete
<div className="h-[34px] flex items-center">
<InlineLoader>{loadingText}</InlineLoader>
</div>
)}
{!isLoading && children}
{errors && <FormError>{errors}</FormError>}
{!!errors && !isLoading && <FormError>{errors}</FormError>}
</div>
</div>
);
@@ -1,5 +1,4 @@
import { CellContext, Column } from '@tanstack/react-table';
import { useSref } from '@uirouter/react';
import { truncate } from '@/portainer/filters/filters';
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
@@ -7,6 +6,7 @@ import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
import { MultipleSelectionFilter } from '@@/datatables/Filter';
import { UnusedBadge } from '@@/Badge/UnusedBadge';
import { Link } from '@@/Link';
import { columnHelper } from './helper';
@@ -62,22 +62,20 @@ function FilterByUsage<TData extends { Used: boolean }>({
}
function Cell({
getValue,
row: { original: image },
row: { original: item },
}: CellContext<ImagesListResponse, string>) {
const name = getValue();
const linkProps = useSref('.image', {
id: image.id,
imageId: image.id,
});
return (
<div className="flex gap-1">
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
{truncate(name, 40)}
</a>
{!image.used && <UnusedBadge />}
</div>
<>
<Link
to=".image"
params={{ id: item.id, nodeName: item.nodeName }}
title={item.id}
data-cy={`image-link-${item.id}`}
className="mr-2"
>
{truncate(item.id, 40)}
</Link>
{!item.used && <UnusedBadge />}
</>
);
}
+18
View File
@@ -16,6 +16,24 @@ describe('fullURIIntoRepoAndTag', () => {
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
});
it('splits image-repo:port/image correctly', () => {
const result = fullURIIntoRepoAndTag('registry.example.com:5000/my-image');
expect(result).toEqual({
repo: 'registry.example.com:5000/my-image',
tag: 'latest',
});
});
it('splits image-repo:port/image:tag correctly', () => {
const result = fullURIIntoRepoAndTag(
'registry.example.com:5000/my-image:v1'
);
expect(result).toEqual({
repo: 'registry.example.com:5000/my-image',
tag: 'v1',
});
});
it('splits registry:port/image-repo:tag correctly', () => {
const result = fullURIIntoRepoAndTag(
'registry.example.com:5000/my-image:v2.1'
+9
View File
@@ -121,9 +121,18 @@ export function fullURIIntoRepoAndTag(fullURI: string) {
// - registry/image-repo:tag
// - image-repo:tag
// - registry:port/image-repo:tag
// - localhost:5000/nginx
// buildImageFullURIFromModel always gives a tag (defaulting to 'latest'), so the tag is always present after the last ':'
const parts = fullURI.split(':');
const tag = parts.pop() || 'latest';
// handle the case of a repo with a non standard port
if (tag.includes('/')) {
return {
repo: fullURI,
tag: 'latest',
};
}
const repo = parts.join(':');
return {
repo,
@@ -139,6 +139,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
/>
{isBE && (
@@ -4,6 +4,7 @@ import { FormikErrors } from 'formik';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { BoxSelector } from '@@/BoxSelector';
import { WebEditorForm } from '@@/WebEditorForm';
@@ -109,6 +110,7 @@ export function KubeManifestForm({
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
/>
)}
</>
@@ -0,0 +1,214 @@
import { render, screen, within } from '@testing-library/react';
import { HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { server, http } from '@/setup-tests/server';
import {
createMockEnvironment,
createMockQueryResult,
} from '@/react-tools/test-mocks';
import { ClusterResourceReservation } from './ClusterResourceReservation';
const mockUseAuthorizations = vi.fn();
const mockUseEnvironmentId = vi.fn(() => 3);
const mockUseCurrentEnvironment = vi.fn();
// Set up mock implementations for hooks
vi.mock('@/react/hooks/useUser', () => ({
useAuthorizations: () => mockUseAuthorizations(),
}));
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
vi.mock('@/react/hooks/useCurrentEnvironment', () => ({
useCurrentEnvironment: () => mockUseCurrentEnvironment(),
}));
function renderComponent() {
const Wrapped = withTestQueryProvider(ClusterResourceReservation);
return render(<Wrapped />);
}
describe('ClusterResourceReservation', () => {
beforeEach(() => {
// Set the return values for the hooks
mockUseAuthorizations.mockReturnValue({
authorized: true,
isLoading: false,
});
mockUseEnvironmentId.mockReturnValue(3);
const mockEnvironment = createMockEnvironment();
mockEnvironment.Kubernetes.Configuration.UseServerMetrics = true;
mockUseCurrentEnvironment.mockReturnValue(
createMockQueryResult(mockEnvironment)
);
// Setup default mock responses
server.use(
http.get('/api/endpoints/3/kubernetes/api/v1/nodes', () =>
HttpResponse.json({
items: [
{
status: {
allocatable: {
cpu: '4',
memory: '8Gi',
},
},
},
],
})
),
http.get('/api/kubernetes/3/metrics/nodes', () =>
HttpResponse.json({
items: [
{
usage: {
cpu: '2',
memory: '4Gi',
},
},
],
})
),
http.get('/api/kubernetes/3/metrics/applications_resources', () =>
HttpResponse.json({
CpuRequest: 1000,
MemoryRequest: '2Gi',
})
)
);
});
it('should display resource limits, reservations and usage when all APIs respond successfully', async () => {
renderComponent();
expect(
await within(await screen.findByTestId('memory-reservation')).findByText(
'2147 / 8589 MB - 25%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('memory-usage')).findByText(
'4294 / 8589 MB - 50%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('cpu-reservation')).findByText(
'1 / 4 - 25%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('cpu-usage')).findByText(
'2 / 4 - 50%'
)
).toBeVisible();
});
it('should not display resource usage if user does not have K8sClusterNodeR authorization', async () => {
mockUseAuthorizations.mockReturnValue({
authorized: false,
isLoading: false,
});
renderComponent();
// Should only show reservation bars
expect(
await within(await screen.findByTestId('memory-reservation')).findByText(
'2147 / 8589 MB - 25%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('cpu-reservation')).findByText(
'1 / 4 - 25%'
)
).toBeVisible();
// Usage bars should not be present
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
});
it('should not display resource usage if metrics server is not enabled', async () => {
const disabledMetricsEnvironment = createMockEnvironment();
disabledMetricsEnvironment.Kubernetes.Configuration.UseServerMetrics =
false;
mockUseCurrentEnvironment.mockReturnValue(
createMockQueryResult(disabledMetricsEnvironment)
);
renderComponent();
// Should only show reservation bars
expect(
await within(await screen.findByTestId('memory-reservation')).findByText(
'2147 / 8589 MB - 25%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('cpu-reservation')).findByText(
'1 / 4 - 25%'
)
).toBeVisible();
// Usage bars should not be present
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
});
it('should display warning if metrics server is enabled but usage query fails', async () => {
server.use(
http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error())
);
// Mock console.error so test logs are not polluted
vi.spyOn(console, 'error').mockImplementation(() => {});
renderComponent();
expect(
await within(await screen.findByTestId('memory-reservation')).findByText(
'2147 / 8589 MB - 25%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('memory-usage')).findByText(
'0 / 8589 MB - 0%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('cpu-reservation')).findByText(
'1 / 4 - 25%'
)
).toBeVisible();
expect(
await within(await screen.findByTestId('cpu-usage')).findByText(
'0 / 4 - 0%'
)
).toBeVisible();
// Should show the warning message
expect(
await screen.findByText(
/Resource usage is not currently available as Metrics Server is not responding/
)
).toBeVisible();
// Restore console.error
vi.spyOn(console, 'error').mockRestore();
});
});
@@ -0,0 +1,39 @@
import { Widget, WidgetBody } from '@/react/components/Widget';
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
import { useClusterResourceReservationData } from './useClusterResourceReservationData';
export function ClusterResourceReservation() {
// Load all data required for this component
const {
cpuLimit,
memoryLimit,
isLoading,
displayResourceUsage,
resourceUsage,
resourceReservation,
displayWarning,
} = useClusterResourceReservationData();
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody>
<ResourceReservation
isLoading={isLoading}
displayResourceUsage={displayResourceUsage}
resourceReservation={resourceReservation}
resourceUsage={resourceUsage}
cpuLimit={cpuLimit}
memoryLimit={memoryLimit}
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
displayWarning={displayWarning}
warningMessage="Resource usage is not currently available as Metrics Server is not responding. If you've recently upgraded, Metrics Server may take a while to restart, so please check back shortly."
/>
</WidgetBody>
</Widget>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { PageHeader } from '@/react/components/PageHeader';
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
import { ClusterResourceReservation } from './ClusterResourceReservation';
export function ClusterView() {
const { data: environment } = useCurrentEnvironment();
return (
<>
<PageHeader
title="Cluster"
breadcrumbs={[
{ label: 'Environments', link: 'portainer.endpoints' },
{
label: environment?.Name || '',
link: 'portainer.endpoints.endpoint',
linkParams: { id: environment?.Id },
},
'Cluster information',
]}
reload
/>
<ClusterResourceReservation />
<div className="row">
<NodesDatatable />
</div>
</>
);
}
@@ -0,0 +1 @@
export { ClusterView } from './ClusterView';
@@ -0,0 +1,3 @@
export * from './useClusterResourceLimitsQuery';
export * from './useClusterResourceReservationQuery';
export * from './useClusterResourceUsageQuery';
@@ -0,0 +1,49 @@
import { round, reduce } from 'lodash';
import filesizeParser from 'filesize-parser';
import { useQuery } from '@tanstack/react-query';
import { Node } from 'kubernetes-types/core/v1';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query';
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
import { parseCpu } from '@/react/kubernetes/utils';
import { getNodes } from '@/react/kubernetes/cluster/HomeView/nodes.service';
export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
return useQuery(
[environmentId, 'clusterResourceLimits'],
async () => getNodes(environmentId),
{
...withGlobalError('Unable to retrieve resource limit data', 'Failure'),
enabled: !!environmentId,
select: aggregateResourceLimits,
}
);
}
/**
* Processes node data to calculate total CPU and memory limits for the cluster
* and sets the state for memory limit in MB and CPU limit rounded to 3 decimal places.
*/
function aggregateResourceLimits(nodes: Node[]) {
const processedNodes = nodes.map((node) => ({
...node,
memory: filesizeParser(node.status?.allocatable?.memory ?? ''),
cpu: parseCpu(node.status?.allocatable?.cpu ?? ''),
}));
return {
nodes: processedNodes,
memoryLimit: reduce(
processedNodes,
(acc, node) =>
KubernetesResourceReservationHelper.megaBytesValue(node.memory || 0) +
acc,
0
),
cpuLimit: round(
reduce(processedNodes, (acc, node) => (node.cpu || 0) + acc, 0),
3
),
};
}
@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { Node } from 'kubernetes-types/core/v1';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics';
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
export function useClusterResourceReservationQuery(
environmentId: EnvironmentId,
nodes: Node[]
) {
return useQuery(
[environmentId, 'clusterResourceReservation'],
() => getTotalResourcesForAllApplications(environmentId),
{
enabled: !!environmentId && nodes.length > 0,
select: (data) => ({
cpu: data.CpuRequest / 1000,
memory: KubernetesResourceReservationHelper.megaBytesValue(
data.MemoryRequest
),
}),
}
);
}
@@ -0,0 +1,46 @@
import { useQuery } from '@tanstack/react-query';
import { Node } from 'kubernetes-types/core/v1';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { getMetricsForAllNodes } from '@/react/kubernetes/metrics/metrics';
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
import { withGlobalError } from '@/react-tools/react-query';
import { NodeMetrics } from '@/react/kubernetes/metrics/types';
export function useClusterResourceUsageQuery(
environmentId: EnvironmentId,
serverMetricsEnabled: boolean,
authorized: boolean,
nodes: Node[]
) {
return useQuery(
[environmentId, 'clusterResourceUsage'],
() => getMetricsForAllNodes(environmentId),
{
enabled:
authorized &&
serverMetricsEnabled &&
!!environmentId &&
nodes.length > 0,
select: aggregateResourceUsage,
...withGlobalError('Unable to retrieve resource usage data.', 'Failure'),
}
);
}
function aggregateResourceUsage(data: NodeMetrics) {
return data.items.reduce(
(total, item) => ({
cpu:
total.cpu +
KubernetesResourceReservationHelper.parseCPU(item.usage.cpu),
memory:
total.memory +
KubernetesResourceReservationHelper.megaBytesValue(item.usage.memory),
}),
{
cpu: 0,
memory: 0,
}
);
}
@@ -0,0 +1,72 @@
import { useAuthorizations } from '@/react/hooks/useUser';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { getSafeValue } from '@/react/kubernetes/utils';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import {
useClusterResourceLimitsQuery,
useClusterResourceReservationQuery,
useClusterResourceUsageQuery,
} from './queries';
export function useClusterResourceReservationData() {
const { data: environment } = useCurrentEnvironment();
const environmentId = useEnvironmentId();
// Check if server metrics is enabled
const serverMetricsEnabled =
environment?.Kubernetes?.Configuration?.UseServerMetrics || false;
// User needs to have K8sClusterNodeR authorization to view resource usage data
const { authorized: hasK8sClusterNodeR } = useAuthorizations(
['K8sClusterNodeR'],
undefined,
true
);
// Get resource limits for the cluster
const { data: resourceLimits, isLoading: isResourceLimitLoading } =
useClusterResourceLimitsQuery(environmentId);
// Get resource reservation info for the cluster
const {
data: resourceReservation,
isFetching: isResourceReservationLoading,
} = useClusterResourceReservationQuery(
environmentId,
resourceLimits?.nodes || []
);
// Get resource usage info for the cluster
const {
data: resourceUsage,
isFetching: isResourceUsageLoading,
isError: isResourceUsageError,
} = useClusterResourceUsageQuery(
environmentId,
serverMetricsEnabled,
hasK8sClusterNodeR,
resourceLimits?.nodes || []
);
return {
memoryLimit: getSafeValue(resourceLimits?.memoryLimit || 0),
cpuLimit: getSafeValue(resourceLimits?.cpuLimit || 0),
displayResourceUsage: hasK8sClusterNodeR && serverMetricsEnabled,
resourceUsage: {
cpu: getSafeValue(resourceUsage?.cpu || 0),
memory: getSafeValue(resourceUsage?.memory || 0),
},
resourceReservation: {
cpu: getSafeValue(resourceReservation?.cpu || 0),
memory: getSafeValue(resourceReservation?.memory || 0),
},
isLoading:
isResourceLimitLoading ||
isResourceReservationLoading ||
isResourceUsageLoading,
// Display warning if server metrics isn't responding but should be
displayWarning:
hasK8sClusterNodeR && serverMetricsEnabled && isResourceUsageError,
};
}
@@ -45,7 +45,7 @@ export function useNodeQuery(environmentId: EnvironmentId, nodeName: string) {
}
// getNodes is used to get a list of nodes using the kubernetes API
async function getNodes(environmentId: EnvironmentId) {
export async function getNodes(environmentId: EnvironmentId) {
try {
const { data: nodeList } = await axios.get<NodeList>(
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
@@ -0,0 +1,129 @@
import { round } from 'lodash';
import { AlertTriangle } from 'lucide-react';
import { FormSectionTitle } from '@/react/components/form-components/FormSectionTitle';
import { TextTip } from '@/react/components/Tip/TextTip';
import { ResourceUsageItem } from '@/react/kubernetes/components/ResourceUsageItem';
import { getPercentageString, getSafeValue } from '@/react/kubernetes/utils';
import { Icon } from '@@/Icon';
interface ResourceMetrics {
cpu: number;
memory: number;
}
interface Props {
displayResourceUsage: boolean;
resourceReservation: ResourceMetrics;
resourceUsage: ResourceMetrics;
cpuLimit: number;
memoryLimit: number;
description: string;
isLoading?: boolean;
title?: string;
displayWarning?: boolean;
warningMessage?: string;
}
export function ResourceReservation({
displayResourceUsage,
resourceReservation,
resourceUsage,
cpuLimit,
memoryLimit,
description,
title = 'Resource reservation',
isLoading = false,
displayWarning = false,
warningMessage = '',
}: Props) {
const memoryReservationAnnotation = `${getSafeValue(
resourceReservation.memory
)} / ${memoryLimit} MB ${getPercentageString(
resourceReservation.memory,
memoryLimit
)}`;
const memoryUsageAnnotation = `${getSafeValue(
resourceUsage.memory
)} / ${memoryLimit} MB ${getPercentageString(
resourceUsage.memory,
memoryLimit
)}`;
const cpuReservationAnnotation = `${round(
getSafeValue(resourceReservation.cpu),
2
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
resourceReservation.cpu,
cpuLimit
)}`;
const cpuUsageAnnotation = `${round(
getSafeValue(resourceUsage.cpu),
2
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
resourceUsage.cpu,
cpuLimit
)}`;
return (
<>
<FormSectionTitle>{title}</FormSectionTitle>
<TextTip color="blue" className="mb-2">
{description}
</TextTip>
<div className="form-horizontal">
{memoryLimit > 0 && (
<ResourceUsageItem
value={resourceReservation.memory}
total={memoryLimit}
label="Memory reservation"
annotation={memoryReservationAnnotation}
isLoading={isLoading}
dataCy="memory-reservation"
/>
)}
{displayResourceUsage && memoryLimit > 0 && (
<ResourceUsageItem
value={resourceUsage.memory}
total={memoryLimit}
label="Memory usage"
annotation={memoryUsageAnnotation}
isLoading={isLoading}
dataCy="memory-usage"
/>
)}
{cpuLimit > 0 && (
<ResourceUsageItem
value={resourceReservation.cpu}
total={cpuLimit}
label="CPU reservation"
annotation={cpuReservationAnnotation}
isLoading={isLoading}
dataCy="cpu-reservation"
/>
)}
{displayResourceUsage && cpuLimit > 0 && (
<ResourceUsageItem
value={resourceUsage.cpu}
total={cpuLimit}
label="CPU usage"
annotation={cpuUsageAnnotation}
isLoading={isLoading}
dataCy="cpu-usage"
/>
)}
{displayWarning && (
<div className="form-group">
<span className="col-sm-12 text-warning small vertical-center">
<Icon icon={AlertTriangle} mode="warning" />
{warningMessage}
</span>
</div>
)}
</div>
</>
);
}
@@ -1,11 +1,13 @@
import { ProgressBar } from '@@/ProgressBar';
import { FormControl } from '@@/form-components/FormControl';
import { ProgressBar } from '@@/ProgressBar';
interface ResourceUsageItemProps {
value: number;
total: number;
annotation?: React.ReactNode;
label: string;
isLoading?: boolean;
dataCy?: string;
}
export function ResourceUsageItem({
@@ -13,9 +15,16 @@ export function ResourceUsageItem({
total,
annotation,
label,
isLoading = false,
dataCy,
}: ResourceUsageItemProps) {
return (
<FormControl label={label}>
<FormControl
label={label}
isLoading={isLoading}
className={isLoading ? 'mb-1.5' : ''}
dataCy={dataCy}
>
<div className="flex items-center gap-2 mt-1">
<ProgressBar
steps={[
@@ -0,0 +1,119 @@
import { render, screen } from '@testing-library/react';
import { HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { server, http } from '@/setup-tests/server';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { HelmApplicationView } from './HelmApplicationView';
// Mock the necessary hooks and dependencies
const mockUseCurrentStateAndParams = vi.fn();
const mockUseEnvironmentId = vi.fn();
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
}));
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(HelmApplicationView), user)
);
return render(<Wrapped />);
}
describe('HelmApplicationView', () => {
beforeEach(() => {
// Set up default mock values
mockUseEnvironmentId.mockReturnValue(3);
mockUseCurrentStateAndParams.mockReturnValue({
params: {
name: 'test-release',
namespace: 'default',
},
});
// Set up default mock API responses
server.use(
http.get('/api/endpoints/3/kubernetes/helm', () =>
HttpResponse.json([
{
name: 'test-release',
chart: 'test-chart-1.0.0',
app_version: '1.0.0',
},
])
)
);
});
it('should display helm release details when data is loaded', async () => {
renderComponent();
// Check for the page header
expect(await screen.findByText('Helm details')).toBeInTheDocument();
// Check for the release details
expect(await screen.findByText('Release')).toBeInTheDocument();
// Check for the table content
expect(await screen.findByText('Name')).toBeInTheDocument();
expect(await screen.findByText('Chart')).toBeInTheDocument();
expect(await screen.findByText('App version')).toBeInTheDocument();
// Check for the actual values
expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent(
'test-release'
);
expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument();
expect(await screen.findByText('1.0.0')).toBeInTheDocument();
});
it('should display error message when API request fails', async () => {
// Mock API failure
server.use(
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error())
);
// Mock console.error to prevent test output pollution
vi.spyOn(console, 'error').mockImplementation(() => {});
renderComponent();
// Wait for the error message to appear
expect(
await screen.findByText('Failed to load Helm application details')
).toBeInTheDocument();
// Restore console.error
vi.spyOn(console, 'error').mockRestore();
});
it('should display error message when release is not found', async () => {
// Mock empty response (no releases found)
server.use(
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([]))
);
// Mock console.error to prevent test output pollution
vi.spyOn(console, 'error').mockImplementation(() => {});
renderComponent();
// Wait for the error message to appear
expect(
await screen.findByText('Failed to load Helm application details')
).toBeInTheDocument();
// Restore console.error
vi.spyOn(console, 'error').mockRestore();
});
});
@@ -0,0 +1,30 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { PageHeader } from '@/react/components/PageHeader';
import { HelmDetailsWidget } from './HelmDetailsWidget';
export function HelmApplicationView() {
const { params } = useCurrentStateAndParams();
const { name, namespace } = params;
return (
<>
<PageHeader
title="Helm details"
breadcrumbs={[
{ label: 'Applications', link: 'kubernetes.applications' },
name,
]}
reload
/>
<div className="row">
<div className="col-sm-12">
<HelmDetailsWidget name={name} namespace={namespace} />
</div>
</div>
</>
);
}
@@ -0,0 +1,67 @@
import {
Loading,
Widget,
WidgetBody,
WidgetTitle,
} from '@/react/components/Widget';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Alert } from '@@/Alert';
import { useHelmRelease } from './queries/useHelmRelease';
interface HelmDetailsWidgetProps {
name: string;
namespace: string;
}
export function HelmDetailsWidget({ name, namespace }: HelmDetailsWidgetProps) {
const environmentId = useEnvironmentId();
const {
data: release,
isInitialLoading,
isError,
} = useHelmRelease(environmentId, name, namespace);
return (
<Widget>
<WidgetTitle icon={helm} title="Release" />
<WidgetBody>
{isInitialLoading && <Loading />}
{isError && (
<Alert
color="error"
title="Failed to load Helm application details"
/>
)}
{!isInitialLoading && !isError && release && (
<table className="table">
<tbody>
<tr>
<td className="!border-none w-40">Name</td>
<td
className="!border-none min-w-[140px]"
data-cy="k8sAppDetail-appName"
>
{release.name}
</td>
</tr>
<tr>
<td className="!border-t">Chart</td>
<td className="!border-t">{release.chart}</td>
</tr>
<tr>
<td>App version</td>
<td>{release.app_version}</td>
</tr>
</tbody>
</table>
)}
</WidgetBody>
</Widget>
);
}
@@ -0,0 +1 @@
export { HelmApplicationView } from './HelmApplicationView';
@@ -0,0 +1,83 @@
import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query';
import PortainerError from 'Portainer/error';
import axios, { parseAxiosError } from '@/portainer/services/axios';
interface HelmRelease {
name: string;
chart: string;
app_version: string;
}
/**
* List all helm releases based on passed in options
* @param environmentId - Environment ID
* @param options - Options for filtering releases
* @returns List of helm releases
*/
export async function listReleases(
environmentId: EnvironmentId,
options: {
namespace?: string;
filter?: string;
selector?: string;
output?: string;
} = {}
): Promise<HelmRelease[]> {
try {
const { namespace, filter, selector, output } = options;
const url = `endpoints/${environmentId}/kubernetes/helm`;
const { data } = await axios.get<HelmRelease[]>(url, {
params: { namespace, filter, selector, output },
});
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve release list');
}
}
/**
* React hook to fetch a specific Helm release
*/
export function useHelmRelease(
environmentId: EnvironmentId,
name: string,
namespace: string
) {
return useQuery(
[environmentId, 'helm', namespace, name],
() => getHelmRelease(environmentId, name, namespace),
{
enabled: !!environmentId,
...withGlobalError('Unable to retrieve helm application details'),
}
);
}
/**
* Get a specific Helm release
*/
async function getHelmRelease(
environmentId: EnvironmentId,
name: string,
namespace: string
): Promise<HelmRelease> {
try {
const releases = await listReleases(environmentId, {
filter: `^${name}$`,
namespace,
});
if (releases.length > 0) {
return releases[0];
}
throw new PortainerError(`Release ${name} not found`);
} catch (err) {
throw new PortainerError(
'Unable to retrieve helm application details',
err as Error
);
}
}
@@ -0,0 +1,190 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { HelmTemplatesList } from './HelmTemplatesList';
import { Chart } from './HelmTemplatesListItem';
// Sample test data
const mockCharts: Chart[] = [
{
name: 'test-chart-1',
description: 'Test Chart 1 Description',
annotations: {
category: 'database',
},
},
{
name: 'test-chart-2',
description: 'Test Chart 2 Description',
annotations: {
category: 'database',
},
},
{
name: 'nginx-chart',
description: 'Nginx Web Server',
annotations: {
category: 'web',
},
},
];
const selectActionMock = vi.fn();
function renderComponent({
loading = false,
titleText = 'Test Helm Templates',
charts = mockCharts,
selectAction = selectActionMock,
} = {}) {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(
withTestRouter(() => (
<HelmTemplatesList
loading={loading}
titleText={titleText}
charts={charts}
selectAction={selectAction}
/>
)),
user
)
);
return { ...render(<Wrapped />), user };
}
describe('HelmTemplatesList', () => {
beforeEach(() => {
selectActionMock.mockClear();
});
it('should display title and charts list', async () => {
renderComponent();
// Check for the title
expect(screen.getByText('Test Helm Templates')).toBeInTheDocument();
// Check for charts
expect(screen.getByText('test-chart-1')).toBeInTheDocument();
expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
});
it('should call selectAction when a chart is clicked', async () => {
renderComponent();
// Find the first chart item
const firstChartItem = screen.getByText('test-chart-1').closest('button');
expect(firstChartItem).not.toBeNull();
// Click on the chart item
if (firstChartItem) {
fireEvent.click(firstChartItem);
}
// Check if selectAction was called with the correct chart
expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
});
it('should filter charts by text search', async () => {
renderComponent();
const user = userEvent.setup();
// Find search input and type "nginx"
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'nginx');
// Wait 300ms for debounce
await new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, 300);
});
// Should show only nginx chart
expect(screen.getByText('nginx-chart')).toBeInTheDocument();
expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
});
it('should filter charts by category', async () => {
renderComponent();
const user = userEvent.setup();
// Find the category select
const categorySelect = screen.getByText('Select a category');
await user.click(categorySelect);
// Select "web" category
const webCategory = screen.getByText('web', {
selector: '[tabindex="-1"]',
});
await user.click(webCategory);
// Should show only web category charts
expect(screen.queryByText('nginx-chart')).toBeInTheDocument();
expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
});
it('should show loading message when loading prop is true', async () => {
renderComponent({ loading: true });
// Check for loading message
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(
screen.getByText('Initial download of Helm charts can take a few minutes')
).toBeInTheDocument();
});
it('should show empty message when no charts are available', async () => {
renderComponent({ charts: [] });
// Check for empty message
expect(screen.getByText('No helm charts available.')).toBeInTheDocument();
});
it('should show no results message when search has no matches', async () => {
renderComponent();
const user = userEvent.setup();
// Find search input and type text that won't match any charts
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'nonexistent chart');
// Wait 300ms for debounce
await new Promise((resolve) => {
setTimeout(() => {
resolve(undefined);
}, 300);
});
// Check for no results message
expect(screen.getByText('No Helm charts found')).toBeInTheDocument();
});
it('should handle keyboard navigation and selection', async () => {
renderComponent();
const user = userEvent.setup();
// Find the first chart item
const firstChartItem = screen.getByText('test-chart-1').closest('button');
expect(firstChartItem).not.toBeNull();
// Focus and press Enter
if (firstChartItem) {
(firstChartItem as HTMLElement).focus();
await user.keyboard('{Enter}');
}
// Check if selectAction was called with the correct chart
expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
});
});
@@ -0,0 +1,179 @@
import { useState, useMemo } from 'react';
import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar';
import { Chart, HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props {
loading: boolean;
titleText: string;
charts?: Chart[];
selectAction: (chart: Chart) => void;
}
/**
* Get categories from charts
* @param charts - The charts to get the categories from
* @returns Categories
*/
function getCategories(charts: Chart[]) {
const annotationCategories = charts
.map((chart) => chart.annotations?.category) // get category
.filter((c): c is string => !!c); // filter out nulls/undefined
const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
// Create options array in the format expected by PortainerSelect
return availableCategories.map((cat) => ({
label: cat,
value: cat,
}));
}
/**
* Get filtered charts
* @param charts - The charts to get the filtered charts from
* @param textFilter - The text filter
* @param selectedCategory - The selected category
* @returns Filtered charts
*/
function getFilteredCharts(
charts: Chart[],
textFilter: string,
selectedCategory: string | null
) {
return charts.filter((chart) => {
// Text filter
if (
textFilter &&
!chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
!chart.description.toLowerCase().includes(textFilter.toLowerCase())
) {
return false;
}
// Category filter
if (
selectedCategory &&
(!chart.annotations || chart.annotations.category !== selectedCategory)
) {
return false;
}
return true;
});
}
export function HelmTemplatesList({
loading,
titleText,
charts = [],
selectAction,
}: Props) {
const [textFilter, setTextFilter] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const categories = useMemo(() => getCategories(charts), [charts]);
const filteredCharts = useMemo(
() => getFilteredCharts(charts, textFilter, selectedCategory),
[charts, textFilter, selectedCategory]
);
return (
<section className="datatable" aria-label="Helm charts">
<div className="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div className="toolBarTitle vertical-center">{titleText}</div>
<SearchBar
value={textFilter}
onChange={(value) => setTextFilter(value)}
placeholder="Search..."
data-cy="helm-templates-search"
className="!mr-0 h-9"
/>
<div className="w-full sm:w-1/5">
<PortainerSelect
placeholder="Select a category"
value={selectedCategory}
options={categories}
onChange={(value) => setSelectedCategory(value)}
isClearable
bindToBody
data-cy="helm-category-select"
/>
</div>
</div>
<div className="w-fit">
<div className="small text-muted mb-2">
Select the Helm chart to use. Bring further Helm charts into your
selection list via{' '}
<Link
to="portainer.account"
params={{ '#': 'helm-repositories' }}
data-cy="helm-repositories-link"
>
User settings - Helm repositories
</Link>
.
</div>
<InsightsBox
header="Disclaimer"
type="slim"
content={
<>
At present Portainer does not support OCI format Helm charts.
Support for OCI charts will be available in a future release.
<br />
If you would like to provide feedback on OCI support or get access
to early releases to test this functionality,{' '}
<a
href="https://bit.ly/3WVkayl"
target="_blank"
rel="noopener noreferrer"
>
please get in touch
</a>
.
</>
}
/>
</div>
<div className="blocklist !px-0" role="list">
{filteredCharts.map((chart) => (
<HelmTemplatesListItem
key={chart.name}
model={chart}
onSelect={selectAction}
/>
))}
{filteredCharts.length === 0 && textFilter && (
<div className="text-muted small mt-4">No Helm charts found</div>
)}
{loading && (
<div className="text-muted text-center">
Loading...
<div className="text-muted text-center">
Initial download of Helm charts can take a few minutes
</div>
</div>
)}
{!loading && charts.length === 0 && (
<div className="text-muted text-center">
No helm charts available.
</div>
)}
</div>
</section>
);
}

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