Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38ca054aee | |||
| faee22c907 | |||
| 6547b06f97 | |||
| 87b1cc80ed | |||
| eaf5283cdb | |||
| afd8507042 | |||
| 2c37f32fa6 | |||
| 7aa9f8b1c3 | |||
| c331ada086 | |||
| ebc25e45d3 | |||
| f82921d2a1 | |||
| d68fe42918 | |||
| 823f2a7991 | |||
| 0ca9321db1 | |||
| 46eddbe7b9 | |||
| 64c796a8c3 | |||
| 264ff5457b | |||
| ad89df4d0d | |||
| 0f10b8ba2b | |||
| 940bf990f9 | |||
| 1b8fbbe7d7 | |||
| f6f07f4690 | |||
| 3800249921 | |||
| a5d857d5e7 | |||
| 4c1e80ff58 | |||
| 7e5db1f55e | |||
| 1edc56c0ce | |||
| 4066a70ea5 | |||
| a0d36cf87a | |||
| 1d12011eb5 | |||
| 7c01f84a5c | |||
| 81c5f4acc3 | |||
| 0ebfe047d1 | |||
| e68bd53e30 | |||
| cdd9851f72 | |||
| 995c3ef81b | |||
| 0dfde1374d | |||
| 34235199dd | |||
| 5d1cd670e9 | |||
| 1d8ea7b0ee | |||
| 4b218553c3 | |||
| a61c1004d3 | |||
| 5d1b42b314 | |||
| 4b992c6f3e | |||
| 38562f9560 | |||
| c01f0271fe | |||
| 0296998fae | |||
| a67b917bdd |
@@ -91,10 +91,13 @@ body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Portainer version
|
||||
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.
|
||||
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 [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.28.1'
|
||||
- '2.28.0'
|
||||
- '2.27.3'
|
||||
- '2.27.2'
|
||||
- '2.27.1'
|
||||
- '2.27.0'
|
||||
- '2.26.1'
|
||||
@@ -111,16 +114,6 @@ body:
|
||||
- '2.21.2'
|
||||
- '2.21.1'
|
||||
- '2.21.0'
|
||||
- '2.20.3'
|
||||
- '2.20.2'
|
||||
- '2.20.1'
|
||||
- '2.20.0'
|
||||
- '2.19.5'
|
||||
- '2.19.4'
|
||||
- '2.19.3'
|
||||
- '2.19.2'
|
||||
- '2.19.1'
|
||||
- '2.19.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ func CLIFlags() *portainer.CLIFlags {
|
||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
|
||||
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-16
@@ -4,20 +4,21 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
)
|
||||
|
||||
+18
-17
@@ -1,21 +1,22 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
)
|
||||
|
||||
@@ -576,6 +576,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
AdminCreationDone: adminCreationDone,
|
||||
PendingActionsService: pendingActionsService,
|
||||
PlatformService: platformService,
|
||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
|
||||
type ReadTransaction interface {
|
||||
GetObject(bucketName string, key []byte, object any) error
|
||||
GetRawBytes(bucketName string, key []byte) ([]byte, error)
|
||||
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
||||
KeyExists(bucketName string, key []byte) (bool, error)
|
||||
}
|
||||
|
||||
type Transaction interface {
|
||||
|
||||
@@ -244,6 +244,32 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
|
||||
})
|
||||
}
|
||||
|
||||
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
var value []byte
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
value, err = tx.GetRawBytes(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
exists, err = tx.KeyExists(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) getEncryptionKey() []byte {
|
||||
if !connection.isEncrypted {
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -31,6 +32,33 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
|
||||
return tx.conn.UnmarshalObject(value, object)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
||||
}
|
||||
|
||||
if tx.conn.getEncryptionKey() != nil {
|
||||
var err error
|
||||
|
||||
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
|
||||
return value, errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
|
||||
return value != nil, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
||||
data, err := tx.conn.MarshalObject(object)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
type BaseCRUD[T any, I constraints.Integer] interface {
|
||||
Create(element *T) error
|
||||
Read(ID I) (*T, error)
|
||||
Exists(ID I) (bool, error)
|
||||
ReadAll() ([]T, error)
|
||||
Update(ID I, element *T) error
|
||||
Delete(ID I) error
|
||||
@@ -42,6 +43,19 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
exists, err = service.Tx(tx).Exists(ID)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
|
||||
var collection = make([]T, 0)
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
|
||||
return &element, nil
|
||||
}
|
||||
|
||||
func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
|
||||
identifier := service.Connection.ConvertToKey(int(ID))
|
||||
|
||||
return service.Tx.KeyExists(service.Bucket, identifier)
|
||||
}
|
||||
|
||||
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
|
||||
var collection = make([]T, 0)
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portaine
|
||||
}
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments += len(endpointIDs)
|
||||
}); err != nil {
|
||||
@@ -119,6 +123,10 @@ func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []porta
|
||||
}
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||
edgeStack.NumDeployments -= len(endpointIDs)
|
||||
}); err != nil {
|
||||
|
||||
@@ -159,6 +159,7 @@ type (
|
||||
|
||||
SnapshotService interface {
|
||||
BaseCRUD[portainer.Snapshot, portainer.EndpointID]
|
||||
ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error)
|
||||
}
|
||||
|
||||
// SSLSettingsService represents a service for managing application settings
|
||||
|
||||
@@ -38,3 +38,16 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
func (service *Service) Create(snapshot *portainer.Snapshot) error {
|
||||
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||
}
|
||||
|
||||
func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
|
||||
var snapshot *portainer.Snapshot
|
||||
|
||||
err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
snapshot, err = service.Tx(tx).ReadWithoutSnapshotRaw(ID)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
@@ -12,3 +12,26 @@ type ServiceTx struct {
|
||||
func (service ServiceTx) Create(snapshot *portainer.Snapshot) error {
|
||||
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
|
||||
}
|
||||
|
||||
func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
|
||||
var snapshot struct {
|
||||
Docker *struct {
|
||||
X struct{} `json:"DockerSnapshotRaw"`
|
||||
*portainer.DockerSnapshot
|
||||
} `json:"Docker"`
|
||||
|
||||
portainer.Snapshot
|
||||
}
|
||||
|
||||
identifier := service.Connection.ConvertToKey(int(ID))
|
||||
|
||||
if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
snapshot.Snapshot.Docker = snapshot.Docker.DockerSnapshot
|
||||
}
|
||||
|
||||
return &snapshot.Snapshot, nil
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.28.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.29.2",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.28.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.29.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -4,17 +4,11 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type kubernetesMockDeployer struct{}
|
||||
type kubernetesMockDeployer struct {
|
||||
portainer.KubernetesDeployer
|
||||
}
|
||||
|
||||
// NewKubernetesDeployer creates a mock kubernetes deployer
|
||||
func NewKubernetesDeployer() portainer.KubernetesDeployer {
|
||||
func NewKubernetesDeployer() *kubernetesMockDeployer {
|
||||
return &kubernetesMockDeployer{}
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", 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)
|
||||
|
||||
@@ -24,10 +24,6 @@ type edgeGroupUpdatePayload struct {
|
||||
}
|
||||
|
||||
func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
|
||||
if len(payload.Name) == 0 {
|
||||
return errors.New("invalid Edge group name")
|
||||
}
|
||||
|
||||
if payload.Dynamic && len(payload.TagIDs) == 0 {
|
||||
return errors.New("tagIDs is mandatory for a dynamic Edge group")
|
||||
}
|
||||
@@ -35,7 +31,7 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id EgeGroupUpdate
|
||||
// @id EdgeGroupUpdate
|
||||
// @summary Updates an EdgeGroup
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_groups
|
||||
|
||||
@@ -145,11 +145,15 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
||||
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
|
||||
|
||||
if len(relatedEnvironmentsToRemove) > 0 {
|
||||
tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to remove edge stack relations from the database")
|
||||
}
|
||||
}
|
||||
|
||||
if len(relatedEnvironmentsToAdd) > 0 {
|
||||
tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID)
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to add edge stack relations to the database")
|
||||
}
|
||||
}
|
||||
|
||||
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
|
||||
|
||||
@@ -80,6 +80,13 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
if handler.PullLimitCheckDisabled {
|
||||
return response.JSON(w, &dockerhubStatusResponse{
|
||||
Limit: 10,
|
||||
Remaining: 10,
|
||||
})
|
||||
}
|
||||
|
||||
httpClient := client.NewHTTPClient()
|
||||
token, err := getDockerHubToken(httpClient, registry)
|
||||
if err != nil {
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @success 200 {object} portainer.Endpoint "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Environment(Endpoint) not found"
|
||||
@@ -37,8 +39,7 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
@@ -51,9 +52,11 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
endpointutils.UpdateEdgeEndpointHeartbeat(endpoint, settings)
|
||||
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
|
||||
|
||||
if !excludeSnapshot(r) {
|
||||
err = handler.SnapshotService.FillSnapshotData(endpoint)
|
||||
if err != nil {
|
||||
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
if !excludeSnapshot {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, !excludeRaw); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
@@ -83,9 +86,3 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
return response.JSON(w, endpoint)
|
||||
}
|
||||
|
||||
func excludeSnapshot(r *http.Request) bool {
|
||||
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
|
||||
|
||||
return excludeSnapshot
|
||||
}
|
||||
|
||||
@@ -38,15 +38,19 @@ const (
|
||||
// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)"
|
||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||
// @param excludeIds query []int false "will exclude these environments(endpoints)"
|
||||
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
||||
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
||||
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
|
||||
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
|
||||
// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)"
|
||||
// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @param name query string false "will return only environments(endpoints) with this name"
|
||||
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
|
||||
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
|
||||
// @param edgeGroupIds query []int false "List environments(endpoints) of these edge groups"
|
||||
// @param excludeEdgeGroupIds query []int false "Exclude environments(endpoints) of these edge groups"
|
||||
// @success 200 {array} portainer.Endpoint "Endpoints"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints [get]
|
||||
@@ -59,6 +63,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
@@ -105,14 +110,16 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
for idx := range paginatedEndpoints {
|
||||
hideFields(&paginatedEndpoints[idx])
|
||||
|
||||
paginatedEndpoints[idx].ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
|
||||
if paginatedEndpoints[idx].EdgeCheckinInterval == 0 {
|
||||
paginatedEndpoints[idx].EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
|
||||
|
||||
if !query.excludeSnapshots {
|
||||
err = handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx])
|
||||
if err != nil {
|
||||
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], !excludeRaw); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
@@ -120,6 +127,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
|
||||
w.Header().Set("X-Total-Available", strconv.Itoa(totalAvailableEndpoints))
|
||||
|
||||
return response.JSON(w, paginatedEndpoints)
|
||||
}
|
||||
|
||||
@@ -130,18 +138,8 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
|
||||
|
||||
endpointCount := len(endpoints)
|
||||
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
if start > endpointCount {
|
||||
start = endpointCount
|
||||
}
|
||||
|
||||
end := start + limit
|
||||
if end > endpointCount {
|
||||
end = endpointCount
|
||||
}
|
||||
start = min(max(start, 0), endpointCount)
|
||||
end := min(start+limit, endpointCount)
|
||||
|
||||
return endpoints[start:end]
|
||||
}
|
||||
@@ -151,8 +149,10 @@ func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.Endp
|
||||
for _, group := range groups {
|
||||
if group.ID == groupID {
|
||||
endpointGroup = group
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return endpointGroup
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint); err != nil {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, true); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ type EnvironmentsQuery struct {
|
||||
edgeStackId portainer.EdgeStackID
|
||||
edgeStackStatus *portainer.EdgeStackStatusType
|
||||
excludeIds []portainer.EndpointID
|
||||
edgeGroupIds []portainer.EdgeGroupID
|
||||
excludeEdgeGroupIds []portainer.EdgeGroupID
|
||||
}
|
||||
|
||||
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
@@ -77,6 +79,16 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
excludeEdgeGroupIds, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "excludeEdgeGroupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
agentVersions := getArrayQueryParameter(r, "agentVersions")
|
||||
|
||||
name, _ := request.RetrieveQueryParameter(r, "name", true)
|
||||
@@ -117,6 +129,8 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
edgeCheckInPassedSeconds: edgeCheckInPassedSeconds,
|
||||
edgeStackId: portainer.EdgeStackID(edgeStackId),
|
||||
edgeStackStatus: edgeStackStatus,
|
||||
edgeGroupIds: edgeGroupIDs,
|
||||
excludeEdgeGroupIds: excludeEdgeGroupIds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -143,6 +157,14 @@ func (handler *Handler) filterEndpointsByQuery(
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||
}
|
||||
|
||||
if len(query.edgeGroupIds) > 0 {
|
||||
filteredEndpoints, edgeGroups = filterEndpointsByEdgeGroupIDs(filteredEndpoints, edgeGroups, query.edgeGroupIds)
|
||||
}
|
||||
|
||||
if len(query.excludeEdgeGroupIds) > 0 {
|
||||
filteredEndpoints, edgeGroups = filterEndpointsByExcludeEdgeGroupIDs(filteredEndpoints, edgeGroups, query.excludeEdgeGroupIds)
|
||||
}
|
||||
|
||||
if query.name != "" {
|
||||
filteredEndpoints = filterEndpointsByName(filteredEndpoints, query.name)
|
||||
}
|
||||
@@ -295,6 +317,70 @@ func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs
|
||||
return endpoints[:n]
|
||||
}
|
||||
|
||||
func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeGroupIDs []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) {
|
||||
edgeGroupIDFilterSet := make(map[portainer.EdgeGroupID]struct{}, len(edgeGroupIDs))
|
||||
for _, id := range edgeGroupIDs {
|
||||
edgeGroupIDFilterSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
n := 0
|
||||
for _, edgeGroup := range edgeGroups {
|
||||
if _, exists := edgeGroupIDFilterSet[edgeGroup.ID]; exists {
|
||||
edgeGroups[n] = edgeGroup
|
||||
n++
|
||||
}
|
||||
}
|
||||
edgeGroups = edgeGroups[:n]
|
||||
|
||||
endpointIDSet := make(map[portainer.EndpointID]struct{})
|
||||
for _, edgeGroup := range edgeGroups {
|
||||
for _, endpointID := range edgeGroup.Endpoints {
|
||||
endpointIDSet[endpointID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
n = 0
|
||||
for _, endpoint := range endpoints {
|
||||
if _, exists := endpointIDSet[endpoint.ID]; exists {
|
||||
endpoints[n] = endpoint
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints[:n], edgeGroups
|
||||
}
|
||||
|
||||
func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, excludeEdgeGroupIds []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) {
|
||||
excludeEdgeGroupIDSet := make(map[portainer.EdgeGroupID]struct{}, len(excludeEdgeGroupIds))
|
||||
for _, id := range excludeEdgeGroupIds {
|
||||
excludeEdgeGroupIDSet[id] = struct{}{}
|
||||
}
|
||||
|
||||
n := 0
|
||||
excludeEndpointIDSet := make(map[portainer.EndpointID]struct{})
|
||||
for _, edgeGroup := range edgeGroups {
|
||||
if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok {
|
||||
for _, endpointID := range edgeGroup.Endpoints {
|
||||
excludeEndpointIDSet[endpointID] = struct{}{}
|
||||
}
|
||||
} else {
|
||||
edgeGroups[n] = edgeGroup
|
||||
n++
|
||||
}
|
||||
}
|
||||
edgeGroups = edgeGroups[:n]
|
||||
|
||||
n = 0
|
||||
for _, endpoint := range endpoints {
|
||||
if _, ok := excludeEndpointIDSet[endpoint.ID]; !ok {
|
||||
endpoints[n] = endpoint
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints[:n], edgeGroups
|
||||
}
|
||||
|
||||
func filterEndpointsBySearchCriteria(
|
||||
endpoints []portainer.Endpoint,
|
||||
endpointGroups []portainer.EndpointGroup,
|
||||
|
||||
@@ -26,19 +26,20 @@ func hideFields(endpoint *portainer.Endpoint) {
|
||||
// Handler is the HTTP handler used to handle environment(endpoint) operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer security.BouncerService
|
||||
DataStore dataservices.DataStore
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
SnapshotService portainer.SnapshotService
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
AuthorizationService *authorization.Service
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
BindAddress string
|
||||
BindAddressHTTPS string
|
||||
PendingActionsService *pendingactions.PendingActionsService
|
||||
requestBouncer security.BouncerService
|
||||
DataStore dataservices.DataStore
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
SnapshotService portainer.SnapshotService
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
AuthorizationService *authorization.Service
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
BindAddress string
|
||||
BindAddressHTTPS string
|
||||
PendingActionsService *pendingactions.PendingActionsService
|
||||
PullLimitCheckDisabled bool
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) operations.
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.28.0
|
||||
// @version 2.29.2
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -54,6 +54,14 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||
|
||||
// `helm get all [RELEASE_NAME]`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}",
|
||||
httperror.LoggerHandler(h.helmGet)).Methods(http.MethodGet)
|
||||
|
||||
// `helm history [RELEASE_NAME]`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}/history",
|
||||
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func Test_helmDelete(t *testing.T) {
|
||||
|
||||
// Install a single chart directly, to be deleted by the handler
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Install(options)
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
|
||||
t.Run("helmDelete succeeds with admin user", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/1/kubernetes/helm/"+options.Name, nil)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
_ "github.com/portainer/portainer/pkg/libhelm/release"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id HelmGet
|
||||
// @summary Get a helm release
|
||||
// @description Get details of a helm release by release name
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param name path string true "Helm release name"
|
||||
// @param namespace query string false "specify an optional namespace"
|
||||
// @param showResources query boolean false "show resources of the release"
|
||||
// @param revision query int false "specify an optional revision"
|
||||
// @success 200 {object} release.Release "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the release."
|
||||
// @router /endpoints/{id}/kubernetes/helm/{name} [get]
|
||||
func (handler *Handler) helmGet(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
release, err := request.RetrieveRouteVariableValue(r, "release")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("No release specified", err)
|
||||
}
|
||||
|
||||
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||
if httperr != nil {
|
||||
return httperr
|
||||
}
|
||||
|
||||
// build the get options
|
||||
getOpts := options.GetOptions{
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
Name: release,
|
||||
}
|
||||
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
// optional namespace. The library defaults to "default"
|
||||
if namespace != "" {
|
||||
getOpts.Namespace = namespace
|
||||
}
|
||||
showResources, _ := request.RetrieveBooleanQueryParameter(r, "showResources", true)
|
||||
getOpts.ShowResources = showResources
|
||||
revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
|
||||
// optional revision. The library defaults to the latest revision if not specified
|
||||
if revision > 0 {
|
||||
getOpts.Revision = revision
|
||||
}
|
||||
|
||||
releases, err := handler.helmPackageManager.Get(getOpts)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Helm returned an error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, releases)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
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/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"
|
||||
)
|
||||
|
||||
func Test_helmGet(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
|
||||
is.NoError(err, "Error creating environment")
|
||||
|
||||
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "Error creating a user")
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
// Install a single chart, to be retrieved by the handler
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
|
||||
t.Run("helmGet sucessfuly retrieves helm release", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"?namespace="+options.Namespace, nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
data := release.Release{}
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
json.Unmarshal(body, &data)
|
||||
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||
is.Equal("nginx-1", data.Name)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
_ "github.com/portainer/portainer/pkg/libhelm/release"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id HelmGetHistory
|
||||
// @summary Get a historical list of releases
|
||||
// @description Get a historical list of releases by release name
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param name path string true "Helm release name"
|
||||
// @param namespace query string false "specify an optional namespace"
|
||||
// @success 200 {array} release.Release "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve the historical list of releases."
|
||||
// @router /endpoints/{id}/kubernetes/helm/{release}/history [get]
|
||||
func (handler *Handler) helmGetHistory(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
release, err := request.RetrieveRouteVariableValue(r, "release")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("No release specified", err)
|
||||
}
|
||||
|
||||
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||
if httperr != nil {
|
||||
return httperr
|
||||
}
|
||||
|
||||
historyOptions := options.HistoryOptions{
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
Name: release,
|
||||
}
|
||||
|
||||
// optional namespace. The library defaults to "default"
|
||||
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
if namespace != "" {
|
||||
historyOptions.Namespace = namespace
|
||||
}
|
||||
|
||||
releases, err := handler.helmPackageManager.GetHistory(historyOptions)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Helm returned an error", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, releases)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/exec/exectest"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
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/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"
|
||||
)
|
||||
|
||||
func Test_helmGetHistory(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
|
||||
is.NoError(err, "Error creating environment")
|
||||
|
||||
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "Error creating a user")
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
is.NoError(err, "Error initiating jwt service")
|
||||
|
||||
kubernetesDeployer := exectest.NewKubernetesDeployer()
|
||||
helmPackageManager := test.NewMockHelmPackageManager()
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
|
||||
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
|
||||
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
// Install a single chart, to be retrieved by the handler
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
|
||||
t.Run("helmGetHistory sucessfuly retrieves helm release history", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"/history?namespace="+options.Namespace, nil)
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
data := []release.Release{}
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
json.Unmarshal(body, &data)
|
||||
is.Equal(http.StatusOK, rr.Code, "Status should be 200")
|
||||
is.Equal(1, len(data))
|
||||
is.Equal("nginx-1", data[0].Name)
|
||||
})
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
||||
installOpts.ValuesFile = file.Name()
|
||||
}
|
||||
|
||||
release, err := handler.helmPackageManager.Install(installOpts)
|
||||
release, err := handler.helmPackageManager.Upgrade(installOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func Test_helmList(t *testing.T) {
|
||||
|
||||
// Install a single chart. We expect to get these values back
|
||||
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
|
||||
h.helmPackageManager.Install(options)
|
||||
h.helmPackageManager.Upgrade(options)
|
||||
|
||||
t.Run("helmList", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil)
|
||||
|
||||
@@ -167,6 +167,16 @@ 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 {
|
||||
|
||||
@@ -146,13 +146,11 @@ func (handler *Handler) getAllKubernetesConfigMaps(r *http.Request) ([]models.K8
|
||||
}
|
||||
|
||||
if isUsed {
|
||||
configMapsWithApplications, err := cli.CombineConfigMapsWithApplications(configMaps)
|
||||
err = cli.SetConfigMapsIsUsed(&configMaps)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to combine configMaps with associated applications")
|
||||
return nil, httperror.InternalServerError("Unable to combine configMaps with associated applications", err)
|
||||
}
|
||||
|
||||
return configMapsWithApplications, nil
|
||||
}
|
||||
|
||||
return configMaps, nil
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/libkubectl"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type describeResourceResponse struct {
|
||||
Describe string `json:"describe"`
|
||||
}
|
||||
|
||||
// @id DescribeResource
|
||||
// @summary Get a description of a kubernetes resource
|
||||
// @description Get a description of a kubernetes resource.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param name query string true "Resource name"
|
||||
// @param kind query string true "Resource kind"
|
||||
// @param namespace query string false "Namespace"
|
||||
// @success 200 {object} describeResourceResponse "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve resource description"
|
||||
// @router /kubernetes/{id}/describe [get]
|
||||
func (handler *Handler) describeResource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
name, err := request.RetrieveQueryParameter(r, "name", false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter name")
|
||||
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter name. Error: ", err)
|
||||
}
|
||||
|
||||
kind, err := request.RetrieveQueryParameter(r, "kind", false)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter kind")
|
||||
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter kind. Error: ", err)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter namespace")
|
||||
return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter namespace. Error: ", err)
|
||||
}
|
||||
|
||||
// fetches the token and the correct server URL for the endpoint, similar to getHelmClusterAccess
|
||||
libKubectlAccess, err := handler.getLibKubectlAccess(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to get libKubectlAccess. Error: ", err)
|
||||
}
|
||||
|
||||
client, err := libkubectl.NewClient(libKubectlAccess, namespace, "", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to create kubernetes client")
|
||||
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to create kubernetes client. Error: ", err)
|
||||
}
|
||||
|
||||
out, err := client.Describe(namespace, name, kind)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "describeResource").Msg("Failed to describe kubernetes resource")
|
||||
return httperror.InternalServerError("an error occurred during the describeResource operation, failed to describe kubernetes resource. Error: ", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, describeResourceResponse{Describe: out})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libkubectl"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -102,6 +103,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
||||
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet)
|
||||
|
||||
// namespaces
|
||||
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
|
||||
@@ -269,3 +271,36 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *Handler) getLibKubectlAccess(r *http.Request) (*libkubectl.ClientAccess, error) {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err)
|
||||
}
|
||||
|
||||
bearerToken, _, err := handler.JwtService.GenerateToken(tokenData)
|
||||
if err != nil {
|
||||
return nil, httperror.Unauthorized("Unauthorized", err)
|
||||
}
|
||||
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to find the Kubernetes endpoint associated to the request.", err)
|
||||
}
|
||||
|
||||
sslSettings, err := handler.DataStore.SSLSettings().Settings()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
hostURL := "localhost"
|
||||
if !sslSettings.SelfSigned {
|
||||
hostURL = r.Host
|
||||
}
|
||||
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
|
||||
return &libkubectl.ClientAccess{
|
||||
Token: bearerToken,
|
||||
ServerUrl: kubeConfigInternal.ClusterServerURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -130,13 +130,11 @@ func (handler *Handler) getAllKubernetesSecrets(r *http.Request) ([]models.K8sSe
|
||||
}
|
||||
|
||||
if isUsed {
|
||||
secretsWithApplications, err := cli.CombineSecretsWithApplications(secrets)
|
||||
err = cli.SetSecretsIsUsed(&secrets)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to combine secrets with associated applications")
|
||||
return nil, httperror.InternalServerError("unable to combine secrets with associated applications. Error: ", err)
|
||||
}
|
||||
|
||||
return secretsWithApplications, nil
|
||||
}
|
||||
|
||||
return secrets, nil
|
||||
|
||||
@@ -33,7 +33,7 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
|
||||
var nodes int
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
|
||||
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint, false); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error {
|
||||
// @produce json
|
||||
// @param body body teamMembershipCreatePayload true "Team membership details"
|
||||
// @success 200 {object} portainer.TeamMembership "Success"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied to manage memberships"
|
||||
// @failure 409 "Team membership already registered"
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
// @produce json
|
||||
// @param id path int true "Team identifier"
|
||||
// @success 200 {object} portainer.Team "Success"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Team not found"
|
||||
|
||||
@@ -30,7 +30,6 @@ func (payload *teamUpdatePayload) Validate(r *http.Request) error {
|
||||
// @param id path int true "Team identifier"
|
||||
// @param body body teamUpdatePayload true "Team details"
|
||||
// @success 200 {object} portainer.Team "Success"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Team not found"
|
||||
|
||||
@@ -52,12 +52,12 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
||||
teamLeaderRouter := h.NewRoute().Subrouter()
|
||||
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
|
||||
|
||||
restrictedRouter := h.NewRoute().Subrouter()
|
||||
restrictedRouter.Use(bouncer.RestrictedAccess)
|
||||
|
||||
authenticatedRouter := h.NewRoute().Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
restrictedRouter := h.NewRoute().Subrouter()
|
||||
restrictedRouter.Use(bouncer.RestrictedAccess)
|
||||
|
||||
publicRouter := h.NewRoute().Subrouter()
|
||||
publicRouter.Use(bouncer.PublicAccess)
|
||||
|
||||
@@ -65,7 +65,6 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
||||
restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet)
|
||||
|
||||
authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet)
|
||||
authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut)
|
||||
adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete)
|
||||
|
||||
@@ -50,7 +50,7 @@ type accessTokenResponse struct {
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @param body body userAccessTokenCreatePayload true "details"
|
||||
// @success 200 {object} accessTokenResponse "Created"
|
||||
// @success 200 {object} accessTokenResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 403 "Permission denied"
|
||||
@@ -115,7 +115,7 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Internal Server Error", err)
|
||||
}
|
||||
|
||||
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusCreated)
|
||||
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
|
||||
|
||||
@@ -60,7 +60,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusCreated, rr.Code)
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func WithPanicLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Error().
|
||||
Any("panic", err).
|
||||
Str("method", req.Method).
|
||||
Str("url", req.URL.String()).
|
||||
Str("stack", string(debug.Stack())).
|
||||
Msg("Panic in request handler")
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
@@ -224,7 +224,7 @@ func (transport *Transport) getDockerID() (string, error) {
|
||||
if transport.snapshotService != nil {
|
||||
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
|
||||
|
||||
if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
|
||||
if err := transport.snapshotService.FillSnapshotData(&endpoint, true); err == nil && len(endpoint.Snapshots) > 0 {
|
||||
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
|
||||
transport.dockerID = dockerID
|
||||
return dockerID, nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+3
-1
@@ -112,6 +112,7 @@ type Server struct {
|
||||
AdminCreationDone chan struct{}
|
||||
PendingActionsService *pendingactions.PendingActionsService
|
||||
PlatformService platform.Service
|
||||
PullLimitCheckDisabled bool
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
@@ -181,6 +182,7 @@ func (server *Server) Start() error {
|
||||
endpointHandler.BindAddress = server.BindAddress
|
||||
endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS
|
||||
endpointHandler.PendingActionsService = server.PendingActionsService
|
||||
endpointHandler.PullLimitCheckDisabled = server.PullLimitCheckDisabled
|
||||
|
||||
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer, server.DataStore, server.FileService, server.ReverseTunnelService)
|
||||
|
||||
@@ -335,7 +337,7 @@ func (server *Server) Start() error {
|
||||
|
||||
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))
|
||||
|
||||
handler = middlewares.WithSlowRequestsLogger(handler)
|
||||
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
|
||||
|
||||
handler, err := csrf.WithProtect(handler)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package edge
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -13,21 +15,19 @@ func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []porta
|
||||
return edgeGroup.Endpoints
|
||||
}
|
||||
|
||||
endpointGroupsMap := map[portainer.EndpointGroupID]*portainer.EndpointGroup{}
|
||||
for i, group := range endpointGroups {
|
||||
endpointGroupsMap[group.ID] = &endpointGroups[i]
|
||||
}
|
||||
|
||||
endpointIDs := []portainer.EndpointID{}
|
||||
for _, endpoint := range endpoints {
|
||||
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
var endpointGroup portainer.EndpointGroup
|
||||
for _, group := range endpointGroups {
|
||||
if endpoint.GroupID == group.ID {
|
||||
endpointGroup = group
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, &endpointGroup) {
|
||||
endpointGroup := endpointGroupsMap[endpoint.GroupID]
|
||||
if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, endpointGroup) {
|
||||
endpointIDs = append(endpointIDs, endpoint.ID)
|
||||
}
|
||||
}
|
||||
@@ -72,17 +72,11 @@ func GetEndpointsFromEdgeGroups(edgeGroupIDs []portainer.EdgeGroupID, datastore
|
||||
// edgeGroupRelatedToEndpoint returns true if edgeGroup is associated with environment(endpoint)
|
||||
func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool {
|
||||
if !edgeGroup.Dynamic {
|
||||
for _, endpointID := range edgeGroup.Endpoints {
|
||||
if endpoint.ID == endpointID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return slices.Contains(edgeGroup.Endpoints, endpoint.ID)
|
||||
}
|
||||
|
||||
endpointTags := tag.Set(endpoint.TagIDs)
|
||||
if endpointGroup.TagIDs != nil {
|
||||
if endpointGroup != nil && endpointGroup.TagIDs != nil {
|
||||
endpointTags = tag.Union(endpointTags, tag.Set(endpointGroup.TagIDs))
|
||||
}
|
||||
|
||||
|
||||
@@ -170,8 +170,8 @@ func (service *Service) Create(snapshot portainer.Snapshot) error {
|
||||
return service.dataStore.Snapshot().Create(&snapshot)
|
||||
}
|
||||
|
||||
func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
|
||||
return FillSnapshotData(service.dataStore, endpoint)
|
||||
func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint, includeRaw bool) error {
|
||||
return FillSnapshotData(service.dataStore, endpoint, includeRaw)
|
||||
}
|
||||
|
||||
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
|
||||
@@ -328,8 +328,16 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, includeRaw bool) error {
|
||||
var snapshot *portainer.Snapshot
|
||||
var err error
|
||||
|
||||
if includeRaw {
|
||||
snapshot, err = tx.Snapshot().Read(endpoint.ID)
|
||||
} else {
|
||||
snapshot, err = tx.Snapshot().ReadWithoutSnapshotRaw(endpoint.ID)
|
||||
}
|
||||
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
@@ -110,9 +110,11 @@ type datastoreOption = func(d *testDatastore)
|
||||
func NewDatastore(options ...datastoreOption) *testDatastore {
|
||||
conn, _ := database.NewDatabase("boltdb", "", nil)
|
||||
d := testDatastore{connection: conn}
|
||||
|
||||
for _, o := range options {
|
||||
o(&d)
|
||||
}
|
||||
|
||||
return &d
|
||||
}
|
||||
|
||||
@@ -128,6 +130,7 @@ func (s *stubSettingsService) Settings() (*portainer.Settings, error) {
|
||||
|
||||
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error {
|
||||
s.settings = settings
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,19 +143,16 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
|
||||
}
|
||||
|
||||
type stubUserService struct {
|
||||
dataservices.UserService
|
||||
|
||||
users []portainer.User
|
||||
}
|
||||
|
||||
func (s *stubUserService) BucketName() string { return "users" }
|
||||
func (s *stubUserService) Read(ID portainer.UserID) (*portainer.User, error) { return nil, nil }
|
||||
func (s *stubUserService) UserByUsername(username string) (*portainer.User, error) { return nil, nil }
|
||||
func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
|
||||
func (s *stubUserService) BucketName() string { return "users" }
|
||||
func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
|
||||
func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
|
||||
return s.users, nil
|
||||
}
|
||||
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 }
|
||||
|
||||
// WithUsers testDatastore option that will instruct testDatastore to return provided users
|
||||
func WithUsers(us []portainer.User) datastoreOption {
|
||||
@@ -162,32 +162,13 @@ func WithUsers(us []portainer.User) datastoreOption {
|
||||
}
|
||||
|
||||
type stubEdgeJobService struct {
|
||||
dataservices.EdgeJobService
|
||||
|
||||
jobs []portainer.EdgeJob
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) BucketName() string { return "edgejobs" }
|
||||
func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil }
|
||||
func (s *stubEdgeJobService) Read(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Create(edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) Update(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
|
||||
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
|
||||
|
||||
// WithEdgeJobs option will instruct testDatastore to return provided jobs
|
||||
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||
@@ -197,6 +178,8 @@ func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||
}
|
||||
|
||||
type stubEndpointRelationService struct {
|
||||
dataservices.EndpointRelationService
|
||||
|
||||
relations []portainer.EndpointRelation
|
||||
}
|
||||
|
||||
@@ -215,10 +198,6 @@ func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID)
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) Create(EndpointRelation *portainer.EndpointRelation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error {
|
||||
for i, r := range s.relations {
|
||||
if r.EndpointID == ID {
|
||||
@@ -253,11 +232,6 @@ func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpoi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubEndpointRelationService) GetNextIdentifier() int { return 0 }
|
||||
|
||||
// WithEndpointRelations option will instruct testDatastore to return provided jobs
|
||||
func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
@@ -356,6 +330,7 @@ func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]port
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
@@ -367,29 +342,19 @@ func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
|
||||
}
|
||||
|
||||
type stubStacksService struct {
|
||||
dataservices.StackService
|
||||
stacks []portainer.Stack
|
||||
}
|
||||
|
||||
func (s *stubStacksService) BucketName() string { return "stacks" }
|
||||
|
||||
func (s *stubStacksService) Create(stack *portainer.Stack) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Update(ID portainer.StackID, stack *portainer.Stack) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Delete(ID portainer.StackID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) {
|
||||
for _, stack := range s.stacks {
|
||||
if stack.ID == ID {
|
||||
return &stack, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
@@ -405,6 +370,7 @@ func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID)
|
||||
result = append(result, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -416,6 +382,7 @@ func (s *stubStacksService) RefreshableStacks() ([]portainer.Stack, error) {
|
||||
result = append(result, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -425,6 +392,7 @@ func (s *stubStacksService) StackByName(name string) (*portainer.Stack, error) {
|
||||
return &stack, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
@@ -436,6 +404,7 @@ func (s *stubStacksService) StacksByName(name string) ([]portainer.Stack, error)
|
||||
result = append(result, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -445,6 +414,7 @@ func (s *stubStacksService) StackByWebhookID(webhookID string) (*portainer.Stack
|
||||
return &stack, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
@@ -452,6 +422,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) {
|
||||
|
||||
@@ -153,46 +153,6 @@ func (kcl *KubeClient) GetApplicationsResource(namespace, node string) (models.K
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// GetApplicationsFromConfigMap gets a list of applications that use a specific ConfigMap
|
||||
// by checking all pods in the same namespace as the ConfigMap
|
||||
func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) {
|
||||
applications := []string{}
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applications = append(applications, application.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) {
|
||||
applications := []string{}
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applications = append(applications, application.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applications, nil
|
||||
}
|
||||
|
||||
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
|
||||
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
|
||||
if isReplicaSetOwner(pod) {
|
||||
@@ -473,23 +433,23 @@ func (kcl *KubeClient) GetApplicationFromServiceSelector(pods []corev1.Pod, serv
|
||||
func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) {
|
||||
configurationOwners := []models.K8sConfigurationOwnerResource{}
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == configMap.Namespace {
|
||||
if isPodUsingConfigMap(&pod, configMap.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPodUsingConfigMap(&pod, configMap) {
|
||||
kind := "Pod"
|
||||
name := pod.Name
|
||||
|
||||
if application != nil {
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: application.Name,
|
||||
ResourceKind: application.Kind,
|
||||
Id: application.UID,
|
||||
})
|
||||
}
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
kind = pod.OwnerReferences[0].Kind
|
||||
name = pod.OwnerReferences[0].Name
|
||||
}
|
||||
|
||||
if isReplicaSetOwner(pod) {
|
||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
||||
}
|
||||
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: name,
|
||||
ResourceKind: kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,23 +461,23 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
|
||||
func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) {
|
||||
configurationOwners := []models.K8sConfigurationOwnerResource{}
|
||||
for _, pod := range pods {
|
||||
if pod.Namespace == secret.Namespace {
|
||||
if isPodUsingSecret(&pod, secret.Name) {
|
||||
application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
|
||||
ReplicaSets: replicaSets,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isPodUsingSecret(&pod, secret) {
|
||||
kind := "Pod"
|
||||
name := pod.Name
|
||||
|
||||
if application != nil {
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: application.Name,
|
||||
ResourceKind: application.Kind,
|
||||
Id: application.UID,
|
||||
})
|
||||
}
|
||||
if len(pod.OwnerReferences) > 0 {
|
||||
kind = pod.OwnerReferences[0].Kind
|
||||
name = pod.OwnerReferences[0].Name
|
||||
}
|
||||
|
||||
if isReplicaSetOwner(pod) {
|
||||
updateOwnerReferenceToDeployment(&pod, replicaSets)
|
||||
}
|
||||
|
||||
configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
|
||||
Name: name,
|
||||
ResourceKind: kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/rs/zerolog/log"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
@@ -95,35 +96,28 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
|
||||
return result
|
||||
}
|
||||
|
||||
// CombineConfigMapsWithApplications combines the config maps with the applications that use them.
|
||||
// SetConfigMapsIsUsed combines the config maps with the applications that use them.
|
||||
// the function fetches all the pods and replica sets in the cluster and checks if the config map is used by any of the pods.
|
||||
// if the config map is used by a pod, the application that uses the pod is added to the config map.
|
||||
// otherwise, the config map is returned as is.
|
||||
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
|
||||
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
|
||||
|
||||
func (kcl *KubeClient) SetConfigMapsIsUsed(configMaps *[]models.K8sConfigMap) error {
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
return fmt.Errorf("an error occurred during the SetConfigMapsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err)
|
||||
}
|
||||
|
||||
for index, configMap := range configMaps {
|
||||
updatedConfigMap := configMap
|
||||
for i := range *configMaps {
|
||||
configMap := &(*configMaps)[i]
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||
for _, pod := range portainerApplicationResources.Pods {
|
||||
if isPodUsingConfigMap(&pod, *configMap) {
|
||||
configMap.IsUsed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
updatedConfigMap.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
updatedConfigMap.IsUsed = true
|
||||
}
|
||||
|
||||
updatedConfigMaps[index] = updatedConfigMap
|
||||
}
|
||||
|
||||
return updatedConfigMaps, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// CombineConfigMapWithApplications combines the config map with the applications that use it.
|
||||
@@ -141,20 +135,22 @@ func (kcl *KubeClient) CombineConfigMapWithApplications(configMap models.K8sConf
|
||||
break
|
||||
}
|
||||
|
||||
var replicaSets *appsv1.ReplicaSetList
|
||||
if containsReplicaSetOwner {
|
||||
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get replica sets. Error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||
}
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err)
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
configMap.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
}
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
configMap.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
configMap.IsUsed = true
|
||||
}
|
||||
|
||||
return configMap, nil
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -235,16 +236,20 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi
|
||||
}
|
||||
|
||||
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
|
||||
func isPodUsingConfigMap(pod *corev1.Pod, configMapName string) bool {
|
||||
func isPodUsingConfigMap(pod *corev1.Pod, configMap models.K8sConfigMap) bool {
|
||||
if pod.Namespace != configMap.Namespace {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, volume := range pod.Spec.Volumes {
|
||||
if volume.ConfigMap != nil && volume.ConfigMap.Name == configMapName {
|
||||
if volume.ConfigMap != nil && volume.ConfigMap.Name == configMap.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, container := range pod.Spec.Containers {
|
||||
for _, env := range container.Env {
|
||||
if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMapName {
|
||||
if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMap.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -254,16 +259,20 @@ func isPodUsingConfigMap(pod *corev1.Pod, configMapName string) bool {
|
||||
}
|
||||
|
||||
// isPodUsingSecret checks if a pod is using a specific Secret
|
||||
func isPodUsingSecret(pod *corev1.Pod, secretName string) bool {
|
||||
func isPodUsingSecret(pod *corev1.Pod, secret models.K8sSecret) bool {
|
||||
if pod.Namespace != secret.Namespace {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, volume := range pod.Spec.Volumes {
|
||||
if volume.Secret != nil && volume.Secret.SecretName == secretName {
|
||||
if volume.Secret != nil && volume.Secret.SecretName == secret.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for _, container := range pod.Spec.Containers {
|
||||
for _, env := range container.Env {
|
||||
if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secretName {
|
||||
if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secret.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/rs/zerolog/log"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -111,34 +112,28 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
|
||||
return result
|
||||
}
|
||||
|
||||
// CombineSecretsWithApplications combines the secrets with the applications that use them.
|
||||
// SetSecretsIsUsed combines the secrets with the applications that use them.
|
||||
// the function fetches all the pods and replica sets in the cluster and checks if the secret is used by any of the pods.
|
||||
// if the secret is used by a pod, the application that uses the pod is added to the secret.
|
||||
// otherwise, the secret is returned as is.
|
||||
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
|
||||
updatedSecrets := make([]models.K8sSecret, len(secrets))
|
||||
|
||||
func (kcl *KubeClient) SetSecretsIsUsed(secrets *[]models.K8sSecret) error {
|
||||
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
|
||||
return fmt.Errorf("an error occurred during the SetSecretsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err)
|
||||
}
|
||||
|
||||
for index, secret := range secrets {
|
||||
updatedSecret := secret
|
||||
for i := range *secrets {
|
||||
secret := &(*secrets)[i]
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
for _, pod := range portainerApplicationResources.Pods {
|
||||
if isPodUsingSecret(&pod, *secret) {
|
||||
secret.IsUsed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
updatedSecret.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
}
|
||||
|
||||
updatedSecrets[index] = updatedSecret
|
||||
}
|
||||
|
||||
return updatedSecrets, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// CombineSecretWithApplications combines the secret with the applications that use it.
|
||||
@@ -156,20 +151,22 @@ func (kcl *KubeClient) CombineSecretWithApplications(secret models.K8sSecret) (m
|
||||
break
|
||||
}
|
||||
|
||||
var replicaSets *appsv1.ReplicaSetList
|
||||
if containsReplicaSetOwner {
|
||||
replicaSets, err := kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get replica sets. Error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
}
|
||||
applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSets.Items)
|
||||
if err != nil {
|
||||
return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err)
|
||||
}
|
||||
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
secret.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
}
|
||||
if len(applicationConfigurationOwners) > 0 {
|
||||
secret.ConfigurationOwnerResources = applicationConfigurationOwners
|
||||
secret.IsUsed = true
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
|
||||
@@ -109,6 +109,7 @@ func (service *kubeClusterAccessService) GetClusterDetails(hostURL string, endpo
|
||||
Str("host_URL", hostURL).
|
||||
Str("HTTPS_bind_address", service.httpsBindAddr).
|
||||
Str("base_URL", baseURL).
|
||||
Bool("is_internal", isInternal).
|
||||
Msg("kubeconfig")
|
||||
|
||||
clusterServerURL, err := url.JoinPath("https://", hostURL, baseURL, "/api/endpoints/", strconv.Itoa(int(endpointID)), "/kubernetes")
|
||||
|
||||
+5
-2
@@ -134,6 +134,7 @@ type (
|
||||
LogLevel *string
|
||||
LogMode *string
|
||||
KubectlShellImage *string
|
||||
PullLimitCheckDisabled *bool
|
||||
}
|
||||
|
||||
// CustomTemplateVariableDefinition
|
||||
@@ -1622,7 +1623,7 @@ type (
|
||||
Start()
|
||||
SetSnapshotInterval(snapshotInterval string) error
|
||||
SnapshotEndpoint(endpoint *Endpoint) error
|
||||
FillSnapshotData(endpoint *Endpoint) error
|
||||
FillSnapshotData(endpoint *Endpoint, includeRaw bool) error
|
||||
}
|
||||
|
||||
// SwarmStackManager represents a service to manage Swarm stacks
|
||||
@@ -1637,7 +1638,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.28.0"
|
||||
APIVersion = "2.29.2"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
@@ -1689,6 +1690,8 @@ const (
|
||||
PortainerCacheHeader = "X-Portainer-Cache"
|
||||
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
|
||||
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
|
||||
// PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check
|
||||
PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED"
|
||||
)
|
||||
|
||||
// List of supported features
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableBindMountsForRegularUsers"
|
||||
name="'disableBindMountsForRegularUsers'"
|
||||
label="'Disable bind mounts for non-administrators'"
|
||||
label="'Hide bind mounts for non-administrators'"
|
||||
tooltip="'When enabled, regular users will not be able to use bind mounts when creating containers.'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableBindMountsForRegularUsers)"
|
||||
@@ -79,7 +79,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disablePrivilegedModeForRegularUsers"
|
||||
name="'disablePrivilegedModeForRegularUsers'"
|
||||
label="'Disable privileged mode for non-administrators'"
|
||||
label="'Hide privileged mode for non-administrators'"
|
||||
tooltip="'When enabled, regular users will not be able to use privileged mode when creating containers.'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisablePrivilegedModeForRegularUsers)"
|
||||
@@ -91,7 +91,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableHostNamespaceForRegularUsers"
|
||||
name="'disableHostNamespaceForRegularUsers'"
|
||||
label="'Disable the use of host PID 1 for non-administrators'"
|
||||
label="'Hide the use of host PID 1 for non-administrators'"
|
||||
tooltip="'Prevent users from accessing the host filesystem through the host PID namespace.'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableHostNamespaceForRegularUsers)"
|
||||
@@ -103,7 +103,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableStackManagementForRegularUsers"
|
||||
name="'disableStackManagementForRegularUsers'"
|
||||
label="'Disable the use of Stacks for non-administrators'"
|
||||
label="'Hide the use of Stacks for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableStackManagementForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -114,7 +114,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableDeviceMappingForRegularUsers"
|
||||
name="'disableDeviceMappingForRegularUsers'"
|
||||
label="'Disable device mappings for non-administrators'"
|
||||
label="'Hide device mappings for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableDeviceMappingForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -125,7 +125,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableContainerCapabilitiesForRegularUsers"
|
||||
name="'disableContainerCapabilitiesForRegularUsers'"
|
||||
label="'Disable container capabilities for non-administrators'"
|
||||
label="'Hide container capabilities for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableContainerCapabilitiesForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -136,7 +136,7 @@
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableSysctlSettingForRegularUsers"
|
||||
name="'disableSysctlSettingForRegularUsers'"
|
||||
label="'Disable sysctl settings for non-administrators'"
|
||||
label="'Hide sysctl settings for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableSysctlSettingForRegularUsers)"
|
||||
></por-switch-field>
|
||||
@@ -146,7 +146,7 @@
|
||||
<div class="form-group" ng-if="$ctrl.isContainerEditDisabled()">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<pr-icon icon="'info'" mode="'primary'" class-name="'mr-0.5'"></pr-icon>
|
||||
Note: The recreate/duplicate/edit feature is currently disabled (for non-admin users) by one or more security settings.
|
||||
Note: The recreate/duplicate/edit feature is currently hidden (for non-admin users) by one or more security settings.
|
||||
</span>
|
||||
</div>
|
||||
<!-- !security -->
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncInterva
|
||||
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
||||
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { AssociatedEdgeGroupEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector';
|
||||
|
||||
const ngModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
@@ -61,6 +62,15 @@ const ngModule = angular
|
||||
'value',
|
||||
'error',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'associatedEdgeGroupEnvironmentsSelector',
|
||||
r2a(withReactQuery(AssociatedEdgeGroupEnvironmentsSelector), [
|
||||
'onChange',
|
||||
'value',
|
||||
'error',
|
||||
'edgeGroupId',
|
||||
])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import _ from 'lodash-es';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { HelmIcon } from './HelmIcon';
|
||||
export default class HelmTemplatesController {
|
||||
/* @ngInject */
|
||||
constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications) {
|
||||
this.$analytics = $analytics;
|
||||
this.$async = $async;
|
||||
this.$window = $window;
|
||||
this.$state = $state;
|
||||
this.$anchorScroll = $anchorScroll;
|
||||
this.Authentication = Authentication;
|
||||
this.HelmService = HelmService;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.Notifications = Notifications;
|
||||
|
||||
this.fallbackIcon = HelmIcon;
|
||||
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.uiCanExit = this.uiCanExit.bind(this);
|
||||
this.installHelmchart = this.installHelmchart.bind(this);
|
||||
this.getHelmValues = this.getHelmValues.bind(this);
|
||||
this.selectHelmChart = this.selectHelmChart.bind(this);
|
||||
this.getHelmRepoURLs = this.getHelmRepoURLs.bind(this);
|
||||
this.getLatestCharts = this.getLatestCharts.bind(this);
|
||||
this.getResourcePools = this.getResourcePools.bind(this);
|
||||
this.clearHelmChart = this.clearHelmChart.bind(this);
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if (this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
clearHelmChart() {
|
||||
this.state.chart = null;
|
||||
this.onSelectHelmChart('');
|
||||
}
|
||||
|
||||
editorUpdate(contentvalues) {
|
||||
if (this.state.originalvalues === contentvalues) {
|
||||
this.state.isEditorDirty = false;
|
||||
} else {
|
||||
this.state.values = contentvalues;
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.state.isEditorDirty) {
|
||||
return confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
async installHelmchart() {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
const payload = {
|
||||
Name: this.name,
|
||||
Repo: this.state.chart.repo,
|
||||
Chart: this.state.chart.name,
|
||||
Values: this.state.values,
|
||||
Namespace: this.namespace,
|
||||
};
|
||||
await this.HelmService.install(this.endpoint.Id, payload);
|
||||
this.Notifications.success('Success', 'Helm chart successfully installed');
|
||||
this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.chart.name } });
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.applications');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Installation error', err);
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getHelmValues() {
|
||||
this.state.loadingValues = true;
|
||||
try {
|
||||
const { values } = await this.HelmService.values(this.state.chart.repo, this.state.chart.name);
|
||||
this.state.values = values;
|
||||
this.state.originalvalues = values;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm chart values.');
|
||||
} finally {
|
||||
this.state.loadingValues = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectHelmChart(chart) {
|
||||
window.scrollTo(0, 0);
|
||||
this.state.showCustomValues = false;
|
||||
this.state.chart = chart;
|
||||
this.onSelectHelmChart(chart.name);
|
||||
await this.getHelmValues();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This function is used to get the helm repo urls for the endpoint and user
|
||||
* @returns {Promise<string[]>} list of helm repo urls
|
||||
*/
|
||||
async getHelmRepoURLs() {
|
||||
this.state.reposLoading = true;
|
||||
try {
|
||||
// fetch globally set helm repo and user helm repos (parallel)
|
||||
const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.user.ID);
|
||||
this.state.globalRepository = GlobalRepository;
|
||||
const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
|
||||
const uniqueHelmRepos = [...new Set([GlobalRepository, ...userHelmReposUrls])].map((url) => url.toLowerCase()).filter((url) => url); // remove duplicates and blank, to lowercase
|
||||
this.state.repos = uniqueHelmRepos;
|
||||
return uniqueHelmRepos;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo urls.');
|
||||
} finally {
|
||||
this.state.reposLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description This function is used to fetch the respective index.yaml files for the provided helm repo urls
|
||||
* @param {string[]} helmRepos list of helm repositories
|
||||
* @param {bool} append append charts returned from repo to existing list of helm charts
|
||||
*/
|
||||
async getLatestCharts(helmRepos) {
|
||||
this.state.chartsLoading = true;
|
||||
try {
|
||||
const promiseList = helmRepos.map((repo) => this.HelmService.search(repo));
|
||||
// fetch helm charts from all the provided helm repositories (parallel)
|
||||
// Promise.allSettled is used to account for promise failure(s) - in cases the user has provided invalid helm repo
|
||||
const chartPromises = await Promise.allSettled(promiseList);
|
||||
const latestCharts = chartPromises
|
||||
.filter((tp) => tp.status === 'fulfilled') // remove failed promises
|
||||
.map((tp) => ({ entries: tp.value.entries, repo: helmRepos[chartPromises.indexOf(tp)] })) // extract chart entries with respective repo data
|
||||
.flatMap(
|
||||
({ entries, repo }) => Object.values(entries).map((charts) => ({ ...charts[0], repo })) // flatten chart entries to single array with respective repo
|
||||
);
|
||||
|
||||
this.state.charts = latestCharts;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm repo charts.');
|
||||
} finally {
|
||||
this.state.chartsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getResourcePools() {
|
||||
this.state.resourcePoolsLoading = true;
|
||||
try {
|
||||
const resourcePools = await this.KubernetesResourcePoolService.get();
|
||||
|
||||
const nonSystemNamespaces = resourcePools.filter(
|
||||
(resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
|
||||
);
|
||||
this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
|
||||
this.state.resourcePool = this.state.resourcePools[0];
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve initial helm data.');
|
||||
} finally {
|
||||
this.state.resourcePoolsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
this.user = this.Authentication.getUserDetails();
|
||||
|
||||
this.state = {
|
||||
appName: '',
|
||||
chart: null,
|
||||
showCustomValues: false,
|
||||
actionInProgress: false,
|
||||
resourcePools: [],
|
||||
resourcePool: '',
|
||||
values: null,
|
||||
originalvalues: null,
|
||||
repos: [],
|
||||
charts: [],
|
||||
loadingValues: false,
|
||||
isEditorDirty: false,
|
||||
chartsLoading: false,
|
||||
resourcePoolsLoading: false,
|
||||
viewReady: false,
|
||||
isAdmin: this.Authentication.isAdmin(),
|
||||
globalRepository: undefined,
|
||||
};
|
||||
|
||||
const helmRepos = await this.getHelmRepoURLs();
|
||||
if (helmRepos) {
|
||||
await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]);
|
||||
}
|
||||
if (this.state.charts.length > 0 && this.$state.params.chartName) {
|
||||
const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName);
|
||||
if (chart) {
|
||||
this.selectHelmChart(chart);
|
||||
}
|
||||
}
|
||||
|
||||
this.state.viewReady = true;
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.state.isEditorDirty = false;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<div class="row">
|
||||
<!-- helmchart-form -->
|
||||
<div class="col-sm-12 p-0" ng-if="$ctrl.state.chart">
|
||||
<rd-widget>
|
||||
<div class="flex">
|
||||
<div class="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
|
||||
<div class="vertical-center p-5">
|
||||
<fallback-image src="$ctrl.state.chart.icon" fallback-icon="$ctrl.fallbackIcon" class-name="'h-16 w-16'" size="'lg'"></fallback-image>
|
||||
<div class="font-medium ml-4">
|
||||
<div class="toolBarTitle text-[24px] mb-2">
|
||||
{{ $ctrl.state.chart.name }}
|
||||
<span class="space-left text-[14px] vertical-center font-normal">
|
||||
<pr-icon icon="'svg-helm'" mode="'primary'"></pr-icon>
|
||||
Helm
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted text-xs" ng-bind-html="$ctrl.state.chart.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="basis-1/4">
|
||||
<div class="h-full w-full vertical-center justify-end pr-5">
|
||||
<button type="button" class="btn btn-sm btn-link !text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white" ng-click="$ctrl.clearHelmChart()">
|
||||
Clear selection
|
||||
<pr-icon icon="'x'" class="ml-1"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget>
|
||||
|
||||
<form class="form-horizontal" name="$ctrl.helmTemplateCreationForm">
|
||||
<div class="form-group mt-4">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
ng-if="!$ctrl.state.showCustomValues && !$ctrl.state.loadingValues"
|
||||
class="btn btn-xs btn-default vertical-center !ml-0 mr-2"
|
||||
ng-click="$ctrl.state.showCustomValues = true;"
|
||||
>
|
||||
<pr-icon icon="'plus'" class="vertical-center"></pr-icon>
|
||||
Show custom values
|
||||
</button>
|
||||
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues" role="status">
|
||||
<inline-loader children="'Loading values.yaml...'" />
|
||||
</span>
|
||||
<button ng-if="$ctrl.state.showCustomValues" class="btn btn-xs btn-default vertical-center !ml-0 mr-2" ng-click="$ctrl.state.showCustomValues = false;">
|
||||
<pr-icon icon="'minus'" class="vertical-center"></pr-icon>
|
||||
Hide custom values
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- values override -->
|
||||
<div ng-if="$ctrl.state.showCustomValues">
|
||||
<!-- web-editor -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<web-editor-form
|
||||
identifier="helm-app-creation-editor"
|
||||
value="$ctrl.state.values"
|
||||
on-change="($ctrl.editorUpdate)"
|
||||
yml="true"
|
||||
placeholder="Define or paste the content of your values yaml file here"
|
||||
>
|
||||
<editor-description class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>
|
||||
You can get more information about Helm values file format in the
|
||||
<a href="https://helm.sh/docs/chart_template_guide/values_files/" target="_blank" class="hyperlink">official documentation</a>.
|
||||
</span>
|
||||
</editor-description>
|
||||
</web-editor-form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
</div>
|
||||
<!-- !values override -->
|
||||
<!-- helm actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!$ctrl.state.resourcePool || $ctrl.state.loadingValues || $ctrl.state.actionInProgress || !$ctrl.name"
|
||||
ng-click="$ctrl.installHelmchart()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
data-cy="helm-install"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
|
||||
<span ng-hide="!$ctrl.state.actionInProgress">Installing Helm chart</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !helm actions -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- helmchart-form -->
|
||||
</div>
|
||||
|
||||
<!-- Helm Charts Component -->
|
||||
<div class="row" ng-if="!$ctrl.state.chart">
|
||||
<div class="col-sm-12 p-0">
|
||||
<helm-templates-list
|
||||
title-text="'Helm chart'"
|
||||
charts="$ctrl.state.charts"
|
||||
table-key="$ctrl.state.charts"
|
||||
select-action="$ctrl.selectHelmChart"
|
||||
loading="$ctrl.state.chartsLoading || $ctrl.state.resourcePoolsLoading"
|
||||
>
|
||||
</helm-templates-list>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !Helm Charts Component -->
|
||||
@@ -1,14 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import controller from './helm-templates.controller';
|
||||
|
||||
angular.module('portainer.kubernetes').component('helmTemplatesView', {
|
||||
templateUrl: './helm-templates.html',
|
||||
controller,
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
namespace: '<',
|
||||
stackName: '<',
|
||||
onSelectHelmChart: '<',
|
||||
name: '<',
|
||||
},
|
||||
});
|
||||
@@ -58,8 +58,7 @@ 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 { HelmTemplates } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplates';
|
||||
|
||||
import { namespacesModule } from './namespaces';
|
||||
import { clusterManagementModule } from './clusterManagement';
|
||||
@@ -209,17 +208,12 @@ export const ngModule = angular
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'helmTemplatesList',
|
||||
r2a(withUIRouter(withCurrentUser(HelmTemplatesList)), [
|
||||
'loading',
|
||||
'titleText',
|
||||
'charts',
|
||||
'selectAction',
|
||||
'helmTemplatesView',
|
||||
r2a(withUIRouter(withCurrentUser(HelmTemplates)), [
|
||||
'onSelectHelmChart',
|
||||
'namespace',
|
||||
'name',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'helmTemplatesListItem',
|
||||
r2a(HelmTemplatesListItem, ['model', 'onSelect', 'actions'])
|
||||
);
|
||||
|
||||
export const componentsModule = ngModule.name;
|
||||
|
||||
@@ -187,13 +187,7 @@
|
||||
<!-- Helm -->
|
||||
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.selectedHelmChart">Selected Helm chart</div>
|
||||
<helm-templates-view
|
||||
on-select-helm-chart="(ctrl.onSelectHelmChart)"
|
||||
endpoint="ctrl.endpoint"
|
||||
namespace="ctrl.formValues.Namespace"
|
||||
stack-name="ctrl.formValues.StackName"
|
||||
name="ctrl.formValues.Name"
|
||||
></helm-templates-view>
|
||||
<helm-templates-view on-select-helm-chart="(ctrl.onSelectHelmChart)" namespace="ctrl.formValues.Namespace" name="ctrl.formValues.Name" />
|
||||
</div>
|
||||
<!-- !Helm -->
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
|
||||
constructor($scope, $async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
|
||||
this.$scope = $scope;
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$window = $window;
|
||||
@@ -110,6 +111,9 @@ class KubernetesDeployController {
|
||||
|
||||
onSelectHelmChart(chart) {
|
||||
this.state.selectedHelmChart = chart;
|
||||
|
||||
// Force a digest cycle to ensure the change is reflected in the UI
|
||||
this.$scope.$apply();
|
||||
}
|
||||
|
||||
onChangeTemplateVariables(value) {
|
||||
|
||||
@@ -6,4 +6,5 @@
|
||||
on-change="($ctrl.handleChange)"
|
||||
value="$ctrl.value"
|
||||
height="$ctrl.height || undefined"
|
||||
schema="$ctrl.schema"
|
||||
></react-code-editor>
|
||||
|
||||
@@ -13,5 +13,6 @@ angular.module('portainer.app').component('codeEditor', {
|
||||
onChange: '<',
|
||||
value: '<',
|
||||
height: '@',
|
||||
schema: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export const webEditorForm = {
|
||||
onChange: '<',
|
||||
hideTitle: '<',
|
||||
height: '@',
|
||||
schema: '<',
|
||||
},
|
||||
|
||||
transclude: {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
value="$ctrl.value"
|
||||
on-change="($ctrl.onChange)"
|
||||
height="{{ $ctrl.height }}"
|
||||
schema="$ctrl.schema"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -232,6 +232,7 @@ export const ngModule = angular
|
||||
'data-cy',
|
||||
'versions',
|
||||
'onVersionChange',
|
||||
'schema',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'axios-cache-interceptor';
|
||||
import { loadProgressBar } from 'axios-progress-bar';
|
||||
import 'axios-progress-bar/dist/nprogress.css';
|
||||
import qs from 'qs';
|
||||
|
||||
import PortainerError from '@/portainer/error';
|
||||
|
||||
@@ -53,6 +54,9 @@ function headerInterpreter(
|
||||
const axios = Axios.create({
|
||||
baseURL: 'api',
|
||||
maxDockerAPIVersion: MAX_DOCKER_API_VERSION,
|
||||
paramsSerializer: {
|
||||
serialize: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
},
|
||||
});
|
||||
axios.interceptors.request.use((req) => {
|
||||
dispatchCacheRefreshEventIfNeeded(req);
|
||||
|
||||
@@ -30,7 +30,7 @@ angular.module('portainer.app').factory('LocalStorage', [
|
||||
return localStorageService.get('UI_STATE');
|
||||
},
|
||||
getUserId() {
|
||||
localStorageService.get('USER_ID');
|
||||
return localStorageService.get('USER_ID');
|
||||
},
|
||||
storeUserId: function (userId) {
|
||||
localStorageService.set('USER_ID', userId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
|
||||
import { getDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
|
||||
|
||||
angular
|
||||
.module('portainer.app')
|
||||
@@ -351,6 +352,12 @@ angular
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve Containers');
|
||||
}
|
||||
|
||||
try {
|
||||
$scope.dockerComposeSchema = await getDockerComposeSchema();
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to load schema validation for editor');
|
||||
}
|
||||
}
|
||||
|
||||
this.uiCanExit = async function () {
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
yml="true"
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
read-only="state.isEditorReadOnly"
|
||||
schema="dockerComposeSchema"
|
||||
>
|
||||
<editor-description>
|
||||
<p>
|
||||
|
||||
@@ -150,8 +150,9 @@
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
|
||||
</span>
|
||||
<div class="col-sm-12" ng-if="state.yamlError">
|
||||
<span class="text-danger small">{{ state.yamlError }}</span>
|
||||
<!-- opacity-0 with fixes the layout shift causing tooltips to go over hovered text -->
|
||||
<div class="col-sm-12" ng-class="{ 'opacity-100': state.yamlError, 'opacity-0': !state.yamlError }">
|
||||
<span class="text-danger small">{{ state.yamlError || ' ' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -163,6 +164,7 @@
|
||||
yml="true"
|
||||
on-change="(editorUpdate)"
|
||||
value="stackFileContent"
|
||||
schema="dockerComposeSchema"
|
||||
></code-editor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-u
|
||||
import { confirm, confirmDelete, confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { getDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
|
||||
|
||||
angular.module('portainer.app').controller('StackController', [
|
||||
'$async',
|
||||
@@ -491,6 +492,12 @@ angular.module('portainer.app').controller('StackController', [
|
||||
}
|
||||
|
||||
$scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion;
|
||||
|
||||
try {
|
||||
$scope.dockerComposeSchema = await getDockerComposeSchema();
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to load schema validation for editor');
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
export function ExternalBadge() {
|
||||
return <Badge type="info">External</Badge>;
|
||||
export function ExternalBadge({ className }: { className?: string }) {
|
||||
return (
|
||||
<Badge type="info" className={className}>
|
||||
External
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
export function SystemBadge() {
|
||||
return <Badge type="success">System</Badge>;
|
||||
export function SystemBadge({ className }: { className?: string }) {
|
||||
return (
|
||||
<Badge type="success" className={className}>
|
||||
System
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
--bg-codemirror-gutters-color: var(--grey-17);
|
||||
--bg-codemirror-selected-color: var(--grey-22);
|
||||
--border-codemirror-cursor-color: var(--black-color);
|
||||
--bg-tooltip-color: var(--white-color);
|
||||
--text-tooltip-color: var(--black-color);
|
||||
}
|
||||
|
||||
:global([theme='dark']) .root {
|
||||
@@ -24,6 +26,8 @@
|
||||
--bg-codemirror-gutters-color: var(--grey-3);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--bg-tooltip-color: var(--grey-3);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
}
|
||||
|
||||
:global([theme='highcontrast']) .root {
|
||||
@@ -37,6 +41,8 @@
|
||||
--bg-codemirror-gutters-color: var(--ui-gray-warm-11);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--bg-tooltip-color: var(--black-color);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
}
|
||||
|
||||
.root :global(.cm-editor .cm-gutters) {
|
||||
@@ -138,3 +144,21 @@
|
||||
.root :global(.cm-panel.cm-search label) {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
/* Tooltip styles for all themes */
|
||||
.root :global(.cm-tooltip) {
|
||||
@apply bg-white border border-solid border-gray-5 shadow-md text-xs rounded h-min;
|
||||
@apply th-dark:bg-gray-9 th-dark:border-gray-7 th-dark:text-white;
|
||||
@apply th-highcontrast:bg-black th-highcontrast:border-gray-7 th-highcontrast:text-white;
|
||||
}
|
||||
|
||||
/* Hide the completionInfo tooltip when it's empty */
|
||||
/* note: I only chose the complicated selector because the simple selector `.cm-tooltip.cm-completionInfo:empty` didn't work */
|
||||
.root :global(.cm-tooltip.cm-completionInfo:not(:has(*:not(:empty)))) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Active line gutter styles for all themes */
|
||||
.root :global(.cm-activeLineGutter) {
|
||||
@apply bg-inherit;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CodeEditor } from './CodeEditor';
|
||||
|
||||
vi.mock('yaml-schema', () => ({}));
|
||||
|
||||
const defaultProps = {
|
||||
id: 'test-editor',
|
||||
onChange: vi.fn(),
|
||||
value: '',
|
||||
'data-cy': 'test-editor',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render with basic props', () => {
|
||||
render(<CodeEditor {...defaultProps} />);
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display placeholder when provided', async () => {
|
||||
const placeholder = 'Enter your code here';
|
||||
const { findByText } = render(
|
||||
<CodeEditor {...defaultProps} placeholder={placeholder} />
|
||||
);
|
||||
|
||||
const placeholderText = await findByText(placeholder);
|
||||
expect(placeholderText).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show copy button and copy content', async () => {
|
||||
const testValue = 'test content';
|
||||
const { findByText } = render(
|
||||
<CodeEditor {...defaultProps} value={testValue} />
|
||||
);
|
||||
|
||||
const mockClipboard = {
|
||||
writeText: vi.fn(),
|
||||
};
|
||||
Object.assign(navigator, {
|
||||
clipboard: mockClipboard,
|
||||
});
|
||||
|
||||
const copyButton = await findByText('Copy to clipboard');
|
||||
expect(copyButton).toBeVisible();
|
||||
|
||||
await userEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(testValue);
|
||||
});
|
||||
|
||||
test('should handle read-only mode', async () => {
|
||||
const { findByRole } = render(<CodeEditor {...defaultProps} readonly />);
|
||||
const editor = await findByRole('textbox');
|
||||
// the editor should not editable
|
||||
await userEvent.type(editor, 'test');
|
||||
expect(editor).not.toHaveValue('test');
|
||||
});
|
||||
|
||||
test('should show version selector when versions are provided', async () => {
|
||||
const versions = [1, 2, 3];
|
||||
const onVersionChange = vi.fn();
|
||||
const { findByRole } = render(
|
||||
<CodeEditor
|
||||
{...defaultProps}
|
||||
versions={versions}
|
||||
onVersionChange={onVersionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const selector = await findByRole('combobox');
|
||||
expect(selector).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle YAML indentation correctly', async () => {
|
||||
const onChange = vi.fn();
|
||||
const yamlContent = 'services:';
|
||||
|
||||
const { findByRole } = render(
|
||||
<CodeEditor
|
||||
{...defaultProps}
|
||||
value={yamlContent}
|
||||
onChange={onChange}
|
||||
type="yaml"
|
||||
/>
|
||||
);
|
||||
|
||||
const editor = await findByRole('textbox');
|
||||
await userEvent.type(editor, '{enter}');
|
||||
await userEvent.keyboard('database:');
|
||||
await userEvent.keyboard('{enter}');
|
||||
await userEvent.keyboard('image: nginx');
|
||||
await userEvent.keyboard('{enter}');
|
||||
await userEvent.keyboard('name: database');
|
||||
|
||||
// Wait for the debounced onChange to be called
|
||||
setTimeout(() => {
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
'services:\n database:\n image: nginx\n name: database'
|
||||
);
|
||||
// debounce timeout is 300ms, so 500ms is enough
|
||||
}, 500);
|
||||
});
|
||||
|
||||
test('should apply custom height', async () => {
|
||||
const customHeight = '300px';
|
||||
const { findByRole } = render(
|
||||
<CodeEditor {...defaultProps} height={customHeight} />
|
||||
);
|
||||
|
||||
const editor = (await findByRole('textbox')).parentElement?.parentElement;
|
||||
expect(editor).toHaveStyle({ height: customHeight });
|
||||
});
|
||||
@@ -1,11 +1,24 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||
import CodeMirror, {
|
||||
keymap,
|
||||
oneDarkHighlightStyle,
|
||||
} from '@uiw/react-codemirror';
|
||||
import {
|
||||
StreamLanguage,
|
||||
LanguageSupport,
|
||||
syntaxHighlighting,
|
||||
indentService,
|
||||
} from '@codemirror/language';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as highlightTags } from '@lezer/highlight';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { lintKeymap, lintGutter } from '@codemirror/lint';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { yamlCompletion, yamlSchema } from 'yaml-schema';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
@@ -23,11 +36,12 @@ interface Props extends AutomationTestingProps {
|
||||
placeholder?: string;
|
||||
type?: Type;
|
||||
readonly?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
value: string;
|
||||
height?: string;
|
||||
versions?: number[];
|
||||
onVersionChange?: (version: number) => void;
|
||||
schema?: JSONSchema7;
|
||||
}
|
||||
|
||||
const theme = createTheme({
|
||||
@@ -57,21 +71,72 @@ const theme = createTheme({
|
||||
],
|
||||
});
|
||||
|
||||
const yamlLanguage = new LanguageSupport(StreamLanguage.define(yaml));
|
||||
// Custom indentation service for YAML
|
||||
const yamlIndentExtension = indentService.of((context, pos) => {
|
||||
const prevLine = context.lineAt(pos, -1);
|
||||
|
||||
// Default to same as previous line
|
||||
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
|
||||
|
||||
// If previous line ends with a colon, increase indent
|
||||
if (/:\s*$/.test(prevLine.text)) {
|
||||
return prevIndent + 2; // Indent 2 spaces after a colon
|
||||
}
|
||||
|
||||
return prevIndent;
|
||||
});
|
||||
|
||||
// Create enhanced YAML language with custom indentation (from @codemirror/legacy-modes/mode/yaml)
|
||||
const yamlLanguageLegacy = new LanguageSupport(StreamLanguage.define(yaml), [
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
]);
|
||||
|
||||
const dockerFileLanguage = new LanguageSupport(
|
||||
StreamLanguage.define(dockerFile)
|
||||
);
|
||||
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
|
||||
|
||||
const docTypeExtensionMap: Record<Type, LanguageSupport> = {
|
||||
yaml: yamlLanguage,
|
||||
yaml: yamlLanguageLegacy,
|
||||
dockerfile: dockerFileLanguage,
|
||||
shell: shellLanguage,
|
||||
};
|
||||
|
||||
function schemaValidationExtensions(schema: JSONSchema7) {
|
||||
// skip the hover extension because fields like 'networks' display as 'null' with no description when using the default hover
|
||||
// skip the completion extension in favor of custom completion
|
||||
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
|
||||
return [
|
||||
yaml,
|
||||
linter,
|
||||
autocompletion({
|
||||
icons: false,
|
||||
activateOnTypingDelay: 300,
|
||||
selectOnOpen: true,
|
||||
activateOnTyping: true,
|
||||
override: [
|
||||
(ctx) => {
|
||||
const getCompletions = yamlCompletion();
|
||||
const completions = getCompletions(ctx);
|
||||
if (Array.isArray(completions)) {
|
||||
return null;
|
||||
}
|
||||
return completions;
|
||||
},
|
||||
],
|
||||
}),
|
||||
stateExtensions,
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
lintGutter(),
|
||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||
];
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
id,
|
||||
onChange,
|
||||
onChange = () => {},
|
||||
placeholder,
|
||||
readonly,
|
||||
value,
|
||||
@@ -79,17 +144,22 @@ export function CodeEditor({
|
||||
onVersionChange,
|
||||
height = '500px',
|
||||
type,
|
||||
schema,
|
||||
'data-cy': dataCy,
|
||||
}: Props) {
|
||||
const [isRollback, setIsRollback] = useState(false);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const extensions = [];
|
||||
if (type && docTypeExtensionMap[type]) {
|
||||
extensions.push(docTypeExtensionMap[type]);
|
||||
if (!type || !docTypeExtensionMap[type]) {
|
||||
return [];
|
||||
}
|
||||
return extensions;
|
||||
}, [type]);
|
||||
// YAML-specific schema validation
|
||||
if (schema && type === 'yaml') {
|
||||
return schemaValidationExtensions(schema);
|
||||
}
|
||||
// Default language support
|
||||
return [docTypeExtensionMap[type]];
|
||||
}, [type, schema]);
|
||||
|
||||
const handleVersionChange = useCallback(
|
||||
(version: number) => {
|
||||
@@ -146,7 +216,7 @@ export function CodeEditor({
|
||||
height={height}
|
||||
basicSetup={{
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: false,
|
||||
autocompletion: !!schema,
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
/>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { NavTabs } from './NavTabs';
|
||||
export type { Option } from './NavTabs';
|
||||
|
||||
@@ -3,6 +3,56 @@ import { AriaAttributes, PropsWithChildren } from 'react';
|
||||
|
||||
import { Icon, IconProps } from '@@/Icon';
|
||||
|
||||
export type StatusBadgeType =
|
||||
| 'success'
|
||||
| 'danger'
|
||||
| 'warning'
|
||||
| 'info'
|
||||
| 'successLite'
|
||||
| 'dangerLite'
|
||||
| 'warningLite'
|
||||
| 'mutedLite'
|
||||
| 'infoLite'
|
||||
| 'default';
|
||||
|
||||
const typeClasses: Record<StatusBadgeType, string> = {
|
||||
success: clsx(
|
||||
'text-white bg-success-7',
|
||||
'th-dark:text-white th-dark:bg-success-9'
|
||||
),
|
||||
warning: clsx(
|
||||
'text-white bg-warning-7',
|
||||
'th-dark:text-white th-dark:bg-warning-9'
|
||||
),
|
||||
danger: clsx(
|
||||
'text-white bg-error-7',
|
||||
'th-dark:text-white th-dark:bg-error-9'
|
||||
),
|
||||
info: clsx('text-white bg-blue-7', 'th-dark:text-white th-dark:bg-blue-9'),
|
||||
// the lite classes are a bit lighter in light mode and the same in dark mode
|
||||
successLite: clsx(
|
||||
'text-success-9 bg-success-3',
|
||||
'th-dark:text-white th-dark:bg-success-9'
|
||||
),
|
||||
warningLite: clsx(
|
||||
'text-warning-9 bg-warning-3',
|
||||
'th-dark:text-white th-dark:bg-warning-9'
|
||||
),
|
||||
dangerLite: clsx(
|
||||
'text-error-9 bg-error-3',
|
||||
'th-dark:text-white th-dark:bg-error-9'
|
||||
),
|
||||
mutedLite: clsx(
|
||||
'text-gray-9 bg-gray-3',
|
||||
'th-dark:text-white th-dark:bg-gray-9'
|
||||
),
|
||||
infoLite: clsx(
|
||||
'text-blue-9 bg-blue-3',
|
||||
'th-dark:text-white th-dark:bg-blue-9'
|
||||
),
|
||||
default: '',
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
className,
|
||||
children,
|
||||
@@ -12,7 +62,7 @@ export function StatusBadge({
|
||||
}: PropsWithChildren<
|
||||
{
|
||||
className?: string;
|
||||
color?: 'success' | 'danger' | 'warning' | 'info' | 'default';
|
||||
color?: StatusBadgeType;
|
||||
icon?: IconProps['icon'];
|
||||
} & AriaAttributes
|
||||
>) {
|
||||
@@ -21,13 +71,8 @@ export function StatusBadge({
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded',
|
||||
'w-fit px-1.5 py-0.5',
|
||||
'text-sm font-medium text-white',
|
||||
{
|
||||
'bg-success-7 th-dark:bg-success-9': color === 'success',
|
||||
'bg-warning-7 th-dark:bg-warning-9': color === 'warning',
|
||||
'bg-error-7 th-dark:bg-error-9': color === 'danger',
|
||||
'bg-blue-9': color === 'info',
|
||||
},
|
||||
'text-sm font-medium',
|
||||
typeClasses[color],
|
||||
className
|
||||
)}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
ReactNode,
|
||||
ComponentProps,
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { useTransitionHook } from '@uirouter/react';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
|
||||
import { BROWSER_OS_PLATFORM } from '@/react/constants';
|
||||
|
||||
@@ -63,6 +64,7 @@ interface Props extends CodeEditorProps {
|
||||
titleContent?: ReactNode;
|
||||
hideTitle?: boolean;
|
||||
error?: string;
|
||||
schema?: JSONSchema7;
|
||||
}
|
||||
|
||||
export function WebEditorForm({
|
||||
@@ -71,6 +73,7 @@ export function WebEditorForm({
|
||||
hideTitle,
|
||||
children,
|
||||
error,
|
||||
schema,
|
||||
...props
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
@@ -94,6 +97,8 @@ export function WebEditorForm({
|
||||
<div className="col-sm-12 col-lg-12">
|
||||
<CodeEditor
|
||||
id={id}
|
||||
type="yaml"
|
||||
schema={schema as JSONSchema7}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||
getRowCanExpand?(row: Row<D>): boolean;
|
||||
noWidget?: boolean;
|
||||
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
||||
includeSearch?: boolean;
|
||||
}
|
||||
|
||||
export function Datatable<D extends DefaultType>({
|
||||
@@ -98,6 +99,7 @@ export function Datatable<D extends DefaultType>({
|
||||
totalCount = dataset.length,
|
||||
isServerSidePagination = false,
|
||||
extendTableOptions = (value) => value,
|
||||
includeSearch,
|
||||
}: Props<D> & PaginationProps) {
|
||||
const pageCount = useMemo(
|
||||
() => Math.ceil(totalCount / settings.pageSize),
|
||||
@@ -192,6 +194,7 @@ export function Datatable<D extends DefaultType>({
|
||||
renderTableActions={() => renderTableActions(selectedItems)}
|
||||
renderTableSettings={() => renderTableSettings(tableInstance)}
|
||||
data-cy={`${dataCy}-header`}
|
||||
includeSearch={includeSearch}
|
||||
/>
|
||||
|
||||
<DatatableContent<D>
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
renderTableActions?(): ReactNode;
|
||||
description?: ReactNode;
|
||||
titleId?: string;
|
||||
includeSearch?: boolean;
|
||||
} & AutomationTestingProps;
|
||||
|
||||
export function DatatableHeader({
|
||||
@@ -28,8 +29,9 @@ export function DatatableHeader({
|
||||
description,
|
||||
titleId,
|
||||
'data-cy': dataCy,
|
||||
includeSearch = !!title,
|
||||
}: Props) {
|
||||
if (!title) {
|
||||
if (!title && !includeSearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -50,12 +52,12 @@ export function DatatableHeader({
|
||||
return (
|
||||
<Table.Title
|
||||
id={titleId}
|
||||
label={title}
|
||||
label={title ?? ''}
|
||||
icon={titleIcon}
|
||||
description={description}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
{searchBar}
|
||||
{includeSearch && searchBar}
|
||||
{tableActions}
|
||||
{tableTitleSettings}
|
||||
</Table.Title>
|
||||
|
||||
@@ -47,10 +47,14 @@ export function Modal({
|
||||
<DialogContent
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={clsx(styles.modalDialog, 'bg-transparent p-0', {
|
||||
'w-[450px]': size === 'md',
|
||||
'w-[700px]': size === 'lg',
|
||||
})}
|
||||
className={clsx(
|
||||
styles.modalDialog,
|
||||
'max-w-[calc(100vw-2rem)] bg-transparent p-0',
|
||||
{
|
||||
'w-[450px]': size === 'md',
|
||||
'w-[700px]': size === 'lg',
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className={clsx(styles.modalContent, 'relative', className)}>
|
||||
{children}
|
||||
|
||||
@@ -47,6 +47,16 @@ export function confirmWebEditorDiscard() {
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmGenericDiscard() {
|
||||
return openConfirm({
|
||||
modalType: ModalType.Warn,
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
'You currently have unsaved changes. Are you sure you want to leave?',
|
||||
confirmButton: buildConfirmButton('Yes', 'danger'),
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmDelete(message: ReactNode) {
|
||||
return confirmDestructive({
|
||||
title: 'Are you sure?',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { EdgeEnvironmentsAssociationTable } from '@/react/edge/components/EdgeEnvironmentsAssociationTable';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||
|
||||
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
|
||||
|
||||
export function AssociatedEdgeEnvironmentsSelector({
|
||||
onChange,
|
||||
value,
|
||||
@@ -20,9 +19,9 @@ export function AssociatedEdgeEnvironmentsSelector({
|
||||
return (
|
||||
<>
|
||||
<div className="col-sm-12 small text-muted">
|
||||
You can select which environment should be part of this group by moving
|
||||
them to the associated environments table. Simply click on any
|
||||
environment entry to move it from one table to the other.
|
||||
You can also select environments individually by moving them to the
|
||||
associated environments table. Simply click on any environment entry to
|
||||
move it from one table to the other.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -36,7 +35,7 @@ export function AssociatedEdgeEnvironmentsSelector({
|
||||
<div className="col-sm-12 mt-4">
|
||||
<div className="flex">
|
||||
<div className="w-1/2">
|
||||
<EdgeGroupAssociationTable
|
||||
<EdgeEnvironmentsAssociationTable
|
||||
title="Available environments"
|
||||
query={{
|
||||
types: EdgeTypes,
|
||||
@@ -51,7 +50,7 @@ export function AssociatedEdgeEnvironmentsSelector({
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<EdgeGroupAssociationTable
|
||||
<EdgeEnvironmentsAssociationTable
|
||||
title="Associated environments"
|
||||
query={{
|
||||
types: EdgeTypes,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
EdgeGroupId,
|
||||
Environment,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { ArrayError } from '@@/form-components/InputList/InputList';
|
||||
|
||||
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
|
||||
|
||||
export function AssociatedEdgeGroupEnvironmentsSelector({
|
||||
onChange,
|
||||
value,
|
||||
error,
|
||||
edgeGroupId,
|
||||
}: {
|
||||
onChange: (
|
||||
value: EnvironmentId[],
|
||||
meta: { type: 'add' | 'remove'; value: EnvironmentId }
|
||||
) => void;
|
||||
value: EnvironmentId[];
|
||||
error?: ArrayError<Array<EnvironmentId>>;
|
||||
edgeGroupId?: EdgeGroupId;
|
||||
}) {
|
||||
const [associatedEnvironments, setAssociatedEnvironments] = useState<
|
||||
Environment[]
|
||||
>([]);
|
||||
const [dissociatedEnvironments, setDissociatedEnvironments] = useState<
|
||||
Environment[]
|
||||
>([]);
|
||||
|
||||
function updateEditedEnvironments(env: Environment) {
|
||||
// If the env is associated, this update is a dissociation
|
||||
const isAssociated = value.includes(env.Id);
|
||||
|
||||
setAssociatedEnvironments((prev) =>
|
||||
isAssociated
|
||||
? prev.filter((prevEnv) => prevEnv.Id !== env.Id)
|
||||
: [...prev, env]
|
||||
);
|
||||
|
||||
setDissociatedEnvironments((prev) =>
|
||||
isAssociated
|
||||
? [...prev, env]
|
||||
: prev.filter((prevEnv) => prevEnv.Id !== env.Id)
|
||||
);
|
||||
|
||||
const updatedValue = isAssociated
|
||||
? value.filter((id) => id !== env.Id)
|
||||
: [...value, env.Id];
|
||||
|
||||
onChange(updatedValue, {
|
||||
type: isAssociated ? 'remove' : 'add',
|
||||
value: env.Id,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-sm-12 small text-muted">
|
||||
You can select which environment should be part of this group by moving
|
||||
them to the associated environments table. Simply click on any
|
||||
environment entry to move it from one table to the other.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="col-sm-12">
|
||||
<FormError>
|
||||
{typeof error === 'string' ? error : error.join(', ')}
|
||||
</FormError>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="col-sm-12 mt-4">
|
||||
<div className="flex">
|
||||
<div className="w-1/2">
|
||||
<EdgeGroupAssociationTable
|
||||
title="Available environments"
|
||||
query={{
|
||||
excludeEdgeGroupIds: edgeGroupId ? [edgeGroupId] : [],
|
||||
}}
|
||||
addEnvironments={dissociatedEnvironments}
|
||||
excludeEnvironments={associatedEnvironments}
|
||||
onClickRow={(env) => {
|
||||
if (!value.includes(env.Id)) {
|
||||
updateEditedEnvironments(env);
|
||||
}
|
||||
}}
|
||||
data-cy="edgeGroupCreate-availableEndpoints"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<EdgeGroupAssociationTable
|
||||
title="Associated environments"
|
||||
query={{
|
||||
edgeGroupIds: edgeGroupId ? [edgeGroupId] : [],
|
||||
endpointIds: edgeGroupId ? undefined : [], // workaround to avoid showing all environments for new edge group
|
||||
}}
|
||||
addEnvironments={associatedEnvironments}
|
||||
excludeEnvironments={dissociatedEnvironments}
|
||||
onClickRow={(env) => {
|
||||
if (value.includes(env.Id)) {
|
||||
updateEditedEnvironments(env);
|
||||
}
|
||||
}}
|
||||
data-cy="edgeGroupCreate-associatedEndpointsTable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
import { Datatable, TableRow } from '@@/datatables';
|
||||
|
||||
import { columns, DecoratedEnvironment } from './associationTableColumnHelper';
|
||||
|
||||
export function EdgeEnvironmentsAssociationTable({
|
||||
title,
|
||||
query,
|
||||
onClickRow = () => {},
|
||||
'data-cy': dataCy,
|
||||
}: {
|
||||
title: string;
|
||||
query: EnvironmentsQueryParams;
|
||||
onClickRow?: (env: Environment) => void;
|
||||
} & AutomationTestingProps) {
|
||||
const tableState = useTableStateWithoutStorage('Name');
|
||||
const [page, setPage] = useState(0);
|
||||
const environmentsQuery = useEnvironmentList({
|
||||
pageLimit: tableState.pageSize,
|
||||
page: page + 1,
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy?.id as 'Group' | 'Name',
|
||||
order: tableState.sortBy?.desc ? 'desc' : 'asc',
|
||||
types: EdgeTypes,
|
||||
...query,
|
||||
});
|
||||
const groupsQuery = useGroups({
|
||||
enabled: environmentsQuery.environments.length > 0,
|
||||
});
|
||||
const tagsQuery = useTags({
|
||||
enabled: environmentsQuery.environments.length > 0,
|
||||
});
|
||||
|
||||
const memoizedEnvironments: Array<DecoratedEnvironment> = useMemo(
|
||||
() =>
|
||||
environmentsQuery.environments.map((env) => ({
|
||||
...env,
|
||||
Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
|
||||
Tags: env.TagIds.map(
|
||||
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
|
||||
),
|
||||
})),
|
||||
[environmentsQuery.environments, groupsQuery.data, tagsQuery.data]
|
||||
);
|
||||
|
||||
const { totalCount } = environmentsQuery;
|
||||
|
||||
return (
|
||||
<Datatable<DecoratedEnvironment>
|
||||
title={title}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
dataset={memoizedEnvironments}
|
||||
isServerSidePagination
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
totalCount={totalCount}
|
||||
renderRow={(row) => (
|
||||
<TableRow<DecoratedEnvironment>
|
||||
cells={row.getVisibleCells()}
|
||||
onClick={() => onClickRow(row.original)}
|
||||
/>
|
||||
)}
|
||||
data-cy={dataCy}
|
||||
disableSelect
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,32 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { truncate } from 'lodash';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
import {
|
||||
columns,
|
||||
DecoratedEnvironment,
|
||||
} from '@/react/edge/components/associationTableColumnHelper';
|
||||
|
||||
import { Datatable, TableRow } from '@@/datatables';
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
|
||||
type DecoratedEnvironment = Environment & {
|
||||
Tags: string[];
|
||||
Group: string;
|
||||
};
|
||||
|
||||
const columHelper = createColumnHelper<DecoratedEnvironment>();
|
||||
|
||||
const columns = [
|
||||
columHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
id: 'Name',
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
columHelper.accessor('Group', {
|
||||
header: 'Group',
|
||||
id: 'Group',
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
columHelper.accessor((row) => row.Tags.join(','), {
|
||||
header: 'Tags',
|
||||
id: 'tags',
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
];
|
||||
|
||||
export function EdgeGroupAssociationTable({
|
||||
title,
|
||||
query,
|
||||
onClickRow = () => {},
|
||||
addEnvironments = [],
|
||||
excludeEnvironments = [],
|
||||
'data-cy': dataCy,
|
||||
}: {
|
||||
title: string;
|
||||
query: EnvironmentsQueryParams;
|
||||
onClickRow?: (env: Environment) => void;
|
||||
addEnvironments?: Environment[];
|
||||
excludeEnvironments?: Environment[];
|
||||
} & AutomationTestingProps) {
|
||||
const tableState = useTableStateWithoutStorage('Name');
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -56,8 +36,11 @@ export function EdgeGroupAssociationTable({
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy?.id as 'Group' | 'Name',
|
||||
order: tableState.sortBy?.desc ? 'desc' : 'asc',
|
||||
types: EdgeTypes,
|
||||
excludeIds: excludeEnvironments?.map((env) => env.Id),
|
||||
...query,
|
||||
});
|
||||
|
||||
const groupsQuery = useGroups({
|
||||
enabled: environmentsQuery.environments.length > 0,
|
||||
});
|
||||
@@ -65,7 +48,7 @@ export function EdgeGroupAssociationTable({
|
||||
enabled: environmentsQuery.environments.length > 0,
|
||||
});
|
||||
|
||||
const environments: Array<DecoratedEnvironment> = useMemo(
|
||||
const memoizedEnvironments: Array<DecoratedEnvironment> = useMemo(
|
||||
() =>
|
||||
environmentsQuery.environments.map((env) => ({
|
||||
...env,
|
||||
@@ -79,12 +62,29 @@ export function EdgeGroupAssociationTable({
|
||||
|
||||
const { totalCount } = environmentsQuery;
|
||||
|
||||
const memoizedAddEnvironments: Array<DecoratedEnvironment> = useMemo(
|
||||
() =>
|
||||
addEnvironments.map((env) => ({
|
||||
...env,
|
||||
Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
|
||||
Tags: env.TagIds.map(
|
||||
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
|
||||
),
|
||||
})),
|
||||
[addEnvironments, groupsQuery.data, tagsQuery.data]
|
||||
);
|
||||
|
||||
// Filter out environments that are already in the table, this is to prevent duplicates, which can happen when an environment is associated and then disassociated
|
||||
const filteredAddEnvironments = memoizedAddEnvironments.filter(
|
||||
(env) => !memoizedEnvironments.some((e) => e.Id === env.Id)
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable<DecoratedEnvironment>
|
||||
title={title}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
dataset={environments}
|
||||
dataset={memoizedEnvironments.concat(filteredAddEnvironments)}
|
||||
isServerSidePagination
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import { truncate } from 'lodash';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
export type DecoratedEnvironment = Environment & {
|
||||
Tags: string[];
|
||||
Group: string;
|
||||
};
|
||||
|
||||
const columHelper = createColumnHelper<DecoratedEnvironment>();
|
||||
|
||||
export const columns = [
|
||||
columHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
id: 'Name',
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
columHelper.accessor('Group', {
|
||||
header: 'Group',
|
||||
id: 'Group',
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
columHelper.accessor((row) => row.Tags.join(','), {
|
||||
header: 'Tags',
|
||||
id: 'tags',
|
||||
enableSorting: false,
|
||||
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
|
||||
}),
|
||||
];
|
||||
@@ -35,6 +35,7 @@ export function EdgeGroupForm({
|
||||
name: group.Name,
|
||||
partialMatch: group.PartialMatch,
|
||||
tagIds: group.TagIds,
|
||||
edgeGroupId: group.Id,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
@@ -42,6 +43,7 @@ export function EdgeGroupForm({
|
||||
environmentIds: [],
|
||||
partialMatch: false,
|
||||
tagIds: [],
|
||||
edgeGroupId: 0,
|
||||
}
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { AssociatedEdgeGroupEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { confirmDestructive } from '@@/modals/confirm';
|
||||
@@ -14,7 +14,7 @@ export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) {
|
||||
return (
|
||||
<FormSection title="Associated environments">
|
||||
<div className="form-group">
|
||||
<AssociatedEdgeEnvironmentsSelector
|
||||
<AssociatedEdgeGroupEnvironmentsSelector
|
||||
value={values.environmentIds}
|
||||
error={errors.environmentIds}
|
||||
onChange={async (environmentIds, meta) => {
|
||||
@@ -33,6 +33,7 @@ export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) {
|
||||
|
||||
setFieldValue('environmentIds', environmentIds);
|
||||
}}
|
||||
edgeGroupId={values.edgeGroupId}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
EdgeGroupId,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { TagId } from '@/portainer/tags/types';
|
||||
|
||||
export interface FormValues {
|
||||
edgeGroupId: EdgeGroupId;
|
||||
name: string;
|
||||
dynamic: boolean;
|
||||
environmentIds: EnvironmentId[];
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useValidation({
|
||||
is: true,
|
||||
then: (schema) => schema.min(1, 'Tags are required'),
|
||||
}),
|
||||
edgeGroupId: number().default(0).notRequired(),
|
||||
}),
|
||||
[nameValidation]
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
|
||||
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
|
||||
@@ -14,7 +16,9 @@ export function DockerContentField({
|
||||
readonly?: boolean;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
const dockerComposeSchemaQuery = useDockerComposeSchema();
|
||||
|
||||
if (isLoading || dockerComposeSchemaQuery.isInitialLoading) {
|
||||
return <InlineLoader>Loading stack content...</InlineLoader>;
|
||||
}
|
||||
|
||||
@@ -27,6 +31,7 @@ export function DockerContentField({
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
error={error}
|
||||
readonly={readonly}
|
||||
schema={dockerComposeSchemaQuery.data}
|
||||
data-cy="stack-creation-editor"
|
||||
>
|
||||
You can get more information about Compose file format in the{' '}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user