diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index e1ab32632..975c456be 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -95,6 +95,8 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
+ - '2.27.1'
+ - '2.27.0'
- '2.26.1'
- '2.26.0'
- '2.25.1'
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index edc9cb897..88e21f17b 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -238,10 +238,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
return err
}
- settings.SnapshotInterval = *cmp.Or(flags.SnapshotInterval, &settings.SnapshotInterval)
- settings.LogoURL = *cmp.Or(flags.Logo, &settings.LogoURL)
- settings.EnableEdgeComputeFeatures = *cmp.Or(flags.EnableEdgeComputeFeatures, &settings.EnableEdgeComputeFeatures)
- settings.TemplatesURL = *cmp.Or(flags.Templates, &settings.TemplatesURL)
+ settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
+ settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
+ settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
+ settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
diff --git a/api/connection.go b/api/connection.go
index 710b978da..14e2dc1ca 100644
--- a/api/connection.go
+++ b/api/connection.go
@@ -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 {
diff --git a/api/database/boltdb/db.go b/api/database/boltdb/db.go
index cef93b345..a0db7f4e0 100644
--- a/api/database/boltdb/db.go
+++ b/api/database/boltdb/db.go
@@ -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
diff --git a/api/database/boltdb/json_test.go b/api/database/boltdb/json_test.go
index 577aa2cfd..ba0863efd 100644
--- a/api/database/boltdb/json_test.go
+++ b/api/database/boltdb/json_test.go
@@ -10,7 +10,7 @@ import (
)
const (
- jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
+ jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://kubernetes.github.io/ingress-nginx","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)
diff --git a/api/database/boltdb/tx.go b/api/database/boltdb/tx.go
index 5de5d5333..2e45ac7b9 100644
--- a/api/database/boltdb/tx.go
+++ b/api/database/boltdb/tx.go
@@ -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 {
diff --git a/api/dataservices/base.go b/api/dataservices/base.go
index 9b1a42a53..04af70b02 100644
--- a/api/dataservices/base.go
+++ b/api/dataservices/base.go
@@ -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)
diff --git a/api/dataservices/base_tx.go b/api/dataservices/base_tx.go
index db1e702cb..d9915b64c 100644
--- a/api/dataservices/base_tx.go
+++ b/api/dataservices/base_tx.go
@@ -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)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index 9fa3e5f09..d08381f63 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -605,12 +605,12 @@
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
- "HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
+ "HelmRepositoryURL": "",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
- "KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc1",
+ "KubectlShellImage": "portainer/kubectl-shell:2.27.2",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.27.0-rc1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.27.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
\ No newline at end of file
diff --git a/api/docker/client/client.go b/api/docker/client/client.go
index 065a40382..1161c4995 100644
--- a/api/docker/client/client.go
+++ b/api/docker/client/client.go
@@ -3,8 +3,8 @@ package client
import (
"bytes"
"errors"
+ "fmt"
"io"
- "maps"
"net/http"
"strings"
"time"
@@ -141,7 +141,6 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
type NodeNameTransport struct {
*http.Transport
- nodeNames map[string]string
}
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -176,18 +175,19 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
return resp, nil
}
- t.nodeNames = make(map[string]string)
- for _, r := range rs {
- t.nodeNames[r.ID] = r.Portainer.Agent.NodeName
+ nodeNames, ok := req.Context().Value("nodeNames").(map[string]string)
+ if ok {
+ for idx, r := range rs {
+ // as there is no way to differentiate the same image available in multiple nodes only by their ID
+ // we append the index of the image in the payload response to match the node name later
+ // from the image.Summary[] list returned by docker's client.ImageList()
+ nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName
+ }
}
return resp, err
}
-func (t *NodeNameTransport) NodeNames() map[string]string {
- return maps.Clone(t.nodeNames)
-}
-
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
transport := &NodeNameTransport{
Transport: &http.Transport{},
diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go
index df49a2706..43e38b4e7 100644
--- a/api/filesystem/filesystem.go
+++ b/api/filesystem/filesystem.go
@@ -841,11 +841,11 @@ func (service *Service) GetDefaultSSLCertsPath() (string, string) {
}
func defaultMTLSCertPathUnderFileStore() (string, string, string) {
- certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename)
+ certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename)
- return certPath, caCertPath, keyPath
+ return caCertPath, certPath, keyPath
}
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
@@ -1014,26 +1014,45 @@ func CreateFile(path string, r io.Reader) error {
return err
}
-func (service *Service) StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error) {
- certPath, caCertPath, keyPath := defaultMTLSCertPathUnderFileStore()
+func (service *Service) StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) {
+ caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
- r := bytes.NewReader(cert)
- err := service.createFileInStore(certPath, r)
- if err != nil {
+ r := bytes.NewReader(caCert)
+ if err := service.createFileInStore(caCertPath, r); err != nil {
return "", "", "", err
}
- r = bytes.NewReader(caCert)
- err = service.createFileInStore(caCertPath, r)
- if err != nil {
+ r = bytes.NewReader(cert)
+ if err := service.createFileInStore(certPath, r); err != nil {
return "", "", "", err
}
r = bytes.NewReader(key)
- err = service.createFileInStore(keyPath, r)
- if err != nil {
+ if err := service.createFileInStore(keyPath, r); err != nil {
return "", "", "", err
}
- return service.wrapFileStore(certPath), service.wrapFileStore(caCertPath), service.wrapFileStore(keyPath), nil
+ return service.wrapFileStore(caCertPath), service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil
+}
+
+func (service *Service) GetMTLSCertificates() (string, string, string, error) {
+ caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
+
+ caCertPath = service.wrapFileStore(caCertPath)
+ certPath = service.wrapFileStore(certPath)
+ keyPath = service.wrapFileStore(keyPath)
+
+ paths := [...]string{caCertPath, certPath, keyPath}
+ for _, path := range paths {
+ exists, err := service.FileExists(path)
+ if err != nil {
+ return "", "", "", err
+ }
+
+ if !exists {
+ return "", "", "", fmt.Errorf("file %s does not exist", path)
+ }
+ }
+
+ return caCertPath, certPath, keyPath, nil
}
diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go
index a5b6ecdee..0f9c8f1a3 100644
--- a/api/http/handler/customtemplates/customtemplate_create.go
+++ b/api/http/handler/customtemplates/customtemplate_create.go
@@ -482,28 +482,3 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
return customTemplate, nil
}
-
-// @id CustomTemplateCreate
-// @summary Create a custom template
-// @description Create a custom template.
-// @description **Access policy**: authenticated
-// @tags custom_templates
-// @security ApiKeyAuth
-// @security jwt
-// @accept json,multipart/form-data
-// @produce json
-// @param method query string true "method for creating template" Enums(string, file, repository)
-// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
-// @success 200 {object} portainer.CustomTemplate
-// @failure 400 "Invalid request"
-// @failure 500 "Server error"
-// @deprecated
-// @router /custom_templates [post]
-func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method", err)
- }
-
- return "/custom_templates/create/" + method, nil
-}
diff --git a/api/http/handler/customtemplates/handler.go b/api/http/handler/customtemplates/handler.go
index 1bb148af6..0da63d81f 100644
--- a/api/http/handler/customtemplates/handler.go
+++ b/api/http/handler/customtemplates/handler.go
@@ -7,7 +7,6 @@ import (
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
@@ -33,7 +32,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/custom_templates/create/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
- h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
h.Handle("/custom_templates",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
h.Handle("/custom_templates/{id}",
diff --git a/api/http/handler/docker/images/images_list.go b/api/http/handler/docker/images/images_list.go
index ac7e980a0..eca99d993 100644
--- a/api/http/handler/docker/images/images_list.go
+++ b/api/http/handler/docker/images/images_list.go
@@ -1,10 +1,11 @@
package images
import (
+ "context"
+ "fmt"
"net/http"
"strings"
- "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/handler/docker/utils"
"github.com/portainer/portainer/api/set"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -46,17 +47,16 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
return httpErr
}
- images, err := cli.ImageList(r.Context(), image.ListOptions{})
+ nodeNames := make(map[string]string)
+
+ // Pass the node names map to the context so the custom NodeNameTransport can use it
+ ctx := context.WithValue(r.Context(), "nodeNames", nodeNames)
+
+ images, err := cli.ImageList(ctx, image.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}
- // Extract the node name from the custom transport
- nodeNames := make(map[string]string)
- if t, ok := cli.HTTPClient().Transport.(*client.NodeNameTransport); ok {
- nodeNames = t.NodeNames()
- }
-
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
if err != nil {
return httperror.BadRequest("Invalid query parameter: withUsage", err)
@@ -85,8 +85,12 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
}
imagesList[i] = ImageResponse{
- Created: image.Created,
- NodeName: nodeNames[image.ID],
+ Created: image.Created,
+ // Only works if the order of `images` is not changed between unmarshaling the agent's response
+ // in NodeNameTransport.RoundTrip() (api/docker/client/client.go)
+ // and docker's cli.ImageList()
+ // As both functions unmarshal the same response body, the resulting array will be ordered the same way.
+ NodeName: nodeNames[fmt.Sprintf("%s-%d", image.ID, i)],
ID: image.ID,
Size: image.Size,
Tags: image.RepoTags,
diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go
index 2d04f7cd2..319a1b03b 100644
--- a/api/http/handler/edgegroups/edgegroup_update.go
+++ b/api/http/handler/edgegroups/edgegroup_update.go
@@ -167,7 +167,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
- if err != nil {
+ if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return err
}
@@ -183,6 +183,12 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
edgeStackSet[edgeStackID] = true
}
+ if relation == nil {
+ relation = &portainer.EndpointRelation{
+ EndpointID: endpoint.ID,
+ EdgeStacks: make(map[portainer.EdgeStackID]bool),
+ }
+ }
relation.EdgeStacks = edgeStackSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go
index 252770aa2..0f7bff73d 100644
--- a/api/http/handler/edgejobs/edgejob_create.go
+++ b/api/http/handler/edgejobs/edgejob_create.go
@@ -271,26 +271,3 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
}
-
-// @id EdgeJobCreate
-// @summary Create an EdgeJob
-// @description **Access policy**: administrator
-// @tags edge_jobs
-// @security ApiKeyAuth
-// @security jwt
-// @produce json
-// @param method query string true "Creation Method" Enums(file, string)
-// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
-// @success 200 {object} portainer.EdgeGroup
-// @failure 503 "Edge compute features are disabled"
-// @failure 500
-// @deprecated
-// @router /edge_jobs [post]
-func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
- }
-
- return "/edge_jobs/create/" + method, nil
-}
diff --git a/api/http/handler/edgejobs/handler.go b/api/http/handler/edgejobs/handler.go
index 93f210bb5..ab3d66b3b 100644
--- a/api/http/handler/edgejobs/handler.go
+++ b/api/http/handler/edgejobs/handler.go
@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -30,8 +29,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
- h.Handle("/edge_jobs",
- bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/{id}",
diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go
index 12dfe620a..65e77764a 100644
--- a/api/http/handler/edgestacks/edgestack_create.go
+++ b/api/http/handler/edgestacks/edgestack_create.go
@@ -55,26 +55,3 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
}
-
-// @id EdgeStackCreate
-// @summary Create an EdgeStack
-// @description **Access policy**: administrator
-// @tags edge_stacks
-// @security ApiKeyAuth
-// @security jwt
-// @produce json
-// @param method query string true "Creation Method" Enums(file,string,repository)
-// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
-// @success 200 {object} portainer.EdgeStack
-// @failure 500
-// @failure 503 "Edge compute features are disabled"
-// @deprecated
-// @router /edge_stacks [post]
-func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
- }
-
- return "/edge_stacks/create/" + method, nil
-}
diff --git a/api/http/handler/edgestacks/edgestack_status_delete.go b/api/http/handler/edgestacks/edgestack_status_delete.go
deleted file mode 100644
index eb54951b6..000000000
--- a/api/http/handler/edgestacks/edgestack_status_delete.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package edgestacks
-
-import (
- "errors"
- "net/http"
- "time"
-
- portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
- "github.com/portainer/portainer/pkg/libhttp/request"
- "github.com/portainer/portainer/pkg/libhttp/response"
-)
-
-// @id EdgeStackStatusDelete
-// @summary Delete an EdgeStack status
-// @description Authorized only if the request is done by an Edge Environment(Endpoint)
-// @tags edge_stacks
-// @produce json
-// @param id path int true "EdgeStack Id"
-// @param environmentId path int true "Environment identifier"
-// @success 200 {object} portainer.EdgeStack
-// @failure 500
-// @failure 400
-// @failure 404
-// @failure 403
-// @deprecated
-// @router /edge_stacks/{id}/status/{environmentId} [delete]
-func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
- if err != nil {
- return httperror.BadRequest("Invalid stack identifier route variable", err)
- }
-
- endpoint, err := middlewares.FetchEndpoint(r)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve a valid endpoint from the handler context", err)
- }
-
- err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
- if err != nil {
- return httperror.Forbidden("Permission denied to access environment", err)
- }
-
- var stack *portainer.EdgeStack
- err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
- stack, err = handler.deleteEdgeStackStatus(tx, portainer.EdgeStackID(stackID), endpoint)
- return err
- })
- if err != nil {
- var httpErr *httperror.HandlerError
- if errors.As(err, &httpErr) {
- return httpErr
- }
-
- return httperror.InternalServerError("Unexpected error", err)
- }
-
- return response.JSON(w, stack)
-}
-
-func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, endpoint *portainer.Endpoint) (*portainer.EdgeStack, error) {
- stack, err := tx.EdgeStack().EdgeStack(stackID)
- if err != nil {
- return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
- }
-
- environmentStatus, ok := stack.Status[endpoint.ID]
- if !ok {
- environmentStatus = portainer.EdgeStackStatus{}
- }
-
- environmentStatus.Status = append(environmentStatus.Status, portainer.EdgeStackDeploymentStatus{
- Time: time.Now().Unix(),
- Type: portainer.EdgeStackStatusRemoved,
- })
-
- stack.Status[endpoint.ID] = environmentStatus
-
- err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
- if err != nil {
- return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
- }
-
- return stack, nil
-}
diff --git a/api/http/handler/edgestacks/edgestack_status_delete_test.go b/api/http/handler/edgestacks/edgestack_status_delete_test.go
deleted file mode 100644
index 7a3db1315..000000000
--- a/api/http/handler/edgestacks/edgestack_status_delete_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package edgestacks
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
-
- portainer "github.com/portainer/portainer/api"
-)
-
-func TestDeleteStatus(t *testing.T) {
- handler, _ := setupHandler(t)
-
- endpoint := createEndpoint(t, handler.DataStore)
- edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
-
- req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil)
- if err != nil {
- t.Fatal("request error:", err)
- }
-
- req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
- rec := httptest.NewRecorder()
- handler.ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
- }
-}
diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go
index 4cf912030..fef5a6927 100644
--- a/api/http/handler/edgestacks/edgestack_status_update.go
+++ b/api/http/handler/edgestacks/edgestack_status_update.go
@@ -79,7 +79,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
}
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
- return handler.updateEdgeStackStatus(stack, endpoint, r, stack.ID, payload)
+ return handler.updateEdgeStackStatus(stack, stack.ID, payload)
}
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
@@ -99,7 +99,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return response.JSON(w, stack)
}
-func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, endpoint *portainer.Endpoint, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
+func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
if payload.Version > 0 && payload.Version < stack.Version {
return stack, nil
}
diff --git a/api/http/handler/edgestacks/edgestack_status_update_coordinator.go b/api/http/handler/edgestacks/edgestack_status_update_coordinator.go
index f81e2fc3e..885b4c6da 100644
--- a/api/http/handler/edgestacks/edgestack_status_update_coordinator.go
+++ b/api/http/handler/edgestacks/edgestack_status_update_coordinator.go
@@ -60,6 +60,11 @@ func (c *EdgeStackStatusUpdateCoordinator) loop() {
return err
}
+ // Return early when the agent tries to update the status on a deleted stack
+ if stack == nil {
+ return nil
+ }
+
// 2. Mutate the edge stack opportunistically until there are no more pending updates
for {
stack, err = u.updateFn(stack)
diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go
index 9b5bb623d..593e403ef 100644
--- a/api/http/handler/edgestacks/edgestack_update.go
+++ b/api/http/handler/edgestacks/edgestack_update.go
@@ -107,7 +107,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
if err != nil {
- return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err)
+ return nil, httperror.InternalServerError("unable to check for existence of non fitting environments: %w", err)
}
if hasWrongType {
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
@@ -151,6 +151,9 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
for endpointID := range endpointsToRemove {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ continue
+ }
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
@@ -170,10 +173,16 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
for endpointID := range endpointsToAdd {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
- if err != nil {
+ if err != nil && !tx.IsErrObjectNotFound(err) {
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
+ if relation == nil {
+ relation = &portainer.EndpointRelation{
+ EndpointID: endpointID,
+ EdgeStacks: map[portainer.EdgeStackID]bool{},
+ }
+ }
relation.EdgeStacks[edgeStackID] = true
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go
index 290524cb0..9fa90776f 100644
--- a/api/http/handler/edgestacks/handler.go
+++ b/api/http/handler/edgestacks/handler.go
@@ -37,8 +37,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/edge_stacks/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
- h.Handle("/edge_stacks",
- bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}",
@@ -55,8 +53,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
edgeStackStatusRouter := h.NewRoute().Subrouter()
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
- edgeStackStatusRouter.PathPrefix("/edge_stacks/{id}/status/{endpoint_id}").Handler(bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusDelete))).Methods(http.MethodDelete)
-
return h
}
diff --git a/api/http/handler/edgetemplates/edgetemplate_list.go b/api/http/handler/edgetemplates/edgetemplate_list.go
deleted file mode 100644
index 5b2c7254d..000000000
--- a/api/http/handler/edgetemplates/edgetemplate_list.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package edgetemplates
-
-import (
- "net/http"
- "slices"
-
- portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/http/client"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
- "github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/segmentio/encoding/json"
-)
-
-type templateFileFormat struct {
- Version string `json:"version"`
- Templates []portainer.Template `json:"templates"`
-}
-
-// @id EdgeTemplateList
-// @deprecated
-// @summary Fetches the list of Edge Templates
-// @description **Access policy**: administrator
-// @tags edge_templates
-// @security ApiKeyAuth
-// @security jwt
-// @accept json
-// @produce json
-// @success 200 {array} portainer.Template
-// @failure 500
-// @router /edge_templates [get]
-func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- settings, err := handler.DataStore.Settings().Settings()
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve settings from the database", err)
- }
-
- url := portainer.DefaultTemplatesURL
- if settings.TemplatesURL != "" {
- url = settings.TemplatesURL
- }
-
- var templateData []byte
- templateData, err = client.Get(url, 10)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve external templates", err)
- }
-
- var templateFile templateFileFormat
-
- err = json.Unmarshal(templateData, &templateFile)
- if err != nil {
- return httperror.InternalServerError("Unable to parse template file", err)
- }
-
- // We only support version 3 of the template format
- // this is only a temporary fix until we have custom edge templates
- if templateFile.Version != "3" {
- return httperror.InternalServerError("Unsupported template version", nil)
- }
-
- filteredTemplates := make([]portainer.Template, 0)
-
- for _, template := range templateFile.Templates {
- if slices.Contains(template.Categories, "edge") && slices.Contains([]portainer.TemplateType{portainer.ComposeStackTemplate, portainer.SwarmStackTemplate}, template.Type) {
- filteredTemplates = append(filteredTemplates, template)
- }
- }
-
- return response.JSON(w, filteredTemplates)
-}
diff --git a/api/http/handler/edgetemplates/handler.go b/api/http/handler/edgetemplates/handler.go
deleted file mode 100644
index d6c98553f..000000000
--- a/api/http/handler/edgetemplates/handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package edgetemplates
-
-import (
- "net/http"
-
- "github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
- "github.com/portainer/portainer/api/http/security"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
-
- "github.com/gorilla/mux"
-)
-
-// Handler is the HTTP handler used to handle edge environment(endpoint) operations.
-type Handler struct {
- *mux.Router
- requestBouncer security.BouncerService
- DataStore dataservices.DataStore
-}
-
-// NewHandler creates a handler to manage environment(endpoint) operations.
-func NewHandler(bouncer security.BouncerService) *Handler {
- h := &Handler{
- Router: mux.NewRouter(),
- requestBouncer: bouncer,
- }
-
- h.Handle("/edge_templates",
- bouncer.AdminAccess(middlewares.Deprecated(httperror.LoggerHandler(h.edgeTemplateList), func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { return "", nil }))).Methods(http.MethodGet)
-
- return h
-}
diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect.go b/api/http/handler/endpointedge/endpointedge_status_inspect.go
index 9bd341561..4d6368493 100644
--- a/api/http/handler/endpointedge/endpointedge_status_inspect.go
+++ b/api/http/handler/endpointedge/endpointedge_status_inspect.go
@@ -264,6 +264,9 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ return nil, nil
+ }
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
}
diff --git a/api/http/handler/endpointgroups/endpoints.go b/api/http/handler/endpointgroups/endpoints.go
index b7c9771bb..b34032d9e 100644
--- a/api/http/handler/endpointgroups/endpoints.go
+++ b/api/http/handler/endpointgroups/endpoints.go
@@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
}
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
- if err != nil {
+ if err != nil && !tx.IsErrObjectNotFound(err) {
return err
}
+ if endpointRelation == nil {
+ endpointRelation = &portainer.EndpointRelation{
+ EndpointID: endpoint.ID,
+ EdgeStacks: make(map[portainer.EdgeStackID]bool),
+ }
+ }
+
edgeGroups, err := tx.EdgeGroup().ReadAll()
if err != nil {
return err
@@ -32,6 +39,9 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ return nil
+ }
return err
}
diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go
index 0add2ccdf..4752364ec 100644
--- a/api/http/handler/endpoints/endpoint_delete.go
+++ b/api/http/handler/endpoints/endpoint_delete.go
@@ -91,7 +91,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete the specified environments."
-// @router /endpoints [delete]
+// @router /endpoints/delete [post]
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var p endpointDeleteBatchPayload
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
@@ -127,6 +127,27 @@ func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Reque
return response.Empty(w)
}
+// @id EndpointDeleteBatchDeprecated
+// @summary Remove multiple environments
+// @deprecated
+// @description Deprecated: use the `POST` endpoint instead.
+// @description Remove multiple environments and optionally clean-up associated resources.
+// @description **Access policy**: Administrator only.
+// @tags endpoints
+// @security ApiKeyAuth || jwt
+// @accept json
+// @produce json
+// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up associated resources (cloud environments only)"
+// @success 204 "Environment(s) successfully deleted."
+// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 403 "Unauthorized access or operation not allowed."
+// @failure 500 "Server error occurred while attempting to delete the specified environments."
+// @router /endpoints [delete]
+func (handler *Handler) endpointDeleteBatchDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ return handler.endpointDeleteBatch(w, r)
+}
+
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go
index fa852633c..d1e323e8f 100644
--- a/api/http/handler/endpoints/handler.go
+++ b/api/http/handler/endpoints/handler.go
@@ -68,8 +68,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
- h.Handle("/endpoints",
- bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
+ h.Handle("/endpoints/delete",
+ bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/snapshot",
@@ -85,6 +85,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
// DEPRECATED
h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
+ h.Handle("/endpoints", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatchDeprecated))).Methods(http.MethodDelete)
return h
}
diff --git a/api/http/handler/endpoints/update_edge_relations.go b/api/http/handler/endpoints/update_edge_relations.go
index 8bebadedd..1390c9fd4 100644
--- a/api/http/handler/endpoints/update_edge_relations.go
+++ b/api/http/handler/endpoints/update_edge_relations.go
@@ -23,6 +23,7 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
+ EdgeStacks: map[portainer.EdgeStackID]bool{},
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return errors.WithMessage(err, "Unable to create environment relation inside the database")
diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go
index 4d7973170..a85315d94 100644
--- a/api/http/handler/handler.go
+++ b/api/http/handler/handler.go
@@ -11,7 +11,6 @@ import (
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
- "github.com/portainer/portainer/api/http/handler/edgetemplates"
"github.com/portainer/portainer/api/http/handler/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy"
@@ -50,7 +49,6 @@ type Handler struct {
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler
- EdgeTemplatesHandler *edgetemplates.Handler
EndpointEdgeHandler *endpointedge.Handler
EndpointGroupHandler *endpointgroups.Handler
EndpointHandler *endpoints.Handler
@@ -83,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
-// @version 2.26.0
+// @version 2.27.2
// @description.markdown api-description.md
// @termsOfService
@@ -190,8 +188,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_jobs"):
http.StripPrefix("/api", h.EdgeJobsHandler).ServeHTTP(w, r)
- case strings.HasPrefix(r.URL.Path, "/api/edge_templates"):
- http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go
index 0108f7ef1..fb941940b 100644
--- a/api/http/handler/helm/handler.go
+++ b/api/http/handler/helm/handler.go
@@ -53,12 +53,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm",
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
- // Deprecated
- h.Handle("/{id}/kubernetes/helm/repositories",
- httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
- h.Handle("/{id}/kubernetes/helm/repositories",
- httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
-
return h
}
diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go
index 869b80554..0b87d3a23 100644
--- a/api/http/handler/helm/helm_install_test.go
+++ b/api/http/handler/helm/helm_install_test.go
@@ -45,7 +45,7 @@ func Test_helmInstall(t *testing.T) {
is.NotNil(h, "Handler should not fail")
// Install a single chart. We expect to get these values back
- options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"}
+ options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
optdata, err := json.Marshal(options)
is.NoError(err)
diff --git a/api/http/handler/helm/helm_repo_search_test.go b/api/http/handler/helm/helm_repo_search_test.go
index 5fde5e642..a556908b9 100644
--- a/api/http/handler/helm/helm_repo_search_test.go
+++ b/api/http/handler/helm/helm_repo_search_test.go
@@ -20,7 +20,7 @@ func Test_helmRepoSearch(t *testing.T) {
assert.NotNil(t, h, "Handler should not fail")
- repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
+ repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
for _, repo := range repos {
t.Run(repo, func(t *testing.T) {
diff --git a/api/http/handler/helm/helm_show_test.go b/api/http/handler/helm/helm_show_test.go
index dd3957c99..fed59388d 100644
--- a/api/http/handler/helm/helm_show_test.go
+++ b/api/http/handler/helm/helm_show_test.go
@@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
t.Run(cmd, func(t *testing.T) {
is.NotNil(h, "Handler should not fail")
- repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami")
+ repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
chart := "nginx"
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
rr := httptest.NewRecorder()
diff --git a/api/http/handler/helm/user_helm_repos.go b/api/http/handler/helm/user_helm_repos.go
deleted file mode 100644
index 5197f88f9..000000000
--- a/api/http/handler/helm/user_helm_repos.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package helm
-
-import (
- "net/http"
- "strings"
-
- portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/http/security"
- "github.com/portainer/portainer/pkg/libhelm"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
- "github.com/portainer/portainer/pkg/libhttp/request"
- "github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/pkg/errors"
-)
-
-type helmUserRepositoryResponse struct {
- GlobalRepository string `json:"GlobalRepository"`
- UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"`
-}
-
-type addHelmRepoUrlPayload struct {
- URL string `json:"url"`
-}
-
-func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
- return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
-}
-
-// @id HelmUserRepositoryCreateDeprecated
-// @summary Create a user helm repository
-// @description Create a user helm repository.
-// @description **Access policy**: authenticated
-// @tags helm
-// @security ApiKeyAuth
-// @security jwt
-// @accept json
-// @produce json
-// @param id path int true "Environment(Endpoint) identifier"
-// @param payload body addHelmRepoUrlPayload true "Helm Repository"
-// @success 200 {object} portainer.HelmUserRepository "Success"
-// @failure 400 "Invalid request"
-// @failure 403 "Permission denied"
-// @failure 500 "Server error"
-// @deprecated
-// @router /endpoints/{id}/kubernetes/helm/repositories [post]
-func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- tokenData, err := security.RetrieveTokenData(r)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve user authentication token", err)
- }
- userID := tokenData.ID
-
- p := new(addHelmRepoUrlPayload)
- err = request.DecodeAndValidateJSONPayload(r, p)
- if err != nil {
- return httperror.BadRequest("Invalid Helm repository URL", err)
- }
-
- // lowercase, remove trailing slash
- p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/")
-
- records, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
- if err != nil {
- return httperror.InternalServerError("Unable to access the DataStore", err)
- }
-
- // check if repo already exists - by doing case insensitive comparison
- for _, record := range records {
- if strings.EqualFold(record.URL, p.URL) {
- errMsg := "Helm repo already registered for user"
- return httperror.BadRequest(errMsg, errors.New(errMsg))
- }
- }
-
- record := portainer.HelmUserRepository{
- UserID: userID,
- URL: p.URL,
- }
-
- err = handler.dataStore.HelmUserRepository().Create(&record)
- if err != nil {
- return httperror.InternalServerError("Unable to save a user Helm repository URL", err)
- }
-
- return response.JSON(w, record)
-}
-
-// @id HelmUserRepositoriesListDeprecated
-// @summary List a users helm repositories
-// @description Inspect a user helm repositories.
-// @description **Access policy**: authenticated
-// @tags helm
-// @security ApiKeyAuth
-// @security jwt
-// @produce json
-// @param id path int true "User identifier"
-// @success 200 {object} helmUserRepositoryResponse "Success"
-// @failure 400 "Invalid request"
-// @failure 403 "Permission denied"
-// @failure 500 "Server error"
-// @deprecated
-// @router /endpoints/{id}/kubernetes/helm/repositories [get]
-func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- tokenData, err := security.RetrieveTokenData(r)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve user authentication token", err)
- }
- userID := tokenData.ID
-
- settings, err := handler.dataStore.Settings().Settings()
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve settings from the database", err)
- }
-
- userRepos, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
- if err != nil {
- return httperror.InternalServerError("Unable to get user Helm repositories", err)
- }
-
- resp := helmUserRepositoryResponse{
- GlobalRepository: settings.HelmRepositoryURL,
- UserRepositories: userRepos,
- }
-
- return response.JSON(w, resp)
-}
diff --git a/api/http/handler/kubernetes/application.go b/api/http/handler/kubernetes/application.go
index 2da8d0fd4..cb67cd7af 100644
--- a/api/http/handler/kubernetes/application.go
+++ b/api/http/handler/kubernetes/application.go
@@ -69,7 +69,6 @@ func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.
// @param id path int true "Environment(Endpoint) identifier"
// @param namespace query string true "Namespace name"
// @param nodeName query string true "Node name"
-// @param withDependencies query boolean false "Include dependencies in the response"
// @success 200 {array} models.K8sApplication "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
@@ -117,12 +116,6 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err)
}
- withDependencies, err := request.RetrieveBooleanQueryParameter(r, "withDependencies", true)
- if err != nil {
- log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the withDependencies query parameter")
- return nil, httperror.BadRequest("Unable to parse the withDependencies query parameter", err)
- }
-
nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter")
@@ -135,7 +128,7 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
- applications, err := cli.GetApplications(namespace, nodeName, withDependencies)
+ applications, err := cli.GetApplications(namespace, nodeName)
if err != nil {
if k8serrors.IsUnauthorized(err) {
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications")
diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go
index 0b36dbc62..895da489c 100644
--- a/api/http/handler/settings/settings_update.go
+++ b/api/http/handler/settings/settings_update.go
@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
// Whether telemetry is enabled
EnableTelemetry *bool `example:"false"`
// Helm repository URL
- HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
+ HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
// Kubectl Shell Image
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go
index 7e5cce040..2ad40a182 100644
--- a/api/http/handler/stacks/handler.go
+++ b/api/http/handler/stacks/handler.go
@@ -11,7 +11,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/consts"
- "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -62,8 +61,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/stacks/create/{type}/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
- h.Handle("/stacks",
- bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
h.Handle("/stacks",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
h.Handle("/stacks/{id}",
diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go
index f45592d09..cb297f9d7 100644
--- a/api/http/handler/stacks/stack_create.go
+++ b/api/http/handler/stacks/stack_create.go
@@ -1,7 +1,6 @@
package stacks
import (
- "fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -141,53 +140,3 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
return response.JSON(w, stack)
}
-
-func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
- stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
- if err != nil {
- return "", err
- }
-
- switch stackType {
- case 1:
- return "swarm", nil
- case 2:
- return "standalone", nil
- case 3:
- return "kubernetes", nil
- }
-
- return "", errors.New(request.ErrInvalidQueryParameter)
-}
-
-// @id StackCreate
-// @summary Deploy a new stack
-// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
-// @description **Access policy**: authenticated
-// @tags stacks
-// @security ApiKeyAuth
-// @security jwt
-// @accept json,multipart/form-data
-// @produce json
-// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
-// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
-// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
-// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
-// @success 200 {object} portainer.Stack
-// @failure 400 "Invalid request"
-// @failure 500 "Server error"
-// @deprecated
-// @router /stacks [post]
-func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
- }
-
- stackType, err := getStackTypeFromQueryParameter(r)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: type", err)
- }
-
- return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
-}
diff --git a/api/http/handler/system/handler.go b/api/http/handler/system/handler.go
index d2e3f5485..4cab43332 100644
--- a/api/http/handler/system/handler.go
+++ b/api/http/handler/system/handler.go
@@ -59,10 +59,6 @@ func NewHandler(bouncer security.BouncerService,
// Deprecated /status endpoint, will be removed in the future.
h.Handle("/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet)
- h.Handle("/status/version",
- bouncer.AuthenticatedAccess(http.HandlerFunc(h.versionDeprecated))).Methods(http.MethodGet)
- h.Handle("/status/nodes",
- bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusNodesCountDeprecated))).Methods(http.MethodGet)
return h
}
diff --git a/api/http/handler/system/nodes_count.go b/api/http/handler/system/nodes_count.go
index 0b9971619..94ab320fa 100644
--- a/api/http/handler/system/nodes_count.go
+++ b/api/http/handler/system/nodes_count.go
@@ -8,8 +8,6 @@ import (
"github.com/portainer/portainer/api/internal/snapshot"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/rs/zerolog/log"
)
type nodesCountResponse struct {
@@ -44,21 +42,3 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
}
-
-// @id statusNodesCount
-// @summary Retrieve the count of nodes
-// @deprecated
-// @description Deprecated: use the `/system/nodes` endpoint instead.
-// @description **Access policy**: authenticated
-// @security ApiKeyAuth
-// @security jwt
-// @tags status
-// @produce json
-// @success 200 {object} nodesCountResponse "Success"
-// @failure 500 "Server error"
-// @router /status/nodes [get]
-func (handler *Handler) statusNodesCountDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- log.Warn().Msg("The /status/nodes endpoint is deprecated, please use the /system/nodes endpoint instead")
-
- return handler.systemNodesCount(w, r)
-}
diff --git a/api/http/handler/system/system_info.go b/api/http/handler/system/system_info.go
index ad5f944ef..64a915313 100644
--- a/api/http/handler/system/system_info.go
+++ b/api/http/handler/system/system_info.go
@@ -3,6 +3,7 @@ package system
import (
"net/http"
+ "github.com/pkg/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
plf "github.com/portainer/portainer/api/platform"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -46,7 +47,12 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
platform, err := handler.platformService.GetPlatform()
if err != nil {
- return httperror.InternalServerError("Failed to get platform", err)
+ if !errors.Is(err, plf.ErrNoLocalEnvironment) {
+ return httperror.InternalServerError("Failed to get platform", err)
+ }
+ // If no local environment is detected, we assume the platform is Docker
+ // UI will stop showing the upgrade banner
+ platform = plf.PlatformDocker
}
return response.JSON(w, &systemInfoResponse{
diff --git a/api/http/handler/system/system_upgrade.go b/api/http/handler/system/system_upgrade.go
index f881b6233..8eb41413f 100644
--- a/api/http/handler/system/system_upgrade.go
+++ b/api/http/handler/system/system_upgrade.go
@@ -4,6 +4,7 @@ import (
"net/http"
"regexp"
+ ceplf "github.com/portainer/portainer/api/platform"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -45,6 +46,9 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
environment, err := handler.platformService.GetLocalEnvironment()
if err != nil {
+ if errors.Is(err, ceplf.ErrNoLocalEnvironment) {
+ return httperror.NotFound("The system upgrade feature is disabled because no local environment was detected.", err)
+ }
return httperror.InternalServerError("Failed to get local environment", err)
}
@@ -53,8 +57,7 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Failed to get platform", err)
}
- err = handler.upgradeService.Upgrade(platform, environment, payload.License)
- if err != nil {
+ if err := handler.upgradeService.Upgrade(platform, environment, payload.License); err != nil {
return httperror.InternalServerError("Failed to upgrade Portainer", err)
}
diff --git a/api/http/handler/system/version.go b/api/http/handler/system/version.go
index 50ad2f6af..9d80b88a9 100644
--- a/api/http/handler/system/version.go
+++ b/api/http/handler/system/version.go
@@ -106,21 +106,3 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
return currentVersionSemver.LessThan(*latestVersionSemver)
}
-
-// @id Version
-// @summary Check for portainer updates
-// @deprecated
-// @description Deprecated: use the `/system/version` endpoint instead.
-// @description Check if portainer has an update available
-// @description **Access policy**: authenticated
-// @security ApiKeyAuth
-// @security jwt
-// @tags status
-// @produce json
-// @success 200 {object} versionResponse "Success"
-// @router /status/version [get]
-func (handler *Handler) versionDeprecated(w http.ResponseWriter, r *http.Request) {
- log.Warn().Msg("The /status/version endpoint is deprecated, please use the /system/version endpoint instead")
-
- handler.version(w, r)
-}
diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go
index ad8ef1347..4f8554faf 100644
--- a/api/http/handler/tags/tag_delete.go
+++ b/api/http/handler/tags/tag_delete.go
@@ -133,10 +133,17 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
- if err != nil {
+ if err != nil && !tx.IsErrObjectNotFound(err) {
return err
}
+ if endpointRelation == nil {
+ endpointRelation = &portainer.EndpointRelation{
+ EndpointID: endpoint.ID,
+ EdgeStacks: make(map[portainer.EdgeStackID]bool),
+ }
+ }
+
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
if err != nil {
return err
@@ -147,6 +154,7 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
for _, edgeStackID := range endpointStacks {
stacksSet[edgeStackID] = true
}
+
endpointRelation.EdgeStacks = stacksSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go
index e604e8abe..1e7b7f05d 100644
--- a/api/http/handler/templates/handler.go
+++ b/api/http/handler/templates/handler.go
@@ -29,7 +29,5 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
h.Handle("/templates/{id}/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
- h.Handle("/templates/file",
- bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFileOld))).Methods(http.MethodPost)
return h
}
diff --git a/api/http/handler/templates/template_file_old.go b/api/http/handler/templates/template_file_old.go
deleted file mode 100644
index 91ac038c5..000000000
--- a/api/http/handler/templates/template_file_old.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package templates
-
-import (
- "errors"
- "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/rs/zerolog/log"
-)
-
-type filePayload struct {
- // URL of a git repository where the file is stored
- RepositoryURL string `example:"https://github.com/portainer/portainer-compose" validate:"required"`
- // Path to the file inside the git repository
- ComposeFilePathInRepository string `example:"./subfolder/docker-compose.yml" validate:"required"`
-}
-
-func (payload *filePayload) Validate(r *http.Request) error {
- if len(payload.RepositoryURL) == 0 {
- return errors.New("Invalid repository url")
- }
-
- if len(payload.ComposeFilePathInRepository) == 0 {
- return errors.New("Invalid file path")
- }
-
- return nil
-}
-
-func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperror.HandlerError {
- response, httpErr := handler.fetchTemplates()
- if httpErr != nil {
- return httpErr
- }
-
- for _, t := range response.Templates {
- if t.Repository.URL == payload.RepositoryURL && t.Repository.StackFile == payload.ComposeFilePathInRepository {
- return nil
- }
- }
- return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
-}
-
-// @id TemplateFileOld
-// @summary Get a template's file
-// @deprecated
-// @description Get a template's file
-// @description **Access policy**: authenticated
-// @tags templates
-// @security ApiKeyAuth
-// @security jwt
-// @accept json
-// @produce json
-// @param body body filePayload true "File details"
-// @success 200 {object} fileResponse "Success"
-// @failure 400 "Invalid request"
-// @failure 500 "Server error"
-// @router /templates/file [post]
-func (handler *Handler) templateFileOld(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- log.Warn().Msg("This api is deprecated. Please use /templates/{id}/file instead")
-
- var payload filePayload
- err := request.DecodeAndValidateJSONPayload(r, &payload)
- if err != nil {
- return httperror.BadRequest("Invalid request payload", err)
- }
-
- if err := handler.ifRequestedTemplateExists(&payload); err != nil {
- return err
- }
-
- projectPath, err := handler.FileService.GetTemporaryPath()
- if err != nil {
- return httperror.InternalServerError("Unable to create temporary folder", err)
- }
-
- defer handler.cleanUp(projectPath)
-
- err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false)
- if err != nil {
- return httperror.InternalServerError("Unable to clone git repository", err)
- }
-
- fileContent, err := handler.FileService.GetFileContent(projectPath, payload.ComposeFilePathInRepository)
- if err != nil {
- return httperror.InternalServerError("Failed loading file content", err)
- }
-
- return response.JSON(w, fileResponse{FileContent: string(fileContent)})
-
-}
diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go
index 00f69e328..eb240692d 100644
--- a/api/http/security/bouncer.go
+++ b/api/http/security/bouncer.go
@@ -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
}
diff --git a/api/http/server.go b/api/http/server.go
index ec86e6220..c5bb0d40f 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -24,7 +24,6 @@ import (
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
- "github.com/portainer/portainer/api/http/handler/edgetemplates"
"github.com/portainer/portainer/api/http/handler/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy"
@@ -169,9 +168,6 @@ func (server *Server) Start() error {
edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
- var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer)
- edgeTemplatesHandler.DataStore = server.DataStore
-
var endpointHandler = endpoints.NewHandler(requestBouncer)
endpointHandler.DataStore = server.DataStore
endpointHandler.FileService = server.FileService
@@ -306,7 +302,6 @@ func (server *Server) Start() error {
EdgeGroupsHandler: edgeGroupsHandler,
EdgeJobsHandler: edgeJobsHandler,
EdgeStacksHandler: edgeStacksHandler,
- EdgeTemplatesHandler: edgeTemplatesHandler,
EndpointGroupHandler: endpointGroupHandler,
EndpointHandler: endpointHandler,
EndpointHelmHandler: endpointHelmHandler,
diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go
index 4fc6943af..3a19e8c9d 100644
--- a/api/internal/edge/edgestacks/service.go
+++ b/api/internal/edge/edgestacks/service.go
@@ -11,6 +11,7 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/edge"
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
+ "github.com/rs/zerolog/log"
"github.com/pkg/errors"
)
@@ -119,6 +120,9 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg
for _, endpointID := range relatedEndpointIds {
relation, err := endpointRelationService.EndpointRelation(endpointID)
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ continue
+ }
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
}
@@ -147,6 +151,14 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
for _, endpointID := range relatedEndpointIds {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ log.Warn().
+ Int("endpoint_id", int(endpointID)).
+ Msg("Unable to find endpoint relation in database, skipping")
+
+ continue
+ }
+
return errors.WithMessage(err, "Unable to find environment relation in database")
}
diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go
index f0bba23fd..652e6f5c0 100644
--- a/api/internal/testhelpers/datastore.go
+++ b/api/internal/testhelpers/datastore.go
@@ -151,6 +151,7 @@ func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User
func (s *stubUserService) Create(user *portainer.User) error { return nil }
func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
+func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
// WithUsers testDatastore option that will instruct testDatastore to return provided users
func WithUsers(us []portainer.User) datastoreOption {
@@ -186,6 +187,9 @@ func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFun
}
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
+func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
+ return false, nil
+}
// WithEdgeJobs option will instruct testDatastore to return provided jobs
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
@@ -426,6 +430,10 @@ func (s *stubStacksService) GetNextIdentifier() int {
return len(s.stacks)
}
+func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) {
+ return false, nil
+}
+
// WithStacks option will instruct testDatastore to return provided stacks
func WithStacks(stacks []portainer.Stack) datastoreOption {
return func(d *testDatastore) {
diff --git a/api/kubernetes/cli/applications.go b/api/kubernetes/cli/applications.go
index e68137c69..c08329a7b 100644
--- a/api/kubernetes/cli/applications.go
+++ b/api/kubernetes/cli/applications.go
@@ -12,45 +12,58 @@ import (
labels "k8s.io/apimachinery/pkg/labels"
)
+// PortainerApplicationResources contains collections of various Kubernetes resources
+// associated with a Portainer application.
+type PortainerApplicationResources struct {
+ Pods []corev1.Pod
+ ReplicaSets []appsv1.ReplicaSet
+ Deployments []appsv1.Deployment
+ StatefulSets []appsv1.StatefulSet
+ DaemonSets []appsv1.DaemonSet
+ Services []corev1.Service
+ HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler
+}
+
// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
-func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
if kcl.IsKubeAdmin {
- return kcl.fetchApplications(namespace, nodeName, withDependencies)
+ return kcl.fetchApplications(namespace, nodeName)
}
- return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies)
+ return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
}
// fetchApplications fetches the applications in the namespaces the user has access to.
// This function is called when the user is an admin.
-func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
podListOptions := metav1.ListOptions{}
if nodeName != "" {
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
}
- if !withDependencies {
- // TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
- pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
- if err != nil {
- return nil, err
- }
- return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
- }
-
- pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
- return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
+ applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
+ if err != nil {
+ return nil, err
+ }
+
+ unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
+ if err != nil {
+ return nil, err
+ }
+
+ return append(applications, unhealthyApplications...), nil
}
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
// This function is called when the user is not an admin.
-func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
@@ -62,28 +75,24 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
}
- if !withDependencies {
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
- if err != nil {
- return nil, err
- }
-
- return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
- }
-
- pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
- applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
+ applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
+ if err != nil {
+ return nil, err
+ }
+
+ unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sApplication, 0)
- for _, application := range applications {
+ for _, application := range append(applications, unhealthyApplications...) {
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
results = append(results, application)
}
@@ -93,11 +102,11 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
}
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
-func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
applications := []models.K8sApplication{}
processedOwners := make(map[string]struct{})
- for _, pod := range pods {
+ for _, pod := range portainerApplicationResources.Pods {
if len(pod.OwnerReferences) > 0 {
ownerUID := string(pod.OwnerReferences[0].UID)
if _, exists := processedOwners[ownerUID]; exists {
@@ -106,7 +115,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
processedOwners[ownerUID] = struct{}{}
}
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
+ application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
if err != nil {
return nil, err
}
@@ -151,7 +160,9 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -168,7 +179,9 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -181,12 +194,12 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
}
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
-func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
+func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
if isReplicaSetOwner(pod) {
- updateOwnerReferenceToDeployment(&pod, replicaSets)
+ updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
}
- application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
+ application := createApplicationFromPod(&pod, portainerApplicationResources)
if application.ID == "" && application.Name == "" {
return nil, nil
}
@@ -203,9 +216,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
return &application, nil
}
-// createApplication creates a K8sApplication object from a pod
+// createApplicationFromPod creates a K8sApplication object from a pod
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
-func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
+func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
kind := "Pod"
name := pod.Name
@@ -221,120 +234,172 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
switch kind {
case "Deployment":
- for _, deployment := range deployments {
+ for _, deployment := range portainerApplicationResources.Deployments {
if deployment.Name == name && deployment.Namespace == pod.Namespace {
- application.ApplicationType = "Deployment"
- application.Kind = "Deployment"
- application.ID = string(deployment.UID)
- application.ResourcePool = deployment.Namespace
- application.Name = name
- application.Image = deployment.Spec.Template.Spec.Containers[0].Image
- application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = deployment.Labels
- application.MatchLabels = deployment.Spec.Selector.MatchLabels
- application.CreationDate = deployment.CreationTimestamp.Time
- application.TotalPodsCount = int(deployment.Status.Replicas)
- application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
- application.DeploymentType = "Replicated"
- application.Metadata = &models.Metadata{
- Labels: deployment.Labels,
- }
-
+ populateApplicationFromDeployment(&application, deployment)
break
}
}
-
case "StatefulSet":
- for _, statefulSet := range statefulSets {
+ for _, statefulSet := range portainerApplicationResources.StatefulSets {
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
- application.Kind = "StatefulSet"
- application.ApplicationType = "StatefulSet"
- application.ID = string(statefulSet.UID)
- application.ResourcePool = statefulSet.Namespace
- application.Name = name
- application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
- application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = statefulSet.Labels
- application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
- application.CreationDate = statefulSet.CreationTimestamp.Time
- application.TotalPodsCount = int(statefulSet.Status.Replicas)
- application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
- application.DeploymentType = "Replicated"
- application.Metadata = &models.Metadata{
- Labels: statefulSet.Labels,
- }
-
+ populateApplicationFromStatefulSet(&application, statefulSet)
break
}
}
-
case "DaemonSet":
- for _, daemonSet := range daemonSets {
+ for _, daemonSet := range portainerApplicationResources.DaemonSets {
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
- application.Kind = "DaemonSet"
- application.ApplicationType = "DaemonSet"
- application.ID = string(daemonSet.UID)
- application.ResourcePool = daemonSet.Namespace
- application.Name = name
- application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
- application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = daemonSet.Labels
- application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
- application.CreationDate = daemonSet.CreationTimestamp.Time
- application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
- application.RunningPodsCount = int(daemonSet.Status.NumberReady)
- application.DeploymentType = "Global"
- application.Metadata = &models.Metadata{
- Labels: daemonSet.Labels,
- }
-
+ populateApplicationFromDaemonSet(&application, daemonSet)
break
}
}
-
case "Pod":
- runningPodsCount := 1
- if pod.Status.Phase != corev1.PodRunning {
- runningPodsCount = 0
- }
-
- application.ApplicationType = "Pod"
- application.Kind = "Pod"
- application.ID = string(pod.UID)
- application.ResourcePool = pod.Namespace
- application.Name = pod.Name
- application.Image = pod.Spec.Containers[0].Image
- application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = pod.Labels
- application.MatchLabels = pod.Labels
- application.CreationDate = pod.CreationTimestamp.Time
- application.TotalPodsCount = 1
- application.RunningPodsCount = runningPodsCount
- application.DeploymentType = string(pod.Status.Phase)
- application.Metadata = &models.Metadata{
- Labels: pod.Labels,
- }
+ populateApplicationFromPod(&application, *pod)
}
- if application.ID != "" && application.Name != "" && len(services) > 0 {
- updateApplicationWithService(&application, services)
+ if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 {
+ updateApplicationWithService(&application, portainerApplicationResources.Services)
}
- if application.ID != "" && application.Name != "" && len(hpas) > 0 {
- updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
+ if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
+ updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
}
return application
}
+// createApplicationFromDeployment creates a K8sApplication from a Deployment
+func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication {
+ var app models.K8sApplication
+ populateApplicationFromDeployment(&app, deployment)
+ return app
+}
+
+// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet
+func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication {
+ var app models.K8sApplication
+ populateApplicationFromStatefulSet(&app, statefulSet)
+ return app
+}
+
+// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet
+func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication {
+ var app models.K8sApplication
+ populateApplicationFromDaemonSet(&app, daemonSet)
+ return app
+}
+
+func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) {
+ application.ApplicationType = "Deployment"
+ application.Kind = "Deployment"
+ application.ID = string(deployment.UID)
+ application.ResourcePool = deployment.Namespace
+ application.Name = deployment.Name
+ application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = deployment.Labels
+ application.MatchLabels = deployment.Spec.Selector.MatchLabels
+ application.CreationDate = deployment.CreationTimestamp.Time
+ application.TotalPodsCount = 0
+ if deployment.Spec.Replicas != nil {
+ application.TotalPodsCount = int(*deployment.Spec.Replicas)
+ }
+ application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
+ application.DeploymentType = "Replicated"
+ application.Metadata = &models.Metadata{
+ Labels: deployment.Labels,
+ }
+
+ // If the deployment has containers, use the first container's image
+ if len(deployment.Spec.Template.Spec.Containers) > 0 {
+ application.Image = deployment.Spec.Template.Spec.Containers[0].Image
+ }
+}
+
+func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) {
+ application.Kind = "StatefulSet"
+ application.ApplicationType = "StatefulSet"
+ application.ID = string(statefulSet.UID)
+ application.ResourcePool = statefulSet.Namespace
+ application.Name = statefulSet.Name
+ application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = statefulSet.Labels
+ application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
+ application.CreationDate = statefulSet.CreationTimestamp.Time
+ application.TotalPodsCount = 0
+ if statefulSet.Spec.Replicas != nil {
+ application.TotalPodsCount = int(*statefulSet.Spec.Replicas)
+ }
+ application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
+ application.DeploymentType = "Replicated"
+ application.Metadata = &models.Metadata{
+ Labels: statefulSet.Labels,
+ }
+
+ // If the statefulSet has containers, use the first container's image
+ if len(statefulSet.Spec.Template.Spec.Containers) > 0 {
+ application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
+ }
+}
+
+func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) {
+ application.Kind = "DaemonSet"
+ application.ApplicationType = "DaemonSet"
+ application.ID = string(daemonSet.UID)
+ application.ResourcePool = daemonSet.Namespace
+ application.Name = daemonSet.Name
+ application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = daemonSet.Labels
+ application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
+ application.CreationDate = daemonSet.CreationTimestamp.Time
+ application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
+ application.RunningPodsCount = int(daemonSet.Status.NumberReady)
+ application.DeploymentType = "Global"
+ application.Metadata = &models.Metadata{
+ Labels: daemonSet.Labels,
+ }
+
+ if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
+ application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
+ }
+}
+
+func populateApplicationFromPod(application *models.K8sApplication, pod corev1.Pod) {
+ runningPodsCount := 1
+ if pod.Status.Phase != corev1.PodRunning {
+ runningPodsCount = 0
+ }
+
+ application.ApplicationType = "Pod"
+ application.Kind = "Pod"
+ application.ID = string(pod.UID)
+ application.ResourcePool = pod.Namespace
+ application.Name = pod.Name
+ application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = pod.Labels
+ application.MatchLabels = pod.Labels
+ application.CreationDate = pod.CreationTimestamp.Time
+ application.TotalPodsCount = 1
+ application.RunningPodsCount = runningPodsCount
+ application.DeploymentType = string(pod.Status.Phase)
+ application.Metadata = &models.Metadata{
+ Labels: pod.Labels,
+ }
+
+ // If the pod has containers, use the first container's image
+ if len(pod.Spec.Containers) > 0 {
+ application.Image = pod.Spec.Containers[0].Image
+ }
+}
+
// updateApplicationWithService updates the application with the services that match the application's selector match labels
// and are in the same namespace as the application
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
@@ -410,7 +475,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -436,7 +503,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -454,3 +523,84 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
return configurationOwners, nil
}
+
+// fetchUnhealthyApplications fetches applications that failed to schedule any pods
+// due to issues like missing resource limits or other scheduling constraints
+func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) {
+ var unhealthyApplications []models.K8sApplication
+
+ // Process Deployments
+ for _, deployment := range resources.Deployments {
+ if hasNoScheduledPods(deployment) {
+ app := createApplicationFromDeployment(deployment)
+ addRelatedResourcesToApplication(&app, resources)
+ unhealthyApplications = append(unhealthyApplications, app)
+ }
+ }
+
+ // Process StatefulSets
+ for _, statefulSet := range resources.StatefulSets {
+ if hasNoScheduledPods(statefulSet) {
+ app := createApplicationFromStatefulSet(statefulSet)
+ addRelatedResourcesToApplication(&app, resources)
+ unhealthyApplications = append(unhealthyApplications, app)
+ }
+ }
+
+ // Process DaemonSets
+ for _, daemonSet := range resources.DaemonSets {
+ if hasNoScheduledPods(daemonSet) {
+ app := createApplicationFromDaemonSet(daemonSet)
+ addRelatedResourcesToApplication(&app, resources)
+ unhealthyApplications = append(unhealthyApplications, app)
+ }
+ }
+
+ return unhealthyApplications, nil
+}
+
+// addRelatedResourcesToApplication adds Services and HPA information to the application
+func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) {
+ if app.ID == "" || app.Name == "" {
+ return
+ }
+
+ if len(resources.Services) > 0 {
+ updateApplicationWithService(app, resources.Services)
+ }
+
+ if len(resources.HorizontalPodAutoscalers) > 0 {
+ updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers)
+ }
+}
+
+// hasNoScheduledPods checks if a workload has completely failed to schedule any pods
+// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running
+// if any pods exist at all (even if not ready), it returns false
+func hasNoScheduledPods(obj interface{}) bool {
+ switch resource := obj.(type) {
+ case appsv1.Deployment:
+ if resource.Status.Replicas > 0 {
+ return false
+ }
+
+ return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0
+
+ case appsv1.StatefulSet:
+ if resource.Status.Replicas > 0 {
+ return false
+ }
+
+ return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0
+
+ case appsv1.DaemonSet:
+ if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 {
+ return false
+ }
+
+ return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0
+
+ default:
+ return false
+ }
+}
diff --git a/api/kubernetes/cli/applications_test.go b/api/kubernetes/cli/applications_test.go
new file mode 100644
index 000000000..81a5cfb71
--- /dev/null
+++ b/api/kubernetes/cli/applications_test.go
@@ -0,0 +1,461 @@
+package cli
+
+import (
+ "context"
+ "testing"
+
+ models "github.com/portainer/portainer/api/http/models/kubernetes"
+ "github.com/stretchr/testify/assert"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/kubernetes/fake"
+)
+
+// Helper functions to create test resources
+func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
+ return &appsv1.Deployment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("deploy-" + name),
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: appsv1.DeploymentSpec{
+ Replicas: &replicas,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": name,
+ },
+ },
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: name,
+ Image: "nginx:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ },
+ },
+ Status: appsv1.DeploymentStatus{
+ Replicas: replicas,
+ ReadyReplicas: replicas,
+ },
+ }
+}
+
+func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
+ return &appsv1.ReplicaSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("rs-" + name),
+ OwnerReferences: []metav1.OwnerReference{
+ {
+ Kind: "Deployment",
+ Name: deploymentName,
+ UID: types.UID("deploy-" + deploymentName),
+ },
+ },
+ },
+ Spec: appsv1.ReplicaSetSpec{
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": deploymentName,
+ },
+ },
+ },
+ }
+}
+
+func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
+ return &appsv1.StatefulSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("sts-" + name),
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: appsv1.StatefulSetSpec{
+ Replicas: &replicas,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": name,
+ },
+ },
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: name,
+ Image: "redis:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ },
+ },
+ Status: appsv1.StatefulSetStatus{
+ Replicas: replicas,
+ ReadyReplicas: replicas,
+ },
+ }
+}
+
+func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
+ return &appsv1.DaemonSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("ds-" + name),
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: appsv1.DaemonSetSpec{
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": name,
+ },
+ },
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: name,
+ Image: "fluentd:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ },
+ },
+ Status: appsv1.DaemonSetStatus{
+ DesiredNumberScheduled: 2,
+ NumberReady: 2,
+ },
+ }
+}
+
+func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
+ phase := corev1.PodPending
+ if isRunning {
+ phase = corev1.PodRunning
+ }
+
+ var ownerReferences []metav1.OwnerReference
+ if ownerKind != "" && ownerName != "" {
+ ownerReferences = []metav1.OwnerReference{
+ {
+ Kind: ownerKind,
+ Name: ownerName,
+ UID: types.UID(ownerKind + "-" + ownerName),
+ },
+ }
+ }
+
+ return &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("pod-" + name),
+ OwnerReferences: ownerReferences,
+ Labels: map[string]string{
+ "app": ownerName,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: "container-" + name,
+ Image: "busybox:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ Status: corev1.PodStatus{
+ Phase: phase,
+ },
+ }
+}
+
+func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
+ return &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("svc-" + name),
+ },
+ Spec: corev1.ServiceSpec{
+ Selector: selector,
+ Type: corev1.ServiceTypeClusterIP,
+ },
+ }
+}
+
+func TestGetApplications(t *testing.T) {
+ t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
+ // Create a fake K8s client
+ fakeClient := fake.NewSimpleClientset()
+
+ // Setup the test namespace
+ namespace := "test-namespace"
+ defaultNamespace := "default"
+
+ // Create resources in the test namespace
+ // 1. Deployment with pods
+ deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
+ _, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
+ _, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 2. Deployment without pods (scaled to 0)
+ deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
+ _, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 3. StatefulSet with pods
+ stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 4. StatefulSet without pods
+ stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 5. DaemonSet with pods
+ dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
+ _, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 6. Naked Pod (no owner reference)
+ nakedPod := createTestPod("naked-pod", namespace, "", "", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 7. Resources in another namespace
+ deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
+ _, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
+ _, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 8. Add a service (dependency)
+ service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
+ _, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create the KubeClient with admin privileges
+ kubeClient := &KubeClient{
+ cli: fakeClient,
+ instanceID: "test-instance",
+ IsKubeAdmin: true,
+ }
+
+ // Test cases
+
+ // 1. All resources, no filtering
+ t.Run("All resources with dependencies", func(t *testing.T) {
+ apps, err := kubeClient.GetApplications("", "")
+ assert.NoError(t, err)
+
+ // We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
+ // Note: Each controller with pods should count once, not per pod
+ assert.Equal(t, 7, len(apps))
+
+ // Verify one of the deployments has services attached
+ appsWithServices := []models.K8sApplication{}
+ for _, app := range apps {
+ if len(app.Services) > 0 {
+ appsWithServices = append(appsWithServices, app)
+ }
+ }
+ assert.Equal(t, 1, len(appsWithServices))
+ assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
+ })
+
+ // 2. Filter by namespace
+ t.Run("Filter by namespace", func(t *testing.T) {
+ apps, err := kubeClient.GetApplications(namespace, "")
+ assert.NoError(t, err)
+
+ // We expect 6 resources in the test namespace
+ assert.Equal(t, 6, len(apps))
+
+ // Verify resources from other namespaces are not included
+ for _, app := range apps {
+ assert.Equal(t, namespace, app.ResourcePool)
+ }
+ })
+ })
+
+ t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
+ // Create a fake K8s client
+ fakeClient := fake.NewSimpleClientset()
+
+ // Setup the test namespaces
+ namespace1 := "allowed-ns"
+ namespace2 := "restricted-ns"
+
+ // Create resources in the allowed namespace
+ sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
+ _, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
+ _, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Add a StatefulSet without pods in the allowed namespace
+ stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create resources in the restricted namespace
+ sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
+ _, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create the KubeClient with non-admin privileges (only allowed namespace1)
+ kubeClient := &KubeClient{
+ cli: fakeClient,
+ instanceID: "test-instance",
+ IsKubeAdmin: false,
+ NonAdminNamespaces: []string{namespace1},
+ }
+
+ // Test that only resources from allowed namespace are returned
+ apps, err := kubeClient.GetApplications("", "")
+ assert.NoError(t, err)
+
+ // We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
+ assert.Equal(t, 2, len(apps))
+
+ // Verify resources are from the allowed namespace
+ for _, app := range apps {
+ assert.Equal(t, namespace1, app.ResourcePool)
+ assert.Equal(t, "StatefulSet", app.Kind)
+ }
+
+ // Verify names of returned resources
+ stsNames := make(map[string]bool)
+ for _, app := range apps {
+ stsNames[app.Name] = true
+ }
+
+ assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
+ assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
+ })
+
+ t.Run("Filter by node name", func(t *testing.T) {
+ // Create a fake K8s client
+ fakeClient := fake.NewSimpleClientset()
+
+ // Setup test namespace
+ namespace := "node-filter-ns"
+ nodeName := "worker-node-1"
+
+ // Create a deployment with pods on specific node
+ deploy := createTestDeployment("node-deploy", namespace, 2)
+ _, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create ReplicaSet for the deployment
+ rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
+ _, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create 2 pods, one on the specified node, one on a different node
+ pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
+ pod1.Spec.NodeName = nodeName
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
+ pod2.Spec.NodeName = "worker-node-2"
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create the KubeClient
+ kubeClient := &KubeClient{
+ cli: fakeClient,
+ instanceID: "test-instance",
+ IsKubeAdmin: true,
+ }
+
+ // Test filtering by node name
+ apps, err := kubeClient.GetApplications(namespace, nodeName)
+ assert.NoError(t, err)
+
+ // We expect to find only the pod on the specified node
+ assert.Equal(t, 1, len(apps))
+ if len(apps) > 0 {
+ assert.Equal(t, "node-deploy", apps[0].Name)
+ }
+ })
+}
diff --git a/api/kubernetes/cli/configmap.go b/api/kubernetes/cli/configmap.go
index 57f36cf74..eb0eec935 100644
--- a/api/kubernetes/cli/configmap.go
+++ b/api/kubernetes/cli/configmap.go
@@ -24,7 +24,7 @@ func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, e
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
- log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
+ log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -110,7 +110,7 @@ func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8s
for index, configMap := range configMaps {
updatedConfigMap := configMap
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, replicaSets)
+ applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
}
diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go
index 8d22a20db..bb3e46077 100644
--- a/api/kubernetes/cli/pod.go
+++ b/api/kubernetes/cli/pod.go
@@ -11,7 +11,6 @@ import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
- autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -110,7 +109,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
},
}
- shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
+ shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
if err != nil {
return nil, errors.Wrap(err, "error creating shell pod")
}
@@ -158,7 +157,7 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
case <-ctx.Done():
return ctx.Err()
default:
- pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
+ pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{})
if err != nil {
return err
}
@@ -172,70 +171,67 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
}
}
-// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces
-func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
- return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false)
-}
-
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
// this is required for the applications list view
-func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
+func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) (PortainerApplicationResources, error) {
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
}
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
-func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
+func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) {
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
if err != nil {
if k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, nil
+ return PortainerApplicationResources{}, nil
}
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
}
- // if replicaSet owner reference exists, fetch the replica sets
- // this also means that the deployments will be fetched because deployments own replica sets
- replicaSets := &appsv1.ReplicaSetList{}
- deployments := &appsv1.DeploymentList{}
- if containsReplicaSetOwnerReference(pods) {
- replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
- if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
- }
-
- deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
- if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
- }
+ portainerApplicationResources := PortainerApplicationResources{
+ Pods: pods.Items,
}
- statefulSets := &appsv1.StatefulSetList{}
- if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
- statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
+ replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
+ if err != nil && !k8serrors.IsNotFound(err) {
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
+ }
+ portainerApplicationResources.ReplicaSets = replicaSets.Items
+
+ deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
+ if err != nil && !k8serrors.IsNotFound(err) {
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err)
+ }
+ portainerApplicationResources.Deployments = deployments.Items
+
+ if includeStatefulSets {
+ statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
}
+ portainerApplicationResources.StatefulSets = statefulSets.Items
}
- daemonSets := &appsv1.DaemonSetList{}
- if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
- daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
+ if includeDaemonSets {
+ daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
}
+ portainerApplicationResources.DaemonSets = daemonSets.Items
}
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
}
+ portainerApplicationResources.Services = services.Items
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
}
+ portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
- return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
+ return portainerApplicationResources, nil
}
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go
index 9a3c6635f..c45e2c299 100644
--- a/api/kubernetes/cli/role.go
+++ b/api/kubernetes/cli/role.go
@@ -10,7 +10,6 @@ import (
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
@@ -137,7 +136,7 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().Roles(namespace)
- role, err := client.Get(context.Background(), name, v1.GetOptions{})
+ role, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
diff --git a/api/kubernetes/cli/role_binding.go b/api/kubernetes/cli/role_binding.go
index e8e90cbb0..7775748e2 100644
--- a/api/kubernetes/cli/role_binding.go
+++ b/api/kubernetes/cli/role_binding.go
@@ -7,11 +7,9 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
- corev1 "k8s.io/api/rbac/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
@@ -98,7 +96,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
return false
}
-func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
+func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
client := kcl.cli.RbacV1().Roles(namespace)
return client.Get(context.Background(), name, metav1.GetOptions{})
}
@@ -111,7 +109,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().RoleBindings(namespace)
- roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
+ roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
@@ -125,7 +123,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
}
- if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
+ if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go
index 8e38c9857..5b7bf4fef 100644
--- a/api/kubernetes/cli/secret.go
+++ b/api/kubernetes/cli/secret.go
@@ -31,7 +31,7 @@ func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error)
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
- log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
+ log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
updatedSecrets := make([]models.K8sSecret, len(secrets))
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -126,7 +126,7 @@ func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret
for index, secret := range secrets {
updatedSecret := secret
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets)
+ applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
}
diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go
index 8a7ef03ab..d1be8c661 100644
--- a/api/kubernetes/cli/service.go
+++ b/api/kubernetes/cli/service.go
@@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
if containsServiceWithSelector(services) {
updatedServices := make([]models.K8sServiceInfo, len(services))
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -182,7 +182,7 @@ func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServ
for index, service := range services {
updatedService := service
- application, err := kcl.GetApplicationFromServiceSelector(pods, service, replicaSets)
+ application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
if err != nil {
return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err)
}
diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go
index 831080efc..af674d794 100644
--- a/api/kubernetes/cli/service_account.go
+++ b/api/kubernetes/cli/service_account.go
@@ -5,7 +5,6 @@ import (
"fmt"
portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/http/models/kubernetes"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
corev1 "k8s.io/api/core/v1"
@@ -92,7 +91,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
// in its given namespace.
-func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
+func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
var errors []error
for namespace := range reqs {
for _, serviceName := range reqs[namespace] {
diff --git a/api/kubernetes/cli/volumes.go b/api/kubernetes/cli/volumes.go
index 52ee02ab8..d8a3cbf56 100644
--- a/api/kubernetes/cli/volumes.go
+++ b/api/kubernetes/cli/volumes.go
@@ -7,7 +7,6 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
- autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
@@ -265,7 +264,12 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
if pod.Spec.Volumes != nil {
for _, podVolume := range pod.Spec.Volumes {
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
- application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSetItems,
+ Deployments: deploymentItems,
+ StatefulSets: statefulSetItems,
+ DaemonSets: daemonSetItems,
+ }, false)
if err != nil {
log.Error().Err(err).Msg("Failed to convert pod to application")
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)
diff --git a/api/platform/service.go b/api/platform/service.go
index 628e1cf9b..e74855fb7 100644
--- a/api/platform/service.go
+++ b/api/platform/service.go
@@ -14,6 +14,10 @@ import (
"github.com/rs/zerolog/log"
)
+var (
+ ErrNoLocalEnvironment = errors.New("No local environment was detected")
+)
+
type Service interface {
GetLocalEnvironment() (*portainer.Endpoint, error)
GetPlatform() (ContainerPlatform, error)
@@ -35,7 +39,7 @@ func (service *service) loadEnvAndPlatform() error {
return nil
}
- environment, platform, err := guessLocalEnvironment(service.dataStore)
+ environment, platform, err := detectLocalEnvironment(service.dataStore)
if err != nil {
return err
}
@@ -73,7 +77,7 @@ var platformToEndpointType = map[ContainerPlatform][]portainer.EndpointType{
PlatformKubernetes: {portainer.KubernetesLocalEnvironment},
}
-func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) {
+func detectLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) {
platform := DetermineContainerPlatform()
if !slices.Contains([]ContainerPlatform{PlatformDocker, PlatformKubernetes}, platform) {
@@ -113,7 +117,7 @@ func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoin
}
}
- return nil, "", errors.New("failed to find local environment")
+ return nil, "", ErrNoLocalEnvironment
}
func checkDockerEnvTypeForUpgrade(environment *portainer.Endpoint) ContainerPlatform {
diff --git a/api/portainer.go b/api/portainer.go
index ad0989437..c83cca917 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -588,7 +588,7 @@ type (
// User identifier
UserID UserID `json:"UserId" example:"1"`
// Helm repository URL
- URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"`
+ URL string `json:"URL" example:"https://kubernetes.github.io/ingress-nginx"`
}
// QuayRegistryData represents data required for Quay registry to work
@@ -984,8 +984,8 @@ type (
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
// Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
- // Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
- HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
+ // Helm repository URL, defaults to ""
+ HelmRepositoryURL string `json:"HelmRepositoryURL"`
// KubectlImage, defaults to portainer/kubectl-shell
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
@@ -1491,7 +1491,8 @@ type (
StoreSSLCertPair(cert, key []byte) (string, string, error)
CopySSLCertPair(certPath, keyPath string) (string, string, error)
CopySSLCACert(caCertPath string) (string, error)
- StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error)
+ StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error)
+ GetMTLSCertificates() (string, string, string, error)
GetDefaultChiselPrivateKeyPath() string
StoreChiselPrivateKey(privateKey []byte) error
}
@@ -1543,7 +1544,7 @@ type (
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetIngressControllers() (models.K8sIngressControllers, error)
- GetApplications(namespace, nodename string, withDependencies bool) ([]models.K8sApplication, error)
+ GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
GetMetrics() (models.K8sMetrics, error)
GetStorage() ([]KubernetesStorageClassConfig, error)
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
@@ -1636,7 +1637,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.27.0-rc1"
+ APIVersion = "2.27.2"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
@@ -1672,8 +1673,8 @@ const (
DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
- // DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
- DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
+ // DefaultHelmrepositoryURL set to empty string until oci support is added
+ DefaultHelmRepositoryURL = ""
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
DefaultUserSessionTimeout = "8h"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
diff --git a/app/constants.ts b/app/constants.ts
index 8a8e687d2..e61172d27 100644
--- a/app/constants.ts
+++ b/app/constants.ts
@@ -5,7 +5,6 @@ export const API_ENDPOINT_CUSTOM_TEMPLATES = 'api/custom_templates';
export const API_ENDPOINT_EDGE_GROUPS = 'api/edge_groups';
export const API_ENDPOINT_EDGE_JOBS = 'api/edge_jobs';
export const API_ENDPOINT_EDGE_STACKS = 'api/edge_stacks';
-export const API_ENDPOINT_EDGE_TEMPLATES = 'api/edge_templates';
export const API_ENDPOINT_ENDPOINTS = 'api/endpoints';
export const API_ENDPOINT_ENDPOINT_GROUPS = 'api/endpoint_groups';
export const API_ENDPOINT_KUBERNETES = 'api/kubernetes';
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
index 8f3f2fb4a..504c53af3 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
@@ -31,10 +31,40 @@
>Select the Helm chart to use. Bring further Helm charts into your selection list via
User settings - Helm repositories.
-
Disclaimer
+