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. - +
+
Select the Helm chart to use. Bring further Helm charts into your selection list via + User settings - Helm repositories.
+
+
+ + + + + +
+
+

Disclaimer

+
+ At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.
+ If you would like to provide feedback on OCI support or get access to early releases to test this functionality, + please get in touch. +
+
+
+
diff --git a/app/kubernetes/services/resourcePoolService.js b/app/kubernetes/services/resourcePoolService.js index 639c44db1..d829afa0b 100644 --- a/app/kubernetes/services/resourcePoolService.js +++ b/app/kubernetes/services/resourcePoolService.js @@ -3,6 +3,7 @@ import _ from 'lodash-es'; import angular from 'angular'; import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool'; import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper'; +import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; /* @ngInject */ export function KubernetesResourcePoolService( @@ -11,7 +12,8 @@ export function KubernetesResourcePoolService( KubernetesNamespaceService, KubernetesResourceQuotaService, KubernetesIngressService, - KubernetesPortainerNamespaces + KubernetesPortainerNamespaces, + EndpointProvider ) { return { get, @@ -37,9 +39,14 @@ export function KubernetesResourcePoolService( // getting the quota for all namespaces is costly by default, so disable getting it by default async function getAll({ getQuota = false }) { - const namespaces = await KubernetesNamespaceService.get(); + const namespaces = await getNamespaces(EndpointProvider.endpointID()); + // there is a lot of downstream logic using the angular namespace type with a '.Status' field (not '.Status.phase'), so format the status here to match this logic + const namespacesFormattedStatus = namespaces.map((namespace) => ({ + ...namespace, + Status: namespace.Status.phase, + })); const pools = await Promise.all( - _.map(namespaces, async (namespace) => { + _.map(namespacesFormattedStatus, async (namespace) => { const name = namespace.Name; const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace); if (getQuota) { diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 50c923d58..79549084a 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -6,13 +6,13 @@ import PortainerError from '@/portainer/error'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { getDeploymentOptions } from '@/react/portainer/environments/environment.service'; -import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; -import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods'; import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; -import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants'; +import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods'; +import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; class KubernetesDeployController { /* @ngInject */ diff --git a/app/ng-constants.ts b/app/ng-constants.ts index 527d42132..87fa45b97 100644 --- a/app/ng-constants.ts +++ b/app/ng-constants.ts @@ -7,7 +7,6 @@ import { API_ENDPOINT_EDGE_GROUPS, API_ENDPOINT_EDGE_JOBS, API_ENDPOINT_EDGE_STACKS, - API_ENDPOINT_EDGE_TEMPLATES, API_ENDPOINT_ENDPOINTS, API_ENDPOINT_ENDPOINT_GROUPS, API_ENDPOINT_KUBERNETES, @@ -42,7 +41,6 @@ export const constantsModule = angular .constant('API_ENDPOINT_EDGE_GROUPS', API_ENDPOINT_EDGE_GROUPS) .constant('API_ENDPOINT_EDGE_JOBS', API_ENDPOINT_EDGE_JOBS) .constant('API_ENDPOINT_EDGE_STACKS', API_ENDPOINT_EDGE_STACKS) - .constant('API_ENDPOINT_EDGE_TEMPLATES', API_ENDPOINT_EDGE_TEMPLATES) .constant('API_ENDPOINT_ENDPOINTS', API_ENDPOINT_ENDPOINTS) .constant('API_ENDPOINT_ENDPOINT_GROUPS', API_ENDPOINT_ENDPOINT_GROUPS) .constant('API_ENDPOINT_KUBERNETES', API_ENDPOINT_KUBERNETES) diff --git a/app/portainer/helpers/strings.ts b/app/portainer/helpers/strings.ts index dc130b0e5..fb8d69bc6 100644 --- a/app/portainer/helpers/strings.ts +++ b/app/portainer/helpers/strings.ts @@ -1,7 +1,7 @@ -export function pluralize(val: number, word: string, plural = `${word}s`) { - return [1, -1].includes(Number(val)) ? word : plural; -} - -export function addPlural(value: number, word: string, plural = `${word}s`) { - return `${value} ${pluralize(value, word, plural)}`; -} +// Re-exporting so we don't have to update one meeeeellion files that are already importing these +// functions from here. +export { + pluralize, + addPlural, + grammaticallyJoin, +} from '@/react/common/string-utils'; diff --git a/app/react/common/string-utils.ts b/app/react/common/string-utils.ts new file mode 100644 index 000000000..591d6de90 --- /dev/null +++ b/app/react/common/string-utils.ts @@ -0,0 +1,27 @@ +export function capitalize(s: string) { + return s.slice(0, 1).toUpperCase() + s.slice(1); +} + +export function pluralize(val: number, word: string, plural = `${word}s`) { + return [1, -1].includes(Number(val)) ? word : plural; +} + +export function addPlural(value: number, word: string, plural = `${word}s`) { + return `${value} ${pluralize(value, word, plural)}`; +} + +/** + * Joins an array of strings into a grammatically correct sentence. + */ +export function grammaticallyJoin( + values: string[], + separator = ', ', + lastSeparator = ' and ' +) { + if (values.length === 0) return ''; + if (values.length === 1) return values[0]; + + const allButLast = values.slice(0, -1); + const last = values[values.length - 1]; + return `${allButLast.join(separator)}${lastSeparator}${last}`; +} diff --git a/app/react/components/datatables/Datatable.test.tsx b/app/react/components/datatables/Datatable.test.tsx index 25e1ed1eb..67ff85f70 100644 --- a/app/react/components/datatables/Datatable.test.tsx +++ b/app/react/components/datatables/Datatable.test.tsx @@ -1,4 +1,5 @@ import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, it, expect } from 'vitest'; import { createColumnHelper, @@ -154,6 +155,84 @@ describe('Datatable', () => { ); expect(screen.getByText('No data available')).toBeInTheDocument(); + const selectAllCheckbox: HTMLInputElement = + screen.getByLabelText('Select all rows'); + expect(selectAllCheckbox.checked).toBe(false); + }); + + it('selects/deselects only page rows when select all is clicked', () => { + render( + + ); + + const selectAllCheckbox = screen.getByLabelText('Select all rows'); + fireEvent.click(selectAllCheckbox); + + // Check if all rows on the page are selected + expect(screen.getByText('2 items selected')).toBeInTheDocument(); + + // Deselect + fireEvent.click(selectAllCheckbox); + const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox'); + expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0); + }); + + it('selects/deselects all rows including other pages when select all is clicked with shift key', () => { + render( + + ); + + const selectAllCheckbox = screen.getByLabelText('Select all rows'); + fireEvent.click(selectAllCheckbox, { shiftKey: true }); + + // Check if all rows on the page are selected + expect(screen.getByText('3 items selected')).toBeInTheDocument(); + + // Deselect + fireEvent.click(selectAllCheckbox, { shiftKey: true }); + const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox'); + expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0); + }); + + it('shows indeterminate state and correct footer text when hidden rows are selected', async () => { + const user = userEvent.setup(); + render( + + ); + + // Select Jane + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[2]); // Select the second row + + // Search for John (will hide selected Jane) + const searchInput = screen.getByPlaceholderText('Search...'); + await user.type(searchInput, 'John'); + + // Check if the footer text is correct + expect( + await screen.findByText('1 item selected (1 hidden by filters)') + ).toBeInTheDocument(); + + // Check if the checkbox is indeterminate + const selectAllCheckbox: HTMLInputElement = + screen.getByLabelText('Select all rows'); + expect(selectAllCheckbox.indeterminate).toBe(true); + expect(selectAllCheckbox.checked).toBe(false); }); }); diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index f845e221d..4ca608aa3 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -171,6 +171,14 @@ export function Datatable({ const selectedRowModel = tableInstance.getSelectedRowModel(); const selectedItems = selectedRowModel.rows.map((row) => row.original); + const filteredItems = tableInstance + .getFilteredRowModel() + .rows.map((row) => row.original); + + const hiddenSelectedItems = useMemo( + () => _.difference(selectedItems, filteredItems), + [selectedItems, filteredItems] + ); return ( @@ -203,6 +211,7 @@ export function Datatable({ pageSize={tableState.pagination.pageSize} pageCount={tableInstance.getPageCount()} totalSelected={selectedItems.length} + totalHiddenSelected={hiddenSelectedItems.length} /> ); diff --git a/app/react/components/datatables/DatatableFooter.tsx b/app/react/components/datatables/DatatableFooter.tsx index 61e7b4dbf..0907ae260 100644 --- a/app/react/components/datatables/DatatableFooter.tsx +++ b/app/react/components/datatables/DatatableFooter.tsx @@ -5,6 +5,7 @@ import { SelectedRowsCount } from './SelectedRowsCount'; interface Props { totalSelected: number; + totalHiddenSelected: number; pageSize: number; page: number; onPageChange(page: number): void; @@ -14,6 +15,7 @@ interface Props { export function DatatableFooter({ totalSelected, + totalHiddenSelected, pageSize, page, onPageChange, @@ -22,7 +24,7 @@ export function DatatableFooter({ }: Props) { return ( - +
+
+ {addPlural(value, 'item')} selected + {hidden !== 0 && ` (${hidden} hidden by filters)`} +
) : null; } diff --git a/app/react/components/datatables/select-column.tsx b/app/react/components/datatables/select-column.tsx index 6e5b4a920..2e4a04272 100644 --- a/app/react/components/datatables/select-column.tsx +++ b/app/react/components/datatables/select-column.tsx @@ -1,7 +1,20 @@ -import { ColumnDef, Row } from '@tanstack/react-table'; +import { ColumnDef, Row, Table } from '@tanstack/react-table'; import { Checkbox } from '@@/form-components/Checkbox'; +function allRowsSelected(table: Table) { + const { rows } = table.getCoreRowModel(); + return rows.length > 0 && rows.every((row) => row.getIsSelected()); +} + +function someRowsSelected(table: Table) { + return table.getCoreRowModel().rows.some((row) => row.getIsSelected()); +} + +function somePageRowsSelected(table: Table) { + return table.getRowModel().rows.some((row) => row.getIsSelected()); +} + export function createSelectColumn(dataCy: string): ColumnDef { let lastSelectedId = ''; @@ -11,15 +24,22 @@ export function createSelectColumn(dataCy: string): ColumnDef { { + // Select all rows if shift key is held down, otherwise only page rows + if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) { + table.toggleAllRowsSelected(); + return; + } + table.toggleAllPageRowsSelected(!somePageRowsSelected(table)); + }} disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())} onClick={(e) => { e.stopPropagation(); }} aria-label="Select all rows" - title="Select all rows" + title="Select all rows. Hold shift key to select across all pages." /> ), cell: ({ row, table }) => ( diff --git a/app/react/components/form-components/Checkbox.tsx b/app/react/components/form-components/Checkbox.tsx index 3e01222b8..03f199de1 100644 --- a/app/react/components/form-components/Checkbox.tsx +++ b/app/react/components/form-components/Checkbox.tsx @@ -42,6 +42,8 @@ export const Checkbox = forwardRef( resolvedRef = defaultRef; } + // Need to check this on every render as the browser will always set the element's + // indeterminate state to false when the checkbox is clicked, even if the indeterminate prop hasn't changed useEffect(() => { if (resolvedRef === null || resolvedRef.current === null) { return; @@ -50,7 +52,7 @@ export const Checkbox = forwardRef( if (typeof indeterminate !== 'undefined') { resolvedRef.current.indeterminate = indeterminate; } - }, [resolvedRef, indeterminate]); + }); return (
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx index d0d1e5373..5d7956822 100644 --- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx @@ -143,7 +143,7 @@ export function NonGitStackForm({ edgeStack }: { edgeStack: EdgeStack }) { updateVersion, webhook: values.webhookEnabled ? edgeStack.Webhook || createWebhookId() - : undefined, + : '', envVars: values.envVars, rollbackTo: values.rollbackTo, staggerConfig: values.staggerConfig, diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx index a12cbd20e..4e6712939 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx @@ -57,7 +57,6 @@ export function ApplicationsDatatable({ const applicationsQuery = useApplications(environmentId, { refetchInterval: tableState.autoRefreshRate * 1000, namespace: tableState.namespace, - withDependencies: true, }); const ingressesQuery = useIngresses(environmentId); const ingresses = ingressesQuery.data ?? []; diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx index f95dbe19b..f0e1bf622 100644 --- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx +++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx @@ -38,7 +38,6 @@ export function ApplicationsStacksDatatable({ const applicationsQuery = useApplications(environmentId, { refetchInterval: tableState.autoRefreshRate * 1000, namespace: tableState.namespace, - withDependencies: true, }); const ingressesQuery = useIngresses(environmentId); const ingresses = ingressesQuery.data ?? []; diff --git a/app/react/kubernetes/applications/queries/query-keys.ts b/app/react/kubernetes/applications/queries/query-keys.ts index 87c042dc4..f787b92bf 100644 --- a/app/react/kubernetes/applications/queries/query-keys.ts +++ b/app/react/kubernetes/applications/queries/query-keys.ts @@ -3,7 +3,6 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; export type GetAppsParams = { namespace?: string; nodeName?: string; - withDependencies?: boolean; }; export const queryKeys = { diff --git a/app/react/kubernetes/applications/queries/useApplications.ts b/app/react/kubernetes/applications/queries/useApplications.ts index 9d09ffad0..6419dd853 100644 --- a/app/react/kubernetes/applications/queries/useApplications.ts +++ b/app/react/kubernetes/applications/queries/useApplications.ts @@ -11,7 +11,6 @@ import { queryKeys } from './query-keys'; type GetAppsParams = { namespace?: string; nodeName?: string; - withDependencies?: boolean; }; type GetAppsQueryOptions = { diff --git a/app/react/kubernetes/applications/utils.ts b/app/react/kubernetes/applications/utils.ts index d705e75c1..a919edd76 100644 --- a/app/react/kubernetes/applications/utils.ts +++ b/app/react/kubernetes/applications/utils.ts @@ -33,7 +33,7 @@ export function isExternalApplication(application: Application) { function getDeploymentRunningPods(deployment: Deployment): number { const availableReplicas = deployment.status?.availableReplicas ?? 0; - const totalReplicas = deployment.status?.replicas ?? 0; + const totalReplicas = deployment.spec?.replicas ?? 0; const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0; return availableReplicas || totalReplicas - unavailableReplicas; } diff --git a/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx b/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx index 1ab17c5d7..e66e9f875 100644 --- a/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx +++ b/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx @@ -30,7 +30,6 @@ export function NamespaceAppsDatatable({ namespace }: { namespace: string }) { const applicationsQuery = useApplications(environmentId, { refetchInterval: tableState.autoRefreshRate * 1000, namespace, - withDependencies: true, }); const applications = applicationsQuery.data ?? []; diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index 4805ce6e6..9884048b3 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -109,6 +109,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) { agentVersions, updateInformation: isBE, edgeAsync: getEdgeAsyncValue(connectionTypes), + platformTypes, }; const queryWithSort = { diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx index 3c4d993e4..06d1c5108 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx @@ -209,6 +209,7 @@ function getPlatformTypeOptions(connectionTypes: ConnectionType[]) { { value: PlatformType.Docker, label: 'Docker' }, { value: PlatformType.Azure, label: 'Azure' }, { value: PlatformType.Kubernetes, label: 'Kubernetes' }, + { value: PlatformType.Podman, label: 'Podman' }, ]; if (connectionTypes.length === 0) { @@ -216,15 +217,25 @@ function getPlatformTypeOptions(connectionTypes: ConnectionType[]) { } const connectionTypePlatformType = { - [ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure], - [ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes], + [ConnectionType.API]: [ + PlatformType.Docker, + PlatformType.Azure, + PlatformType.Podman, + ], + [ConnectionType.Agent]: [ + PlatformType.Docker, + PlatformType.Kubernetes, + PlatformType.Podman, + ], [ConnectionType.EdgeAgentStandard]: [ PlatformType.Kubernetes, PlatformType.Docker, + PlatformType.Podman, ], [ConnectionType.EdgeAgentAsync]: [ PlatformType.Docker, PlatformType.Kubernetes, + PlatformType.Podman, ], }; diff --git a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts index 01a87681f..48f539f11 100644 --- a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts +++ b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts @@ -55,11 +55,11 @@ async function deleteEnvironments( environments: { id: EnvironmentId; deleteCluster?: boolean }[] ) { try { - const { data } = await axios.delete<{ + const { data } = await axios.post<{ deleted: EnvironmentId[]; errors: EnvironmentId[]; - } | null>(buildUrl(), { - data: { endpoints: environments }, + } | null>(buildUrl(undefined, 'delete'), { + endpoints: environments, }); return data; } catch (e) { diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts index 09844e6ce..8971a4af0 100644 --- a/app/react/portainer/environments/environment.service/index.ts +++ b/app/react/portainer/environments/environment.service/index.ts @@ -6,6 +6,7 @@ import { EnvironmentSecuritySettings, EnvironmentStatus, EnvironmentGroupId, + PlatformType, } from '@/react/portainer/environments/types'; import { type TagId } from '@/portainer/tags/types'; import { UserId } from '@/portainer/users/types'; @@ -45,6 +46,7 @@ export interface BaseEnvironmentsQueryParams { agentVersions?: string[]; updateInformation?: boolean; edgeCheckInPassedSeconds?: number; + platformTypes?: PlatformType[]; } export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams & diff --git a/app/react/portainer/environments/queries/useEnvironmentList.ts b/app/react/portainer/environments/queries/useEnvironmentList.ts index ef545b06f..850fc7cfd 100644 --- a/app/react/portainer/environments/queries/useEnvironmentList.ts +++ b/app/react/portainer/environments/queries/useEnvironmentList.ts @@ -1,8 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import { withError } from '@/react-tools/react-query'; +import { + PlatformType, + EnvironmentStatus, +} from '@/react/portainer/environments/types'; -import { EnvironmentStatus } from '../types'; import { EnvironmentsQueryParams, getEnvironments, @@ -98,6 +101,30 @@ export function useEnvironmentList( } ); + if (data?.value && query && query.platformTypes) { + const platforms = Array.from(query.platformTypes); + + if ( + platforms.includes(PlatformType.Podman) !== + platforms.includes(PlatformType.Docker) + ) { + const isPodmanSelected = platforms.includes(PlatformType.Podman); + const containerEngineToExclude = isPodmanSelected ? 'docker' : 'podman'; + + const filteredList = data?.value.filter( + (env) => env.ContainerEngine !== containerEngineToExclude + ); + + return { + isLoading, + environments: filteredList, + totalCount: data ? data.totalCount : 0, + totalAvailable: data ? data.totalAvailable : 0, + updateAvailable: data ? data.updateAvailable : false, + }; + } + } + return { isLoading, environments: data ? data.value : [], diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx index 22790e781..e5d519174 100644 --- a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx +++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx @@ -4,6 +4,7 @@ import { TextTip } from '@@/Tip/TextTip'; import { FormControl } from '@@/form-components/FormControl'; import { FormSection } from '@@/form-components/FormSection'; import { Input } from '@@/form-components/Input'; +import { InsightsBox } from '@@/InsightsBox'; export function HelmSection() { const [{ name }, { error }] = useField('helmRepositoryUrl'); @@ -24,13 +25,34 @@ export function HelmSection() {
+ + At present Portainer does not support OCI format Helm charts. + Support for OCI charts will be available in a future release. If you + would like to provide feedback on OCI support or get access to early + releases to test this functionality,{' '} + + please get in touch + + . + + } + className="block w-fit mt-2 mb-1" + /> + diff --git a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx index 00e4e54c5..35e446e8a 100644 --- a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx +++ b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx @@ -110,6 +110,7 @@ export function AppTemplatesList({ pageSize={listState.pageSize} pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)} totalSelected={0} + totalHiddenSelected={0} /> ); diff --git a/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx index a0bb23720..18b1bdaa9 100644 --- a/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx +++ b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx @@ -86,6 +86,7 @@ export function CustomTemplatesList({ pageSize={listState.pageSize} pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)} totalSelected={0} + totalHiddenSelected={0} /> ); diff --git a/binary-version.json b/binary-version.json index c91bf37a6..8b37fa345 100644 --- a/binary-version.json +++ b/binary-version.json @@ -1,6 +1,6 @@ { - "docker": "v27.1.2", - "helm": "v3.16.4", - "kubectl": "v1.31.4", - "mingit": "2.46.0.1" + "docker": "v27.5.1", + "helm": "v3.17.2", + "kubectl": "v1.32.2", + "mingit": "2.48.1.1" } diff --git a/go.mod b/go.mod index cc34f23f2..b66dd3739 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/portainer/portainer -go 1.23.2 +go 1.23.5 require ( github.com/Masterminds/semver v1.5.0 @@ -21,7 +21,7 @@ require ( github.com/docker/docker v27.4.0+incompatible github.com/fvbommel/sortorder v1.1.0 github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 - github.com/go-git/go-git/v5 v5.11.0 + github.com/go-git/go-git/v5 v5.13.0 github.com/go-ldap/ldap/v3 v3.4.1 github.com/go-playground/validator/v10 v10.12.0 github.com/gofrs/uuid v4.2.0+incompatible @@ -30,6 +30,7 @@ require ( github.com/gorilla/csrf v1.7.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.0 + github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/golang-lru v0.5.4 github.com/joho/godotenv v1.4.0 github.com/jpillora/chisel v1.10.0 @@ -47,11 +48,11 @@ require ( github.com/urfave/negroni v1.0.0 github.com/viney-shih/go-lock v1.1.1 go.etcd.io/bbolt v1.3.11 - golang.org/x/crypto v0.31.0 + golang.org/x/crypto v0.35.0 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 golang.org/x/mod v0.21.0 golang.org/x/oauth2 v0.23.0 - golang.org/x/sync v0.10.0 + golang.org/x/sync v0.11.0 gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.29.2 @@ -71,7 +72,7 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect github.com/BurntSushi/toml v1.3.2 // indirect github.com/Masterminds/semver/v3 v3.2.1 // indirect - github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/ProtonMail/go-crypto v1.1.3 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect @@ -105,7 +106,7 @@ require ( github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect github.com/containers/ocicrypt v1.1.10 // indirect github.com/containers/storage v1.53.0 // indirect - github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/cyphar/filepath-securejoin v0.2.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/buildx v0.18.0 // indirect @@ -125,7 +126,7 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-git/go-billy/v5 v5.6.0 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect @@ -147,7 +148,6 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -205,10 +205,10 @@ require ( github.com/rivo/uniseg v0.4.4 // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/sergi/go-diff v1.3.1 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect - github.com/skeema/knownhosts v1.2.1 // indirect + github.com/skeema/knownhosts v1.3.0 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect @@ -242,10 +242,10 @@ require ( go.opentelemetry.io/otel/trace v1.25.0 // indirect go.opentelemetry.io/proto/otlp v1.1.0 // indirect go.uber.org/mock v0.5.0 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect + golang.org/x/text v0.22.0 // indirect golang.org/x/time v0.6.0 // indirect google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect diff --git a/go.sum b/go.sum index 9a98a81e3..8d645e726 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ github.com/Microsoft/hcsshim v0.12.5 h1:bpTInLlDy/nDRWFVcefDZZ1+U8tS+rz3MxjKgu9b github.com/Microsoft/hcsshim v0.12.5/go.mod h1:tIUGego4G1EN5Hb6KC90aDYiUI2dqLSTTOCjVNpOgZ8= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= -github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= +github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= @@ -99,7 +99,6 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembj github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprYoU= github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -110,7 +109,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8= github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0= -github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI= @@ -163,8 +161,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= +github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -205,8 +203,8 @@ github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNE github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= -github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= -github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/elazarl/goproxy v1.2.1 h1:njjgvO6cRG9rIqN2ebkqy6cQz2Njkx7Fsfv/zIZqgug= +github.com/elazarl/goproxy v1.2.1/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -227,18 +225,18 @@ github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQ github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI= -github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= -github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8= github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= -github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= +github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4= -github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY= +github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= +github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-ldap/ldap/v3 v3.4.1 h1:fU/0xli6HY02ocbMuozHAYsaHLcnkLjvho2r5a34BUU= github.com/go-ldap/ldap/v3 v3.4.1/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg= @@ -486,8 +484,8 @@ github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4 github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= -github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= -github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -559,8 +557,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.3.6 h1:E6lVLyDPseWEulBmCmAKPanDd3jiyGDo5gMcugCRwZQ= github.com/segmentio/encoding v0.3.6/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b h1:h+3JX2VoWTFuyQEo87pStk/a99dzIO1mM9KxIyLPGTU= github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= @@ -571,8 +569,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= -github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= +github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY= @@ -697,16 +695,13 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -720,11 +715,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -735,9 +727,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -764,29 +755,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -794,7 +776,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/package.json b/package.json index d9a9dcd6d..a761dbf7b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "2.26.0", + "version": "2.27.2", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" diff --git a/pkg/endpoints/utils.go b/pkg/endpoints/utils.go index e32b56fea..4c704d86f 100644 --- a/pkg/endpoints/utils.go +++ b/pkg/endpoints/utils.go @@ -1,6 +1,7 @@ package endpoints import ( + "github.com/hashicorp/go-version" portainer "github.com/portainer/portainer/api" ) @@ -15,6 +16,11 @@ func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool { return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment } +// IsStandardEdgeEndpoint returns true if this is a standard Edge endpoint and not in async mode on either Docker or Kubernetes +func IsStandardEdgeEndpoint(endpoint *portainer.Endpoint) bool { + return (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) && !endpoint.Edge.AsyncMode +} + // IsAssociatedEdgeEndpoint returns true if the environment is an Edge environment // and has a set EdgeID and UserTrusted is true. func IsAssociatedEdgeEndpoint(endpoint *portainer.Endpoint) bool { @@ -26,3 +32,11 @@ func IsAssociatedEdgeEndpoint(endpoint *portainer.Endpoint) bool { func HasDirectConnectivity(endpoint *portainer.Endpoint) bool { return !IsEdgeEndpoint(endpoint) || (IsAssociatedEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode) } + +// IsNewerThan225 returns true if the agent version is newer than 2.25.0 +// this is used to check if the agent is compatible with the new diagnostics feature +func IsNewerThan225(agentVersion string) bool { + v1, _ := version.NewVersion(agentVersion) + v2, _ := version.NewVersion("2.25.0") + return v1.GreaterThanOrEqual(v2) +} diff --git a/pkg/endpoints/utils_test.go b/pkg/endpoints/utils_test.go index 47ef2db8d..4231cf60a 100644 --- a/pkg/endpoints/utils_test.go +++ b/pkg/endpoints/utils_test.go @@ -202,3 +202,61 @@ func TestHasDirectConnectivity(t *testing.T) { }) } } + +func TestIsStandardEdgeEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint *portainer.Endpoint + expected bool + }{ + { + name: "StandardEdgeEndpoint", + endpoint: &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false}, + }, + expected: true, + }, + { + name: "AsyncEdgeEndpoint", + endpoint: &portainer.Endpoint{ + Type: portainer.EdgeAgentOnDockerEnvironment, + Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsStandardEdgeEndpoint(tt.endpoint) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestIsNewerThan225(t *testing.T) { + tests := []struct { + name string + version string + expected bool + }{ + { + name: "NewerThan225", + version: "2.25.1", + expected: true, + }, + { + name: "OlderThan225", + version: "2.24.0", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsNewerThan225(tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/libhelm/binary/install_test.go b/pkg/libhelm/binary/install_test.go index e3252e1f2..3d6c74707 100644 --- a/pkg/libhelm/binary/install_test.go +++ b/pkg/libhelm/binary/install_test.go @@ -53,11 +53,11 @@ func Test_Install(t *testing.T) { hbpm := NewHelmBinaryPackageManager(path) t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) { - // helm install test-nginx --repo https://charts.bitnami.com/bitnami nginx + // helm install test-nginx --repo https://kubernetes.github.io/ingress-nginx nginx installOpts := options.InstallOptions{ Name: "test-nginx", Chart: "nginx", - Repo: "https://charts.bitnami.com/bitnami", + Repo: "https://kubernetes.github.io/ingress-nginx", } release, err := hbpm.Install(installOpts) @@ -67,10 +67,10 @@ func Test_Install(t *testing.T) { }) t.Run("successfully installs nginx chart with generated name", func(t *testing.T) { - // helm install --generate-name --repo https://charts.bitnami.com/bitnami nginx + // helm install --generate-name --repo https://kubernetes.github.io/ingress-nginx nginx installOpts := options.InstallOptions{ Chart: "nginx", - Repo: "https://charts.bitnami.com/bitnami", + Repo: "https://kubernetes.github.io/ingress-nginx", } release, err := hbpm.Install(installOpts) defer hbpm.run("uninstall", []string{release.Name}, nil) @@ -79,7 +79,7 @@ func Test_Install(t *testing.T) { }) t.Run("successfully installs nginx with values", func(t *testing.T) { - // helm install test-nginx-2 --repo https://charts.bitnami.com/bitnami nginx --values /tmp/helm-values3161785816 + // helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx nginx --values /tmp/helm-values3161785816 values, err := createValuesFile("service:\n port: 8081") is.NoError(err, "should create a values file") @@ -88,7 +88,7 @@ func Test_Install(t *testing.T) { installOpts := options.InstallOptions{ Name: "test-nginx-2", Chart: "nginx", - Repo: "https://charts.bitnami.com/bitnami", + Repo: "https://kubernetes.github.io/ingress-nginx", ValuesFile: values, } release, err := hbpm.Install(installOpts) diff --git a/pkg/libhelm/binary/search_repo_test.go b/pkg/libhelm/binary/search_repo_test.go index 4d5dd4d82..48786a0d7 100644 --- a/pkg/libhelm/binary/search_repo_test.go +++ b/pkg/libhelm/binary/search_repo_test.go @@ -22,7 +22,7 @@ func Test_SearchRepo(t *testing.T) { tests := []testCase{ {"not a helm repo", "https://portainer.io", true}, - {"bitnami helm repo", "https://charts.bitnami.com/bitnami", false}, + {"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false}, {"portainer helm repo", "https://portainer.github.io/k8s/", false}, {"gitlap helm repo with trailing slash", "https://charts.gitlab.io/", false}, {"elastic helm repo with trailing slash", "https://helm.elastic.co/", false}, diff --git a/pkg/libhelm/validate_repo_test.go b/pkg/libhelm/validate_repo_test.go index 7f81311ba..dce1257d0 100644 --- a/pkg/libhelm/validate_repo_test.go +++ b/pkg/libhelm/validate_repo_test.go @@ -26,7 +26,7 @@ func Test_ValidateHelmRepositoryURL(t *testing.T) { {"not helm repo", "http://google.com", true}, {"not valid repo with trailing slash", "http://google.com/", true}, {"not valid repo with trailing slashes", "http://google.com////", true}, - {"bitnami helm repo", "https://charts.bitnami.com/bitnami/", false}, + {"ingress helm repo", "https://kubernetes.github.io/ingress-nginx/", false}, {"gitlap helm repo", "https://charts.gitlab.io/", false}, {"portainer helm repo", "https://portainer.github.io/k8s/", false}, {"elastic helm repo", "https://helm.elastic.co/", false}, diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go index 7b6a639c4..c83685351 100644 --- a/pkg/libstack/compose/composeplugin.go +++ b/pkg/libstack/compose/composeplugin.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "maps" + "os" "path/filepath" "slices" "strconv" @@ -31,8 +32,10 @@ const PortainerEdgeStackLabel = "io.portainer.edge_stack_id" var mu sync.Mutex func init() { - // Redirect Compose logging to zerolog - logrus.SetOutput(log.Logger) + logrus.SetOutput(&LogrusToZerologWriter{}) + logrus.SetFormatter(&logrus.TextFormatter{ + DisableTimestamp: true, + }) } func withCli( @@ -85,7 +88,7 @@ func withComposeService( return composeFn(composeService, nil) } - env, err := parseEnvironment(options) + env, err := parseEnvironment(options, filePaths) if err != nil { return err } @@ -324,7 +327,7 @@ func addServiceLabels(project *types.Project, oneOff bool, edgeStackID portainer } } -func parseEnvironment(options libstack.Options) (map[string]string, error) { +func parseEnvironment(options libstack.Options, filePaths []string) (map[string]string, error) { env := make(map[string]string) for _, envLine := range options.Env { @@ -337,7 +340,22 @@ func parseEnvironment(options libstack.Options) (map[string]string, error) { } if options.EnvFilePath == "" { - return env, nil + if len(filePaths) == 0 { + return env, nil + } + + defaultDotEnv := filepath.Join(filepath.Dir(filePaths[0]), ".env") + s, err := os.Stat(defaultDotEnv) + if os.IsNotExist(err) { + return env, nil + } + if err != nil { + return env, err + } + if s.IsDir() { + return env, nil + } + options.EnvFilePath = defaultDotEnv } e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath}) diff --git a/pkg/libstack/compose/logwriter.go b/pkg/libstack/compose/logwriter.go new file mode 100644 index 000000000..477433552 --- /dev/null +++ b/pkg/libstack/compose/logwriter.go @@ -0,0 +1,58 @@ +package compose + +import ( + "strings" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +// LogrusToZerologWriter is a custom logrus writer that writes logrus logs to zerolog. +// logrus is the logging library used by Docker Compose. +type LogrusToZerologWriter struct{} + +func (ltzw *LogrusToZerologWriter) Write(p []byte) (n int, err error) { + logMessage := string(p) + logMessage = strings.TrimSuffix(logMessage, "\n") + + // Parse the log level and message from the logrus log. + // This assumes logrus's default text format. + var level, message string + if strings.HasPrefix(logMessage, "time=") { + // Example logrus log: `time="2023-10-01T12:34:56Z" level=info msg="This is a log message" key=value` + parts := strings.SplitN(logMessage, " ", 4) + if len(parts) >= 3 { + level = strings.TrimPrefix(parts[1], "level=") + message = strings.TrimPrefix(parts[2], "msg=") + message = strings.Trim(message, `"`) + } + } else { + // Fallback for simpler log formats. + level = "info" + message = logMessage + } + + // Map logrus levels to zerolog levels. + var zlogLevel zerolog.Level + switch level { + case "debug": + zlogLevel = zerolog.DebugLevel + case "info": + zlogLevel = zerolog.InfoLevel + case "warn", "warning": + zlogLevel = zerolog.WarnLevel + case "error": + zlogLevel = zerolog.ErrorLevel + case "fatal": + zlogLevel = zerolog.FatalLevel + case "panic": + zlogLevel = zerolog.PanicLevel + default: + zlogLevel = zerolog.InfoLevel + } + + // Log the message using zerolog. + log.WithLevel(zlogLevel).Msg(message) + + return len(p), nil +} diff --git a/pkg/snapshot/docker_test.go b/pkg/snapshot/docker_test.go deleted file mode 100644 index 8df14bc39..000000000 --- a/pkg/snapshot/docker_test.go +++ /dev/null @@ -1 +0,0 @@ -package snapshot diff --git a/pkg/snapshot/kubernetes_test.go b/pkg/snapshot/kubernetes_test.go index 8df14bc39..142241a5c 100644 --- a/pkg/snapshot/kubernetes_test.go +++ b/pkg/snapshot/kubernetes_test.go @@ -1 +1,44 @@ package snapshot + +import ( + "context" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func TestCreateKubernetesSnapshot(t *testing.T) { + cli := kfake.NewSimpleClientset() + kubernetesSnapshot := &portainer.KubernetesSnapshot{} + + serverInfo, err := cli.Discovery().ServerVersion() + if err != nil { + t.Fatalf("error getting the kubernetesserver version: %v", err) + } + + kubernetesSnapshot.KubernetesVersion = serverInfo.GitVersion + require.Equal(t, kubernetesSnapshot.KubernetesVersion, serverInfo.GitVersion) + + nodeList, err := cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("error listing kubernetes nodes: %v", err) + } + + var totalCPUs, totalMemory int64 + for _, node := range nodeList.Items { + totalCPUs += node.Status.Capacity.Cpu().Value() + totalMemory += node.Status.Capacity.Memory().Value() + } + + kubernetesSnapshot.TotalCPU = totalCPUs + kubernetesSnapshot.TotalMemory = totalMemory + kubernetesSnapshot.NodeCount = len(nodeList.Items) + require.Equal(t, kubernetesSnapshot.TotalCPU, totalCPUs) + require.Equal(t, kubernetesSnapshot.TotalMemory, totalMemory) + require.Equal(t, kubernetesSnapshot.NodeCount, len(nodeList.Items)) + + t.Logf("Kubernetes snapshot: %+v", kubernetesSnapshot) +}