Compare commits

..

43 Commits

Author SHA1 Message Date
Steven Kang 730e05f40c security: cve-2025-30204 and other low ones - release 2.29 [BE-11781] (#640) 2025-04-16 10:09:18 +12:00
Oscar Zhou 2c37f32fa6 version: bump version to 2.29.0 (#637) 2025-04-14 13:13:38 +12:00
LP B 7aa9f8b1c3 Revert "feat(app): 1s staleTime to avoid sending repeated requests" (#639) 2025-04-14 11:12:11 +12:00
LP B c331ada086 feat(app): 1s staleTime to avoid sending repeated requests (#607) 2025-04-14 09:05:48 +12:00
Oscar Zhou ebc25e45d3 fix(edge): redeploy edge stack doesn't apply to std agents [BE-11766] (#633) 2025-04-12 10:24:23 +12:00
andres-portainer f82921d2a1 fix(edgestacks): fix edge stack update when using Git BE-11766 (#629) 2025-04-10 20:12:27 -03:00
Ali d68fe42918 fix(apps): better align sub tables [r8s-255] (#617) 2025-04-11 08:39:39 +12:00
Oscar Zhou 823f2a7991 fix(edge): missing env var in async agent docker snapshot [BE-11709] (#625) 2025-04-11 08:26:11 +12:00
Ali 0ca9321db1 feat(helm): update helm view [r8s-256] (#582)
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: stevensbkang <skan070@gmail.com>
2025-04-10 16:08:24 +12:00
James Player 46eddbe7b9 fix(UI): Make sure localStorage.getUserId actually returns user id R8S-290 (#623) 2025-04-09 09:09:07 +12:00
James Player 64c796a8c3 fix(kubernetes): Config maps and secrets show as unused BE-11684 (#596)
Co-authored-by: stevensbkang <skan070@gmail.com>
2025-04-08 12:52:21 +12:00
James Player 264ff5457b chore(kubernetes): Migrate Helm Templates View to React R8S-239 (#587) 2025-04-08 12:51:36 +12:00
LP B ad89df4d0d refactor(app): reword docker security features (#608) 2025-04-07 17:14:51 +02:00
Anthony Lapenna 0f10b8ba2b api: update TeamInspect doc (#618) 2025-04-07 11:25:23 +12:00
Oscar Zhou 940bf990f9 fix(edgeconfig): add edge config file interpolation info message on edge stack page [BE-11741] (#606) 2025-04-04 11:56:42 +13:00
Devon Steenberg 1b8fbbe7d7 fix(libstack): compose project working directory [BE-11751] (#600) 2025-04-04 09:07:35 +13:00
James Player f6f07f4690 improvement(kubernetes): right align tags in datatables R8S-250 (#601)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-04-03 14:18:31 +13:00
Anthony Lapenna 3800249921 api: use response code 200 (#604) 2025-04-03 11:12:24 +13:00
Oscar Zhou a5d857d5e7 feat(docker): add --pull-limit-check-disabled cli flag [BE-11739] (#581) 2025-04-03 09:13:01 +13:00
Devon Steenberg 4c1e80ff58 fix(axios): correctly encode urls [BE-11648] (#517)
fix(edgegroup): nil pointer defer
2025-04-02 08:51:58 +13:00
Oscar Zhou 7e5db1f55e refactor(edgegroup): optimize edge group search performance [BE-11716] (#579) 2025-04-01 14:05:56 +13:00
Anthony Lapenna 1edc56c0ce api: remove name from edgegroupupdate payload validation (#588) 2025-04-01 13:25:09 +13:00
Anthony Lapenna 4066a70ea5 api: fix typo in operation name (#585) 2025-04-01 13:24:55 +13:00
andres-portainer a0d36cf87a fix(server): add panic logging middleware BE-11750 (#599) 2025-03-31 18:58:20 -03:00
Viktor Pettersson 1d12011eb5 fix(edge groups): make large edge groups editable [BE-11720] (#558) 2025-03-28 15:16:05 +01:00
Steven Kang 7c01f84a5c fix: improve the node view for detecting roles - develop (#354) 2025-03-28 10:52:59 +13:00
Ali 81c5f4acc3 feat(editor): provide yaml validation for docker compose in the portainer web editor [BE-11697] (#526) 2025-03-27 17:11:55 +13:00
Ali 0ebfe047d1 feat(helm): use helm upgrade for install [r8s-258] (#568) 2025-03-26 11:32:26 +13:00
samdulam e68bd53e30 Update bug_report template with 2.27.3 (#572) 2025-03-25 08:40:15 +05:30
andres-portainer cdd9851f72 fix(stubs): clean up the stubs and mocks BE-11722 (#557) 2025-03-24 19:56:08 -03:00
andres-portainer 995c3ef81b feat(snapshots): avoid parsing raw snapshots when possible BE-11724 (#560) 2025-03-24 19:33:05 -03:00
James Player 0dfde1374d fix(kubernetes): Cluster reservation CPU not showing R8S-268 (#569) 2025-03-25 10:59:28 +13:00
Devon Steenberg 34235199dd fix(libstack): correctly load COMPOSE_* env vars [BE-11474] (#536) 2025-03-25 08:57:23 +13:00
Anthony Lapenna 5d1cd670e9 docs: review TeamMembershipCreate API operation (#565) 2025-03-24 09:55:33 +13:00
Anthony Lapenna 1d8ea7b0ee docs: review TeamUpdate API operation (#564) 2025-03-21 16:45:43 +13:00
Oscar Zhou 4b218553c3 fix(libstack): data loss for stack with relative path [FR-437] (#548) 2025-03-21 09:19:25 +13:00
Viktor Pettersson a61c1004d3 fix(agent-updates): fix remote agent updates cannot be scheduled properly for large edge groups [BE-11691] (#528) 2025-03-20 10:05:15 +01:00
James Carppe 5d1b42b314 Update bug report template for 2.28.1 (#549) 2025-03-20 15:54:53 +13:00
Oscar Zhou 4b992c6f3e fix(k8s/config): force insecure-skip-tls-verify option for internal use [BE-11706] (#537) 2025-03-20 08:49:27 +13:00
Viktor Pettersson 38562f9560 fix(api): remove duplicated /users/me route [BE-11689] (#516) 2025-03-19 13:08:03 +01:00
James Carppe c01f0271fe Update bug report template for 2.27.2 (#539) 2025-03-19 17:41:36 +13:00
andres-portainer 0296998fae fix(users): optimize the /users/me API endpoint BE-11688 (#515)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-03-18 17:55:53 -03:00
James Player a67b917bdd Bump version to 2.28.0 (#523) 2025-03-17 16:00:33 +13:00
226 changed files with 8044 additions and 1453 deletions
+4 -11
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
)
+1
View File
@@ -576,6 +576,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
AdminCreationDone: adminCreationDone,
PendingActionsService: pendingActionsService,
PlatformService: platformService,
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
}
}
+2
View File
@@ -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 {
+26
View File
@@ -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
+28
View File
@@ -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 {
+14
View File
@@ -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)
+6
View File
@@ -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)
+8
View File
@@ -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 {
+1
View File
@@ -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
+13
View File
@@ -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
}
+23
View File
@@ -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.0",
"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.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+4 -10
View File
@@ -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
}
+1 -1
View File
@@ -68,7 +68,7 @@ func copyFile(src, dst string) error {
defer from.Close()
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
if err := os.MkdirAll(filepath.Dir(dst), 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 {
+8 -11
View File
@@ -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
}
+14 -14
View File
@@ -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)
}
+86
View File
@@ -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,
+14 -13
View File
@@ -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.
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.28.0
// @version 2.29.0
// @description.markdown api-description.md
// @termsOfService
+8
View File
@@ -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
}
+1 -1
View File
@@ -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)
+67
View File
@@ -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)
}
+66
View File
@@ -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)
})
}
+58
View File
@@ -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)
})
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
+10
View File
@@ -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 {
+1 -3
View File
@@ -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
+73
View File
@@ -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})
}
+35
View File
@@ -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
}
+1 -3
View File
@@ -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
+1 -1
View File
@@ -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"
-1
View File
@@ -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"
-1
View File
@@ -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"
+3 -4
View File
@@ -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")
+25
View File
@@ -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)
})
}
+1 -1
View File
@@ -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
+3 -5
View File
@@ -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
View File
@@ -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 {
+11 -17
View File
@@ -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))
}
+12 -4
View File
@@ -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{}
+23 -49
View File
@@ -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) {
+30 -70
View File
@@ -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,
})
}
}
+22 -26
View File
@@ -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
+15 -6
View File
@@ -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
}
}
+22 -25
View File
@@ -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
View File
@@ -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.0"
// 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 -->
+10
View File
@@ -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: '<',
},
});
+6 -12
View File
@@ -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;
+1 -7
View File
@@ -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>
+1
View File
@@ -232,6 +232,7 @@ export const ngModule = angular
'data-cy',
'versions',
'onVersionChange',
'schema',
])
)
.component(
+4
View File
@@ -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);
+1 -1
View File
@@ -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>
+4 -2
View File
@@ -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 &nbsp; 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 || '&nbsp;' }}</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();
+6 -2
View File
@@ -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>
);
}
+6 -2
View File
@@ -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;
}
+115
View File
@@ -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 });
});
+82 -12
View File
@@ -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
View File
@@ -1 +1,2 @@
export { NavTabs } from './NavTabs';
export type { Option } from './NavTabs';
+53 -8
View File
@@ -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
+7 -2
View File
@@ -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>
+8 -4
View File
@@ -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}
+10
View File
@@ -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