Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8461422ffe | |||
| 4039c3a693 | |||
| b1dceb15e4 | |||
| 2feaacddb9 | |||
| 65e0344975 | |||
| 915beecce3 | |||
| fbabeb098f | |||
| d5981a4be9 | |||
| b0de6d41b7 | |||
| 3898b9e09e | |||
| c0a4a9ab5c | |||
| b9a68e9f31 | |||
| 52afa6cf67 | |||
| 1abb77aea5 | |||
| ab824da5d7 | |||
| ded33a33a0 | |||
| 4bd9569e63 | |||
| 9e04145875 | |||
| 3c6f61134e | |||
| 9ac8641f7e | |||
| 0fddedc1a9 | |||
| 2e6a3a42be | |||
| a245e93902 | |||
| d1f48ce043 | |||
| 2c1156da75 | |||
| 5ed95ce714 | |||
| 3e5ec79b21 | |||
| 157c83deee | |||
| 2865fd6b84 | |||
| 96285817ab | |||
| c2c1ac70f8 | |||
| b73f846397 | |||
| a43bb23bef | |||
| c93b2fedb4 | |||
| 156b223287 |
@@ -2,17 +2,18 @@ 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
|
||||
@@ -44,7 +45,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
|
||||
|
||||
@@ -70,7 +71,7 @@ body:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -94,7 +95,6 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [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,6 +121,10 @@ 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
|
||||
|
||||
@@ -158,7 +162,7 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser
|
||||
description: |
|
||||
description: |
|
||||
Enter your browser and version. Example: Google Chrome 114.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -49,7 +49,6 @@ 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"
|
||||
@@ -170,8 +169,8 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||
}
|
||||
|
||||
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager()
|
||||
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
|
||||
}
|
||||
|
||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||
@@ -438,7 +437,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager()
|
||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
type ReadTransaction interface {
|
||||
GetObject(bucketName string, key []byte, object any) error
|
||||
GetRawBytes(bucketName string, key []byte) ([]byte, error)
|
||||
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
||||
KeyExists(bucketName string, key []byte) (bool, error)
|
||||
}
|
||||
|
||||
type Transaction interface {
|
||||
|
||||
@@ -244,6 +244,32 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
|
||||
})
|
||||
}
|
||||
|
||||
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
var value []byte
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
value, err = tx.GetRawBytes(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
exists, err = tx.KeyExists(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) getEncryptionKey() []byte {
|
||||
if !connection.isEncrypted {
|
||||
return nil
|
||||
|
||||
@@ -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://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}`
|
||||
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}`
|
||||
passphrase = "my secret key"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -31,6 +32,33 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
|
||||
return tx.conn.UnmarshalObject(value, object)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
||||
}
|
||||
|
||||
if tx.conn.getEncryptionKey() != nil {
|
||||
var err error
|
||||
|
||||
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
|
||||
return value, errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
|
||||
return value != nil, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
||||
data, err := tx.conn.MarshalObject(object)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type BaseCRUD[T any, I constraints.Integer] interface {
|
||||
Create(element *T) error
|
||||
Read(ID I) (*T, error)
|
||||
Exists(ID I) (bool, error)
|
||||
ReadAll() ([]T, error)
|
||||
Update(ID I, element *T) error
|
||||
Delete(ID I) error
|
||||
@@ -42,6 +43,19 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
exists, err = service.Tx(tx).Exists(ID)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
||||
var collection = make([]T, 0)
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
|
||||
return &element, nil
|
||||
}
|
||||
|
||||
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
||||
identifier := service.Connection.ConvertToKey(int(ID))
|
||||
|
||||
return service.Tx.KeyExists(service.Bucket, identifier)
|
||||
}
|
||||
|
||||
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
||||
var collection = make([]T, 0)
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ type Service struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &Service{}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -111,18 +109,6 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
@@ -13,8 +13,6 @@ type ServiceTx struct {
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
var _ dataservices.EndpointRelationService = &ServiceTx{}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
@@ -76,58 +74,6 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
rel, err := service.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel.EdgeStacks[edgeStackID] = true
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||
cache.Del(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments += len(endpointIDs)
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||
for _, endpointID := range endpointIDs {
|
||||
rel, err := service.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(rel.EdgeStacks, edgeStackID)
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||
cache.Del(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments -= len(endpointIDs)
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
@@ -115,8 +115,6 @@ 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,10 +94,6 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
|
||||
continue
|
||||
}
|
||||
|
||||
if environmentStatus.Details == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
statusArray := []portainer.EdgeStackDeploymentStatus{}
|
||||
if environmentStatus.Details.Pending {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
|
||||
@@ -75,10 +75,6 @@ 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
|
||||
@@ -97,10 +93,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
||||
edgeStack.Status[endpointId] = status
|
||||
}
|
||||
|
||||
if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
|
||||
err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -605,12 +605,12 @@
|
||||
"GlobalDeploymentOptions": {
|
||||
"hideStacksFunctionality": false
|
||||
},
|
||||
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
|
||||
"HelmRepositoryURL": "",
|
||||
"InternalAuthSettings": {
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.28.1",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.4",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.28.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.4\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
|
||||
return err
|
||||
}
|
||||
|
||||
args = append(args, "stack", "rm", "--detach=false", stack.Name)
|
||||
args = append(args, "stack", "rm", stack.Name)
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func copyFile(src, dst string) error {
|
||||
defer from.Close()
|
||||
|
||||
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
to, err := os.Create(dst)
|
||||
|
||||
@@ -15,19 +15,15 @@ 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, []string) {
|
||||
func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) []DirEntry {
|
||||
var filteredDirEntries []DirEntry
|
||||
|
||||
var envFiles []string
|
||||
|
||||
for _, multiFilterArg := range multiFilterArgs {
|
||||
tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
|
||||
tmp := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
|
||||
filteredDirEntries = append(filteredDirEntries, tmp...)
|
||||
|
||||
envFiles = append(envFiles, efs...)
|
||||
}
|
||||
|
||||
return deduplicate(filteredDirEntries), envFiles
|
||||
return deduplicate(filteredDirEntries)
|
||||
}
|
||||
|
||||
func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
@@ -36,7 +32,8 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
|
||||
marks := make(map[string]struct{})
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
if _, ok := marks[dirEntry.Name]; !ok {
|
||||
_, ok := marks[dirEntry.Name]
|
||||
if !ok {
|
||||
marks[dirEntry.Name] = struct{}{}
|
||||
deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry)
|
||||
}
|
||||
@@ -47,33 +44,34 @@ 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/B/C
|
||||
// 2. For filterType file:
|
||||
// 1. all entries outside of dir A
|
||||
// 2. dir entries A, A/B, A/B/C
|
||||
// 3. For filterType file:
|
||||
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
|
||||
// 3. For filterType dir:
|
||||
// 4. 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, []string) {
|
||||
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
|
||||
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, envFiles
|
||||
return filteredDirEntries
|
||||
}
|
||||
|
||||
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
|
||||
|
||||
// Include all entries outside of dir A
|
||||
if !isInConfigDir(dirEntry, configPath) {
|
||||
if !isInConfigRootDir(dirEntry, configPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Include dir entries A, A/B, A/B/C
|
||||
if isParentDir(dirEntry, configPath) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -92,9 +90,21 @@ func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filter
|
||||
return false
|
||||
}
|
||||
|
||||
func isInConfigDir(dirEntry DirEntry, configPath string) bool {
|
||||
// return true if entry name starts with "A/B"
|
||||
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
|
||||
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 shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
|
||||
@@ -128,15 +138,6 @@ 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,17 +4,14 @@ import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
|
||||
t.Helper()
|
||||
|
||||
dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
|
||||
require.Equal(t, wantDirEntries, dirEntries)
|
||||
type args struct {
|
||||
dirEntries []DirEntry
|
||||
configPath string
|
||||
multiFilterArgs MultiFilterArgs
|
||||
}
|
||||
|
||||
baseDirEntries := []DirEntry{
|
||||
@@ -29,94 +26,67 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
||||
{"configs/folder2/config2", "", true, 420},
|
||||
}
|
||||
|
||||
// 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},
|
||||
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]},
|
||||
},
|
||||
[]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},
|
||||
{
|
||||
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[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)
|
||||
}
|
||||
|
||||
baseDirEntries := []DirEntry{
|
||||
{".env", "", true, 420},
|
||||
{"docker-compose.yaml", "", true, 420},
|
||||
{"configs", "", false, 420},
|
||||
{"configs/edge-id/edge-id.env", "", true, 420},
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -138,19 +138,57 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||
newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||
oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||
newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||
|
||||
relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
|
||||
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
|
||||
|
||||
if len(relatedEnvironmentsToRemove) > 0 {
|
||||
tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
|
||||
endpointsToRemove := set.Set[portainer.EndpointID]{}
|
||||
for endpointID := range oldRelatedSet {
|
||||
if !newRelatedSet[endpointID] {
|
||||
endpointsToRemove[endpointID] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(relatedEnvironmentsToAdd) > 0 {
|
||||
tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.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")
|
||||
}
|
||||
}
|
||||
|
||||
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
|
||||
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
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.28.1
|
||||
// @version 2.27.4
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -9,8 +8,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"
|
||||
@@ -24,11 +23,11 @@ type Handler struct {
|
||||
jwtService portainer.JWTService
|
||||
kubeClusterAccessService kubernetes.KubeClusterAccessService
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
helmPackageManager libhelmtypes.HelmPackageManager
|
||||
helmPackageManager libhelm.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 libhelmtypes.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
@@ -58,7 +57,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 libhelmtypes.HelmPackageManager) *Handler {
|
||||
func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelm.HelmPackageManager) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
helmPackageManager: helmPackageManager,
|
||||
@@ -79,7 +78,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.
|
||||
// The cluster access is passed in as kube config CLI params to helm binary.
|
||||
func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
@@ -108,9 +107,6 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
|
||||
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
|
||||
return &options.KubernetesClusterAccess{
|
||||
ClusterName: fmt.Sprintf("%s-%s", "portainer-cluster", endpoint.Name),
|
||||
ContextName: fmt.Sprintf("%s-%s", "portainer-ctx", endpoint.Name),
|
||||
UserName: fmt.Sprintf("%s-%s", "portainer-sa-user", tokenData.Username),
|
||||
ClusterServerURL: kubeConfigInternal.ClusterServerURL,
|
||||
CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile,
|
||||
AuthToken: bearerToken,
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -34,7 +34,7 @@ func Test_helmDelete(t *testing.T) {
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
|
||||
@@ -99,11 +99,15 @@ 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: clusterAccess,
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
Namespace: p.Namespace,
|
||||
Repo: p.Repo,
|
||||
KubernetesClusterAccess: &options.KubernetesClusterAccess{
|
||||
ClusterServerURL: clusterAccess.ClusterServerURL,
|
||||
CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile,
|
||||
AuthToken: clusterAccess.AuthToken,
|
||||
},
|
||||
}
|
||||
|
||||
if p.Values != "" {
|
||||
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
"github.com/portainer/portainer/pkg/libhelm/release"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -38,14 +38,14 @@ func Test_helmInstall(t *testing.T) {
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
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://charts.bitnami.com/bitnami"}
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
|
||||
optdata, err := json.Marshal(options)
|
||||
is.NoError(err)
|
||||
|
||||
|
||||
@@ -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.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
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/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_helmRepoSearch(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
|
||||
|
||||
assert.NotNil(t, h, "Handler should not fail")
|
||||
|
||||
repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
|
||||
repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
|
||||
|
||||
for _, repo := range repos {
|
||||
t.Run(repo, func(t *testing.T) {
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
"testing"
|
||||
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/pkg/libhelm/test"
|
||||
"github.com/portainer/portainer/pkg/libhelm/binary/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_helmShow(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
helmPackageManager := test.NewMockHelmBinaryPackageManager("")
|
||||
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://charts.bitnami.com/bitnami")
|
||||
repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
|
||||
chart := "nginx"
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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"
|
||||
@@ -167,48 +162,11 @@ 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: selfSignedCert,
|
||||
InsecureSkipTLSVerify: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -257,38 +215,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry *bool `example:"false"`
|
||||
// Helm repository URL
|
||||
HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
|
||||
HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
|
||||
// Kubectl Shell Image
|
||||
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
|
||||
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||
|
||||
@@ -35,8 +35,8 @@ type (
|
||||
}
|
||||
|
||||
K8sServiceIngress struct {
|
||||
IP string `json:"IP"`
|
||||
Hostname string `json:"Hostname"`
|
||||
IP string `json:"IP"`
|
||||
Host string `json:"Host"`
|
||||
}
|
||||
|
||||
// K8sServiceDeleteRequests is a mapping of namespace names to a slice of
|
||||
|
||||
@@ -243,8 +243,7 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler,
|
||||
return
|
||||
}
|
||||
|
||||
_, err = bouncer.dataStore.User().Read(tokenData.ID)
|
||||
if bouncer.dataStore.IsErrObjectNotFound(err) {
|
||||
if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
|
||||
return
|
||||
} else if err != nil {
|
||||
@@ -322,9 +321,8 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
||||
return
|
||||
}
|
||||
|
||||
user, _ := bouncer.dataStore.User().Read(token.ID)
|
||||
if user == nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
|
||||
if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
+2
-2
@@ -67,7 +67,7 @@ import (
|
||||
"github.com/portainer/portainer/api/platform"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -103,7 +103,7 @@ type Server struct {
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
HelmPackageManager libhelmtypes.HelmPackageManager
|
||||
HelmPackageManager libhelm.HelmPackageManager
|
||||
Scheduler *scheduler.Scheduler
|
||||
ShutdownCtx context.Context
|
||||
ShutdownTrigger context.CancelFunc
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
)
|
||||
@@ -99,15 +100,12 @@ 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)
|
||||
}
|
||||
@@ -150,8 +148,25 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEndpointIds, edgeStackID); err != nil {
|
||||
return errors.WithMessage(err, "unable to remove environment relation in 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.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil {
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
)
|
||||
|
||||
var _ dataservices.DataStore = &testDatastore{}
|
||||
|
||||
type testDatastore struct {
|
||||
customTemplate dataservices.CustomTemplateService
|
||||
edgeGroup dataservices.EdgeGroupService
|
||||
@@ -153,6 +151,7 @@ func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User
|
||||
func (s *stubUserService) Create(user *portainer.User) error { return nil }
|
||||
func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
|
||||
func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
|
||||
func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
|
||||
|
||||
// WithUsers testDatastore option that will instruct testDatastore to return provided users
|
||||
func WithUsers(us []portainer.User) datastoreOption {
|
||||
@@ -188,6 +187,9 @@ func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFun
|
||||
}
|
||||
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
|
||||
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
|
||||
func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// WithEdgeJobs option will instruct testDatastore to return provided jobs
|
||||
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||
@@ -229,30 +231,6 @@ 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
|
||||
}
|
||||
@@ -452,6 +430,10 @@ func (s *stubStacksService) GetNextIdentifier() int {
|
||||
return len(s.stacks)
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// WithStacks option will instruct testDatastore to return provided stacks
|
||||
func WithStacks(stacks []portainer.Stack) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
|
||||
@@ -265,12 +265,9 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
|
||||
return systemLabelValue == "true"
|
||||
}
|
||||
|
||||
return isSystemDefaultNamespace(namespace.Name)
|
||||
}
|
||||
|
||||
func isSystemDefaultNamespace(namespace string) bool {
|
||||
systemNamespaces := defaultSystemNamespaces()
|
||||
_, isSystem := systemNamespaces[namespace]
|
||||
|
||||
_, isSystem := systemNamespaces[namespace.Name]
|
||||
return isSystem
|
||||
}
|
||||
|
||||
@@ -393,9 +390,7 @@ 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 {
|
||||
if !isSystemDefaultNamespace(namespace) {
|
||||
nonAdminNamespaceSet[namespace] = struct{}{}
|
||||
}
|
||||
nonAdminNamespaceSet[namespace] = struct{}{}
|
||||
}
|
||||
|
||||
return nonAdminNamespaceSet
|
||||
|
||||
@@ -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,
|
||||
Hostname: status.Hostname,
|
||||
IP: status.IP,
|
||||
Host: 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.Hostname},
|
||||
corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,6 @@ 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
-15
@@ -309,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,omitempty"`
|
||||
ConfigHash string `json:"ConfigHash"`
|
||||
}
|
||||
|
||||
// EdgeStack represents an edge stack
|
||||
@@ -353,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 `json:"ReadyRePullImage,omitempty"`
|
||||
ReadyRePullImage bool
|
||||
|
||||
// Deprecated
|
||||
Details *EdgeStackStatusDetails `json:"Details,omitempty"`
|
||||
Details EdgeStackStatusDetails
|
||||
// Deprecated
|
||||
Error string `json:"Error,omitempty"`
|
||||
Error string
|
||||
// Deprecated
|
||||
Type EdgeStackStatusType `json:"Type,omitempty"`
|
||||
Type EdgeStackStatusType `json:"Type"`
|
||||
}
|
||||
|
||||
// EdgeStackDeploymentStatus represents an edge stack deployment status
|
||||
EdgeStackDeploymentStatus struct {
|
||||
Time int64
|
||||
Type EdgeStackStatusType
|
||||
Error string `json:"Error,omitempty"`
|
||||
Error string
|
||||
// EE only feature
|
||||
RollbackTo *int `json:"RollbackTo,omitempty"`
|
||||
Version int `json:"Version,omitempty"`
|
||||
RollbackTo *int
|
||||
Version int `json:"Version,omitempty"`
|
||||
}
|
||||
|
||||
// EdgeStackStatusType represents an edge stack status type
|
||||
@@ -588,7 +588,7 @@ type (
|
||||
// User identifier
|
||||
UserID UserID `json:"UserId" example:"1"`
|
||||
// Helm repository URL
|
||||
URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"`
|
||||
URL string `json:"URL" example:"https://kubernetes.github.io/ingress-nginx"`
|
||||
}
|
||||
|
||||
// QuayRegistryData represents data required for Quay registry to work
|
||||
@@ -984,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 "https://charts.bitnami.com/bitnami"
|
||||
HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
|
||||
// Helm repository URL, defaults to ""
|
||||
HelmRepositoryURL string `json:"HelmRepositoryURL"`
|
||||
// KubectlImage, defaults to portainer/kubectl-shell
|
||||
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
|
||||
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
|
||||
@@ -1637,9 +1637,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.28.1"
|
||||
APIVersion = "2.27.4"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1673,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 represents the URL to the official templates supported by Bitnami
|
||||
DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
|
||||
// DefaultHelmrepositoryURL set to empty string until oci support is added
|
||||
DefaultHelmRepositoryURL = ""
|
||||
// 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
|
||||
|
||||
+6114
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { buildImageFullURIFromModel, imageContainsURL, fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
import { buildImageFullURIFromModel, imageContainsURL } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory);
|
||||
function ImageHelperFactory() {
|
||||
@@ -18,12 +18,8 @@ function ImageHelperFactory() {
|
||||
* @param {PorImageRegistryModel} registry
|
||||
*/
|
||||
function createImageConfigForContainer(imageModel) {
|
||||
const fromImage = buildImageFullURIFromModel(imageModel);
|
||||
const { tag, repo } = fullURIIntoRepoAndTag(fromImage);
|
||||
return {
|
||||
fromImage,
|
||||
tag,
|
||||
repo,
|
||||
fromImage: buildImageFullURIFromModel(imageModel),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -207,9 +207,9 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||
async function commitContainerAsync() {
|
||||
$scope.config.commitInProgress = true;
|
||||
const registryModel = $scope.config.RegistryModel;
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const imageConfig = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
try {
|
||||
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo, tag });
|
||||
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo: imageConfig.fromImage });
|
||||
Notifications.success('Image created', $transition$.params().id);
|
||||
$state.reload();
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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',
|
||||
@@ -70,7 +71,8 @@ angular.module('portainer.docker').controller('ImageController', [
|
||||
$scope.tagImage = function () {
|
||||
const registryModel = $scope.formValues.RegistryModel;
|
||||
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
||||
|
||||
ImageService.tagImage($transition$.params().id, repo, tag)
|
||||
.then(function success() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||
|
||||
angular.module('portainer.docker').controller('ImportImageController', [
|
||||
'$scope',
|
||||
@@ -33,7 +34,8 @@ angular.module('portainer.docker').controller('ImportImageController', [
|
||||
async function tagImage(id) {
|
||||
const registryModel = $scope.formValues.RegistryModel;
|
||||
if (registryModel.Image) {
|
||||
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
||||
try {
|
||||
await ImageService.tagImage(id, repo, tag);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,281 +1,274 @@
|
||||
<page-header title="'Service details'" breadcrumbs="[{label:'Services', link:'docker.services'}, service.Name]" reload="true"> </page-header>
|
||||
|
||||
<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 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 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'">
|
||||
<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>
|
||||
</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>
|
||||
</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
|
||||
>
|
||||
<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>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-3 col-xs-3">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
||||
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
||||
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
||||
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
||||
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
||||
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
||||
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
||||
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
||||
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
||||
</ul>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<access-control-panel
|
||||
ng-if="service"
|
||||
resource-id="service.Id"
|
||||
resource-control="service.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
environment-id="endpoint.Id"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="container-specs">Container specification</h3>
|
||||
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
||||
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
||||
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
||||
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
||||
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-network-specs">Networks & ports</h3>
|
||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||
|
||||
<docker-service-ports-mapping-field
|
||||
id="service-published-ports"
|
||||
class="block padding-top"
|
||||
values="formValues.ports"
|
||||
on-change="(onChangePorts)"
|
||||
has-changes="hasChanges(service, ['Ports'])"
|
||||
on-reset="(onResetPorts)"
|
||||
on-submit="(onSubmit)"
|
||||
></docker-service-ports-mapping-field>
|
||||
|
||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-specs">Service specification</h3>
|
||||
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
||||
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
||||
<div
|
||||
id="service-placement-preferences"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="padding-top"
|
||||
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
||||
></div>
|
||||
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
||||
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
||||
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
||||
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
||||
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
||||
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
||||
</div>
|
||||
<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'">
|
||||
<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>
|
||||
</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>
|
||||
</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
|
||||
>
|
||||
<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>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-3 col-xs-3">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
||||
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
||||
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
||||
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
||||
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
||||
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
||||
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
||||
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
||||
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
||||
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
||||
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
||||
</ul>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<access-control-panel
|
||||
ng-if="service"
|
||||
resource-id="service.Id"
|
||||
resource-control="service.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
environment-id="endpoint.Id"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="container-specs">Container specification</h3>
|
||||
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
||||
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
||||
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
||||
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
||||
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-network-specs">Networks & ports</h3>
|
||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||
|
||||
<docker-service-ports-mapping-field
|
||||
id="service-published-ports"
|
||||
class="block padding-top"
|
||||
values="formValues.ports"
|
||||
on-change="(onChangePorts)"
|
||||
has-changes="hasChanges(service, ['Ports'])"
|
||||
on-reset="(onResetPorts)"
|
||||
on-submit="(onSubmit)"
|
||||
></docker-service-ports-mapping-field>
|
||||
|
||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<hr />
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-specs">Service specification</h3>
|
||||
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
||||
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
||||
<div
|
||||
id="service-placement-preferences"
|
||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||
class="padding-top"
|
||||
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
||||
></div>
|
||||
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
||||
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
||||
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
||||
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
||||
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
||||
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
||||
|
||||
@@ -731,7 +731,6 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$scope.isLoading = true;
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
||||
|
||||
@@ -856,9 +855,6 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
$scope.secrets = [];
|
||||
$scope.configs = [];
|
||||
Notifications.error('Failure', err, 'Unable to retrieve service details');
|
||||
})
|
||||
.finally(() => {
|
||||
$scope.isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
.helm-template-item-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.helm-template-item-details .helm-template-item-details-sub {
|
||||
width: 100%;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<!-- 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>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<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>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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"
|
||||
|
||||
@@ -58,8 +58,6 @@ 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';
|
||||
@@ -207,19 +205,6 @@ 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;
|
||||
|
||||
@@ -22,8 +22,6 @@ 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', [])
|
||||
@@ -80,14 +78,6 @@ 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))), [])
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.release-table tr {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: 1fr 4fr;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<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>
|
||||
@@ -0,0 +1,11 @@
|
||||
import angular from 'angular';
|
||||
import controller from './helm.controller';
|
||||
import './helm.css';
|
||||
|
||||
angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
|
||||
templateUrl: './helm.html',
|
||||
controller,
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<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>
|
||||
@@ -0,0 +1,8 @@
|
||||
angular.module('portainer.kubernetes').component('kubernetesClusterView', {
|
||||
templateUrl: './cluster.html',
|
||||
controller: 'KubernetesClusterController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
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) // kube deploy + docker stack create
|
||||
.component('gitForm', gitForm)
|
||||
.component('gitFormAuthFieldset', gitFormAuthFieldset)
|
||||
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdate)
|
||||
.component('gitFormRefField', gitFormRefField).name;
|
||||
|
||||
@@ -29,7 +29,6 @@ export const gitFormModule = angular
|
||||
'webhookId',
|
||||
'webhooksDocs',
|
||||
'createdFromCustomTemplateId',
|
||||
'isAutoUpdateVisible',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -135,38 +134,3 @@ 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 { PropsWithChildren, ReactNode } from 'react';
|
||||
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
@@ -10,11 +10,10 @@ export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
|
||||
|
||||
export interface Props {
|
||||
inputId?: string;
|
||||
dataCy?: string;
|
||||
label: ReactNode;
|
||||
size?: Size;
|
||||
tooltip?: ReactNode;
|
||||
setTooltipHtmlMessage?: boolean;
|
||||
tooltip?: ComponentProps<typeof Tooltip>['message'];
|
||||
setTooltipHtmlMessage?: ComponentProps<typeof Tooltip>['setHtmlMessage'];
|
||||
children: ReactNode;
|
||||
errors?: ReactNode;
|
||||
required?: boolean;
|
||||
@@ -25,7 +24,6 @@ export interface Props {
|
||||
|
||||
export function FormControl({
|
||||
inputId,
|
||||
dataCy,
|
||||
label,
|
||||
size = 'small',
|
||||
tooltip = '',
|
||||
@@ -44,7 +42,6 @@ export function FormControl({
|
||||
'form-group',
|
||||
'after:clear-both after:table after:content-[""]' // to fix issues with float
|
||||
)}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
@@ -59,15 +56,10 @@ export function FormControl({
|
||||
)}
|
||||
</label>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<div className={sizeClassChildren(size)}>
|
||||
{isLoading && <InlineLoader>{loadingText}</InlineLoader>}
|
||||
{!isLoading && children}
|
||||
{!!errors && !isLoading && <FormError>{errors}</FormError>}
|
||||
{errors && <FormError>{errors}</FormError>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CellContext, Column } from '@tanstack/react-table';
|
||||
import { useSref } from '@uirouter/react';
|
||||
|
||||
import { truncate } from '@/portainer/filters/filters';
|
||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||
@@ -6,7 +7,6 @@ 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,20 +62,22 @@ function FilterByUsage<TData extends { Used: boolean }>({
|
||||
}
|
||||
|
||||
function Cell({
|
||||
row: { original: item },
|
||||
getValue,
|
||||
row: { original: image },
|
||||
}: CellContext<ImagesListResponse, string>) {
|
||||
const name = getValue();
|
||||
|
||||
const linkProps = useSref('.image', {
|
||||
id: image.id,
|
||||
imageId: image.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 />}
|
||||
</>
|
||||
<div className="flex gap-1">
|
||||
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
|
||||
{truncate(name, 40)}
|
||||
</a>
|
||||
{!image.used && <UnusedBadge />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,24 +16,6 @@ describe('fullURIIntoRepoAndTag', () => {
|
||||
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
|
||||
});
|
||||
|
||||
it('splits image-repo:port/image correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag('registry.example.com:5000/my-image');
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com:5000/my-image',
|
||||
tag: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
it('splits image-repo:port/image:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'registry.example.com:5000/my-image:v1'
|
||||
);
|
||||
expect(result).toEqual({
|
||||
repo: 'registry.example.com:5000/my-image',
|
||||
tag: 'v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('splits registry:port/image-repo:tag correctly', () => {
|
||||
const result = fullURIIntoRepoAndTag(
|
||||
'registry.example.com:5000/my-image:v2.1'
|
||||
|
||||
@@ -121,18 +121,9 @@ 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,7 +139,6 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
}
|
||||
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||
webhookId={webhookId}
|
||||
isAutoUpdateVisible={isBE}
|
||||
/>
|
||||
|
||||
{isBE && (
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -110,7 +109,6 @@ export function KubeManifestForm({
|
||||
}
|
||||
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||
webhookId={webhookId}
|
||||
isAutoUpdateVisible={isBE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { PageHeader } from '@/react/components/PageHeader';
|
||||
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
|
||||
|
||||
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||
|
||||
export function ClusterView() {
|
||||
const { data: environment } = useCurrentEnvironment();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cluster"
|
||||
breadcrumbs={[
|
||||
{ label: 'Environments', link: 'portainer.endpoints' },
|
||||
{
|
||||
label: environment?.Name || '',
|
||||
link: 'portainer.endpoints.endpoint',
|
||||
linkParams: { id: environment?.Id },
|
||||
},
|
||||
'Cluster information',
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
|
||||
<ClusterResourceReservation />
|
||||
|
||||
<div className="row">
|
||||
<NodesDatatable />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ClusterView } from './ClusterView';
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './useClusterResourceLimitsQuery';
|
||||
export * from './useClusterResourceReservationQuery';
|
||||
export * from './useClusterResourceUsageQuery';
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
),
|
||||
};
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
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
|
||||
),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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
|
||||
export async function getNodes(environmentId: EnvironmentId) {
|
||||
async function getNodes(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: nodeList } = await axios.get<NodeList>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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,119 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { HelmApplicationView } from './HelmApplicationView';
|
||||
@@ -1,83 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { HelmIcon } from '@/kubernetes/components/helm/helm-templates/HelmIcon';
|
||||
import { FallbackImage } from '@/react/components/FallbackImage';
|
||||
|
||||
import Svg from '@@/Svg';
|
||||
|
||||
export interface Chart {
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
annotations?: {
|
||||
category?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface HelmTemplatesListItemProps {
|
||||
model: Chart;
|
||||
onSelect: (model: Chart) => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function HelmTemplatesListItem(props: HelmTemplatesListItemProps) {
|
||||
const { model, onSelect, actions } = props;
|
||||
|
||||
function handleSelect() {
|
||||
onSelect(model);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="blocklist-item mx-0 bg-inherit text-start"
|
||||
onClick={handleSelect}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="blocklist-item-box">
|
||||
<span className="shrink-0">
|
||||
<FallbackImage
|
||||
src={model.icon}
|
||||
fallbackIcon={HelmIcon}
|
||||
className="blocklist-item-logo h-16 w-auto"
|
||||
alt="Helm chart icon"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="col-sm-12 flex flex-wrap justify-between gap-2">
|
||||
<div className="blocklist-item-line">
|
||||
<span>
|
||||
<span className="blocklist-item-title">{model.name}</span>
|
||||
<span className="space-left blocklist-item-subtitle">
|
||||
<span className="vertical-center">
|
||||
<Svg icon="helm" className="icon icon-primary" />
|
||||
</span>
|
||||
<span> Helm </span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="blocklist-item-actions">{actions}</span>
|
||||
|
||||
<div className="blocklist-item-line w-full">
|
||||
<span className="blocklist-item-desc">{model.description}</span>
|
||||
{model.annotations?.category && (
|
||||
<span className="small text-muted">
|
||||
{model.annotations.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -37,8 +37,8 @@ export type Usage = {
|
||||
};
|
||||
|
||||
export type ApplicationResource = {
|
||||
CpuRequest: number;
|
||||
CpuLimit: number;
|
||||
MemoryRequest: number;
|
||||
MemoryLimit: number;
|
||||
cpuRequest: number;
|
||||
cpuLimit: number;
|
||||
memoryRequest: number;
|
||||
memoryLimit: number;
|
||||
};
|
||||
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
import { useNamespaceResourceReservationData } from './useNamespaceResourceReservationData';
|
||||
|
||||
interface Props {
|
||||
namespaceName: string;
|
||||
environmentId: number;
|
||||
resourceQuotaValues: ResourceQuotaFormValues;
|
||||
}
|
||||
|
||||
export function NamespaceResourceReservation({
|
||||
environmentId,
|
||||
namespaceName,
|
||||
resourceQuotaValues,
|
||||
}: Props) {
|
||||
const {
|
||||
cpuLimit,
|
||||
memoryLimit,
|
||||
displayResourceUsage,
|
||||
resourceUsage,
|
||||
resourceReservation,
|
||||
isLoading,
|
||||
} = useNamespaceResourceReservationData(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
resourceQuotaValues
|
||||
);
|
||||
|
||||
if (!resourceQuotaValues.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourceReservation
|
||||
displayResourceUsage={displayResourceUsage}
|
||||
resourceReservation={resourceReservation}
|
||||
resourceUsage={resourceUsage}
|
||||
cpuLimit={cpuLimit}
|
||||
memoryLimit={memoryLimit}
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -13,8 +13,8 @@ import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||
|
||||
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
|
||||
|
||||
import { ResourceReservationUsage } from './ResourceReservationUsage';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
import { NamespaceResourceReservation } from './NamespaceResourceReservation';
|
||||
|
||||
interface Props {
|
||||
values: ResourceQuotaFormValues;
|
||||
@@ -128,7 +128,7 @@ export function ResourceQuotaFormSection({
|
||||
</div>
|
||||
)}
|
||||
{namespaceName && isEdit && (
|
||||
<NamespaceResourceReservation
|
||||
<ResourceReservationUsage
|
||||
namespaceName={namespaceName}
|
||||
environmentId={environmentId}
|
||||
resourceQuotaValues={values}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
import { round } from 'lodash';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
|
||||
import { megaBytesValue, parseCPU } from '../../../resourceQuotaUtils';
|
||||
import { ResourceUsageItem } from '../../ResourceUsageItem';
|
||||
|
||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function ResourceReservationUsage({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
resourceQuotaValues,
|
||||
}: {
|
||||
namespaceName: string;
|
||||
environmentId: EnvironmentId;
|
||||
resourceQuotaValues: ResourceQuotaFormValues;
|
||||
}) {
|
||||
const namespaceMetricsQuery = useMetricsForNamespace(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
{
|
||||
select: aggregatePodUsage,
|
||||
}
|
||||
);
|
||||
const usedResourceQuotaQuery = useResourceQuotaUsed(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
const { data: namespaceMetrics } = namespaceMetricsQuery;
|
||||
const { data: usedResourceQuota } = usedResourceQuotaQuery;
|
||||
|
||||
const memoryQuota = Number(resourceQuotaValues.memory) ?? 0;
|
||||
const cpuQuota = Number(resourceQuotaValues.cpu) ?? 0;
|
||||
|
||||
if (!resourceQuotaValues.enabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>Resource reservation</FormSectionTitle>
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Resource reservation represents the total amount of resource assigned to
|
||||
all the applications deployed inside this namespace.
|
||||
</TextTip>
|
||||
{!!usedResourceQuota && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory reservation"
|
||||
annotation={`${usedResourceQuota.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(usedResourceQuota.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory used"
|
||||
annotation={`${namespaceMetrics.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(namespaceMetrics.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!usedResourceQuota && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU reservation"
|
||||
annotation={`${
|
||||
usedResourceQuota.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
usedResourceQuota.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU used"
|
||||
annotation={`${
|
||||
namespaceMetrics.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
namespaceMetrics.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getSafeValue(value: number | string) {
|
||||
const valueNumber = Number(value);
|
||||
if (Number.isNaN(valueNumber)) {
|
||||
return 0;
|
||||
}
|
||||
return valueNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the percentage of the value over the total.
|
||||
* @param value - The value to calculate the percentage for.
|
||||
* @param total - The total value to compare the percentage to.
|
||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||
*/
|
||||
function getPercentageString(value: number, total?: number | string) {
|
||||
const totalNumber = Number(total);
|
||||
if (
|
||||
totalNumber === 0 ||
|
||||
total === undefined ||
|
||||
total === '' ||
|
||||
Number.isNaN(totalNumber)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (value > totalNumber) {
|
||||
return '- Exceeded';
|
||||
}
|
||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the resource usage of all the containers in the namespace.
|
||||
* @param podMetricsList - List of pod metrics
|
||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||
i.containers.map((c) => c.usage)
|
||||
);
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||
(total, usage) => ({
|
||||
cpu: total.cpu + parseCPU(usage.cpu),
|
||||
memory: total.memory + megaBytesValue(usage.memory),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 }
|
||||
);
|
||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||
return namespaceResourceUsage;
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { round } from 'lodash';
|
||||
|
||||
import { getSafeValue } from '@/react/kubernetes/utils';
|
||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||
import {
|
||||
megaBytesValue,
|
||||
parseCPU,
|
||||
} from '@/react/kubernetes/namespaces/resourceQuotaUtils';
|
||||
|
||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function useNamespaceResourceReservationData(
|
||||
environmentId: number,
|
||||
namespaceName: string,
|
||||
resourceQuotaValues: ResourceQuotaFormValues
|
||||
) {
|
||||
const { data: quota, isLoading: isQuotaLoading } = useResourceQuotaUsed(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
const { data: metrics, isLoading: isMetricsLoading } = useMetricsForNamespace(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
{
|
||||
select: aggregatePodUsage,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
cpuLimit: Number(resourceQuotaValues.cpu) || 0,
|
||||
memoryLimit: Number(resourceQuotaValues.memory) || 0,
|
||||
displayResourceUsage: !!metrics,
|
||||
resourceReservation: {
|
||||
cpu: getSafeValue(quota?.cpu || 0),
|
||||
memory: getSafeValue(quota?.memory || 0),
|
||||
},
|
||||
resourceUsage: {
|
||||
cpu: getSafeValue(metrics?.cpu || 0),
|
||||
memory: getSafeValue(metrics?.memory || 0),
|
||||
},
|
||||
isLoading: isQuotaLoading || isMetricsLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the resource usage of all the containers in the namespace.
|
||||
* @param podMetricsList - List of pod metrics
|
||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||
i.containers.map((c) => c.usage)
|
||||
);
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||
(total, usage) => ({
|
||||
cpu: total.cpu + parseCPU(usage.cpu),
|
||||
memory: total.memory + megaBytesValue(usage.memory),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 }
|
||||
);
|
||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||
return namespaceResourceUsage;
|
||||
}
|
||||
+2
-11
@@ -1,13 +1,11 @@
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { ProgressBar } from '@@/ProgressBar';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
|
||||
interface ResourceUsageItemProps {
|
||||
value: number;
|
||||
total: number;
|
||||
annotation?: React.ReactNode;
|
||||
label: string;
|
||||
isLoading?: boolean;
|
||||
dataCy?: string;
|
||||
}
|
||||
|
||||
export function ResourceUsageItem({
|
||||
@@ -15,16 +13,9 @@ export function ResourceUsageItem({
|
||||
total,
|
||||
annotation,
|
||||
label,
|
||||
isLoading = false,
|
||||
dataCy,
|
||||
}: ResourceUsageItemProps) {
|
||||
return (
|
||||
<FormControl
|
||||
label={label}
|
||||
isLoading={isLoading}
|
||||
className={isLoading ? 'mb-1.5' : ''}
|
||||
dataCy={dataCy}
|
||||
>
|
||||
<FormControl label={label}>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<ProgressBar
|
||||
steps={[
|
||||
@@ -1,38 +0,0 @@
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { ServiceRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const externalHost = columnHelper.accessor(
|
||||
(row) => {
|
||||
if (row.Type === 'ExternalName') {
|
||||
return row.ExternalName || '-';
|
||||
}
|
||||
|
||||
return (
|
||||
row.IngressStatus?.map((status) => status.Hostname)
|
||||
.filter(Boolean)
|
||||
.join(' ,') || '-'
|
||||
);
|
||||
},
|
||||
{
|
||||
header: 'External Host',
|
||||
id: 'externalHost',
|
||||
cell: Cell,
|
||||
}
|
||||
);
|
||||
|
||||
function Cell({ row }: CellContext<ServiceRowData, string>) {
|
||||
if (row.original.Type === 'ExternalName') {
|
||||
return <div>{row.original.ExternalName || '-'}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{row.original.IngressStatus?.map((status) => status.Hostname)
|
||||
.filter(Boolean)
|
||||
.join(' ,') || '-'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { type } from './type';
|
||||
import { namespace } from './namespace';
|
||||
import { ports } from './ports';
|
||||
import { clusterIP } from './clusterIP';
|
||||
import { externalHost } from './externalHost';
|
||||
import { externalIP } from './externalIP';
|
||||
import { targetPorts } from './targetPorts';
|
||||
import { application } from './application';
|
||||
@@ -17,7 +16,6 @@ export const columns = [
|
||||
ports,
|
||||
targetPorts,
|
||||
clusterIP,
|
||||
externalHost,
|
||||
externalIP,
|
||||
created,
|
||||
];
|
||||
|
||||
@@ -20,38 +20,3 @@ export function prepareAnnotations(annotations?: Annotation[]) {
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the safe value of the given number or string.
|
||||
* @param value - The value to get the safe value for.
|
||||
* @returns The safe value of the given number or string.
|
||||
*/
|
||||
export function getSafeValue(value: number | string) {
|
||||
const valueNumber = Number(value);
|
||||
if (Number.isNaN(valueNumber)) {
|
||||
return 0;
|
||||
}
|
||||
return valueNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the percentage of the value over the total.
|
||||
* @param value - The value to calculate the percentage for.
|
||||
* @param total - The total value to compare the percentage to.
|
||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||
*/
|
||||
export function getPercentageString(value: number, total?: number | string) {
|
||||
const totalNumber = Number(total);
|
||||
if (
|
||||
totalNumber === 0 ||
|
||||
total === undefined ||
|
||||
total === '' ||
|
||||
Number.isNaN(totalNumber)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (value > totalNumber) {
|
||||
return '- Exceeded';
|
||||
}
|
||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RefField } from '@/react/portainer/gitops/RefField';
|
||||
import { GitFormUrlField } from '@/react/portainer/gitops/GitFormUrlField';
|
||||
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { validateForm } from '@@/form-components/validate-form';
|
||||
@@ -34,7 +35,6 @@ interface Props {
|
||||
webhookId?: string;
|
||||
webhooksDocs?: string;
|
||||
createdFromCustomTemplateId?: number;
|
||||
isAutoUpdateVisible?: boolean;
|
||||
}
|
||||
|
||||
export function GitForm({
|
||||
@@ -51,7 +51,6 @@ export function GitForm({
|
||||
webhookId,
|
||||
webhooksDocs,
|
||||
createdFromCustomTemplateId,
|
||||
isAutoUpdateVisible = true,
|
||||
}: Props) {
|
||||
const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
|
||||
|
||||
@@ -118,7 +117,7 @@ export function GitForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAutoUpdateVisible && value.AutoUpdate && (
|
||||
{isBE && value.AutoUpdate && (
|
||||
<AutoUpdateFieldset
|
||||
environmentType={environmentType}
|
||||
webhookId={webhookId || ''}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user