Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c30c14d555 | |||
| ded33a33a0 | |||
| 4bd9569e63 | |||
| 9e04145875 | |||
| 3c6f61134e | |||
| 9ac8641f7e | |||
| 0fddedc1a9 | |||
| 2e6a3a42be | |||
| a245e93902 | |||
| d1f48ce043 | |||
| 2c1156da75 | |||
| 5ed95ce714 | |||
| 3e5ec79b21 | |||
| 157c83deee | |||
| 2865fd6b84 | |||
| 96285817ab | |||
| c2c1ac70f8 | |||
| b73f846397 | |||
| a43bb23bef | |||
| c93b2fedb4 | |||
| 156b223287 | |||
| 9ea41f68bc | |||
| e943aa8f03 | |||
| 17a4750d8e | |||
| 7d18c22aa1 | |||
| c80cc6e268 | |||
| b30a1b5250 | |||
| b753371700 | |||
| 3ca5ab180f | |||
| 4971f5510c | |||
| 20fa7e508d | |||
| ebffc340d9 | |||
| 9a86737caa | |||
| d35d8a7307 | |||
| 701ff5d6bc | |||
| 9044b25a23 | |||
| 7f089fab86 | |||
| a259c28678 | |||
| db48da185a | |||
| cab667c23b | |||
| 154ca9f1b1 | |||
| 2abe40b786 | |||
| 6be2420b32 |
@@ -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.26.1'
|
||||
- '2.26.0'
|
||||
- '2.25.1'
|
||||
- '2.25.0'
|
||||
- '2.24.1'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -605,12 +605,12 @@
|
||||
"GlobalDeploymentOptions": {
|
||||
"hideStacksFunctionality": false
|
||||
},
|
||||
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
|
||||
"HelmRepositoryURL": "",
|
||||
"InternalAuthSettings": {
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.26.1",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.26.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package edgestacks
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -52,10 +53,14 @@ func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups)
|
||||
if err != nil {
|
||||
if err := handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups); err != nil {
|
||||
return httperror.InternalServerError("Unable to delete edge stack", err)
|
||||
}
|
||||
|
||||
stackFolder := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(edgeStack.ID)))
|
||||
if err := handler.FileService.RemoveDirectory(stackFolder); err != nil {
|
||||
return httperror.InternalServerError("Unable to remove edge stack project folder", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -101,3 +103,52 @@ func TestDeleteInvalidEdgeStack(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
edgeGroup := createEdgeGroup(t, handler.DataStore)
|
||||
|
||||
payload := edgeStackFromStringPayload{
|
||||
Name: "test-stack",
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
||||
StackFileContent: "version: '3.7'\nservices:\n test:\n image: test",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
||||
t.Fatal("error encoding payload:", err)
|
||||
}
|
||||
|
||||
// Create
|
||||
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
|
||||
if err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
|
||||
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
|
||||
// Delete
|
||||
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
|
||||
assert.NoDirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
|
||||
if err != nil {
|
||||
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
fileName := stack.EntryPoint
|
||||
|
||||
@@ -30,7 +30,7 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if err != nil {
|
||||
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
return response.JSON(w, edgeStack)
|
||||
|
||||
@@ -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, handler.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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -69,15 +69,21 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID))
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if r.Context().Err() != nil {
|
||||
return err
|
||||
}
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
}
|
||||
|
||||
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
|
||||
return err
|
||||
}); err != nil {
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
|
||||
return handler.updateEdgeStackStatus(stack, stack.ID, payload)
|
||||
}
|
||||
|
||||
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
@@ -93,36 +99,11 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// Skip error because agent tries to report on deleted stack
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("stackID", int(stackID)).
|
||||
Int("status", int(*payload.Status)).
|
||||
Msg("Unable to find a stack inside the database, skipping error")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w. Environment ID: %d", err, payload.EndpointID)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
status := *payload.Status
|
||||
|
||||
log.Debug().
|
||||
@@ -138,10 +119,6 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
|
||||
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
|
||||
|
||||
if err := tx.EdgeStack().UpdateEdgeStack(stackID, stack); err != nil {
|
||||
return nil, handler.handlerDBErr(fmt.Errorf("unable to update Edge stack to the database: %w. Environment name: %s", err, endpoint.Name), "unable to update Edge stack")
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
@@ -160,7 +137,11 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
|
||||
}
|
||||
}
|
||||
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
if containsStatus := slices.ContainsFunc(environmentStatus.Status, func(e portainer.EdgeStackDeploymentStatus) bool {
|
||||
return e.Type == deploymentStatus.Type
|
||||
}); !containsStatus {
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
}
|
||||
|
||||
stack.Status[environmentId] = environmentStatus
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type statusRequest struct {
|
||||
respCh chan statusResponse
|
||||
stackID portainer.EdgeStackID
|
||||
updateFn statusUpdateFn
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Stack *portainer.EdgeStack
|
||||
Error error
|
||||
}
|
||||
|
||||
type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error)
|
||||
|
||||
type EdgeStackStatusUpdateCoordinator struct {
|
||||
updateCh chan statusRequest
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress")
|
||||
|
||||
func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator {
|
||||
return &EdgeStackStatusUpdateCoordinator{
|
||||
updateCh: make(chan statusRequest),
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) Start() {
|
||||
for {
|
||||
c.loop()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) loop() {
|
||||
u := <-c.updateCh
|
||||
|
||||
respChs := []chan statusResponse{u.respCh}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
|
||||
err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
// 1. Load the edge stack
|
||||
var err error
|
||||
|
||||
stack, err = loadEdgeStack(tx, u.stackID)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if m, ok := c.getNextUpdate(stack.ID); ok {
|
||||
u = m
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
respChs = append(respChs, u.respCh)
|
||||
}
|
||||
|
||||
// 3. Save the changes back to the database
|
||||
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
|
||||
return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// 4. Send back the responses
|
||||
for _, ch := range respChs {
|
||||
ch <- statusResponse{Stack: stack, Error: err}
|
||||
}
|
||||
}
|
||||
|
||||
func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// Skip the error when the agent tries to update the status on a deleted stack
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("stackID", int(stackID)).
|
||||
Msg("Unable to find a stack inside the database, skipping error")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err)
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) {
|
||||
for {
|
||||
select {
|
||||
case u := <-c.updateCh:
|
||||
// Discard the update and let the agent retry
|
||||
if u.stackID != stackID {
|
||||
u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return u, true
|
||||
|
||||
default:
|
||||
return statusRequest{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) {
|
||||
respCh := make(chan statusResponse)
|
||||
defer close(respCh)
|
||||
|
||||
msg := statusRequest{
|
||||
respCh: respCh,
|
||||
stackID: stackID,
|
||||
updateFn: updateFn,
|
||||
}
|
||||
|
||||
select {
|
||||
case c.updateCh <- msg:
|
||||
r := <-respCh
|
||||
|
||||
return r.Stack, r.Error
|
||||
|
||||
case <-r.Context().Done():
|
||||
return nil, r.Context().Err()
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,14 @@ func setupHandler(t *testing.T) (*Handler, string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
coord := NewEdgeStackStatusUpdateCoordinator(store)
|
||||
go coord.Start()
|
||||
|
||||
handler := NewHandler(
|
||||
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
||||
store,
|
||||
edgestacks.NewService(store),
|
||||
coord,
|
||||
)
|
||||
|
||||
handler.FileService = fs
|
||||
@@ -144,3 +148,15 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
|
||||
|
||||
return edgeStack
|
||||
}
|
||||
|
||||
func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeGroup {
|
||||
edgeGroup := portainer.EdgeGroup{
|
||||
ID: 1,
|
||||
Name: "EdgeGroup 1",
|
||||
}
|
||||
|
||||
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return edgeGroup
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||
func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
relationConfig, err := edge.FetchEndpointRelationsConfig(tx)
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,21 +22,21 @@ type Handler struct {
|
||||
GitService portainer.GitService
|
||||
edgeStacksService *edgestackservice.Service
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
stackCoordinator *EdgeStackStatusUpdateCoordinator
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
DataStore: dataStore,
|
||||
edgeStacksService: edgeStacksService,
|
||||
stackCoordinator: stackCoordinator,
|
||||
}
|
||||
|
||||
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}",
|
||||
@@ -53,15 +53,13 @@ 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
|
||||
}
|
||||
|
||||
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
func handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
httpErr := httperror.InternalServerError(msg, err)
|
||||
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
httpErr.StatusCode = http.StatusNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.1
|
||||
// @version 2.27.0
|
||||
// @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"):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
|
||||
t.Run(cmd, func(t *testing.T) {
|
||||
is.NotNil(h, "Handler should not fail")
|
||||
|
||||
repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami")
|
||||
repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
|
||||
chart := "nginx"
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@@ -1,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)
|
||||
}
|
||||
@@ -36,5 +36,9 @@ func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *ht
|
||||
return httperror.InternalServerError("Unable to retrieve registries from the database", err)
|
||||
}
|
||||
|
||||
for idx := range registries {
|
||||
hideFields(®istries[idx], false)
|
||||
}
|
||||
|
||||
return response.JSON(w, registries)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ package system
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
statusutil "github.com/portainer/portainer/api/internal/nodes"
|
||||
"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 {
|
||||
@@ -31,32 +30,15 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.InternalServerError("Failed to get environment list", err)
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
err = snapshot.FillSnapshotData(handler.dataStore, &endpoints[i])
|
||||
if err != nil {
|
||||
var nodes int
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
|
||||
nodes := statusutil.NodesCount(endpoints)
|
||||
nodes += statusutil.NodesCount([]portainer.Endpoint{endpoint})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
|
||||
}
|
||||
+4
-6
@@ -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"
|
||||
@@ -161,14 +160,14 @@ func (server *Server) Start() error {
|
||||
edgeJobsHandler.FileService = server.FileService
|
||||
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
|
||||
edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore)
|
||||
go edgeStackCoordinator.Start()
|
||||
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator)
|
||||
edgeStacksHandler.FileService = server.FileService
|
||||
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
|
||||
@@ -303,7 +302,6 @@ func (server *Server) Start() error {
|
||||
EdgeGroupsHandler: edgeGroupsHandler,
|
||||
EdgeJobsHandler: edgeJobsHandler,
|
||||
EdgeStacksHandler: edgeStacksHandler,
|
||||
EdgeTemplatesHandler: edgeTemplatesHandler,
|
||||
EndpointGroupHandler: endpointGroupHandler,
|
||||
EndpointHandler: endpointHandler,
|
||||
EndpointHelmHandler: endpointHelmHandler,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings
|
||||
return "", err
|
||||
}
|
||||
|
||||
maps.Copy(idToken, resource)
|
||||
maps.Copy(resource, idToken)
|
||||
|
||||
username, err := GetUsername(resource, configuration.UserIdentifier)
|
||||
if err != nil {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+9
-8
@@ -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
|
||||
}
|
||||
@@ -1636,9 +1637,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.26.1"
|
||||
APIVersion = "2.27.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
+34
-4
@@ -31,10 +31,40 @@
|
||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
||||
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
||||
>
|
||||
<beta-alert
|
||||
is-html="true"
|
||||
message="'Beta feature - so far, this functionality has been tested in limited scenarios. For more information, see this <a href=\'https://www.portainer.io/blog/portainer-now-with-helm-support\' target=\'_blank\' class=\'hyperlink\'>blog post on Portainer Helm support</a>.'"
|
||||
></beta-alert>
|
||||
<div class="w-full">
|
||||
<div class="small text-muted mb-2"
|
||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
||||
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
||||
>
|
||||
<div class="relative flex w-fit gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3 mt-2">
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-lightbulb h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6"
|
||||
>
|
||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
|
||||
<path d="M9 18h6"></path>
|
||||
<path d="M10 22h4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="align-middle text-[0.9em] font-medium pr-10 mb-2">Disclaimer</p>
|
||||
<div class="small">
|
||||
At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.<br />
|
||||
If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
|
||||
<a href="https://bit.ly/3WVkayl" target="_blank" rel="noopener noreferrer">please get in touch</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blocklist !px-0" role="list">
|
||||
|
||||
@@ -5,6 +5,7 @@ class KubernetesConfigurationConverter {
|
||||
static secretToConfiguration(secret) {
|
||||
const res = new KubernetesConfiguration();
|
||||
res.Kind = KubernetesConfigurationKinds.SECRET;
|
||||
res.kind = 'Secret';
|
||||
res.Id = secret.Id;
|
||||
res.Name = secret.Name;
|
||||
res.Type = secret.Type;
|
||||
@@ -19,8 +20,15 @@ class KubernetesConfigurationConverter {
|
||||
res.IsRegistrySecret = secret.IsRegistrySecret;
|
||||
res.SecretType = secret.SecretType;
|
||||
if (secret.Annotations) {
|
||||
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
|
||||
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
|
||||
const serviceAccountKey = 'kubernetes.io/service-account.name';
|
||||
if (typeof secret.Annotations === 'object') {
|
||||
res.ServiceAccountName = secret.Annotations[serviceAccountKey];
|
||||
} else if (Array.isArray(secret.Annotations)) {
|
||||
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
|
||||
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
|
||||
} else {
|
||||
res.ServiceAccountName = undefined;
|
||||
}
|
||||
}
|
||||
res.Labels = secret.Labels;
|
||||
return res;
|
||||
@@ -29,6 +37,7 @@ class KubernetesConfigurationConverter {
|
||||
static configMapToConfiguration(configMap) {
|
||||
const res = new KubernetesConfiguration();
|
||||
res.Kind = KubernetesConfigurationKinds.CONFIGMAP;
|
||||
res.kind = 'ConfigMap';
|
||||
res.Id = configMap.Id;
|
||||
res.Name = configMap.Name;
|
||||
res.Namespace = configMap.Namespace;
|
||||
|
||||
@@ -9,6 +9,7 @@ const _KubernetesConfigurationFormValues = Object.freeze({
|
||||
Name: '',
|
||||
ConfigurationOwner: '',
|
||||
Kind: KubernetesConfigurationKinds.CONFIGMAP,
|
||||
kind: 'ConfigMap',
|
||||
Data: [],
|
||||
DataYaml: '',
|
||||
IsSimple: true,
|
||||
|
||||
@@ -22,29 +22,6 @@
|
||||
</kubernetes-resource-reservation>
|
||||
</form>
|
||||
<!-- !resource-reservation -->
|
||||
|
||||
<!-- leader-status -->
|
||||
<div ng-if="ctrl.systemEndpoints.length > 0">
|
||||
<div class="col-sm-12 form-section-title"> Leader status </div>
|
||||
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr class="text-muted">
|
||||
<td style="border-top: none; width: 25%">Component</td>
|
||||
<td style="border-top: none; width: 25%">Leader node</td>
|
||||
</tr>
|
||||
<tr ng-repeat="ep in ctrl.systemEndpoints">
|
||||
<td style="width: 25%">
|
||||
{{ ep.Name }}
|
||||
</td>
|
||||
<td style="width: 25%">
|
||||
{{ ep.HolderIdentity }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- !leader-status -->
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<page-header ng-if="ctrl.state.viewReady" title="'Create from file'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
|
||||
<page-header ng-if="ctrl.state.viewReady" title="'Create from code'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
/**
|
||||
* @deprecated use withGlobalError
|
||||
* `onError` and other callbacks are not supported on react-query v5
|
||||
* @deprecated for `useQuery` ONLY. Use `withGlobalError`.
|
||||
*
|
||||
* `onError` and other callbacks are not supported on `useQuery` in react-query v5
|
||||
*
|
||||
* Using `withError` is fine for mutations (`useMutation`)
|
||||
*
|
||||
* see https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose
|
||||
*/
|
||||
export function withError(fallbackMessage?: string, title = 'Failure') {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable no-console */
|
||||
import { intersection } from 'lodash';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
function logPropDifferences(
|
||||
newProps: Record<string, unknown>,
|
||||
lastProps: Record<string, unknown>,
|
||||
verbose: boolean
|
||||
) {
|
||||
const allKeys = intersection(Object.keys(newProps), Object.keys(lastProps));
|
||||
|
||||
const changedKeys: string[] = [];
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
const newValue = newProps[key];
|
||||
const lastValue = lastProps[key];
|
||||
if (newValue !== lastValue) {
|
||||
changedKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (changedKeys.length) {
|
||||
if (verbose) {
|
||||
changedKeys.forEach((key) => {
|
||||
const newValue = newProps[key];
|
||||
const lastValue = lastProps[key];
|
||||
console.log('Key [', key, '] changed');
|
||||
console.log('From: ', lastValue);
|
||||
console.log('To: ', newValue);
|
||||
console.log('------');
|
||||
});
|
||||
} else {
|
||||
console.log('Changed keys: ', changedKeys.join());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDebugPropChanges(
|
||||
newProps: Record<string, unknown>,
|
||||
verbose: boolean = true
|
||||
) {
|
||||
const lastProps = useRef<Record<string, unknown>>();
|
||||
// Should only run when the component re-mounts
|
||||
useEffect(() => {
|
||||
console.log('Mounted');
|
||||
}, []);
|
||||
if (lastProps.current) {
|
||||
logPropDifferences(newProps, lastProps.current, verbose);
|
||||
}
|
||||
lastProps.current = newProps;
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
@@ -3,7 +3,7 @@ import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as highlightTags } from '@lezer/highlight';
|
||||
|
||||
@@ -11,6 +11,8 @@ import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
|
||||
import styles from './CodeEditor.module.css';
|
||||
import { TextTip } from './Tip/TextTip';
|
||||
import { StackVersionSelector } from './StackVersionSelector';
|
||||
@@ -89,17 +91,17 @@ export function CodeEditor({
|
||||
return extensions;
|
||||
}, [type]);
|
||||
|
||||
function handleVersionChange(version: number) {
|
||||
if (versions && versions.length > 1) {
|
||||
if (version < versions[0]) {
|
||||
setIsRollback(true);
|
||||
} else {
|
||||
setIsRollback(false);
|
||||
const handleVersionChange = useCallback(
|
||||
(version: number) => {
|
||||
if (versions && versions.length > 1) {
|
||||
setIsRollback(version < versions[0]);
|
||||
}
|
||||
}
|
||||
onVersionChange?.(version);
|
||||
},
|
||||
[onVersionChange, versions]
|
||||
);
|
||||
|
||||
onVersionChange?.(version);
|
||||
}
|
||||
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -136,8 +138,8 @@ export function CodeEditor({
|
||||
<CodeMirror
|
||||
className={styles.root}
|
||||
theme={theme}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
value={debouncedValue}
|
||||
onChange={debouncedOnChange}
|
||||
readOnly={readonly || isRollback}
|
||||
id={id}
|
||||
extensions={extensions}
|
||||
|
||||
@@ -40,17 +40,10 @@ export function useDocsUrl(doc?: string): string {
|
||||
}
|
||||
|
||||
let url = 'https://docs.portainer.io/';
|
||||
if (versionQuery.data) {
|
||||
let { ServerVersion } = versionQuery.data;
|
||||
if (ServerVersion[0] === 'v') {
|
||||
ServerVersion = ServerVersion.substring(1);
|
||||
}
|
||||
|
||||
const parts = ServerVersion.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const version = parts.slice(0, 2).join('.');
|
||||
url += `v/${version}`;
|
||||
}
|
||||
// Add LTS or STS version if we have it
|
||||
if (versionQuery.data?.VersionSupport) {
|
||||
url += versionQuery.data.VersionSupport.toLowerCase();
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
|
||||
@@ -104,6 +104,7 @@ export function TagSelector({
|
||||
onCreateOption={handleCreateOption}
|
||||
aria-label="Tags"
|
||||
data-cy="environment-tags-selector"
|
||||
id="environment-tags-selector"
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
|
||||
@@ -36,6 +36,7 @@ export function UsersSelector({
|
||||
onChange(selectedUsers.map((user) => user.Id))
|
||||
}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
|
||||
@@ -155,6 +155,50 @@ describe('Datatable', () => {
|
||||
|
||||
expect(screen.getByText('No data available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects/deselects only page rows when select all is clicked', () => {
|
||||
render(
|
||||
<Datatable
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
settingsManager={{ ...mockSettingsManager, pageSize: 2 }}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
const selectAllCheckbox = screen.getByLabelText('Select all rows');
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
// Check if all rows on the page are selected
|
||||
expect(screen.getByText('2 item(s) 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(
|
||||
<Datatable
|
||||
dataset={mockData}
|
||||
columns={mockColumns}
|
||||
settingsManager={{ ...mockSettingsManager, pageSize: 2 }}
|
||||
data-cy="test-table"
|
||||
/>
|
||||
);
|
||||
|
||||
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 item(s) selected')).toBeInTheDocument();
|
||||
|
||||
// Deselect
|
||||
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
||||
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Test the defaultGlobalFilterFn used in searches
|
||||
|
||||
@@ -11,15 +11,22 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
data-cy={`select-all-checkbox-${dataCy}`}
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
onChange={(e) => {
|
||||
// Select all rows if shift key is held down, otherwise only page rows
|
||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
|
||||
table.getToggleAllRowsSelectedHandler()(e);
|
||||
return;
|
||||
}
|
||||
table.getToggleAllPageRowsSelectedHandler()(e);
|
||||
}}
|
||||
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 }) => (
|
||||
|
||||
@@ -18,6 +18,7 @@ export function Select<T extends number | string>({
|
||||
options,
|
||||
className,
|
||||
'data-cy': dataCy,
|
||||
id,
|
||||
...props
|
||||
}: Props<T> & SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
|
||||
@@ -111,6 +111,7 @@ export function SingleSelect<TValue = string>({
|
||||
onChange={(option) => onChange(option ? option.value : null)}
|
||||
isOptionDisabled={(option) => !!option.disabled}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
@@ -177,6 +178,7 @@ export function MultiSelect<TValue = string>({
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(newValue) => onChange(newValue.map((option) => option.value))}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
|
||||
@@ -65,6 +65,7 @@ export function Select<
|
||||
}: Props<Option, IsMulti, Group> &
|
||||
AutomationTestingProps & {
|
||||
isItemVisible?: (item: Option, search: string) => boolean;
|
||||
id: string;
|
||||
}) {
|
||||
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
|
||||
const { options } = props;
|
||||
|
||||
@@ -152,6 +152,7 @@ export function GpuFieldset({
|
||||
options={options}
|
||||
components={{ MultiValueRemove }}
|
||||
data-cy="docker-containers-gpu-select"
|
||||
id="docker-containers-gpu-select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -173,6 +174,7 @@ export function GpuFieldset({
|
||||
components={{ Option }}
|
||||
onChange={onChangeSelectedCaps}
|
||||
data-cy="docker-containers-gpu-capabilities-select"
|
||||
id="docker-containers-gpu-capabilities-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function VolumeSelector({
|
||||
onChange={(vol) => onChange(vol?.Name)}
|
||||
inputId={inputId}
|
||||
data-cy="docker-containers-volume-selector"
|
||||
id="docker-containers-volume-selector"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ export function CreatableSelector({
|
||||
isDisabled={isLoading}
|
||||
closeMenuOnSelect={false}
|
||||
data-cy="edge-devices-assignment-selector"
|
||||
id="edge-devices-assignment-selector"
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
+1
@@ -45,6 +45,7 @@ export function GroupSelector() {
|
||||
placeholder="Select a group"
|
||||
isClearable
|
||||
data-cy="edge-devices-assignment-selector"
|
||||
id="edge-devices-assignment-selector"
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -29,13 +29,16 @@ export function CreateForm() {
|
||||
const [webhookId] = useState(() => createWebhookId());
|
||||
|
||||
const [templateParams, setTemplateParams] = useTemplateParams();
|
||||
const templateQuery = useTemplate(templateParams.type, templateParams.id);
|
||||
const templateQuery = useTemplate(
|
||||
templateParams.templateType,
|
||||
templateParams.templateId
|
||||
);
|
||||
|
||||
const validation = useValidation(templateQuery);
|
||||
const mutation = useCreate({
|
||||
webhookId,
|
||||
template: templateQuery.customTemplate || templateQuery.appTemplate,
|
||||
templateType: templateParams.type,
|
||||
templateType: templateParams.templateType,
|
||||
});
|
||||
|
||||
const initialValues = useInitialValues(templateQuery, templateParams);
|
||||
@@ -53,6 +56,7 @@ export function CreateForm() {
|
||||
initialValues={initialValues}
|
||||
onSubmit={mutation.onSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
>
|
||||
<InnerForm
|
||||
webhookId={webhookId}
|
||||
@@ -118,8 +122,8 @@ function useInitialValues(
|
||||
customTemplate: CustomTemplate | undefined;
|
||||
},
|
||||
templateParams: {
|
||||
id: number | undefined;
|
||||
type: 'app' | 'custom' | undefined;
|
||||
templateId: number | undefined;
|
||||
templateType: 'app' | 'custom' | undefined;
|
||||
}
|
||||
) {
|
||||
const template = templateQuery.customTemplate || templateQuery.appTemplate;
|
||||
@@ -139,7 +143,7 @@ function useInitialValues(
|
||||
staggerConfig:
|
||||
templateQuery.customTemplate?.EdgeSettings?.StaggerConfig ??
|
||||
getDefaultStaggerConfig(),
|
||||
method: templateParams.id ? 'template' : 'editor',
|
||||
method: templateParams.templateId ? 'template' : 'editor',
|
||||
git: toGitFormModel(
|
||||
templateQuery.customTemplate?.GitConfig,
|
||||
parseAutoUpdateResponse()
|
||||
@@ -149,19 +153,19 @@ function useInitialValues(
|
||||
getDefaultRelativePathModel(),
|
||||
enableWebhook: false,
|
||||
fileContent: '',
|
||||
templateValues: getTemplateValues(templateParams.type, template),
|
||||
templateValues: getTemplateValues(templateParams.templateType, template),
|
||||
useManifestNamespaces: false,
|
||||
}),
|
||||
[
|
||||
templateQuery.customTemplate,
|
||||
templateParams.id,
|
||||
templateParams.type,
|
||||
templateParams.templateId,
|
||||
templateParams.templateType,
|
||||
template,
|
||||
]
|
||||
);
|
||||
|
||||
if (
|
||||
templateParams.id &&
|
||||
templateParams.templateId &&
|
||||
!templateQuery.customTemplate &&
|
||||
!templateQuery.appTemplate
|
||||
) {
|
||||
|
||||
@@ -17,7 +17,11 @@ import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import {
|
||||
DeployMethod,
|
||||
GitFormModel,
|
||||
RelativePathModel,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
@@ -133,7 +137,10 @@ export function useValidation({
|
||||
);
|
||||
},
|
||||
}) as SchemaOf<GitFormModel>,
|
||||
relativePath: relativePathValidation(),
|
||||
relativePath: mixed().when('method', {
|
||||
is: 'repository',
|
||||
then: () => relativePathValidation(),
|
||||
}) as SchemaOf<RelativePathModel>,
|
||||
useManifestNamespaces: boolean().default(false),
|
||||
})
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormikErrors, useFormikContext } from 'formik';
|
||||
import { SetStateAction } from 'react';
|
||||
import { SetStateAction, useCallback } from 'react';
|
||||
|
||||
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
@@ -28,8 +28,8 @@ const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
||||
interface Props {
|
||||
webhookId: string;
|
||||
onChangeTemplate: (change: {
|
||||
type: 'app' | 'custom' | undefined;
|
||||
id: number | undefined;
|
||||
templateType: 'app' | 'custom' | undefined;
|
||||
templateId: number | undefined;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,23 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
||||
const { method } = values;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValues: Partial<DockerFormValues>) => {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...newValues,
|
||||
}));
|
||||
},
|
||||
[setValues]
|
||||
);
|
||||
|
||||
const saveFileContent = useCallback(
|
||||
(value: string) => {
|
||||
handleChange({ fileContent: value });
|
||||
},
|
||||
[handleChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSection title="Build Method">
|
||||
@@ -59,8 +76,8 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
values.templateValues
|
||||
);
|
||||
onChangeTemplate({
|
||||
id: templateValues.templateId,
|
||||
type: templateValues.type,
|
||||
templateId: templateValues.templateId,
|
||||
templateType: templateValues.type,
|
||||
});
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
@@ -91,7 +108,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
{method === editor.value && (
|
||||
<DockerContentField
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
onChange={saveFileContent}
|
||||
error={errors?.fileContent}
|
||||
/>
|
||||
)}
|
||||
@@ -128,6 +145,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
<FormSection title="Advanced configurations">
|
||||
<RelativePathFieldset
|
||||
values={values.relativePath}
|
||||
errors={errors.relativePath}
|
||||
gitModel={values.git}
|
||||
onChange={(relativePath) =>
|
||||
setValues((values) => ({
|
||||
@@ -145,13 +163,6 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(newValues: Partial<DockerFormValues>) {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...newValues,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
type TemplateContentFieldProps = {
|
||||
|
||||
@@ -29,11 +29,11 @@ export function InnerForm({
|
||||
webhookId: string;
|
||||
isLoading: boolean;
|
||||
onChangeTemplate: ({
|
||||
type,
|
||||
id,
|
||||
templateType,
|
||||
templateId,
|
||||
}: {
|
||||
type: 'app' | 'custom' | undefined;
|
||||
id: number | undefined;
|
||||
templateType: 'app' | 'custom' | undefined;
|
||||
templateId: number | undefined;
|
||||
}) => void;
|
||||
}) {
|
||||
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
|
||||
@@ -128,6 +128,7 @@ export function InnerForm({
|
||||
<StaggerFieldset
|
||||
isEdit={false}
|
||||
values={values.staggerConfig}
|
||||
errors={errors.staggerConfig}
|
||||
onChange={(newStaggerValues) =>
|
||||
setFieldValue('staggerConfig', newStaggerValues)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export function TemplateSelector({
|
||||
onChange(getTemplate({ type, id: templateId }), type);
|
||||
}}
|
||||
data-cy="edge-stacks-create-template-selector"
|
||||
id="edge-stacks-create-template-selector"
|
||||
/>
|
||||
{isLoadingValues && (
|
||||
<InlineLoader>Loading template values...</InlineLoader>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TemplateViewModel } from '@/react/portainer/templates/app-templates/vie
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||
|
||||
import {
|
||||
BasePayload,
|
||||
@@ -49,12 +50,18 @@ export function useCreate({
|
||||
),
|
||||
});
|
||||
|
||||
mutation.mutate(getPayload(method, values), {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Edge stack created');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
});
|
||||
mutation.mutate(
|
||||
getPayload(method, values),
|
||||
mutationOptions(
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Edge stack created');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
},
|
||||
withError('unable to create edge stack')
|
||||
)
|
||||
);
|
||||
|
||||
function getPayload(
|
||||
method: 'string' | 'file' | 'git',
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useParamsState } from '@/react/hooks/useParamState';
|
||||
|
||||
export function useTemplateParams() {
|
||||
const [{ id, type }, setTemplateParams] = useParamsState(
|
||||
['templateId', 'templateType'],
|
||||
const [{ templateId, templateType }, setTemplateParams] = useParamsState(
|
||||
(params) => ({
|
||||
id: parseTemplateId(params.templateId),
|
||||
type: parseTemplateType(params.templateType),
|
||||
templateId: parseTemplateId(params.templateId),
|
||||
templateType: parseTemplateType(params.templateType),
|
||||
})
|
||||
);
|
||||
|
||||
return [{ id, type }, setTemplateParams] as const;
|
||||
return [{ templateId, templateType }, setTemplateParams] as const;
|
||||
}
|
||||
|
||||
function parseTemplateId(param?: string) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,6 +97,7 @@ function InnerSelector({
|
||||
placeholder="Select one or multiple group(s)"
|
||||
closeMenuOnSelect={false}
|
||||
data-cy="edge-stacks-groups-selector"
|
||||
id="edge-stacks-groups-selector"
|
||||
inputId={inputId}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -102,6 +102,7 @@ export function PrivateRegistryFieldset({
|
||||
onChange={(value) => onSelect(value?.Id)}
|
||||
className="w-full"
|
||||
data-cy="private-registry-selector"
|
||||
id="private-registry-selector"
|
||||
/>
|
||||
{method !== 'repository' && (
|
||||
<Button
|
||||
|
||||
@@ -22,32 +22,15 @@ export function useParamState<T>(
|
||||
|
||||
/** Use this when you need to use/update multiple params at once. */
|
||||
export function useParamsState<T extends Record<string, unknown>>(
|
||||
params: string[],
|
||||
parseParams: (params: Record<string, string | undefined>) => T
|
||||
) {
|
||||
const { params: stateParams } = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
|
||||
const state = parseParams(
|
||||
params.reduce(
|
||||
(acc, param) => {
|
||||
acc[param] = stateParams[param];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | undefined>
|
||||
)
|
||||
);
|
||||
const state = parseParams(stateParams);
|
||||
|
||||
function setState(newState: Partial<T>) {
|
||||
const newStateParams = Object.entries(newState).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
|
||||
router.stateService.go('.', newStateParams, { reload: false });
|
||||
router.stateService.go('.', newState, { reload: false });
|
||||
}
|
||||
|
||||
return [state, setState] as const;
|
||||
|
||||
+1
@@ -122,6 +122,7 @@ export function AppIngressPathForm({
|
||||
onChangeIngressPath(newIngressPath);
|
||||
}}
|
||||
data-cy="k8sAppCreate-ingressPathHostSelect"
|
||||
id="k8sAppCreate-ingressPathHostSelect"
|
||||
/>
|
||||
<InputGroup.ButtonWrapper>
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ export function HelmInsightsBox() {
|
||||
content={
|
||||
<span>
|
||||
From 2.20 and on, the Helm menu sidebar option has moved to the{' '}
|
||||
<strong>Create from file screen</strong> - accessed via the button
|
||||
<strong>Create from code screen</strong> - accessed via the button
|
||||
above.
|
||||
</span>
|
||||
}
|
||||
|
||||
+55
-45
@@ -7,64 +7,74 @@ import { Icon } from '@@/Icon';
|
||||
import { Application } from './types';
|
||||
|
||||
export function PublishedPorts({ item }: { item: Application }) {
|
||||
const urls = getPublishedUrls(item);
|
||||
const urlsWithTypes = getPublishedUrls(item);
|
||||
|
||||
if (urls.length === 0) {
|
||||
if (urlsWithTypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="published-url-container">
|
||||
<div>
|
||||
<div className="text-muted"> Published URL(s) </div>
|
||||
</div>
|
||||
<div>
|
||||
{urls.map((url) => (
|
||||
<div key={url}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="publish-url-link vertical-center"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon icon={ExternalLinkIcon} />
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
<div className="published-url-container pl-10 flex">
|
||||
<div className="text-muted mr-12">Published URL(s)</div>
|
||||
<div className="flex flex-col">
|
||||
{urlsWithTypes.map(({ url, type }) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="publish-url-link vertical-center mb-1"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon icon={ExternalLinkIcon} />
|
||||
{type && (
|
||||
<span className="text-muted w-24 inline-block">{type}</span>
|
||||
)}
|
||||
<span>{url}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClusterIPUrls(services?: Application['Services']) {
|
||||
return (
|
||||
services?.flatMap(
|
||||
(service) =>
|
||||
(service.spec?.type === 'ClusterIP' &&
|
||||
service.spec?.ports?.map((port) => ({
|
||||
url: `${getSchemeFromPort(port.port)}://${service?.spec
|
||||
?.clusterIP}:${port.port}`,
|
||||
type: 'ClusterIP',
|
||||
}))) ||
|
||||
[]
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
||||
function getNodePortUrls(services?: Application['Services']) {
|
||||
return (
|
||||
services?.flatMap(
|
||||
(service) =>
|
||||
(service.spec?.type === 'NodePort' &&
|
||||
service.spec?.ports?.map((port) => ({
|
||||
url: `${getSchemeFromPort(port.port)}://${
|
||||
window.location.hostname
|
||||
}:${port.nodePort}`,
|
||||
type: 'NodePort',
|
||||
}))) ||
|
||||
[]
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublishedUrls(item: Application) {
|
||||
// Map all ingress rules in published ports to their respective URLs
|
||||
const ingressUrls =
|
||||
item.PublishedPorts?.flatMap((pp) => pp.IngressRules)
|
||||
.filter(({ Host, IP }) => Host || IP)
|
||||
.map(({ Host, IP, Path, TLS }) => {
|
||||
const scheme =
|
||||
TLS &&
|
||||
TLS.filter((tls) => tls.hosts && tls.hosts.includes(Host)).length > 0
|
||||
? 'https'
|
||||
: 'http';
|
||||
return `${scheme}://${Host || IP}${Path}`;
|
||||
}) || [];
|
||||
// Get URLs from clusterIP and nodePort services
|
||||
const clusterIPs = getClusterIPUrls(item.Services);
|
||||
const nodePortUrls = getNodePortUrls(item.Services);
|
||||
|
||||
// Map all load balancer service ports to ip address
|
||||
const loadBalancerURLs =
|
||||
(item.LoadBalancerIPAddress &&
|
||||
item.PublishedPorts?.map(
|
||||
(pp) =>
|
||||
`${getSchemeFromPort(pp.Port)}://${item.LoadBalancerIPAddress}:${
|
||||
pp.Port
|
||||
}`
|
||||
)) ||
|
||||
[];
|
||||
// combine all urls
|
||||
const publishedUrls = [...clusterIPs, ...nodePortUrls];
|
||||
|
||||
// combine ingress urls
|
||||
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
|
||||
|
||||
// Return the first URL - priority given to ingress urls, then services (load balancers)
|
||||
return publishedUrls.length > 0 ? publishedUrls : [];
|
||||
}
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ export function ConfigurationItem({
|
||||
onChange={onSelectConfigMap}
|
||||
size="sm"
|
||||
data-cy={`k8sAppCreate-add${configurationType}Select_${index}`}
|
||||
id={`k8sAppCreate-add${configurationType}Select_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{formikError?.selectedConfiguration && (
|
||||
|
||||
+3
@@ -144,6 +144,7 @@ export function PersistedFolderItem({
|
||||
applicationValues.Containers.length > 1
|
||||
}
|
||||
data-cy={`k8sAppCreate-persistentFolderSizeUnitSelect_${index}`}
|
||||
id={`k8sAppCreate-persistentFolderSizeUnitSelect_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{formikError?.size && <FormError>{formikError?.size}</FormError>}
|
||||
@@ -175,6 +176,7 @@ export function PersistedFolderItem({
|
||||
storageClasses.length <= 1
|
||||
}
|
||||
data-cy={`k8sAppCreate-storageSelect_${index}`}
|
||||
id={`k8sAppCreate-storageSelect_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
</>
|
||||
@@ -207,6 +209,7 @@ export function PersistedFolderItem({
|
||||
availableVolumes.length < 1
|
||||
}
|
||||
data-cy={`k8sAppCreate-pvcSelect_${index}`}
|
||||
id={`k8sAppCreate-pvcSelect_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,7 @@ export function PlacementItem({
|
||||
className={clsx({ striked: !!item.needsDeletion })}
|
||||
isDisabled={!!item.needsDeletion}
|
||||
data-cy={`k8sAppCreate-placementLabel_${index}`}
|
||||
id={`k8sAppCreate-placementLabel_${index}`}
|
||||
/>
|
||||
{placementError?.label && (
|
||||
<FormError>{placementError.label}</FormError>
|
||||
@@ -65,6 +66,7 @@ export function PlacementItem({
|
||||
className={clsx({ striked: !!item.needsDeletion })}
|
||||
isDisabled={!!item.needsDeletion}
|
||||
data-cy={`k8sAppCreate-placementName_${index}`}
|
||||
id={`k8sAppCreate-placementName_${index}`}
|
||||
/>
|
||||
{placementError?.value && (
|
||||
<FormError>{placementError.value}</FormError>
|
||||
|
||||
@@ -69,11 +69,10 @@ export function getTotalPods(
|
||||
): number {
|
||||
switch (application.kind) {
|
||||
case 'Deployment':
|
||||
return application.status?.replicas ?? 0;
|
||||
case 'StatefulSet':
|
||||
return application.spec?.replicas ?? 0;
|
||||
case 'DaemonSet':
|
||||
return application.status?.desiredNumberScheduled ?? 0;
|
||||
case 'StatefulSet':
|
||||
return application.status?.replicas ?? 0;
|
||||
default:
|
||||
throw new Error('Unknown application type');
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function StorageAccessModeSelector({
|
||||
inputId={inputId}
|
||||
placeholder="Not configured"
|
||||
data-cy={`kubeSetup-storageAccessSelect${storageClassName}`}
|
||||
id={`kubeSetup-storageAccessSelect${storageClassName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function NamespacesSelector({
|
||||
onChange(selectedTeams.map((namespace) => namespace.name))
|
||||
}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function CreateFromManifestButton({
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
Create from file
|
||||
Create from code
|
||||
</AddButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ export function IngressForm({
|
||||
}
|
||||
noOptionsMessage={() => 'No namespaces available'}
|
||||
data-cy="k8sAppCreate-namespaceSelect"
|
||||
id="k8sAppCreate-namespaceSelect"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -266,6 +267,7 @@ export function IngressForm({
|
||||
}
|
||||
noOptionsMessage={() => 'No ingress classes available'}
|
||||
data-cy="k8sAppCreate-ingressClassSelect"
|
||||
id="k8sAppCreate-ingressClassSelect"
|
||||
/>
|
||||
{errors.className && (
|
||||
<FormError className="error-inline mt-1">
|
||||
@@ -464,6 +466,7 @@ export function IngressForm({
|
||||
noOptionsMessage={() => 'No TLS secrets available'}
|
||||
size="sm"
|
||||
data-cy={`k8sAppCreate-tlsSelect_${hostIndex}`}
|
||||
id={`k8sAppCreate-tlsSelect_${hostIndex}`}
|
||||
/>
|
||||
{!host.NoHost && (
|
||||
<div className="input-group-btn">
|
||||
|
||||
@@ -35,6 +35,7 @@ export function NamespaceAccessUsersSelector({
|
||||
closeMenuOnSelect={false}
|
||||
onChange={onChange}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
components={{ MultiValueLabel, Option: OptionComponent }}
|
||||
|
||||
+1
@@ -67,6 +67,7 @@ export function RegistriesSelector({
|
||||
onChange={onChange}
|
||||
inputId={inputId}
|
||||
data-cy="namespaceCreate-registrySelect"
|
||||
id="namespaceCreate-registrySelect"
|
||||
placeholder="Select one or more registries"
|
||||
isDisabled={isEditingDisabled}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user