Compare commits
71 Commits
release/2.
...
2.37.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8869b91b71 | ||
|
|
2406d67bfc | ||
|
|
f0266e9316 | ||
|
|
c08f42315e | ||
|
|
d2649dac90 | ||
|
|
300681055e | ||
|
|
712dbc9396 | ||
|
|
f6b8e8615f | ||
|
|
4826c13848 | ||
|
|
80f497a185 | ||
|
|
d2a9adb4be | ||
|
|
8675086441 | ||
|
|
b79e784764 | ||
|
|
93ba3e700e | ||
|
|
bf6cb8d0b8 | ||
|
|
7010d7bf66 | ||
|
|
1a862157a0 | ||
|
|
532575cab5 | ||
|
|
0794d0f89f | ||
|
|
e227ffd6d8 | ||
|
|
5058b40871 | ||
|
|
5d847b59b2 | ||
|
|
c8d44b9416 | ||
|
|
14d67d1ec7 | ||
|
|
6866faf4fe | ||
|
|
567d628a52 | ||
|
|
a3eab75405 | ||
|
|
566f6b067c | ||
|
|
e73d07281c | ||
|
|
e59d4dea77 | ||
|
|
4ca5370b86 | ||
|
|
e831971dd1 | ||
|
|
99d996dde9 | ||
|
|
712d31b416 | ||
|
|
0394855b2f | ||
|
|
9024b021ee | ||
|
|
8071641179 | ||
|
|
0075374241 | ||
|
|
c35ddc8c76 | ||
|
|
4b4aef7ef8 | ||
|
|
6db4a62e01 | ||
|
|
db394b6145 | ||
|
|
53e7704724 | ||
|
|
f607c7c271 | ||
|
|
48c689e5d6 | ||
|
|
2f2251ff33 | ||
|
|
29254d1a66 | ||
|
|
19cbae1732 | ||
|
|
73ad27640c | ||
|
|
1be96e1bd1 | ||
|
|
a9834be2ff | ||
|
|
d8ab86d86f | ||
|
|
3f1bd8e290 | ||
|
|
34a7d75e10 | ||
|
|
ae53de42df | ||
|
|
b70321a0aa | ||
|
|
0ff39f9a61 | ||
|
|
876ba0fa0f | ||
|
|
c7c65d2f97 | ||
|
|
736f7e198f | ||
|
|
8cb3589fb8 | ||
|
|
56530d8791 | ||
|
|
da6b0e3dcc | ||
|
|
eb02f99cae | ||
|
|
cb0efae81c | ||
|
|
e5f98e6145 | ||
|
|
8a23007ad2 | ||
|
|
592b196848 | ||
|
|
8eb273e54b | ||
|
|
78c7e752f9 | ||
|
|
7c51a3b5ff |
@@ -17,7 +17,7 @@ plugins:
|
||||
- import
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: 2018
|
||||
ecmaVersion: latest
|
||||
sourceType: module
|
||||
project: './tsconfig.json'
|
||||
ecmaFeatures:
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
5
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,7 +94,12 @@ 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 [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
- '2.33.3'
|
||||
- '2.33.2'
|
||||
- '2.33.1'
|
||||
- '2.33.0'
|
||||
|
||||
@@ -9,3 +9,8 @@ linters:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
@@ -53,4 +53,5 @@ type Connection interface {
|
||||
|
||||
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
|
||||
ConvertToKey(v int) []byte
|
||||
ConvertStringToKey(v string) []byte
|
||||
}
|
||||
|
||||
@@ -233,6 +233,10 @@ func (connection *DbConnection) ConvertToKey(v int) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func (connection *DbConnection) ConvertStringToKey(v string) []byte {
|
||||
return []byte(v)
|
||||
}
|
||||
|
||||
// keyToString Converts a key to a string value suitable for logging
|
||||
func keyToString(b []byte) string {
|
||||
if len(b) != 8 {
|
||||
|
||||
@@ -50,6 +50,9 @@ func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
|
||||
func (m mockConnection) ConvertToKey(v int) []byte {
|
||||
return []byte(strconv.Itoa(v))
|
||||
}
|
||||
func (c mockConnection) ConvertStringToKey(v string) []byte {
|
||||
return []byte(v)
|
||||
}
|
||||
|
||||
func TestReadAll(t *testing.T) {
|
||||
service := BaseDataService[testObject, int]{
|
||||
|
||||
70
api/dataservices/version/tx.go
Normal file
70
api/dataservices/version/tx.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
dataservices.BaseDataServiceTx[models.Version, int] // ID is not used
|
||||
}
|
||||
|
||||
func (tx ServiceTx) InstanceID() (string, error) {
|
||||
v, err := tx.Version()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.InstanceID, nil
|
||||
}
|
||||
|
||||
func (tx ServiceTx) UpdateInstanceID(ID string) error {
|
||||
v, err := tx.Version()
|
||||
if err != nil {
|
||||
if !dataservices.IsErrObjectNotFound(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
v = &models.Version{}
|
||||
}
|
||||
|
||||
v.InstanceID = ID
|
||||
|
||||
return tx.UpdateVersion(v)
|
||||
}
|
||||
|
||||
func (tx ServiceTx) Edition() (portainer.SoftwareEdition, error) {
|
||||
v, err := tx.Version()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return portainer.SoftwareEdition(v.Edition), nil
|
||||
}
|
||||
|
||||
func (tx ServiceTx) Version() (*models.Version, error) {
|
||||
var v models.Version
|
||||
|
||||
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func (tx ServiceTx) UpdateVersion(version *models.Version) error {
|
||||
return tx.Tx.UpdateObject(BucketName, []byte(versionKey), version)
|
||||
}
|
||||
|
||||
func (tx ServiceTx) SchemaVersion() (string, error) {
|
||||
var v models.Version
|
||||
|
||||
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.SchemaVersion, nil
|
||||
}
|
||||
@@ -33,6 +33,16 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[models.Version, int]{
|
||||
Bucket: BucketName,
|
||||
Connection: service.connection,
|
||||
Tx: tx,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) SchemaVersion() (string, error) {
|
||||
v, err := service.Version()
|
||||
if err != nil {
|
||||
|
||||
@@ -614,7 +614,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.35.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.37.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.35.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.37.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
|
||||
"github.com/containers/image/v5/docker"
|
||||
imagetypes "github.com/containers/image/v5/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.podman.io/image/v5/docker"
|
||||
imagetypes "go.podman.io/image/v5/types"
|
||||
)
|
||||
|
||||
// Options holds docker registry object options
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/containers/image/v5/docker/reference"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/opencontainers/go-digest"
|
||||
"github.com/pkg/errors"
|
||||
"go.podman.io/image/v5/docker/reference"
|
||||
)
|
||||
|
||||
type ImageID string
|
||||
|
||||
@@ -3,8 +3,8 @@ package images
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/containers/image/v5/docker"
|
||||
"github.com/containers/image/v5/types"
|
||||
"go.podman.io/image/v5/docker"
|
||||
"go.podman.io/image/v5/types"
|
||||
)
|
||||
|
||||
func ParseReference(imageStr string) (types.ImageReference, error) {
|
||||
|
||||
@@ -60,11 +60,23 @@ type (
|
||||
// EnvVars is a list of environment variables to inject into the stack
|
||||
EnvVars []portainer.Pair
|
||||
|
||||
// Used only for EE async edge agent
|
||||
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
|
||||
ReadyRePullImage bool
|
||||
// ForceUpdate is a flag indicating if the agent must force the update of the stack.
|
||||
// Used only for EE
|
||||
ForceUpdate bool
|
||||
|
||||
DeployerOptionsPayload DeployerOptionsPayload
|
||||
|
||||
// Used only for EE async edge agent
|
||||
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
|
||||
// Deprecated(2.36): use DeployerOptionsPayload.ForceRecreate instead
|
||||
ReadyRePullImage bool
|
||||
|
||||
// CreatedBy is the username that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedBy string
|
||||
// CreatedByUserId is the user ID that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedByUserId string
|
||||
}
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
@@ -77,6 +89,14 @@ type (
|
||||
// This flag drives `docker compose down --volumes` option
|
||||
// Used only for EE
|
||||
RemoveVolumes bool
|
||||
|
||||
// ForceRecreate is a flag indicating if the agent must force the redeployment of the stack.
|
||||
// This field is only used when the Force Redeployment is triggered.
|
||||
// Once the stack is redeployed, this field will be reset to false.
|
||||
// For standard edge agent, this field is used in agent side
|
||||
// For async edge agent, this field is used in both agent side and server side.
|
||||
// This flag drives `docker compose up --force-recreate` option
|
||||
ForceRecreate bool
|
||||
}
|
||||
|
||||
// RegistryCredentials holds the credentials for a Docker registry.
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// UpdateGitObject updates a git object based on its config
|
||||
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, forceUpdate, enableVersionFolder bool, projectPath string) (bool, string, error) {
|
||||
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) {
|
||||
if gitConfig == nil {
|
||||
return false, "", nil
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
|
||||
|
||||
hashChanged := !strings.EqualFold(newHash, gitConfig.ConfigHash)
|
||||
|
||||
if !hashChanged && !forceUpdate {
|
||||
if !hashChanged {
|
||||
log.Debug().
|
||||
Str("hash", newHash).
|
||||
Str("url", gitConfig.URL).
|
||||
|
||||
@@ -27,7 +27,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
var edgeStack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData.ID, r)
|
||||
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData, r)
|
||||
return err
|
||||
}); err != nil {
|
||||
switch {
|
||||
@@ -43,14 +43,14 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||
return response.JSON(w, edgeStack)
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, userID portainer.UserID, r *http.Request) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, tokenData *portainer.TokenData, r *http.Request) (*portainer.EdgeStack, error) {
|
||||
switch method {
|
||||
case "string":
|
||||
return handler.createEdgeStackFromFileContent(r, tx, dryrun)
|
||||
return handler.createEdgeStackFromFileContent(r, tx, tokenData, dryrun)
|
||||
case "repository":
|
||||
return handler.createEdgeStackFromGitRepository(r, tx, dryrun, userID)
|
||||
return handler.createEdgeStackFromGitRepository(r, tx, tokenData, dryrun)
|
||||
case "file":
|
||||
return handler.createEdgeStackFromFileUpload(r, tx, dryrun)
|
||||
return handler.createEdgeStackFromFileUpload(r, tx, tokenData, dryrun)
|
||||
}
|
||||
|
||||
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
@@ -99,7 +101,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
// @failure 500 "Internal server error"
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/create/file [post]
|
||||
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
payload := &edgeStackFromFileUploadPayload{}
|
||||
if err := payload.Validate(r); err != nil {
|
||||
return nil, err
|
||||
@@ -113,6 +115,8 @@ func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx datase
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeFileContent(tx, stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
@@ -103,7 +104,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
|
||||
// @failure 500 "Internal server error"
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/create/repository [post]
|
||||
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
var payload edgeStackFromGitRepositoryPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -133,8 +134,11 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
|
||||
}
|
||||
}
|
||||
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeManifestFromGitRepository(tx, stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig)
|
||||
return handler.storeManifestFromGitRepository(tx, stackFolder, relatedEndpointIds, payload.DeploymentType, tokenData.ID, repoConfig)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
@@ -74,7 +75,7 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
|
||||
// @failure 500 "Internal server error"
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/create/string [post]
|
||||
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
var payload edgeStackFromStringPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return nil, err
|
||||
@@ -85,6 +86,9 @@ func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx datas
|
||||
return nil, errors.Wrap(err, "failed to create Edge stack object")
|
||||
}
|
||||
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
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))
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, payload.EndpointID))
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
|
||||
@@ -24,8 +24,8 @@ func (payload *logsPayload) Validate(r *http.Request) error {
|
||||
}
|
||||
|
||||
// endpointEdgeJobsLogs
|
||||
// @summary Inspect an EdgeJob Log
|
||||
// @description **Access policy**: public
|
||||
// @summary Update the logs collected from an Edge Job
|
||||
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
|
||||
// @tags edge, endpoints
|
||||
// @accept json
|
||||
// @produce json
|
||||
@@ -34,6 +34,7 @@ func (payload *logsPayload) Validate(r *http.Request) error {
|
||||
// @success 200
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 403
|
||||
// @router /endpoints/{id}/edge/jobs/{jobID}/logs [post]
|
||||
func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
@@ -42,35 +43,35 @@ func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
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))
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
var payload logsPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return handler.getEdgeJobLobs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
|
||||
return handler.updateEdgeJobLogs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
|
||||
}); err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
|
||||
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
return response.JSON(w, nil)
|
||||
}
|
||||
|
||||
func (handler *Handler) getEdgeJobLobs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error {
|
||||
func (handler *Handler) updateEdgeJobLogs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error {
|
||||
endpoint, err := tx.Endpoint().Endpoint(endpointID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
@@ -85,6 +86,11 @@ func (handler *Handler) getEdgeJobLobs(tx dataservices.DataStoreTx, endpointID p
|
||||
return httperror.InternalServerError("Unable to find an edge job with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
if resp, err := handler.buildSchedules(tx, endpoint, []portainer.EdgeJob{*edgeJob}); err != nil || len(resp) == 0 {
|
||||
return httperror.InternalServerError("Unable to verify if the edge job is assigned to the environment",
|
||||
fmt.Errorf("unable to verify if the edge job is assigned to the environment: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
if err := handler.FileService.StoreEdgeJobTaskLogFileFromBytes(strconv.Itoa(int(edgeJobID)), strconv.Itoa(int(endpoint.ID)), []byte(payload.FileContent)); err != nil {
|
||||
return httperror.InternalServerError("Unable to save task log to the filesystem", err)
|
||||
}
|
||||
|
||||
40
api/http/handler/endpointedge/endpointedge_job_logs_test.go
Normal file
40
api/http/handler/endpointedge/endpointedge_job_logs_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package endpointedge
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateUnrelatedEdgeJobLogs(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
h := &Handler{DataStore: store}
|
||||
|
||||
endpointID := portainer.EndpointID(2)
|
||||
edgeJobID := portainer.EdgeJobID(3)
|
||||
payload := logsPayload{FileContent: "log content"}
|
||||
|
||||
err := store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: endpointID,
|
||||
Name: "test-endpoint",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.EdgeJob().CreateWithID(edgeJobID, &portainer.EdgeJob{
|
||||
ID: edgeJobID,
|
||||
Name: "test-edge-job",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// There is no relation between the edge job and the endpoint, so the
|
||||
// update must fail
|
||||
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return h.updateEdgeJobLogs(tx, endpointID, edgeJobID, payload)
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -40,18 +40,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
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))
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
return edgeStack, err
|
||||
@@ -62,7 +62,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
// WARNING: this variable must not be mutated
|
||||
@@ -71,7 +71,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
fileName := edgeStack.EntryPoint
|
||||
if endpointutils.IsDockerEndpoint(endpoint) {
|
||||
if fileName == "" {
|
||||
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment name: %s", endpoint.Name))
|
||||
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment ID: %d", endpoint.ID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,18 +84,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
fileName = edgeStack.ManifestPath
|
||||
|
||||
if fileName == "" {
|
||||
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment name: %s", endpoint.Name))
|
||||
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment ID: %d", endpoint.ID))
|
||||
}
|
||||
}
|
||||
|
||||
dirEntries, err := filesystem.LoadDir(edgeStack.ProjectPath)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
fileContent, err := filesystem.FilterDirForCompatibility(dirEntries, fileName, endpoint.Agent.Version)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
dirEntries = filesystem.FilterDirForEntryFile(dirEntries, fileName)
|
||||
@@ -106,5 +106,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
StackFileContent: fileContent,
|
||||
Name: edgeStack.Name,
|
||||
Namespace: namespace,
|
||||
CreatedBy: edgeStack.CreatedBy,
|
||||
CreatedByUserId: edgeStack.CreatedByUserId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,13 +97,13 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
|
||||
firstConn := endpoint.LastCheckInDate == 0
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
handler.DataStore.Endpoint().UpdateHeartbeat(endpoint.ID)
|
||||
|
||||
if err := handler.requestBouncer.TrustedEdgeEnvironmentAccess(handler.DataStore, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
var statusResponse *endpointEdgeStatusInspectResponse
|
||||
@@ -113,11 +113,11 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
|
||||
}); err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
|
||||
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
|
||||
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
|
||||
}
|
||||
|
||||
return cacheResponse(w, endpoint.ID, *statusResponse)
|
||||
@@ -170,7 +170,7 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
|
||||
Credentials: tunnel.Credentials,
|
||||
}
|
||||
|
||||
schedules, handlerErr := handler.buildSchedules(tx, endpoint)
|
||||
schedules, handlerErr := handler.buildAllSchedules(tx, endpoint)
|
||||
if handlerErr != nil {
|
||||
return nil, handlerErr
|
||||
}
|
||||
@@ -208,14 +208,18 @@ func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
|
||||
schedules := []edgeJobResponse{}
|
||||
|
||||
func (handler *Handler) buildAllSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
|
||||
edgeJobs, err := tx.EdgeJob().ReadAll()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve Edge Jobs", err)
|
||||
}
|
||||
|
||||
return handler.buildSchedules(tx, endpoint, edgeJobs)
|
||||
}
|
||||
|
||||
func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob) ([]edgeJobResponse, *httperror.HandlerError) {
|
||||
schedules := []edgeJobResponse{}
|
||||
|
||||
endpointGroups, err := tx.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve endpoint groups", err)
|
||||
@@ -240,17 +244,10 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *po
|
||||
continue
|
||||
}
|
||||
|
||||
var collectLogs bool
|
||||
if _, ok := job.GroupLogsCollection[endpoint.ID]; ok {
|
||||
collectLogs = job.GroupLogsCollection[endpoint.ID].CollectLogs
|
||||
} else {
|
||||
collectLogs = job.Endpoints[endpoint.ID].CollectLogs
|
||||
}
|
||||
|
||||
schedule := edgeJobResponse{
|
||||
ID: job.ID,
|
||||
CronExpression: job.CronExpression,
|
||||
CollectLogs: collectLogs,
|
||||
CollectLogs: job.GroupLogsCollection[endpoint.ID].CollectLogs || job.Endpoints[endpoint.ID].CollectLogs,
|
||||
Version: job.Version,
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @success 200 {object} portainer.Endpoint "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Environment(Endpoint) not found"
|
||||
@@ -53,10 +52,9 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
|
||||
|
||||
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
if !excludeSnapshot {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, !excludeRaw); err != nil {
|
||||
if err := handler.SnapshotService.FillSnapshotData(endpoint, false); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ const (
|
||||
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
|
||||
// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)"
|
||||
// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved"
|
||||
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
|
||||
// @param name query string false "will return only environments(endpoints) with this name"
|
||||
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
|
||||
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
|
||||
@@ -63,7 +62,6 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
|
||||
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
|
||||
if err != nil {
|
||||
@@ -118,7 +116,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
|
||||
|
||||
if !query.excludeSnapshots {
|
||||
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], !excludeRaw); err != nil {
|
||||
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], false); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.35.0
|
||||
// @version 2.37.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @deprecated
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied to access settings"
|
||||
|
||||
@@ -63,6 +63,7 @@ func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @deprecated
|
||||
// @param body body openAMTConfigurePayload true "OpenAMT Settings"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @deprecated
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied to access settings"
|
||||
@@ -79,6 +80,7 @@ func (payload *deviceActionPayload) Validate(r *http.Request) error {
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @deprecated
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param deviceId path int true "Device identifier"
|
||||
// @param body body deviceActionPayload true "Device Action"
|
||||
@@ -141,6 +143,7 @@ type AuthorizationResponse struct {
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @deprecated
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param deviceId path int true "Device identifier"
|
||||
// @param body body deviceFeaturesPayload true "Device Features"
|
||||
|
||||
@@ -48,6 +48,7 @@ const (
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @deprecated
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied to access settings"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
@@ -26,6 +27,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
Router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
h.Use(middlewares.DeprecatedSimple)
|
||||
h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
|
||||
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
|
||||
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
|
||||
|
||||
@@ -22,8 +22,18 @@ import (
|
||||
|
||||
func hideFields(registry *portainer.Registry, hideAccesses bool) {
|
||||
registry.Password = ""
|
||||
registry.ManagementConfiguration = nil
|
||||
if registry.ManagementConfiguration != nil {
|
||||
// TLS and SkipTLSVerify should be retained since it's not sensitive information
|
||||
minimalManagementConfig := &portainer.RegistryManagementConfiguration{}
|
||||
minimalManagementConfig.TLSConfig = registry.ManagementConfiguration.TLSConfig
|
||||
registry.ManagementConfiguration = minimalManagementConfig
|
||||
}
|
||||
if hideAccesses {
|
||||
if registry.ManagementConfiguration != nil {
|
||||
registry.ManagementConfiguration.TLSConfig.TLSCACertPath = ""
|
||||
registry.ManagementConfiguration.TLSConfig.TLSCertPath = ""
|
||||
registry.ManagementConfiguration.TLSConfig.TLSKeyPath = ""
|
||||
}
|
||||
registry.RegistryAccesses = nil
|
||||
}
|
||||
}
|
||||
@@ -71,6 +81,7 @@ func (handler *Handler) initRouter(bouncer accessGuard) {
|
||||
// Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access
|
||||
authenticatedRouter := handler.NewRoute().Subrouter()
|
||||
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
|
||||
authenticatedRouter.Handle("/registries/ping", httperror.LoggerHandler(handler.pingRegistry)).Methods(http.MethodPost)
|
||||
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/portainer/portainer/api/pendingactions/handlers"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
@@ -80,7 +81,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", cli.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
180
api/http/handler/registries/registry_ping.go
Normal file
180
api/http/handler/registries/registry_ping.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/liboras"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"oras.land/oras-go/v2/registry/remote/errcode"
|
||||
)
|
||||
|
||||
type registryPingPayload struct {
|
||||
// Registry Type. Valid values are:
|
||||
// 1 (Quay.io),
|
||||
// 2 (Azure container registry),
|
||||
// 3 (custom registry),
|
||||
// 4 (Gitlab registry),
|
||||
// 5 (ProGet registry),
|
||||
// 6 (DockerHub)
|
||||
// 7 (ECR)
|
||||
// 8 (Github registry)
|
||||
Type portainer.RegistryType `example:"6" validate:"required" enums:"1,2,3,4,5,6,7,8"`
|
||||
// URL or IP address of the Docker registry
|
||||
URL string `example:"registry-1.docker.io" validate:"required"`
|
||||
// Username used to authenticate against this registry
|
||||
Username string `example:"registry_user"`
|
||||
// Password used to authenticate against this registry
|
||||
Password string `example:"registry_password"`
|
||||
// Use TLS
|
||||
TLS bool `example:"true"`
|
||||
}
|
||||
|
||||
type registryPingResponse struct {
|
||||
// Success indicates if the registry connection was successful
|
||||
Success bool `json:"success" example:"true"`
|
||||
// Message provides details about the connection test result
|
||||
Message string `json:"message" example:"Registry connection successful"`
|
||||
}
|
||||
|
||||
func (payload *registryPingPayload) Validate(_ *http.Request) error {
|
||||
if len(payload.Username) == 0 || len(payload.Password) == 0 {
|
||||
return httperror.BadRequest("Username and password are required", nil)
|
||||
}
|
||||
|
||||
switch payload.Type {
|
||||
case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry, portainer.EcrRegistry, portainer.GithubRegistry:
|
||||
default:
|
||||
return httperror.BadRequest("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry), 6 (DockerHub), 7 (ECR), 8 (Github registry)", nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id RegistryPing
|
||||
// @summary Test registry connection
|
||||
// @description Test connection to a registry with provided credentials
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags registries
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body registryPingPayload true "Registry credentials to test"
|
||||
// @success 200 {object} registryPingResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /registries/ping [post]
|
||||
func (handler *Handler) pingRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload registryPingPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
// Create a temporary registry configuration for testing
|
||||
tempRegistry := &portainer.Registry{
|
||||
Type: payload.Type,
|
||||
URL: payload.URL,
|
||||
Authentication: true,
|
||||
Username: payload.Username,
|
||||
Password: payload.Password,
|
||||
}
|
||||
|
||||
// For DockerHub, ensure URL is set correctly
|
||||
if payload.Type == portainer.DockerHubRegistry && payload.URL == "" {
|
||||
tempRegistry.URL = "registry-1.docker.io"
|
||||
}
|
||||
|
||||
// Set up TLS configuration
|
||||
if payload.Type == portainer.CustomRegistry {
|
||||
tempRegistry.ManagementConfiguration = &portainer.RegistryManagementConfiguration{
|
||||
Type: payload.Type,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: payload.TLS || fips.FIPSMode(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Test the registry connection
|
||||
success, message := handler.testRegistryConnection(tempRegistry)
|
||||
|
||||
responseData := registryPingResponse{
|
||||
Success: success,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
return response.JSON(w, responseData)
|
||||
}
|
||||
|
||||
// testRegistryConnection tests if we can connect to the registry
|
||||
func (handler *Handler) testRegistryConnection(registry *portainer.Registry) (bool, string) {
|
||||
registryClient, err := liboras.CreateClient(*registry)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("registryURL", registry.URL).Msg("Failed to create registry client")
|
||||
return false, "Connection error: Failed to create registry client - " + err.Error()
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err = registryClient.Ping(ctx)
|
||||
if err != nil {
|
||||
errorMessage := categorizeRegistryError(err, registry.URL)
|
||||
return false, errorMessage
|
||||
}
|
||||
|
||||
log.Debug().Str("registryURL", registry.URL).Msg("Registry ping successful")
|
||||
return true, "Registry connection successful"
|
||||
}
|
||||
|
||||
// categorizeRegistryError analyzes the error and returns a user-friendly message
|
||||
// that distinguishes between connection errors and authentication errors
|
||||
func categorizeRegistryError(err error, registryURL string) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var userMessage string
|
||||
|
||||
var errResp *errcode.ErrorResponse
|
||||
if errors.As(err, &errResp) {
|
||||
|
||||
// 401 Unauthorized or 403 Forbidden = authentication/authorization issue
|
||||
if errResp.StatusCode == http.StatusUnauthorized || errResp.StatusCode == http.StatusForbidden {
|
||||
userMessage = "Access token invalid: Authentication failed - please verify your username and access token"
|
||||
} else {
|
||||
userMessage = "Connection error: " + err.Error()
|
||||
}
|
||||
|
||||
logEvent := log.Error().
|
||||
Err(err).
|
||||
Str("registryURL", registryURL).
|
||||
Int("statusCode", errResp.StatusCode).
|
||||
Str("userMessage", userMessage)
|
||||
|
||||
if len(errResp.Errors) > 0 {
|
||||
logEvent.Interface("errors", errResp.Errors)
|
||||
}
|
||||
|
||||
logEvent.Msg("Registry ping failed")
|
||||
|
||||
return userMessage
|
||||
}
|
||||
|
||||
// Default: treat everything else as connection error
|
||||
userMessage = "Connection error: " + err.Error()
|
||||
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("registryURL", registryURL).
|
||||
Str("userMessage", userMessage).
|
||||
Msg("Registry ping failed")
|
||||
|
||||
return userMessage
|
||||
}
|
||||
334
api/http/handler/registries/registry_ping_test.go
Normal file
334
api/http/handler/registries/registry_ping_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"oras.land/oras-go/v2/registry/remote/errcode"
|
||||
)
|
||||
|
||||
func Test_categorizeRegistryError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
registryURL string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil error returns empty string",
|
||||
err: nil,
|
||||
registryURL: "registry.example.com",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "401 Unauthorized returns access token invalid message",
|
||||
err: &errcode.ErrorResponse{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
},
|
||||
registryURL: "registry-1.docker.io",
|
||||
want: "Access token invalid: Authentication failed - please verify your username and access token",
|
||||
},
|
||||
{
|
||||
name: "403 Forbidden returns access token invalid message",
|
||||
err: &errcode.ErrorResponse{
|
||||
StatusCode: http.StatusForbidden,
|
||||
},
|
||||
registryURL: "registry-1.docker.io",
|
||||
want: "Access token invalid: Authentication failed - please verify your username and access token",
|
||||
},
|
||||
{
|
||||
name: "500 Internal Server Error returns connection error",
|
||||
err: &errcode.ErrorResponse{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Method: "GET",
|
||||
URL: &url.URL{Scheme: "https", Host: "registry-1.docker.io", Path: "/v2/"},
|
||||
Errors: errcode.Errors{},
|
||||
},
|
||||
registryURL: "registry-1.docker.io",
|
||||
want: "Connection error: GET \"https://registry-1.docker.io/v2/\": response status code 500: Internal Server Error",
|
||||
},
|
||||
{
|
||||
name: "404 Not Found returns connection error",
|
||||
err: &errcode.ErrorResponse{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Method: "GET",
|
||||
URL: &url.URL{Scheme: "https", Host: "registry.example.com", Path: "/v2/"},
|
||||
Errors: errcode.Errors{},
|
||||
},
|
||||
registryURL: "registry.example.com",
|
||||
want: "Connection error: GET \"https://registry.example.com/v2/\": response status code 404: Not Found",
|
||||
},
|
||||
{
|
||||
name: "400 Bad Request with error details returns connection error with details",
|
||||
err: &errcode.ErrorResponse{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Method: "GET",
|
||||
URL: &url.URL{Scheme: "https", Host: "registry.example.com", Path: "/v2/"},
|
||||
Errors: errcode.Errors{
|
||||
{
|
||||
Code: errcode.ErrorCodeNameInvalid,
|
||||
Message: "invalid repository name",
|
||||
},
|
||||
},
|
||||
},
|
||||
registryURL: "registry.example.com",
|
||||
want: "Connection error: GET \"https://registry.example.com/v2/\": response status code 400: name invalid: invalid repository name",
|
||||
},
|
||||
{
|
||||
name: "non-errcode error returns connection error",
|
||||
err: errors.New("dial tcp: lookup registry.example.com: no such host"),
|
||||
registryURL: "registry.example.com",
|
||||
want: "Connection error: dial tcp: lookup registry.example.com: no such host",
|
||||
},
|
||||
{
|
||||
name: "network timeout error returns connection error",
|
||||
err: errors.New("context deadline exceeded"),
|
||||
registryURL: "registry.example.com",
|
||||
want: "Connection error: context deadline exceeded",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := categorizeRegistryError(tt.err, tt.registryURL)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_registryPingPayload_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload registryPingPayload
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid DockerHub payload",
|
||||
payload: registryPingPayload{
|
||||
Type: 6, // DockerHub
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid custom registry payload",
|
||||
payload: registryPingPayload{
|
||||
Type: 3, // Custom
|
||||
URL: "registry.example.com",
|
||||
Username: "admin",
|
||||
Password: "secret",
|
||||
TLS: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty username returns error",
|
||||
payload: registryPingPayload{
|
||||
Type: 6,
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "",
|
||||
Password: "testpass",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "Username and password are required",
|
||||
},
|
||||
{
|
||||
name: "empty password returns error",
|
||||
payload: registryPingPayload{
|
||||
Type: 6,
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "testuser",
|
||||
Password: "",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "Username and password are required",
|
||||
},
|
||||
{
|
||||
name: "invalid registry type returns error",
|
||||
payload: registryPingPayload{
|
||||
Type: 99, // Invalid type
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "Invalid registry type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.payload.Validate(nil)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_pingRegistry(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payload registryPingPayload
|
||||
wantStatusCode int
|
||||
wantSuccess bool
|
||||
checkResponse func(t *testing.T, resp registryPingResponse)
|
||||
}{
|
||||
{
|
||||
name: "invalid payload - empty username",
|
||||
payload: registryPingPayload{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "",
|
||||
Password: "testpass",
|
||||
},
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid payload - empty password",
|
||||
payload: registryPingPayload{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "testuser",
|
||||
Password: "",
|
||||
},
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid payload - invalid registry type",
|
||||
payload: registryPingPayload{
|
||||
Type: 99,
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
},
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "valid payload with invalid credentials returns 200 with success=false",
|
||||
payload: registryPingPayload{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
URL: "registry-1.docker.io",
|
||||
Username: "invalid-user",
|
||||
Password: "invalid-pass",
|
||||
},
|
||||
wantStatusCode: http.StatusOK,
|
||||
wantSuccess: false,
|
||||
checkResponse: func(t *testing.T, resp registryPingResponse) {
|
||||
assert.False(t, resp.Success)
|
||||
assert.Contains(t, resp.Message, "Access token invalid")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payloadBytes, err := json.Marshal(tt.payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/registries/ping", bytes.NewReader(payloadBytes))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Set up security context
|
||||
restrictedContext := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: 1,
|
||||
UserMemberships: []portainer.TeamMembership{},
|
||||
}
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handlerErr := handler.pingRegistry(w, r)
|
||||
|
||||
if tt.wantStatusCode != http.StatusOK {
|
||||
// For error cases, check the handler returns an error
|
||||
require.NotNil(t, handlerErr)
|
||||
assert.Equal(t, tt.wantStatusCode, handlerErr.StatusCode)
|
||||
} else {
|
||||
// For success cases (200), even if the ping failed
|
||||
require.Nil(t, handlerErr)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp registryPingResponse
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wantSuccess, resp.Success)
|
||||
|
||||
if tt.checkResponse != nil {
|
||||
tt.checkResponse(t, resp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_pingRegistry_DockerHubURL(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
|
||||
t.Run("empty URL for DockerHub gets default URL", func(t *testing.T) {
|
||||
payload := registryPingPayload{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
URL: "", // Empty URL
|
||||
Username: "testuser",
|
||||
Password: "testpass",
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/registries/ping", bytes.NewReader(payloadBytes))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
restrictedContext := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: 1,
|
||||
UserMemberships: []portainer.TeamMembership{},
|
||||
}
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
handlerErr := handler.pingRegistry(w, r)
|
||||
|
||||
// Should succeed (handler returns nil), but the ping itself will fail with auth error
|
||||
require.Nil(t, handlerErr)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp registryPingResponse
|
||||
err = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The ping will fail (invalid credentials), but that's expected
|
||||
// We're just testing that the URL defaulting logic works
|
||||
assert.False(t, resp.Success)
|
||||
assert.Contains(t, resp.Message, "Access token invalid")
|
||||
})
|
||||
}
|
||||
53
api/http/handler/stacks/stack_test_helper.go
Normal file
53
api/http/handler/stacks/stack_test_helper.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
func mockCreateUser(store *datastore.Store) (*portainer.User, error) {
|
||||
user := &portainer.User{ID: 1, Username: "testUser", Role: portainer.AdministratorRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err := store.User().Create(user)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func mockCreateEndpoint(store *datastore.Store) (*portainer.Endpoint, error) {
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
Name: "testEndpoint",
|
||||
SecuritySettings: portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowVolumeBrowserForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
AllowDeviceMappingForRegularUsers: true,
|
||||
AllowStackManagementForRegularUsers: true,
|
||||
AllowContainerCapabilitiesForRegularUsers: true,
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
EnableHostManagementFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
|
||||
return endpoint, err
|
||||
}
|
||||
|
||||
func mockCreateStackRequestWithSecurityContext(method, target string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method,
|
||||
target,
|
||||
body)
|
||||
|
||||
ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: portainer.UserID(1),
|
||||
})
|
||||
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
@@ -23,6 +24,10 @@ type updateComposeStackPayload struct {
|
||||
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"`
|
||||
// A list of environment(endpoint) variables used during stack deployment
|
||||
Env []portainer.Pair
|
||||
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
|
||||
RepullImageAndRedeploy bool
|
||||
|
||||
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
}
|
||||
@@ -42,6 +47,10 @@ type updateSwarmStackPayload struct {
|
||||
Env []portainer.Pair
|
||||
// Prune services that are no longer referenced (only available for Swarm stacks)
|
||||
Prune bool `example:"true"`
|
||||
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
|
||||
RepullImageAndRedeploy bool
|
||||
|
||||
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
}
|
||||
@@ -78,13 +87,6 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
|
||||
return httperror.BadRequest("Invalid stack identifier route variable", err)
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
|
||||
// The EndpointID property is not available for these stacks, this API endpoint
|
||||
// can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack.
|
||||
@@ -92,63 +94,84 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: endpointId", err)
|
||||
}
|
||||
if endpointID != int(stack.EndpointID) {
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
|
||||
var stack *portainer.Stack
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var httpErr *httperror.HandlerError
|
||||
stack, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID))
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return response.TxResponse(w, stack, err)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, *httperror.HandlerError) {
|
||||
stack, err := tx.Stack().Read(stackID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
|
||||
if endpointID != 0 && endpointID != stack.EndpointID {
|
||||
stack.EndpointID = endpointID
|
||||
}
|
||||
|
||||
endpoint, err := tx.Endpoint().Endpoint(stack.EndpointID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
|
||||
return nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
//only check resource control when it is a DockerSwarmStack or a DockerComposeStack
|
||||
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
|
||||
return nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
|
||||
}
|
||||
|
||||
if access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl); err != nil {
|
||||
return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
|
||||
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
|
||||
} else if !access {
|
||||
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||
return nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
|
||||
}
|
||||
}
|
||||
|
||||
if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil {
|
||||
return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
|
||||
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
|
||||
} else if !canManage {
|
||||
errMsg := "Stack editing is disabled for non-admin users"
|
||||
|
||||
return httperror.Forbidden(errMsg, errors.New(errMsg))
|
||||
return nil, httperror.Forbidden(errMsg, errors.New(errMsg))
|
||||
}
|
||||
|
||||
if err := handler.updateAndDeployStack(r, stack, endpoint); err != nil {
|
||||
return err
|
||||
if err := handler.updateAndDeployStack(tx, r, stack, endpoint); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().Read(securityContext.UserID)
|
||||
user, err := tx.User().Read(securityContext.UserID)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
|
||||
return nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
|
||||
}
|
||||
|
||||
stack.UpdatedBy = user.Username
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
|
||||
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
||||
if err := tx.Stack().Update(stack.ID, stack); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
@@ -156,19 +179,19 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
switch stack.Type {
|
||||
case portainer.DockerSwarmStack:
|
||||
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
return handler.updateSwarmStack(r, stack, endpoint)
|
||||
return handler.updateSwarmStack(tx, r, stack, endpoint)
|
||||
case portainer.DockerComposeStack:
|
||||
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
return handler.updateComposeStack(r, stack, endpoint)
|
||||
return handler.updateComposeStack(tx, r, stack, endpoint)
|
||||
case portainer.KubernetesStack:
|
||||
return handler.updateKubernetesStack(r, stack, endpoint)
|
||||
}
|
||||
@@ -176,7 +199,7 @@ func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.S
|
||||
return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type))
|
||||
}
|
||||
|
||||
func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
// Must not be git based stack. stop the auto update job if there is any
|
||||
if stack.AutoUpdate != nil {
|
||||
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
|
||||
@@ -191,6 +214,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
|
||||
stack.Env = payload.Env
|
||||
|
||||
if stack.GitConfig != nil {
|
||||
@@ -213,14 +237,13 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(securityContext,
|
||||
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfigTx(tx, securityContext,
|
||||
stack,
|
||||
endpoint,
|
||||
handler.DataStore,
|
||||
handler.FileService,
|
||||
handler.StackDeployer,
|
||||
payload.PullImage,
|
||||
false)
|
||||
payload.RepullImageAndRedeploy,
|
||||
payload.RepullImageAndRedeploy)
|
||||
if err != nil {
|
||||
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
|
||||
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
|
||||
@@ -243,7 +266,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
// Must not be git based stack. stop the auto update job if there is any
|
||||
if stack.AutoUpdate != nil {
|
||||
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
|
||||
@@ -257,7 +280,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
|
||||
stack.Env = payload.Env
|
||||
|
||||
if stack.GitConfig != nil {
|
||||
@@ -280,14 +303,13 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(securityContext,
|
||||
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfigTx(tx, securityContext,
|
||||
stack,
|
||||
endpoint,
|
||||
handler.DataStore,
|
||||
handler.FileService,
|
||||
handler.StackDeployer,
|
||||
payload.Prune,
|
||||
payload.PullImage)
|
||||
payload.RepullImageAndRedeploy)
|
||||
if err != nil {
|
||||
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
|
||||
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
|
||||
@@ -296,6 +318,14 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
if stack.Option != nil {
|
||||
stack.Option.Prune = payload.Prune
|
||||
} else {
|
||||
stack.Option = &portainer.StackOption{
|
||||
Prune: payload.Prune,
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy the stack
|
||||
if err := swarmDeploymentConfig.Deploy(); err != nil {
|
||||
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
|
||||
|
||||
@@ -27,10 +27,13 @@ type stackGitRedployPayload struct {
|
||||
RepositoryAuthorizationType gittypes.GitCredentialAuthType
|
||||
Env []portainer.Pair
|
||||
Prune bool
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
|
||||
RepullImageAndRedeploy bool
|
||||
|
||||
StackName string
|
||||
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
@@ -124,7 +127,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
stack.Env = payload.Env
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
@@ -168,7 +171,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
defer clean()
|
||||
|
||||
if err := handler.deployStack(r, stack, payload.PullImage, endpoint); err != nil {
|
||||
if err := handler.deployStack(r, stack, payload.RepullImageAndRedeploy, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
419
api/http/handler/stacks/stack_update_test.go
Normal file
419
api/http/handler/stacks/stack_update_test.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_updateStackInTx(t *testing.T) {
|
||||
t.Run("Transaction commits successfully - changes are persisted", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}},
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
|
||||
// Execute updateStackInTx within a successful transaction
|
||||
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err, "transction should succeed")
|
||||
|
||||
// Verify the stack was updated in the database (transaction committed)
|
||||
stackAfterCommit, err := setup.store.Stack().Read(setup.stack.ID)
|
||||
require.NoError(t, err, "should be able to read stack after commit")
|
||||
require.NotNil(t, stackAfterCommit)
|
||||
require.Equal(t, "BAR", stackAfterCommit.Env[0].Value, "stack env variable should be updated")
|
||||
})
|
||||
|
||||
t.Run("Transaction rollback on error - changes not persisted", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}},
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
|
||||
// Execute updateStackInTx within a transaction that we force to fail
|
||||
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
updatedStack, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// Verify changes are visible within the transaction
|
||||
assert.NotNil(t, updatedStack)
|
||||
assert.Equal(t, setup.user.Username, updatedStack.UpdatedBy)
|
||||
assert.NotZero(t, updatedStack.UpdateDate)
|
||||
|
||||
// Force the transaction to fail by returning an error
|
||||
return errors.New("forced transaction failure")
|
||||
})
|
||||
|
||||
// Verify the transaction failed
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "forced transaction failure")
|
||||
|
||||
// Verify the stack was NOT updated in the database (transaction rolled back)
|
||||
stackAfterRollback, err := setup.store.Stack().Read(setup.stack.ID)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, stackAfterRollback.Env, "stack env variable should remain unchanged after rollback")
|
||||
})
|
||||
|
||||
t.Run("Error: Stack not found returns NotFound httperror", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
setup.req.URL.Path = "/stacks/9999" // Non-existent stack ID
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, 9999, setup.endpoint.ID)
|
||||
return handlerErr
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound")
|
||||
assert.Contains(t, handlerErr.Message, "Unable to find a stack", "error message should mention stack")
|
||||
})
|
||||
|
||||
t.Run("Error: Endpoint not found returns NotFound httperror", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, 2999) // Non-existent endpoint ID
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound")
|
||||
assert.Contains(t, handlerErr.Message, "Unable to find the environment", "error message should mention environment")
|
||||
})
|
||||
|
||||
t.Run("Error: user cannot access the stack", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
originalUser, err := setup.store.User().Read(setup.user.ID)
|
||||
require.NoError(t, err, "error reading user")
|
||||
|
||||
// Modify the user's role to restrict access
|
||||
originalUser.Role = portainer.StandardUserRole
|
||||
err = setup.store.User().Update(originalUser.ID, originalUser)
|
||||
require.NoError(t, err, "error updating user role")
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode, "should return 403 Forbidden")
|
||||
assert.Contains(t, handlerErr.Message, "Access denied", "error message should mention access")
|
||||
})
|
||||
|
||||
t.Run("Error: user not found", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
err := setup.store.User().Delete(setup.user.ID) // Delete the user to simulate "user not found"
|
||||
require.NoError(t, err, "error deleting user")
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusInternalServerError, handlerErr.StatusCode, "should return 500 Internal Server Error")
|
||||
assert.Contains(t, handlerErr.Message, "Unable to verify user authorizations to validate stack access", "error message should mention user authorizations")
|
||||
})
|
||||
}
|
||||
|
||||
func TestStackUpdate(t *testing.T) {
|
||||
t.Helper()
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
testDataPath := filepath.Join(t.TempDir())
|
||||
fileService, err := filesystem.NewService(testDataPath, "")
|
||||
require.NoError(t, err, "error init file service")
|
||||
|
||||
// Create test user
|
||||
_, err = mockCreateUser(store)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
// Create test endpoint
|
||||
endpoint, err := mockCreateEndpoint(store)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
|
||||
// Create test stack
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
EndpointID: endpoint.ID,
|
||||
ProjectPath: fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", 1),
|
||||
Type: portainer.DockerSwarmStack,
|
||||
}
|
||||
|
||||
err = store.Stack().Create(stack)
|
||||
require.NoError(t, err, "error creating stack")
|
||||
|
||||
// Create resource control for the stack
|
||||
resourceControl := &portainer.ResourceControl{
|
||||
ID: portainer.ResourceControlID(stack.ID),
|
||||
ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name),
|
||||
Type: portainer.StackResourceControl,
|
||||
AdministratorsOnly: false,
|
||||
}
|
||||
err = store.ResourceControl().Create(resourceControl)
|
||||
require.NoError(t, err, "error creating resource control")
|
||||
|
||||
// Store initial stack file
|
||||
_, err = fileService.StoreStackFileFromBytes(
|
||||
strconv.Itoa(int(stack.ID)),
|
||||
stack.EntryPoint,
|
||||
[]byte("version: '3'\nservices:\n web:\n image: nginx:v1"),
|
||||
)
|
||||
require.NoError(t, err, "error storing stack file")
|
||||
|
||||
// Create handler
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
handler.FileService = fileService
|
||||
handler.StackDeployer = testStackDeployer{}
|
||||
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||
handler.SwarmStackManager = swarmStackManager{}
|
||||
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
// Create mock request with security context
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Endpoint is not provided in query param nor header", func(t *testing.T) {
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/%d", stack.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "expected status BadRequest when endpoint is not provided")
|
||||
})
|
||||
|
||||
t.Run("Stack doesn't exist", func(t *testing.T) {
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/test-stack-1?endpointId=%d", endpoint.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "expected status NotFound when stack doesn't exist")
|
||||
})
|
||||
|
||||
t.Run("Update stack successfully", func(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, "expected status OK when stack is updated successfully")
|
||||
var stackResponse portainer.Stack
|
||||
err = json.NewDecoder(rec.Body).Decode(&stackResponse)
|
||||
require.NoError(t, err, "error decoding response body")
|
||||
require.NotZero(t, stackResponse.UpdateDate, "stack update date should be set")
|
||||
})
|
||||
}
|
||||
|
||||
// setupUpdateStackInTxTest creates a fresh test environment for each subtest
|
||||
type updateStackInTxTestSetup struct {
|
||||
store *datastore.Store
|
||||
fileService portainer.FileService
|
||||
handler *Handler
|
||||
user *portainer.User
|
||||
endpoint *portainer.Endpoint
|
||||
stack *portainer.Stack
|
||||
resourceControl *portainer.ResourceControl
|
||||
jsonPayload []byte
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *updateComposeStackPayload) *updateStackInTxTestSetup {
|
||||
t.Helper()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
testDataPath := filepath.Join(t.TempDir())
|
||||
fileService, err := filesystem.NewService(testDataPath, "")
|
||||
require.NoError(t, err, "error init file service")
|
||||
|
||||
// Create test user
|
||||
user, err := mockCreateUser(store)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
// Create test endpoint
|
||||
endpoint, err := mockCreateEndpoint(store)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
|
||||
// Create test stack
|
||||
stack.EndpointID = endpoint.ID
|
||||
stack.ProjectPath = fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", stack.ID)
|
||||
|
||||
err = store.Stack().Create(stack)
|
||||
require.NoError(t, err, "error creating stack")
|
||||
|
||||
// Create resource control for the stack
|
||||
resourceControl := &portainer.ResourceControl{
|
||||
ID: portainer.ResourceControlID(stack.ID),
|
||||
ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name),
|
||||
Type: portainer.StackResourceControl,
|
||||
AdministratorsOnly: false,
|
||||
}
|
||||
err = store.ResourceControl().Create(resourceControl)
|
||||
require.NoError(t, err, "error creating resource control")
|
||||
|
||||
// Store initial stack file
|
||||
_, err = fileService.StoreStackFileFromBytes(
|
||||
strconv.Itoa(int(stack.ID)),
|
||||
stack.EntryPoint,
|
||||
[]byte("version: '3'\nservices:\n web:\n image: nginx:v1"),
|
||||
)
|
||||
require.NoError(t, err, "error storing stack file")
|
||||
|
||||
// Create handler
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
handler.FileService = fileService
|
||||
handler.StackDeployer = testStackDeployer{}
|
||||
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||
|
||||
// Create mock request with security context
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
return &updateStackInTxTestSetup{
|
||||
store: store,
|
||||
fileService: fileService,
|
||||
handler: handler,
|
||||
user: user,
|
||||
endpoint: endpoint,
|
||||
stack: stack,
|
||||
resourceControl: resourceControl,
|
||||
jsonPayload: jsonPayload,
|
||||
req: req,
|
||||
}
|
||||
}
|
||||
|
||||
type swarmStackManager struct {
|
||||
portainer.SwarmStackManager
|
||||
}
|
||||
|
||||
func (manager swarmStackManager) NormalizeStackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
type testStackDeployer struct {
|
||||
deployments.StackDeployer
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
|
||||
return nil
|
||||
}
|
||||
@@ -29,3 +29,12 @@ func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *h
|
||||
router.ServeHTTP(w, redirectedRequest)
|
||||
})
|
||||
}
|
||||
|
||||
// DeprecatedSimple is a middleware that marks an API route as deprecated
|
||||
//
|
||||
// if needed, use Deprecated with a custom urlBuilder
|
||||
func DeprecatedSimple(h http.Handler) http.Handler {
|
||||
return Deprecated(h, func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "", nil
|
||||
})
|
||||
}
|
||||
|
||||
316
api/http/middlewares/deprecated_test.go
Normal file
316
api/http/middlewares/deprecated_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeprecated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)
|
||||
requestPath string
|
||||
expectedStatusCode int
|
||||
expectedPath string
|
||||
expectRedirect bool
|
||||
}{
|
||||
{
|
||||
name: "empty URL - no redirect",
|
||||
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "", nil
|
||||
},
|
||||
requestPath: "/api/old",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedPath: "/api/old",
|
||||
expectRedirect: false,
|
||||
},
|
||||
{
|
||||
name: "new URL provided - redirects",
|
||||
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/api/new", nil
|
||||
},
|
||||
requestPath: "/api/old",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedPath: "/api/new",
|
||||
expectRedirect: true,
|
||||
},
|
||||
{
|
||||
name: "urlBuilder returns error - returns error response",
|
||||
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "", httperror.BadRequest("invalid request", nil)
|
||||
},
|
||||
requestPath: "/api/old",
|
||||
expectedStatusCode: http.StatusBadRequest,
|
||||
expectedPath: "",
|
||||
expectRedirect: false,
|
||||
},
|
||||
{
|
||||
name: "urlBuilder returns server error",
|
||||
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "", httperror.InternalServerError("server error", nil)
|
||||
},
|
||||
requestPath: "/api/old",
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
expectedPath: "",
|
||||
expectRedirect: false,
|
||||
},
|
||||
{
|
||||
name: "dynamic URL based on request path",
|
||||
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/v2" + r.URL.Path, nil
|
||||
},
|
||||
requestPath: "/api/resource/123",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedPath: "/v2/api/resource/123",
|
||||
expectRedirect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a test handler that records the request path
|
||||
var handledPath string
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handledPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("success"))
|
||||
})
|
||||
|
||||
// Wrap with Deprecated middleware
|
||||
wrappedHandler := Deprecated(testHandler, tt.urlBuilder)
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Execute request
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
// Check status code
|
||||
assert.Equal(t, tt.expectedStatusCode, rec.Code, "unexpected status code")
|
||||
|
||||
// For error cases, don't check the path
|
||||
if tt.expectedStatusCode >= 400 {
|
||||
return
|
||||
}
|
||||
|
||||
// Check that the correct path was handled
|
||||
if tt.expectRedirect {
|
||||
assert.Equal(t, tt.expectedPath, handledPath, "path was not redirected correctly")
|
||||
} else {
|
||||
assert.Equal(t, tt.requestPath, handledPath, "original path was not preserved")
|
||||
}
|
||||
|
||||
// Check response body for success cases
|
||||
body, err := io.ReadAll(rec.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "success", string(body), "unexpected response body")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeprecatedSimple(t *testing.T) {
|
||||
// Create a test handler
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test response"))
|
||||
})
|
||||
|
||||
// Wrap with DeprecatedSimple middleware
|
||||
wrappedHandler := DeprecatedSimple(testHandler)
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
// Execute request
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
// Check that request was successful
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "unexpected status code")
|
||||
|
||||
// Check response body
|
||||
body, err := io.ReadAll(rec.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test response", string(body), "unexpected response body")
|
||||
}
|
||||
|
||||
func TestDeprecated_PreservesRequestContext(t *testing.T) {
|
||||
// Test that the middleware preserves request context when redirecting
|
||||
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/new-path", nil
|
||||
}
|
||||
|
||||
var receivedRequest *http.Request
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedRequest = r
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := Deprecated(testHandler, urlBuilder)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/old-path", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
require.NotNil(t, receivedRequest, "request was not passed to handler")
|
||||
assert.Equal(t, req.Context(), receivedRequest.Context(), "request context was not preserved")
|
||||
}
|
||||
|
||||
func TestDeprecated_PreservesRequestMethod(t *testing.T) {
|
||||
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
|
||||
|
||||
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/new-path", nil
|
||||
}
|
||||
|
||||
for _, method := range methods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
var receivedMethod string
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedMethod = r.Method
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := Deprecated(testHandler, urlBuilder)
|
||||
|
||||
req := httptest.NewRequest(method, "/old-path", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, method, receivedMethod, "HTTP method was not preserved")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeprecated_PreservesRequestHeaders(t *testing.T) {
|
||||
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/new-path", nil
|
||||
}
|
||||
|
||||
var receivedHeaders http.Header
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedHeaders = r.Header
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := Deprecated(testHandler, urlBuilder)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/old-path", nil)
|
||||
req.Header.Set("Authorization", "Bearer token123")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, "Bearer token123", receivedHeaders.Get("Authorization"), "Authorization header was not preserved")
|
||||
assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type"), "Content-Type header was not preserved")
|
||||
}
|
||||
|
||||
func TestDeprecated_PreservesRequestBody(t *testing.T) {
|
||||
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/new-path", nil
|
||||
}
|
||||
|
||||
var receivedBody string
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := Deprecated(testHandler, urlBuilder)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/old-path", http.NoBody)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
// Body should be preserved (empty in this case since we used http.NoBody)
|
||||
assert.Empty(t, receivedBody, "expected empty body")
|
||||
}
|
||||
|
||||
func TestDeprecated_ErrorResponseFormat(t *testing.T) {
|
||||
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "", httperror.BadRequest("test error message", nil)
|
||||
}
|
||||
|
||||
handlerCalled := false
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlerCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := Deprecated(testHandler, urlBuilder)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
assert.False(t, handlerCalled, "handler should not be called when urlBuilder returns error")
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code, "unexpected status code")
|
||||
|
||||
// The httperror.WriteError function should have written the error response
|
||||
body, err := io.ReadAll(rec.Body)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, body, "expected error response body")
|
||||
}
|
||||
|
||||
func TestDeprecated_WithQueryParameters(t *testing.T) {
|
||||
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/api/v2/resource", nil
|
||||
}
|
||||
|
||||
var receivedQuery string
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedQuery = r.URL.RawQuery
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := Deprecated(testHandler, urlBuilder)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/resource?filter=active&sort=name", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, "filter=active&sort=name", receivedQuery, "query parameters were not preserved")
|
||||
}
|
||||
|
||||
func TestDeprecated_WithMultipleRedirects(t *testing.T) {
|
||||
// Test that multiple deprecated middleware can be chained
|
||||
urlBuilder1 := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/v2" + r.URL.Path, nil
|
||||
}
|
||||
|
||||
urlBuilder2 := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
return "/api" + r.URL.Path, nil
|
||||
}
|
||||
|
||||
var finalPath string
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
finalPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Chain two deprecated middlewares
|
||||
wrappedHandler := Deprecated(Deprecated(testHandler, urlBuilder2), urlBuilder1)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/old", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrappedHandler.ServeHTTP(rec, req)
|
||||
|
||||
// First middleware redirects to /v2/old
|
||||
// Second middleware redirects to /api/v2/old
|
||||
assert.Equal(t, "/api/v2/old", finalPath, "chained redirects did not work correctly")
|
||||
}
|
||||
@@ -12,7 +12,7 @@ type K8sApplication struct {
|
||||
Name string `json:"Name"`
|
||||
Image string `json:"Image"`
|
||||
Containers []interface{} `json:"Containers,omitempty"`
|
||||
Services []corev1.Service `json:"Services"`
|
||||
Services []corev1.Service `json:"Services" swaggerignore:"true"`
|
||||
CreationDate time.Time `json:"CreationDate"`
|
||||
ApplicationOwner string `json:"ApplicationOwner,omitempty"`
|
||||
StackName string `json:"StackName,omitempty"`
|
||||
@@ -38,8 +38,9 @@ type K8sApplication struct {
|
||||
Labels map[string]string `json:"Labels,omitempty"`
|
||||
Annotations map[string]string `json:"Annotations,omitempty"`
|
||||
Resource K8sApplicationResource `json:"Resource,omitempty"`
|
||||
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
|
||||
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty" swaggerignore:"true"`
|
||||
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
|
||||
StackKind string `json:"StackKind,omitempty"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
@@ -48,7 +49,9 @@ type Metadata struct {
|
||||
}
|
||||
|
||||
type CustomResourceMetadata struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Scope string `json:"scope"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Plural string `json:"plural"`
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
|
||||
// ProxyDockerRequest intercepts a Docker API request and apply logic based
|
||||
// on the requested operation.
|
||||
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
|
||||
// from : /v1.41/containers/{id}/json
|
||||
// from : /v1.44/containers/{id}/json
|
||||
// or : /containers/{id}/json
|
||||
// to : /containers/{id}/json
|
||||
unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -34,30 +33,30 @@ var allowedHeaders = map[string]struct{}{
|
||||
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
|
||||
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
|
||||
func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{Director: createDirector(target)}
|
||||
return &httputil.ReverseProxy{Rewrite: createRewriteFn(target)}
|
||||
}
|
||||
|
||||
func createDirector(target *url.URL) func(*http.Request) {
|
||||
func createRewriteFn(target *url.URL) func(*httputil.ProxyRequest) {
|
||||
targetQuery := target.RawQuery
|
||||
return func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
|
||||
req.Host = req.URL.Host
|
||||
if targetQuery == "" || req.URL.RawQuery == "" {
|
||||
req.URL.RawQuery = targetQuery + req.URL.RawQuery
|
||||
return func(proxyReq *httputil.ProxyRequest) {
|
||||
proxyReq.Out.URL.Scheme = target.Scheme
|
||||
proxyReq.Out.URL.Host = target.Host
|
||||
proxyReq.Out.URL.Path = singleJoiningSlash(target.Path, proxyReq.In.URL.Path)
|
||||
proxyReq.Out.Host = proxyReq.Out.URL.Host
|
||||
if targetQuery == "" || proxyReq.Out.URL.RawQuery == "" {
|
||||
proxyReq.Out.URL.RawQuery = targetQuery + proxyReq.Out.URL.RawQuery
|
||||
} else {
|
||||
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
|
||||
proxyReq.Out.URL.RawQuery = targetQuery + "&" + proxyReq.Out.URL.RawQuery
|
||||
}
|
||||
if _, ok := req.Header["User-Agent"]; !ok {
|
||||
if _, ok := proxyReq.Out.Header["User-Agent"]; !ok {
|
||||
// explicitly disable User-Agent so it's not set to default value
|
||||
req.Header.Set("User-Agent", "")
|
||||
proxyReq.Out.Header.Set("User-Agent", "")
|
||||
}
|
||||
|
||||
for k := range req.Header {
|
||||
for k := range proxyReq.Out.Header {
|
||||
if _, ok := allowedHeaders[k]; !ok {
|
||||
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
|
||||
delete(req.Header, k)
|
||||
delete(proxyReq.Out.Header, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
@@ -9,7 +11,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func Test_createDirector(t *testing.T) {
|
||||
func Test_createRewriteFn(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
target *url.URL
|
||||
@@ -143,10 +145,18 @@ func Test_createDirector(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
director := createDirector(tc.target)
|
||||
director(tc.req)
|
||||
rewriteFn := createRewriteFn(tc.target)
|
||||
proxyRequest := httputil.ProxyRequest{
|
||||
In: tc.req.Clone(context.Background()),
|
||||
Out: tc.req.Clone(context.Background()),
|
||||
}
|
||||
rewriteFn(&proxyRequest)
|
||||
|
||||
if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
|
||||
if diff := cmp.Diff(proxyRequest.In, tc.req, cmp.Comparer(compareRequests)); diff != "" {
|
||||
t.Fatalf("rewriteFn modified in request: \n%s", diff)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(proxyRequest.Out, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
|
||||
t.Fatalf("requests are different: \n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -535,7 +535,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
|
||||
}
|
||||
|
||||
if csp {
|
||||
w.Header().Set("Content-Security-Policy", "script-src 'self' https://cdn.matomo.cloud https://js.hsforms.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/")
|
||||
w.Header().Set("Content-Security-Policy", "script-src 'self' https://js.hsforms.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/")
|
||||
}
|
||||
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
11
api/internal/registryutils/get_registry_name.go
Normal file
11
api/internal/registryutils/get_registry_name.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package registryutils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func RegistrySecretName(registryID portainer.RegistryID) string {
|
||||
return "registry-" + strconv.Itoa(int(registryID))
|
||||
}
|
||||
12
api/internal/testhelpers/user_activity_service.go
Normal file
12
api/internal/testhelpers/user_activity_service.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package testhelpers
|
||||
|
||||
type userActivityService struct {
|
||||
}
|
||||
|
||||
func NewUserActivityService() *userActivityService {
|
||||
return &userActivityService{}
|
||||
}
|
||||
|
||||
func (service *userActivityService) LogUserActivity(username string, context string, action string, payload []byte) error {
|
||||
return nil
|
||||
}
|
||||
@@ -263,6 +263,7 @@ func populateApplicationFromDeployment(application *models.K8sApplication, deplo
|
||||
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.StackKind = deployment.Labels["io.portainer.kubernetes.application.stackKind"]
|
||||
application.Labels = deployment.Labels
|
||||
application.MatchLabels = deployment.Spec.Selector.MatchLabels
|
||||
application.CreationDate = deployment.CreationTimestamp.Time
|
||||
@@ -292,6 +293,7 @@ func populateApplicationFromStatefulSet(application *models.K8sApplication, stat
|
||||
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.StackKind = statefulSet.Labels["io.portainer.kubernetes.application.stackKind"]
|
||||
application.Labels = statefulSet.Labels
|
||||
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = statefulSet.CreationTimestamp.Time
|
||||
@@ -321,6 +323,7 @@ func populateApplicationFromDaemonSet(application *models.K8sApplication, daemon
|
||||
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.StackKind = daemonSet.Labels["io.portainer.kubernetes.application.stackKind"]
|
||||
application.Labels = daemonSet.Labels
|
||||
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
|
||||
application.CreationDate = daemonSet.CreationTimestamp.Time
|
||||
@@ -351,6 +354,7 @@ func populateApplicationFromPod(application *models.K8sApplication, pod corev1.P
|
||||
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
|
||||
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
|
||||
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
|
||||
application.StackKind = pod.Labels["io.portainer.kubernetes.application.stackKind"]
|
||||
application.Labels = pod.Labels
|
||||
application.MatchLabels = pod.Labels
|
||||
application.CreationDate = pod.CreationTimestamp.Time
|
||||
|
||||
@@ -2,7 +2,6 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -34,7 +33,7 @@ type (
|
||||
)
|
||||
|
||||
func (kcl *KubeClient) DeleteRegistrySecret(registry portainer.RegistryID, namespace string) error {
|
||||
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), kcl.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
|
||||
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), registryutils.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
|
||||
return errors.Wrap(err, "failed removing secret")
|
||||
}
|
||||
|
||||
@@ -62,11 +61,15 @@ func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namesp
|
||||
}
|
||||
|
||||
secret := &v1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{},
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "Secret",
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: kcl.RegistrySecretName(registry.ID),
|
||||
Name: registryutils.RegistrySecretName(registry.ID),
|
||||
Labels: map[string]string{
|
||||
labelRegistryType: strconv.Itoa(int(registry.Type)),
|
||||
labelRegistryType: strconv.Itoa(int(registry.Type)),
|
||||
"app.kubernetes.io/managed-by": "portainer",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
annotationRegistryID: strconv.Itoa(int(registry.ID)),
|
||||
@@ -99,7 +102,3 @@ func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, err
|
||||
|
||||
return isSecret, nil
|
||||
}
|
||||
|
||||
func (*KubeClient) RegistrySecretName(registryID portainer.RegistryID) string {
|
||||
return fmt.Sprintf("registry-%d", registryID)
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
labelPortainerAppStack = "io.portainer.kubernetes.application.stack"
|
||||
labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid"
|
||||
labelPortainerAppName = "io.portainer.kubernetes.application.name"
|
||||
labelPortainerAppOwner = "io.portainer.kubernetes.application.owner"
|
||||
labelPortainerAppKind = "io.portainer.kubernetes.application.kind"
|
||||
labelPortainerAppStack = "io.portainer.kubernetes.application.stack"
|
||||
labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid"
|
||||
labelPortainerAppName = "io.portainer.kubernetes.application.name"
|
||||
labelPortainerAppOwner = "io.portainer.kubernetes.application.owner"
|
||||
labelPortainerAppOwnerId = "io.portainer.kubernetes.application.owner.id"
|
||||
labelPortainerAppKind = "io.portainer.kubernetes.application.kind"
|
||||
labelPortainerAppStackKind = "io.portainer.kubernetes.application.stackKind"
|
||||
)
|
||||
|
||||
// KubeAppLabels are labels applied to all resources deployed in a kubernetes stack
|
||||
@@ -25,18 +27,28 @@ type KubeAppLabels struct {
|
||||
StackID int
|
||||
StackName string
|
||||
Owner string
|
||||
OwnerId string
|
||||
Kind string
|
||||
StackKind string
|
||||
}
|
||||
|
||||
// ToMap converts KubeAppLabels to a map[string]string
|
||||
func (kal *KubeAppLabels) ToMap() map[string]string {
|
||||
return map[string]string{
|
||||
labels := map[string]string{
|
||||
labelPortainerAppStackID: strconv.Itoa(kal.StackID),
|
||||
labelPortainerAppStack: stackutils.SanitizeLabel(kal.StackName),
|
||||
labelPortainerAppName: stackutils.SanitizeLabel(kal.StackName),
|
||||
labelPortainerAppOwner: stackutils.SanitizeLabel(kal.Owner),
|
||||
labelPortainerAppKind: kal.Kind,
|
||||
labelPortainerAppOwnerId: kal.OwnerId,
|
||||
}
|
||||
|
||||
// Add optional labels only if they are non-empty
|
||||
if kal.StackKind != "" {
|
||||
labels[labelPortainerAppStackKind] = kal.StackKind
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
// GetHelmAppLabels returns the labels to be applied to portainer deployed helm applications
|
||||
|
||||
@@ -40,6 +40,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
@@ -88,6 +89,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
@@ -177,6 +179,7 @@ items:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
@@ -198,6 +201,7 @@ items:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: redis
|
||||
@@ -221,6 +225,7 @@ items:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
@@ -303,6 +308,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
@@ -329,6 +335,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
@@ -348,6 +355,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
@@ -397,6 +405,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
@@ -619,6 +628,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
---
|
||||
@@ -630,6 +640,7 @@ metadata:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.owner.id: ""
|
||||
io.portainer.kubernetes.application.stack: best-name
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
`
|
||||
|
||||
100
api/portainer.go
100
api/portainer.go
@@ -29,6 +29,8 @@ type (
|
||||
AccessPolicy struct {
|
||||
// Role identifier. Reference the role that will be associated to this access policy
|
||||
RoleID RoleID `json:"RoleId" example:"1"`
|
||||
// Namespaces is a list of namespaces that this access policy applies to. Only used for namespaced level roles
|
||||
Namespaces []string `json:"Namespaces,omitempty"`
|
||||
}
|
||||
|
||||
// AgentPlatform represents a platform type for an Agent
|
||||
@@ -347,6 +349,10 @@ type (
|
||||
DeploymentType EdgeStackDeploymentType `json:"DeploymentType"`
|
||||
// Uses the manifest's namespaces instead of the default one
|
||||
UseManifestNamespaces bool
|
||||
// The username id which created this stack
|
||||
CreatedByUserId string `example:"1"`
|
||||
// The username which created this stack
|
||||
CreatedBy string `example:"admin"`
|
||||
}
|
||||
|
||||
EdgeStackStatusForEnv struct {
|
||||
@@ -354,6 +360,14 @@ type (
|
||||
Status []EdgeStackDeploymentStatus
|
||||
// EE only feature
|
||||
DeploymentInfo StackDeploymentInfo
|
||||
// RePullImage is a flag to indicate whether the auto update is trigger to re-pull image
|
||||
RePullImage bool `json:"RePullImage,omitempty"`
|
||||
// ForceRedeploy is a flag to indicate whether the force redeployment is set for the current
|
||||
// deployment of the edge stack. The redeployment could be triggered by GitOps Update or manually by user.
|
||||
ForceRedeploy bool `json:"ForceRedeploy,omitempty"`
|
||||
|
||||
// Deprecated(2.36): use ForceRedeploy and RePullImage instead for cleaner
|
||||
// responsibility, but keep it for backward compatibility. To remove in future versions (2.44+)
|
||||
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
|
||||
ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"`
|
||||
}
|
||||
@@ -524,6 +538,65 @@ type (
|
||||
Tags []string `json:"Tags,omitempty"`
|
||||
}
|
||||
|
||||
PolicyChartSummary struct {
|
||||
ChartName string `json:"ChartName"`
|
||||
Fingerprint string `json:"Fingerprint"`
|
||||
}
|
||||
|
||||
PolicyChartStatus struct {
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
ImageBundle struct {
|
||||
FileName string `json:"FileName"`
|
||||
EncodedTarGz string `json:"EncodedTarGz"`
|
||||
}
|
||||
|
||||
PolicyChartBundle struct {
|
||||
PolicyChartSummary
|
||||
EncodedTgz string `json:"EncodedTgz"`
|
||||
Namespace string `json:"Namespace"`
|
||||
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
||||
EncodedValues string `json:"EncodedValues"`
|
||||
PreInstallDeletions []ResourceDeletion `json:"PreInstallDeletions,omitempty"`
|
||||
PreInstallAdoptions []ResourceAdoption `json:"PreInstallAdoptions,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceDeletion identifies an existing Kubernetes resource to delete before policy install
|
||||
ResourceDeletion struct {
|
||||
APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"`
|
||||
Kind string `json:"kind" example:"Secret" yaml:"kind"`
|
||||
Name string `json:"name" example:"registry-1" yaml:"name"`
|
||||
Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceAdoption identifies an existing Kubernetes resource to adopt into a Helm release
|
||||
ResourceAdoption struct {
|
||||
APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"`
|
||||
Kind string `json:"kind" example:"Secret" yaml:"kind"`
|
||||
Name string `json:"name" example:"registry-1" yaml:"name"`
|
||||
Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"`
|
||||
}
|
||||
|
||||
// RestoreSettings contains instructions for restoring environment-level settings
|
||||
RestoreSettings struct {
|
||||
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
|
||||
}
|
||||
|
||||
// RestoreSettingsBundle maps restore type to restoration instructions
|
||||
RestoreSettingsBundle map[PolicyType]RestoreSettings
|
||||
|
||||
PolicyID int
|
||||
|
||||
// PolicyType represents the type of policy
|
||||
PolicyType string
|
||||
)
|
||||
|
||||
type (
|
||||
// EndpointGroupID represents an environment(endpoint) group identifier
|
||||
EndpointGroupID int
|
||||
|
||||
@@ -854,9 +927,11 @@ type (
|
||||
RegistryAccesses map[EndpointID]RegistryAccessPolicies
|
||||
|
||||
RegistryAccessPolicies struct {
|
||||
// Docker specific fields (with docker, users/teams have access to a registry)
|
||||
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
|
||||
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
|
||||
Namespaces []string `json:"Namespaces"`
|
||||
// Kubernetes specific fields (with kubernetes, namespaces have access to a registry, if users/teams have access to the same namespace, they have access to the registry)
|
||||
Namespaces []string `json:"Namespaces"`
|
||||
}
|
||||
|
||||
// RegistryID represents a registry identifier
|
||||
@@ -1782,7 +1857,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.35.0"
|
||||
APIVersion = "2.37.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
@@ -2355,3 +2430,24 @@ const (
|
||||
ContainerEngineDocker = "docker"
|
||||
ContainerEnginePodman = "podman"
|
||||
)
|
||||
|
||||
const (
|
||||
// PolicyType constants
|
||||
RbacK8s PolicyType = "rbac-k8s"
|
||||
SecurityK8s PolicyType = "security-k8s"
|
||||
SetupK8s PolicyType = "setup-k8s"
|
||||
RegistryK8s PolicyType = "registry-k8s"
|
||||
RbacDocker PolicyType = "rbac-docker"
|
||||
SecurityDocker PolicyType = "security-docker"
|
||||
SetupDocker PolicyType = "setup-docker"
|
||||
RegistryDocker PolicyType = "registry-docker"
|
||||
)
|
||||
|
||||
type HelmInstallStatus string
|
||||
|
||||
const (
|
||||
HelmInstallStatusInstalling HelmInstallStatus = "installing"
|
||||
HelmInstallStatusInstalled HelmInstallStatus = "installed"
|
||||
HelmInstallStatusFailed HelmInstallStatus = "failed"
|
||||
HelmInstallStatusUninstalling HelmInstallStatus = "uninstalling"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -18,136 +19,150 @@ func requireNoShutdownErr(t *testing.T, fn func() error) {
|
||||
}
|
||||
|
||||
func Test_ScheduledJobRuns(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
s := NewScheduler(t.Context())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
|
||||
|
||||
var workDone bool
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
var workDone bool
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
|
||||
cancel()
|
||||
cancel()
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
|
||||
<-ctx.Done()
|
||||
assert.True(t, workDone, "value should been set in the job")
|
||||
})
|
||||
|
||||
<-ctx.Done()
|
||||
assert.True(t, workDone, "value should been set in the job")
|
||||
}
|
||||
|
||||
func Test_JobCanBeStopped(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
s := NewScheduler(t.Context())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
|
||||
|
||||
var workDone bool
|
||||
jobID := s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
var workDone bool
|
||||
jobID := s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
|
||||
cancel()
|
||||
cancel()
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
|
||||
err := s.StopJob(jobID)
|
||||
require.NoError(t, err)
|
||||
|
||||
<-ctx.Done()
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
})
|
||||
|
||||
err := s.StopJob(jobID)
|
||||
require.NoError(t, err)
|
||||
|
||||
<-ctx.Done()
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
}
|
||||
|
||||
func Test_JobShouldStop_UponPermError(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
s := NewScheduler(t.Context())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
|
||||
var acc int
|
||||
var acc int
|
||||
|
||||
ch := make(chan struct{})
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
acc++
|
||||
close(ch)
|
||||
|
||||
return NewPermanentError(errors.New("failed"))
|
||||
})
|
||||
|
||||
<-time.After(3 * jobInterval)
|
||||
<-ch
|
||||
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
|
||||
}
|
||||
|
||||
func Test_JobShouldNotStop_UponError(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
|
||||
var acc atomic.Int64
|
||||
|
||||
ch := make(chan struct{})
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
if acc.Add(1) == 2 {
|
||||
ch := make(chan struct{})
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
acc++
|
||||
close(ch)
|
||||
|
||||
return NewPermanentError(errors.New("failed"))
|
||||
}
|
||||
})
|
||||
|
||||
return errors.New("non-permanent error")
|
||||
<-time.After(3 * jobInterval)
|
||||
<-ch
|
||||
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
|
||||
})
|
||||
}
|
||||
|
||||
<-time.After(3 * jobInterval)
|
||||
<-ch
|
||||
assert.Equal(t, int64(2), acc.Load())
|
||||
func Test_JobShouldNotStop_UponError(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
s := NewScheduler(t.Context())
|
||||
defer requireNoShutdownErr(t, s.Shutdown)
|
||||
|
||||
var acc atomic.Int64
|
||||
|
||||
ch := make(chan struct{})
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
if acc.Add(1) == 2 {
|
||||
close(ch)
|
||||
|
||||
return NewPermanentError(errors.New("failed"))
|
||||
}
|
||||
|
||||
return errors.New("non-permanent error")
|
||||
})
|
||||
|
||||
<-time.After(3 * jobInterval)
|
||||
<-ch
|
||||
assert.Equal(t, int64(2), acc.Load())
|
||||
})
|
||||
}
|
||||
|
||||
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
s := NewScheduler(t.Context())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
|
||||
|
||||
var workDone bool
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
cancel()
|
||||
var workDone bool
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
cancel()
|
||||
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
|
||||
requireNoShutdownErr(t, s.Shutdown)
|
||||
|
||||
<-ctx.Done()
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
})
|
||||
|
||||
requireNoShutdownErr(t, s.Shutdown)
|
||||
|
||||
<-ctx.Done()
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
}
|
||||
|
||||
func Test_CanTerminateAllJobs_ByCancellingParentContext(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
|
||||
s := NewScheduler(ctx)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
|
||||
s := NewScheduler(ctx)
|
||||
|
||||
var workDone bool
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
cancel()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
var workDone bool
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
workDone = true
|
||||
cancel()
|
||||
|
||||
return nil
|
||||
<-ctx.Done()
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
})
|
||||
|
||||
cancel()
|
||||
|
||||
<-ctx.Done()
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
}
|
||||
|
||||
func Test_StartJobEvery_Concurrently(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
|
||||
s := NewScheduler(ctx)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
|
||||
s := NewScheduler(ctx)
|
||||
|
||||
f := func() error {
|
||||
return errors.New("error")
|
||||
}
|
||||
f := func() error {
|
||||
return errors.New("error")
|
||||
}
|
||||
|
||||
go s.StartJobEvery(jobInterval, f)
|
||||
s.StartJobEvery(jobInterval, f)
|
||||
go s.StartJobEvery(jobInterval, f)
|
||||
s.StartJobEvery(jobInterval, f)
|
||||
|
||||
cancel()
|
||||
cancel()
|
||||
|
||||
<-ctx.Done()
|
||||
<-ctx.Done()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func redeployWhenChangedSecondStage(
|
||||
var gitCommitChangedOrForceUpdate bool
|
||||
|
||||
if !stack.FromAppTemplate {
|
||||
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stack.ID), stack.GitConfig, false, false, stack.ProjectPath)
|
||||
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stack.ID), stack.GitConfig, false, stack.ProjectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -131,6 +131,10 @@ func redeployWhenChangedSecondStage(
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
gitCommitChangedOrForceUpdate = updated
|
||||
}
|
||||
|
||||
if stack.AutoUpdate != nil && stack.AutoUpdate.ForceUpdate {
|
||||
gitCommitChangedOrForceUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if !gitCommitChangedOrForceUpdate {
|
||||
|
||||
@@ -55,12 +55,11 @@ func (d *stackDeployer) DeployRemoteComposeStack(
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.swarmStackManager.Login(registries, endpoint)
|
||||
defer d.swarmStackManager.Logout(endpoint)
|
||||
options := portainer.ComposeOptions{Registries: registries}
|
||||
|
||||
// --force-recreate doesn't pull updated images
|
||||
if forcePullImage {
|
||||
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, portainer.ComposeOptions{}); err != nil {
|
||||
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,19 @@ type ComposeStackDeploymentConfig struct {
|
||||
}
|
||||
|
||||
func CreateComposeStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
|
||||
user, err := dataStore.User().Read(securityContext.UserID)
|
||||
return CreateComposeStackDeploymentConfigTx(dataStore, securityContext, stack, endpoint, fileService, deployer, forcePullImage, forceCreate)
|
||||
}
|
||||
|
||||
// Alternate function that works within a transaction
|
||||
// We didn't update the original function to use a transaction because it would be a breaking change for many other files.
|
||||
// Let's do this only where necessary for now. This is also planed to be refactored in the future, but not prioritized right now.
|
||||
func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
|
||||
user, err := tx.User().Read(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load user information from the database: %w", err)
|
||||
}
|
||||
|
||||
registries, err := dataStore.Registry().ReadAll()
|
||||
registries, err := tx.Registry().ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err)
|
||||
}
|
||||
|
||||
@@ -24,12 +24,19 @@ type SwarmStackDeploymentConfig struct {
|
||||
}
|
||||
|
||||
func CreateSwarmStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, prune bool, pullImage bool) (*SwarmStackDeploymentConfig, error) {
|
||||
user, err := dataStore.User().Read(securityContext.UserID)
|
||||
return CreateSwarmStackDeploymentConfigTx(dataStore, securityContext, stack, endpoint, fileService, deployer, prune, pullImage)
|
||||
}
|
||||
|
||||
// Alternate function that works within a transaction
|
||||
// We didn't update the original function to use a transaction because it would be a breaking change for many other files.
|
||||
// Let's do this only where necessary for now. This is also planed to be refactored in the future, but not prioritized right now.
|
||||
func CreateSwarmStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, prune bool, pullImage bool) (*SwarmStackDeploymentConfig, error) {
|
||||
user, err := tx.User().Read(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load user information from the database: %w", err)
|
||||
}
|
||||
|
||||
registries, err := dataStore.Registry().ReadAll()
|
||||
registries, err := tx.Registry().ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
const categories = [
|
||||
'docker',
|
||||
'kubernetes',
|
||||
@@ -23,91 +21,5 @@ export interface TrackEventProps {
|
||||
dimensions?: DimensionConfig;
|
||||
}
|
||||
|
||||
export function setPortainerStatus(instanceID: string, version: string) {
|
||||
setCustomDimension(DimensionConfig.PortainerInstanceID, instanceID);
|
||||
setCustomDimension(DimensionConfig.PortainerVersion, version);
|
||||
}
|
||||
|
||||
export function setUserRole(role: string) {
|
||||
setCustomDimension(DimensionConfig.PortainerUserRole, role);
|
||||
}
|
||||
|
||||
export function clearUserRole() {
|
||||
deleteCustomDimension(DimensionConfig.PortainerUserRole);
|
||||
}
|
||||
|
||||
export function setUserEndpointRole(role: string) {
|
||||
setCustomDimension(DimensionConfig.PortainerEndpointUserRole, role);
|
||||
}
|
||||
|
||||
export function clearUserEndpointRole() {
|
||||
deleteCustomDimension(DimensionConfig.PortainerEndpointUserRole);
|
||||
}
|
||||
|
||||
function setCustomDimension(dimensionId: number, value: string) {
|
||||
push('setCustomDimension', dimensionId, value);
|
||||
}
|
||||
|
||||
function deleteCustomDimension(dimensionId: number) {
|
||||
push('deleteCustomDimension', dimensionId.toString());
|
||||
}
|
||||
|
||||
export function push(
|
||||
name: string,
|
||||
...args: (string | number | DimensionConfig)[]
|
||||
) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window._paq.push([name, ...args]);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackEvent(action: string, properties: TrackEventProps) {
|
||||
/**
|
||||
* @description Logs an event with an event category (Videos, Music, Games...), an event
|
||||
* action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...), and an optional
|
||||
* event name and optional numeric value.
|
||||
*
|
||||
* @link https://piwik.org/docs/event-tracking/
|
||||
* @link https://developer.piwik.org/api-reference/tracking-javascript#using-the-tracker-object
|
||||
*
|
||||
*/
|
||||
|
||||
let { value } = properties;
|
||||
const { metadata, dimensions, category } = properties;
|
||||
// PAQ requires that eventValue be an integer, see: http://piwik.org/docs/event-tracking
|
||||
if (value) {
|
||||
const parsed = parseInt(value.toString(), 10);
|
||||
value = Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
throw new Error('missing category');
|
||||
}
|
||||
|
||||
if (!categories.includes(category)) {
|
||||
throw new Error('unsupported category');
|
||||
}
|
||||
|
||||
let metadataString = '';
|
||||
if (metadata) {
|
||||
const kebabCasedMetadata = Object.fromEntries(
|
||||
Object.entries(metadata).map(([key, value]) => [_.kebabCase(key), value])
|
||||
);
|
||||
metadataString = JSON.stringify(kebabCasedMetadata).toLowerCase();
|
||||
}
|
||||
|
||||
push(
|
||||
'trackEvent',
|
||||
category,
|
||||
action.toLowerCase(),
|
||||
metadataString, // Changed in favour of Piwik documentation. Added fallback so it's backwards compatible.
|
||||
value || '',
|
||||
dimensions || <DimensionConfig>{}
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_paq: [string, ...(string | number)[]][];
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function trackEvent(action: string, properties: TrackEventProps) {}
|
||||
|
||||
@@ -1,107 +1,14 @@
|
||||
/* eslint-disable no-empty-function */
|
||||
import angular from 'angular';
|
||||
import { setPortainerStatus, setUserRole, clearUserRole, setUserEndpointRole, clearUserEndpointRole, push, trackEvent } from './analytics-services';
|
||||
const basePath = 'http://portainer-ce.app';
|
||||
|
||||
const excludedPaths = ['/auth'];
|
||||
|
||||
// forked from https://github.com/angulartics/angulartics-piwik/blob/master/src/angulartics-piwik.js
|
||||
|
||||
/**
|
||||
* @ngdoc overview
|
||||
* @name angulartics.piwik
|
||||
* Enables analytics support for Piwik/Matomo (http://piwik.org/docs/tracking-api/)
|
||||
*/
|
||||
export default angular.module('angulartics.matomo', ['angulartics']).config(config).name;
|
||||
export default angular.module('analytics-stub', []).service('$analytics', service).name;
|
||||
|
||||
/* @ngInject */
|
||||
function config($analyticsProvider, $windowProvider) {
|
||||
const $window = $windowProvider.$get();
|
||||
|
||||
$analyticsProvider.settings.pageTracking.trackRelativePath = true;
|
||||
|
||||
$analyticsProvider.api.setPortainerStatus = setPortainerStatus;
|
||||
|
||||
$analyticsProvider.api.setUserRole = setUserRole;
|
||||
$analyticsProvider.api.clearUserRole = clearUserRole;
|
||||
|
||||
$analyticsProvider.api.setUserEndpointRole = setUserEndpointRole;
|
||||
$analyticsProvider.api.clearUserEndpointRole = clearUserEndpointRole;
|
||||
|
||||
// scope: visit or page. Defaults to 'page'
|
||||
$analyticsProvider.api.setCustomVariable = function (varIndex, varName, value, scope = 'page') {
|
||||
push('setCustomVariable', varIndex, varName, value, scope);
|
||||
function service() {
|
||||
return {
|
||||
setOptOut() {},
|
||||
setPortainerStatus() {},
|
||||
setUserRole() {},
|
||||
eventTrack() {},
|
||||
};
|
||||
|
||||
// scope: visit or page. Defaults to 'page'
|
||||
$analyticsProvider.api.deleteCustomVariable = function (varIndex, scope = 'page') {
|
||||
push('deleteCustomVariable', varIndex, scope);
|
||||
};
|
||||
|
||||
// trackSiteSearch(keyword, category, [searchCount])
|
||||
$analyticsProvider.api.trackSiteSearch = function (keyword, category, searchCount) {
|
||||
// keyword is required
|
||||
if (keyword) {
|
||||
const params = ['trackSiteSearch', keyword, category || false];
|
||||
|
||||
// searchCount is optional
|
||||
if (angular.isDefined(searchCount)) {
|
||||
params.push(searchCount);
|
||||
}
|
||||
|
||||
push(params);
|
||||
}
|
||||
};
|
||||
|
||||
// logs a conversion for goal 1. revenue is optional
|
||||
// trackGoal(goalID, [revenue]);
|
||||
$analyticsProvider.api.trackGoal = function (goalID, revenue) {
|
||||
push('trackGoal', goalID, revenue || 0);
|
||||
};
|
||||
|
||||
// track outlink or download
|
||||
// linkType is 'link' or 'download', 'link' by default
|
||||
// trackLink(url, [linkType]);
|
||||
$analyticsProvider.api.trackLink = function (url, linkType) {
|
||||
const type = linkType || 'link';
|
||||
push('trackLink', url, type);
|
||||
};
|
||||
|
||||
// Set default angulartics page and event tracking
|
||||
|
||||
$analyticsProvider.registerSetUsername(function (username) {
|
||||
push('setUserId', username);
|
||||
});
|
||||
|
||||
// locationObj is the angular $location object
|
||||
$analyticsProvider.registerPageTrack(function (path) {
|
||||
if (excludedPaths.includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
push('setDocumentTitle', $window.document.title);
|
||||
push('setReferrerUrl', '');
|
||||
push('setCustomUrl', basePath + path);
|
||||
push('trackPageView');
|
||||
push('enableLinkTracking');
|
||||
});
|
||||
|
||||
/**
|
||||
* @name eventTrack
|
||||
* Track a basic event in Piwik, or send an ecommerce event.
|
||||
*
|
||||
* @param {string} action A string corresponding to the type of event that needs to be tracked.
|
||||
* @param {object} properties The properties that need to be logged with the event.
|
||||
*/
|
||||
$analyticsProvider.registerEventTrack(trackEvent);
|
||||
|
||||
/**
|
||||
* @name exceptionTrack
|
||||
* Sugar on top of the eventTrack method for easily handling errors
|
||||
*
|
||||
* @param {object} error An Error object to track: error.toString() used for event 'action', error.stack used for event 'label'.
|
||||
* @param {object} cause The cause of the error given from $exceptionHandler, not used.
|
||||
*/
|
||||
$analyticsProvider.registerExceptionTrack(function (error) {
|
||||
push('trackEvent', 'Exceptions', error.toString(), error.stack, 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ pr-icon {
|
||||
.icon {
|
||||
color: currentColor;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
|
||||
font-size: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
|
||||
@@ -117,9 +117,7 @@ div.input-mask {
|
||||
.widget .widget-body .error {
|
||||
color: #ff0000;
|
||||
}
|
||||
.widget .widget-body button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.widget .widget-body div.alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
url: '/configs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/configs/configs.html',
|
||||
controller: 'ConfigsController',
|
||||
controllerAs: 'ctrl',
|
||||
component: 'configsListView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<host-details-panel
|
||||
host="$ctrl.hostDetails"
|
||||
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && $ctrl.hostFeaturesEnabled"
|
||||
is-browse-enabled="$ctrl.isAdmin && $ctrl.isAgent && $ctrl.agentApiVersion > 1 && $ctrl.hostFeaturesEnabled"
|
||||
browse-url="{{ $ctrl.browseUrl }}"
|
||||
></host-details-panel>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ angular.module('portainer.docker').component('hostOverview', {
|
||||
refreshUrl: '@',
|
||||
browseUrl: '@',
|
||||
hostFeaturesEnabled: '<',
|
||||
isAdmin: '<',
|
||||
},
|
||||
transclude: true,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NodeStatus, TaskState } from 'docker-types/generated/1.41';
|
||||
import { NodeStatus, TaskState } from 'docker-types/generated/1.44';
|
||||
import _ from 'lodash';
|
||||
|
||||
export function trimVersionTag(fullName: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Config } from 'docker-types/generated/1.41';
|
||||
import { Config } from 'docker-types/generated/1.44';
|
||||
|
||||
import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn';
|
||||
import { PortainerResponse } from '@/react/docker/types';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageSummary } from 'docker-types/generated/1.41';
|
||||
import { ImageSummary } from 'docker-types/generated/1.44';
|
||||
|
||||
import { PortainerResponse } from '@/react/docker/types';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageInspect } from 'docker-types/generated/1.41';
|
||||
import { ImageInspect } from 'docker-types/generated/1.44';
|
||||
|
||||
type ImageInspectConfig = NonNullable<ImageInspect['Config']>;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IPAM, Network, NetworkContainer } from 'docker-types/generated/1.41';
|
||||
import { IPAM, Network, NetworkContainer } from 'docker-types/generated/1.44';
|
||||
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ObjectVersion,
|
||||
Platform,
|
||||
ResourceObject,
|
||||
} from 'docker-types/generated/1.41';
|
||||
} from 'docker-types/generated/1.44';
|
||||
|
||||
export class NodeViewModel {
|
||||
Model: Node;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Secret } from 'docker-types/generated/1.41';
|
||||
import { Secret } from 'docker-types/generated/1.44';
|
||||
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { PortainerResponse } from '@/react/docker/types';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Service,
|
||||
ServiceSpec,
|
||||
TaskSpec,
|
||||
} from 'docker-types/generated/1.41';
|
||||
} from 'docker-types/generated/1.44';
|
||||
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { PortainerResponse } from '@/react/docker/types';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Task } from 'docker-types/generated/1.41';
|
||||
import { Task } from 'docker-types/generated/1.44';
|
||||
|
||||
import { DeepPick } from '@/types/deepPick';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Volume } from 'docker-types/generated/1.41';
|
||||
import { Volume } from 'docker-types/generated/1.44';
|
||||
|
||||
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn';
|
||||
|
||||
@@ -13,7 +13,6 @@ import { InsightsBox } from '@/react/components/InsightsBox';
|
||||
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
|
||||
import { ImagesDatatable } from '@/react/docker/images/ListView/ImagesDatatable/ImagesDatatable';
|
||||
import { EventsDatatable } from '@/react/docker/events/EventsDatatables';
|
||||
import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatable';
|
||||
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
|
||||
import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser';
|
||||
import { ProcessesDatatable } from '@/react/docker/containers/StatsView/ProcessesDatatable';
|
||||
@@ -79,14 +78,6 @@ const ngModule = angular
|
||||
'onRemove',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'dockerConfigsDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
|
||||
'dataset',
|
||||
'onRemoveClick',
|
||||
'onRefresh',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'agentHostBrowserReact',
|
||||
r2a(withUIRouter(withCurrentUser(AgentHostBrowser)), [
|
||||
|
||||
14
app/docker/react/views/configs.ts
Normal file
14
app/docker/react/views/configs.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { ListView } from '@/react/docker/configs/ListView/ListView';
|
||||
|
||||
export const configsModule = angular
|
||||
.module('portainer.docker.react.views.configs', [])
|
||||
.component(
|
||||
'configsListView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
|
||||
).name;
|
||||
@@ -8,9 +8,10 @@ import { DashboardView } from '@/react/docker/DashboardView/DashboardView';
|
||||
import { ListView } from '@/react/docker/events/ListView';
|
||||
|
||||
import { containersModule } from './containers';
|
||||
import { configsModule } from './configs';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.docker.react.views', [containersModule])
|
||||
.module('portainer.docker.react.views', [containersModule, configsModule])
|
||||
.component(
|
||||
'dockerDashboardView',
|
||||
r2a(withUIRouter(withCurrentUser(DashboardView)), [])
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getConfigs } from '@/react/docker/configs/queries/useConfigs';
|
||||
|
||||
import { deleteConfig } from '@/react/docker/configs/queries/useDeleteConfigMutation';
|
||||
import { createConfig } from '@/react/docker/configs/queries/useCreateConfigMutation';
|
||||
import { ConfigViewModel } from '../models/config';
|
||||
import { ConfigViewModel } from '@/react/docker/configs/model';
|
||||
|
||||
angular.module('portainer.docker').factory('ConfigService', ConfigServiceFactory);
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<page-header title="'Configs list'" breadcrumbs="['Configs']" reload="true"> </page-header>
|
||||
|
||||
<docker-configs-datatable dataset="ctrl.configs" on-remove-click="(ctrl.removeAction)" on-refresh="(ctrl.getConfigs)"></docker-configs-datatable>
|
||||
@@ -1,59 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
class ConfigsController {
|
||||
/* @ngInject */
|
||||
constructor($state, ConfigService, Notifications, $async, endpoint) {
|
||||
this.$state = $state;
|
||||
this.ConfigService = ConfigService;
|
||||
this.Notifications = Notifications;
|
||||
this.$async = $async;
|
||||
this.endpoint = endpoint;
|
||||
|
||||
this.removeAction = this.removeAction.bind(this);
|
||||
this.removeActionAsync = this.removeActionAsync.bind(this);
|
||||
this.getConfigs = this.getConfigs.bind(this);
|
||||
this.getConfigsAsync = this.getConfigsAsync.bind(this);
|
||||
}
|
||||
|
||||
getConfigs() {
|
||||
return this.$async(this.getConfigsAsync);
|
||||
}
|
||||
|
||||
async getConfigsAsync() {
|
||||
try {
|
||||
this.configs = await this.ConfigService.configs(this.endpoint.Id);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve configs');
|
||||
}
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
this.configs = [];
|
||||
this.getConfigs();
|
||||
}
|
||||
|
||||
async removeAction(selectedItems) {
|
||||
return this.$async(this.removeActionAsync, selectedItems);
|
||||
}
|
||||
|
||||
async removeActionAsync(selectedItems) {
|
||||
let actionCount = selectedItems.length;
|
||||
for (const config of selectedItems) {
|
||||
try {
|
||||
await this.ConfigService.remove(this.endpoint.Id, config.Id);
|
||||
this.Notifications.success('Config successfully removed', config.Name);
|
||||
const index = this.configs.indexOf(config);
|
||||
this.configs.splice(index, 1);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to remove config');
|
||||
} finally {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
this.$state.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default ConfigsController;
|
||||
angular.module('portainer.docker').controller('ConfigsController', ConfigsController);
|
||||
@@ -8,4 +8,5 @@
|
||||
refresh-url="docker.host"
|
||||
browse-url="docker.host.browser"
|
||||
host-features-enabled="$ctrl.state.enableHostManagementFeatures"
|
||||
is-admin="$ctrl.state.isAdmin"
|
||||
></host-overview>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
refresh-url="docker.nodes.node"
|
||||
browse-url="docker.nodes.node.browse"
|
||||
host-features-enabled="$ctrl.state.enableHostManagementFeatures"
|
||||
is-admin="$ctrl.state.isAdmin"
|
||||
>
|
||||
<swarm-node-details-panel details="$ctrl.nodeDetails" original-node="$ctrl.originalNode"></swarm-node-details-panel>
|
||||
</host-overview>
|
||||
|
||||
@@ -720,7 +720,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
|
||||
$scope.onResetPorts = function (all = false) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.formValues.ports = portsMappingUtils.toViewModel($scope.service.Model.Spec.EndpointSpec.Ports);
|
||||
$scope.formValues.ports = portsMappingUtils.toViewModel($scope.service.Model.Spec.EndpointSpec?.Ports);
|
||||
|
||||
$scope.cancelChanges($scope.service, all ? undefined : ['Ports']);
|
||||
});
|
||||
@@ -744,7 +744,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
$scope.lastVersion = service.Version;
|
||||
}
|
||||
|
||||
$scope.formValues.ports = portsMappingUtils.toViewModel(service.Model.Spec.EndpointSpec.Ports);
|
||||
$scope.formValues.ports = portsMappingUtils.toViewModel(service.Model.Spec.EndpointSpec?.Ports);
|
||||
|
||||
transformResources(service);
|
||||
translateServiceArrays(service);
|
||||
|
||||
@@ -5,8 +5,6 @@ import './i18n';
|
||||
import angular from 'angular';
|
||||
import { UI_ROUTER_REACT_HYBRID } from '@uirouter/react-hybrid';
|
||||
|
||||
import './matomo-setup';
|
||||
|
||||
import { Edition } from '@/react/portainer/feature-flags/enums';
|
||||
import { init as initFeatureService } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
@@ -58,7 +56,6 @@ angular
|
||||
'portainer.edge',
|
||||
'rzModule',
|
||||
'moment-picker',
|
||||
'angulartics',
|
||||
analyticsModule,
|
||||
constantsModule,
|
||||
])
|
||||
|
||||
@@ -482,7 +482,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
yaml: '',
|
||||
},
|
||||
data: {
|
||||
docs: '/user/kubernetes/applications/manifest',
|
||||
docs: '/user/kubernetes/applications/manifest/helm',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const _paq = (window._paq = window._paq || []);
|
||||
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
|
||||
|
||||
var u = 'https://portainer-ce.matomo.cloud/';
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
||||
_paq.push(['setSiteId', '1']);
|
||||
var d = document,
|
||||
g = d.createElement('script'),
|
||||
s = d.getElementsByTagName('script')[0];
|
||||
g.type = 'text/javascript';
|
||||
g.async = true;
|
||||
g.src = '//cdn.matomo.cloud/portainer-ce.matomo.cloud/matomo.js';
|
||||
s.parentNode.insertBefore(g, s);
|
||||
@@ -1,100 +0,0 @@
|
||||
<form class="form-horizontal" name="$ctrl.registryFormDockerhub" ng-submit="$ctrl.formAction()">
|
||||
<div class="col-sm-12 form-section-title"> Important notice </div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<p>
|
||||
For information on how to generate a DockerHub Access Token, follow the
|
||||
<a href="https://docs.docker.com/docker-hub/access-tokens/" target="_blank">dockerhub guide</a>.
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title"> DockerHub account details </div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="registry_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="registry_name"
|
||||
name="registry_name"
|
||||
ng-model="$ctrl.model.Name"
|
||||
placeholder="dockerhub-prod-us"
|
||||
required
|
||||
data-cy="component-registryName"
|
||||
/>
|
||||
<div class="help-block" ng-show="$ctrl.registryFormDockerhub.registry_name.$invalid">
|
||||
<div class="small text-warning">
|
||||
<div ng-messages="$ctrl.registryFormDockerhub.registry_name.$error">
|
||||
<p ng-message="required" class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'"></pr-icon>
|
||||
This field is required.
|
||||
</p>
|
||||
<p ng-message="used" class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'"></pr-icon>
|
||||
A registry with the same name already exists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- credentials-user -->
|
||||
<div class="form-group">
|
||||
<label for="registry_username" class="col-sm-3 col-lg-2 control-label required text-left">DockerHub username</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_username" name="registry_username" ng-model="$ctrl.model.Username" required data-cy="component-registryUsername" />
|
||||
<div class="help-block" ng-show="$ctrl.registryFormDockerhub.registry_username.$invalid">
|
||||
<div class="small text-warning">
|
||||
<div ng-messages="$ctrl.registryFormDockerhub.registry_username.$error">
|
||||
<p ng-message="required" class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'"></pr-icon>
|
||||
This field is required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-user -->
|
||||
<!-- credentials-password -->
|
||||
<div class="form-group">
|
||||
<label for="registry_password" class="col-sm-3 col-lg-2 control-label required text-left">DockerHub access token</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="password" class="form-control" id="registry_password" name="registry_password" ng-model="$ctrl.model.Password" required />
|
||||
<div class="help-block" ng-show="$ctrl.registryFormDockerhub.registry_password.$invalid">
|
||||
<div class="small text-warning">
|
||||
<div ng-messages="$ctrl.registryFormDockerhub.registry_password.$error">
|
||||
<p ng-message="required" class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'"></pr-icon>
|
||||
This field is required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="$ctrl.actionInProgress || !$ctrl.registryFormDockerhub.$valid"
|
||||
button-spinner="$ctrl.actionInProgress"
|
||||
analytics-on
|
||||
analytics-category="portainer"
|
||||
analytics-event="portainer-registry-creation"
|
||||
analytics-properties="{ metadata: { type: 'dockerhub' } }"
|
||||
>
|
||||
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
|
||||
<span ng-show="$ctrl.actionInProgress">In progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
@@ -1,17 +0,0 @@
|
||||
class controller {
|
||||
$postLink() {
|
||||
this.registryFormDockerhub.registry_name.$validators.used = (modelValue) => !this.nameIsUsed(modelValue);
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('portainer.app').component('registryFormDockerhub', {
|
||||
templateUrl: './registry-form-dockerhub.html',
|
||||
bindings: {
|
||||
model: '=',
|
||||
formAction: '<',
|
||||
formActionLabel: '@',
|
||||
actionInProgress: '<',
|
||||
nameIsUsed: '<',
|
||||
},
|
||||
controller,
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import controller from './stack-redeploy-git-form.controller.js';
|
||||
|
||||
export const stackRedeployGitForm = {
|
||||
templateUrl: './stack-redeploy-git-form.html',
|
||||
controller,
|
||||
bindings: {
|
||||
model: '<',
|
||||
stack: '<',
|
||||
endpoint: '<',
|
||||
},
|
||||
};
|
||||
|
||||
angular.module('portainer.app').component('stackRedeployGitForm', stackRedeployGitForm);
|
||||
@@ -1,240 +0,0 @@
|
||||
import { RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
|
||||
|
||||
import { parseAutoUpdateResponse } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||
import { confirmEnableTLSVerify } from '@/react/portainer/gitops/utils';
|
||||
|
||||
class StackRedeployGitFormController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $compile, $scope, StackService, Notifications, FormHelper) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$compile = $compile;
|
||||
this.$scope = $scope;
|
||||
this.StackService = StackService;
|
||||
this.Notifications = Notifications;
|
||||
this.FormHelper = FormHelper;
|
||||
$scope.stackPullImageFeature = FeatureId.STACK_PULL_IMAGE;
|
||||
this.state = {
|
||||
inProgress: false,
|
||||
redeployInProgress: false,
|
||||
showConfig: false,
|
||||
isEdit: false,
|
||||
|
||||
// isAuthEdit is used to preserve the editing state of the AuthFieldset component.
|
||||
// Within the stack editing page, users have the option to turn the AuthFieldset on or off
|
||||
// and save the stack setting. If the user enables the AuthFieldset, it implies that they
|
||||
// must input new Git authentication, rather than edit existing authentication. Thus,
|
||||
// a dedicated state tracker is required to differentiate between the editing state of
|
||||
// AuthFieldset component and the whole stack
|
||||
// When isAuthEdit is true, PAT field needs to be validated.
|
||||
isAuthEdit: false,
|
||||
hasUnsavedChanges: false,
|
||||
baseWebhookUrl: baseStackWebhookUrl(),
|
||||
webhookId: createWebhookId(),
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
RefName: '',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
Env: [],
|
||||
PullImage: false,
|
||||
Option: {
|
||||
Prune: false,
|
||||
},
|
||||
// auto update
|
||||
AutoUpdate: parseAutoUpdateResponse(),
|
||||
};
|
||||
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onChangeRef = this.onChangeRef.bind(this);
|
||||
this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this);
|
||||
this.onChangeEnvVar = this.onChangeEnvVar.bind(this);
|
||||
this.onChangeOption = this.onChangeOption.bind(this);
|
||||
this.onChangeGitAuth = this.onChangeGitAuth.bind(this);
|
||||
this.onChangeTLSSkipVerify = this.onChangeTLSSkipVerify.bind(this);
|
||||
}
|
||||
|
||||
buildAnalyticsProperties() {
|
||||
const metadata = {};
|
||||
|
||||
if (this.formValues.RepositoryAutomaticUpdates) {
|
||||
metadata.automaticUpdates = autoSyncLabel(this.formValues.RepositoryMechanism);
|
||||
}
|
||||
return { metadata };
|
||||
|
||||
function autoSyncLabel(type) {
|
||||
switch (type) {
|
||||
case RepositoryMechanismTypes.INTERVAL:
|
||||
return 'polling';
|
||||
case RepositoryMechanismTypes.WEBHOOK:
|
||||
return 'webhook';
|
||||
}
|
||||
return 'off';
|
||||
}
|
||||
}
|
||||
|
||||
onChange(values) {
|
||||
return this.$async(async () => {
|
||||
this.formValues = {
|
||||
...this.formValues,
|
||||
...values,
|
||||
};
|
||||
this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues);
|
||||
});
|
||||
}
|
||||
|
||||
onChangeRef(value) {
|
||||
this.onChange({ RefName: value });
|
||||
}
|
||||
|
||||
onChangeEnvVar(value) {
|
||||
this.onChange({ Env: value });
|
||||
}
|
||||
|
||||
async onChangeTLSSkipVerify(value) {
|
||||
return this.$async(async () => {
|
||||
if (this.model.TLSSkipVerify && !value) {
|
||||
const confirmed = await confirmEnableTLSVerify();
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.onChange({ TLSSkipVerify: value });
|
||||
});
|
||||
}
|
||||
|
||||
onChangeOption(values) {
|
||||
this.onChange({
|
||||
Option: {
|
||||
...this.formValues.Option,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const isSwarmStack = this.stack.Type === 1;
|
||||
const that = this;
|
||||
confirmStackUpdate(
|
||||
'Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption. Do you wish to continue?',
|
||||
isSwarmStack
|
||||
).then(async function (result) {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
that.state.redeployInProgress = true;
|
||||
await that.StackService.updateGit(
|
||||
that.stack.Id,
|
||||
that.stack.EndpointId,
|
||||
that.FormHelper.removeInvalidEnvVars(that.formValues.Env),
|
||||
that.formValues.Option.Prune,
|
||||
that.formValues,
|
||||
result.pullImage
|
||||
);
|
||||
|
||||
that.Notifications.success('Success', 'Pulled and redeployed stack successfully');
|
||||
that.$state.reload();
|
||||
} catch (err) {
|
||||
that.Notifications.error('Failure', err, 'Failed redeploying stack');
|
||||
} finally {
|
||||
that.state.redeployInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveGitSettings() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.inProgress = true;
|
||||
const stack = await this.StackService.updateGitStackSettings(
|
||||
this.stack.Id,
|
||||
this.stack.EndpointId,
|
||||
this.FormHelper.removeInvalidEnvVars(this.formValues.Env),
|
||||
this.formValues,
|
||||
this.state.webhookId
|
||||
);
|
||||
this.savedFormValues = angular.copy(this.formValues);
|
||||
this.state.hasUnsavedChanges = false;
|
||||
this.Notifications.success('Success', 'Save stack settings successfully');
|
||||
|
||||
if (!(this.stack.GitConfig && this.stack.GitConfig.Authentication)) {
|
||||
// update the AuthFieldset setting
|
||||
this.state.isAuthEdit = false;
|
||||
this.formValues.RepositoryUsername = '';
|
||||
this.formValues.RepositoryPassword = '';
|
||||
}
|
||||
this.stack = stack;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to save stack settings');
|
||||
} finally {
|
||||
this.state.inProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disablePullAndRedeployButton() {
|
||||
return this.isSubmitButtonDisabled() || this.state.hasUnsavedChanges || !this.redeployGitForm.$valid;
|
||||
}
|
||||
|
||||
disableSaveSettingsButton() {
|
||||
return this.isSubmitButtonDisabled() || !this.state.hasUnsavedChanges || !this.redeployGitForm.$valid;
|
||||
}
|
||||
|
||||
isSubmitButtonDisabled() {
|
||||
return this.state.inProgress || this.state.redeployInProgress;
|
||||
}
|
||||
|
||||
isAutoUpdateChanged() {
|
||||
const wasEnabled = !!(this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook));
|
||||
const isEnabled = this.formValues.AutoUpdate.RepositoryAutomaticUpdates;
|
||||
return isEnabled !== wasEnabled;
|
||||
}
|
||||
|
||||
onChangeGitAuth(values) {
|
||||
this.onChange(values);
|
||||
}
|
||||
|
||||
onChangeAutoUpdate(values) {
|
||||
this.onChange({
|
||||
AutoUpdate: {
|
||||
...this.formValues.AutoUpdate,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
this.formValues.RefName = this.model.ReferenceName;
|
||||
this.formValues.TLSSkipVerify = this.model.TLSSkipVerify;
|
||||
this.formValues.Env = this.stack.Env;
|
||||
|
||||
if (this.stack.Option) {
|
||||
this.formValues.Option = this.stack.Option;
|
||||
}
|
||||
|
||||
this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate);
|
||||
|
||||
if (this.stack.AutoUpdate && this.stack.AutoUpdate.Webhook) {
|
||||
this.state.webhookId = this.stack.AutoUpdate.Webhook;
|
||||
}
|
||||
|
||||
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
|
||||
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
|
||||
this.formValues.RepositoryPassword = this.stack.GitConfig.Authentication.Password;
|
||||
this.formValues.RepositoryAuthentication = true;
|
||||
this.state.isEdit = true;
|
||||
this.state.isAuthEdit = true;
|
||||
}
|
||||
|
||||
this.savedFormValues = angular.copy(this.formValues);
|
||||
}
|
||||
}
|
||||
|
||||
export default StackRedeployGitFormController;
|
||||
@@ -1,110 +0,0 @@
|
||||
<form name="$ctrl.redeployGitForm" class="form-horizontal my-8">
|
||||
<div class="col-sm-12 form-section-title"> Redeploy from git repository </div>
|
||||
|
||||
<git-form-info-panel
|
||||
class-name="'text-muted small'"
|
||||
url="$ctrl.model.URL"
|
||||
type="'stack'"
|
||||
config-file-path="$ctrl.model.ConfigFilePath"
|
||||
additional-files="$ctrl.stack.AdditionalFiles"
|
||||
></git-form-info-panel>
|
||||
|
||||
<git-form-auto-update-fieldset
|
||||
value="$ctrl.formValues.AutoUpdate"
|
||||
on-change="($ctrl.onChangeAutoUpdate)"
|
||||
environment-type="DOCKER"
|
||||
is-force-pull-visible="$ctrl.stack.Type !== 3"
|
||||
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||
webhooks-docs="/user/docker/stacks/webhooks"
|
||||
></git-form-auto-update-fieldset>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<p>
|
||||
<a class="small interactive" ng-click="$ctrl.state.showConfig = !$ctrl.state.showConfig">
|
||||
<pr-icon ng-if="$ctrl.state.showConfig" icon="'minus'" class-name="'mr-1'"></pr-icon>
|
||||
<pr-icon ng-if="!$ctrl.state.showConfig" icon="'plus'" class-name="'mr-1'"></pr-icon>
|
||||
{{ $ctrl.state.showConfig ? 'Hide' : 'Advanced' }} configuration
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<git-form-ref-field
|
||||
ng-if="$ctrl.state.showConfig"
|
||||
value="$ctrl.formValues.RefName"
|
||||
on-change="($ctrl.onChangeRef)"
|
||||
model="$ctrl.formValues"
|
||||
is-url-valid="true"
|
||||
stack-id="$ctrl.gitStackId"
|
||||
></git-form-ref-field>
|
||||
|
||||
<git-form-auth-fieldset
|
||||
ng-if="$ctrl.state.showConfig"
|
||||
value="$ctrl.formValues"
|
||||
on-change="($ctrl.onChangeGitAuth)"
|
||||
is-auth-explanation-visible="true"
|
||||
is-auth-edit="$ctrl.state.isAuthEdit"
|
||||
></git-form-auth-fieldset>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.state.showConfig">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
name="TLSSkipVerify"
|
||||
checked="$ctrl.formValues.TLSSkipVerify"
|
||||
tooltip="'Enabling this will allow skipping TLS validation for any self-signed certificate.'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
label="'Skip TLS Verification'"
|
||||
on-change="($ctrl.onChangeTLSSkipVerify)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="$ctrl.state.showConfig">
|
||||
<relative-path-fieldset values="$ctrl.stack" git-model="$ctrl.stack" is-editing="true" hide-edge-configs="true"></relative-path-fieldset>
|
||||
</div>
|
||||
|
||||
<stack-environment-variables-panel
|
||||
values="$ctrl.formValues.Env"
|
||||
on-change="($ctrl.onChangeEnvVar)"
|
||||
show-help-message="true"
|
||||
is-foldable="true"
|
||||
></stack-environment-variables-panel>
|
||||
|
||||
<option-panel ng-if="$ctrl.stack.Type === 1 && $ctrl.endpoint.apiVersion >= 1.27" ng-model="$ctrl.formValues.Option" on-change="($ctrl.onChangeOption)"></option-panel>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.submit()"
|
||||
ng-disabled="$ctrl.disablePullAndRedeployButton()"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="$ctrl.state.redeployInProgress"
|
||||
analytics-on
|
||||
analytics-event="docker-stack-pull-redeploy"
|
||||
analytics-category="docker"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.redeployInProgress">
|
||||
<pr-icon icon="'refresh-cw'" class="!mr-1"></pr-icon>
|
||||
Pull and redeploy
|
||||
</span>
|
||||
<span ng-show="$ctrl.state.redeployInProgress">In progress...</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.saveGitSettings()"
|
||||
ng-disabled="$ctrl.disableSaveSettingsButton()"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="$ctrl.state.inProgress"
|
||||
analytics-on
|
||||
analytics-event="docker-stack-update-git-settings"
|
||||
analytics-category="docker"
|
||||
analytics-properties="$ctrl.buildAnalyticsProperties()"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.inProgress"> Save settings </span>
|
||||
<span ng-show="$ctrl.state.inProgress">In progress...</span>
|
||||
</button>
|
||||
</form>
|
||||
@@ -1,26 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import parse from 'parse-duration';
|
||||
|
||||
angular.module('portainer.app').directive('intervalFormat', function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
require: 'ngModel',
|
||||
link: function ($scope, $element, $attrs, ngModel) {
|
||||
ngModel.$validators.invalidIntervalFormat = function (modelValue) {
|
||||
try {
|
||||
return modelValue && modelValue.toUpperCase().match(/^P?(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T?(?=\d+[HMS])(\d+H)?(\d+M)?(\d+S)?)?$/gm) !== null;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
ngModel.$validators.minimumInterval = function (modelValue) {
|
||||
try {
|
||||
return modelValue && parse(modelValue, 'minute') >= 1;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
||||
|
||||
angular.module('portainer.app').controller('StackDuplicationFormController', [
|
||||
'Notifications',
|
||||
'$scope',
|
||||
function StackDuplicationFormController(Notifications, $scope) {
|
||||
var ctrl = this;
|
||||
|
||||
ctrl.environmentSelectorOptions = null;
|
||||
|
||||
ctrl.state = {
|
||||
duplicationInProgress: false,
|
||||
migrationInProgress: false,
|
||||
};
|
||||
|
||||
ctrl.formValues = {
|
||||
endpointId: null,
|
||||
newName: '',
|
||||
};
|
||||
|
||||
ctrl.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX;
|
||||
|
||||
ctrl.isFormValidForDuplication = isFormValidForDuplication;
|
||||
ctrl.isFormValidForMigration = isFormValidForMigration;
|
||||
ctrl.duplicateStack = duplicateStack;
|
||||
ctrl.migrateStack = migrateStack;
|
||||
ctrl.isMigrationButtonDisabled = isMigrationButtonDisabled;
|
||||
ctrl.isEndpointSelected = isEndpointSelected;
|
||||
ctrl.onChangeEnvironment = onChangeEnvironment;
|
||||
ctrl.$onChanges = $onChanges;
|
||||
|
||||
function isFormValidForMigration() {
|
||||
return ctrl.formValues.endpointId;
|
||||
}
|
||||
|
||||
function isFormValidForDuplication() {
|
||||
return isFormValidForMigration() && ctrl.formValues.newName && !ctrl.yamlError;
|
||||
}
|
||||
|
||||
function onChangeEnvironment(endpointId) {
|
||||
return $scope.$evalAsync(() => {
|
||||
ctrl.formValues.endpointId = endpointId;
|
||||
});
|
||||
}
|
||||
|
||||
function duplicateStack() {
|
||||
if (!ctrl.formValues.newName) {
|
||||
Notifications.error('Failure', null, 'Stack name is required for duplication');
|
||||
return;
|
||||
}
|
||||
ctrl.state.duplicationInProgress = true;
|
||||
ctrl
|
||||
.onDuplicate({
|
||||
endpointId: ctrl.formValues.endpointId,
|
||||
name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined,
|
||||
})
|
||||
.finally(function () {
|
||||
ctrl.state.duplicationInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function migrateStack() {
|
||||
ctrl.state.migrationInProgress = true;
|
||||
ctrl
|
||||
.onMigrate({
|
||||
endpointId: ctrl.formValues.endpointId,
|
||||
name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined,
|
||||
})
|
||||
.finally(function () {
|
||||
ctrl.state.migrationInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function isMigrationButtonDisabled() {
|
||||
return !ctrl.isFormValidForMigration() || ctrl.state.duplicationInProgress || ctrl.state.migrationInProgress || isTargetEndpointAndCurrentEquals();
|
||||
}
|
||||
|
||||
function isTargetEndpointAndCurrentEquals() {
|
||||
return ctrl.formValues.endpointId === ctrl.currentEndpointId;
|
||||
}
|
||||
|
||||
function isEndpointSelected() {
|
||||
return ctrl.formValues.endpointId;
|
||||
}
|
||||
|
||||
function $onChanges() {
|
||||
ctrl.environmentSelectorOptions = getOptions(ctrl.groups, ctrl.endpoints);
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
function getOptions(groups, environments) {
|
||||
if (!groups || !environments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupSet = environments.reduce((groupSet, environment) => {
|
||||
const groupEnvironments = groupSet[environment.GroupId] || [];
|
||||
|
||||
return {
|
||||
...groupSet,
|
||||
[environment.GroupId]: [...groupEnvironments, { label: environment.Name, value: environment.Id }],
|
||||
};
|
||||
}, {});
|
||||
|
||||
return Object.entries(groupSet).map(([groupId, environments]) => {
|
||||
const group = groups.find((group) => group.Id === parseInt(groupId, 10));
|
||||
if (!group) {
|
||||
throw new Error('missing group');
|
||||
}
|
||||
|
||||
return {
|
||||
label: group.Name,
|
||||
options: environments,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<div authorization="PortainerStackMigrate">
|
||||
<div class="col-sm-12 form-section-title"> Stack duplication / migration </div>
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="dupStackForm">
|
||||
<div class="form-group">
|
||||
<span class="small" style="margin-top: 10px">
|
||||
<p class="text-muted"> This feature allows you to duplicate or migrate this stack. </p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<input
|
||||
class="form-control"
|
||||
placeholder="Stack name (optional for migration)"
|
||||
aria-placeholder="Stack name"
|
||||
name="new_stack_name"
|
||||
ng-pattern="$ctrl.STACK_NAME_VALIDATION_REGEX"
|
||||
ng-model="$ctrl.formValues.newName"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group" ng-show="dupStackForm.new_stack_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="dupStackForm.new_stack_name.$error">
|
||||
<p ng-message="pattern">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.endpoints && $ctrl.groups">
|
||||
<por-select value="$ctrl.formValues.endpointId" on-change="($ctrl.onChangeEnvironment)" options="$ctrl.environmentSelectorOptions"></por-select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.migrateStack()"
|
||||
ng-disabled="$ctrl.isMigrationButtonDisabled()"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="$ctrl.state.migrationInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.migrationInProgress">
|
||||
<pr-icon icon="'arrow-right'" class-name="'mr-1'"></pr-icon>
|
||||
Migrate
|
||||
</span>
|
||||
<span ng-show="$ctrl.state.migrationInProgress">Migration in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.duplicateStack()"
|
||||
ng-disabled="!$ctrl.isFormValidForDuplication() || $ctrl.state.duplicationInProgress || $ctrl.state.migrationInProgress"
|
||||
style="margin-top: 7px; margin-left: 0"
|
||||
button-spinner="$ctrl.state.duplicationInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.duplicationInProgress">
|
||||
<pr-icon icon="'copy'" class-name="'space-right'"></pr-icon>
|
||||
Duplicate
|
||||
</span>
|
||||
<span ng-show="$ctrl.state.duplicationInProgress">Duplication in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div ng-if="$ctrl.yamlError && $ctrl.isEndpointSelected()">
|
||||
<span class="text-danger small">{{ $ctrl.yamlError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
@@ -1,12 +0,0 @@
|
||||
angular.module('portainer.app').component('stackDuplicationForm', {
|
||||
templateUrl: './stack-duplication-form.html',
|
||||
controller: 'StackDuplicationFormController',
|
||||
bindings: {
|
||||
onDuplicate: '&',
|
||||
onMigrate: '&',
|
||||
endpoints: '<',
|
||||
groups: '<',
|
||||
currentEndpointId: '<',
|
||||
yamlError: '<',
|
||||
},
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
class GenericHelper {
|
||||
static findDeepAll(obj, target, res = []) {
|
||||
if (typeof obj === 'object') {
|
||||
_.forEach(obj, (child, key) => {
|
||||
if (key === target) res.push(child);
|
||||
if (typeof child === 'object') GenericHelper.findDeepAll(child, target, res);
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export default GenericHelper;
|
||||
@@ -1,7 +1,6 @@
|
||||
import _ from 'lodash-es';
|
||||
import YAML from 'yaml';
|
||||
import GenericHelper from '@/portainer/helpers/genericHelper';
|
||||
import { ExternalStackViewModel } from '@/react/docker/stacks/view-models/external-stack';
|
||||
import { validateYAML } from '@/react/docker/stacks/ItemView/StackEditorTab/stackYamlValidation';
|
||||
|
||||
angular.module('portainer.app').factory('StackHelper', [
|
||||
function StackHelperFactory() {
|
||||
@@ -28,40 +27,3 @@ angular.module('portainer.app').factory('StackHelper', [
|
||||
return helper;
|
||||
},
|
||||
]);
|
||||
|
||||
function validateYAML(yaml, containerNames, originalContainersNames = []) {
|
||||
let yamlObject;
|
||||
|
||||
try {
|
||||
yamlObject = YAML.parse(yaml, { mapAsMap: true, maxAliasCount: 10000 });
|
||||
} catch (err) {
|
||||
return 'There is an error in the yaml syntax: ' + err;
|
||||
}
|
||||
|
||||
const names = _.uniq(GenericHelper.findDeepAll(yamlObject, 'container_name'));
|
||||
|
||||
const duplicateContainers = _.intersection(_.difference(containerNames, originalContainersNames), names);
|
||||
|
||||
if (duplicateContainers.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (
|
||||
(duplicateContainers.length === 1 ? 'This container name is' : 'These container names are') +
|
||||
' already used by another container running in this environment: ' +
|
||||
_.join(duplicateContainers, ', ') +
|
||||
'.'
|
||||
);
|
||||
}
|
||||
|
||||
export function extractContainerNames(yaml = '') {
|
||||
let yamlObject;
|
||||
|
||||
try {
|
||||
yamlObject = YAML.parse(yaml, { maxAliasCount: 10000 });
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.uniq(GenericHelper.findDeepAll(yamlObject, 'container_name'));
|
||||
}
|
||||
|
||||
@@ -15,14 +15,15 @@ export function RegistryViewModel(data) {
|
||||
this.Gitlab = data.Gitlab;
|
||||
this.Quay = data.Quay;
|
||||
this.Ecr = data.Ecr;
|
||||
this.ManagementConfiguration = data.ManagementConfiguration;
|
||||
}
|
||||
|
||||
export function RegistryManagementConfigurationDefaultModel(registry) {
|
||||
this.Authentication = registry.Authentication;
|
||||
this.Username = registry.Username;
|
||||
this.Password = '';
|
||||
this.TLS = false;
|
||||
this.TLSSkipVerify = false;
|
||||
this.TLS = (registry.ManagementConfiguration && registry.ManagementConfiguration.TLSConfig && registry.ManagementConfiguration.TLSConfig.TLS) || false;
|
||||
this.TLSSkipVerify = (registry.ManagementConfiguration && registry.ManagementConfiguration.TLSConfig && registry.ManagementConfiguration.TLSConfig.TLSSkipVerify) || false;
|
||||
this.TLSCACertFile = null;
|
||||
this.TLSCertFile = null;
|
||||
this.TLSKeyFile = null;
|
||||
|
||||
@@ -53,6 +53,7 @@ import { accountModule } from './account';
|
||||
import { usersModule } from './users';
|
||||
import { activityLogsModule } from './activity-logs';
|
||||
import { rbacModule } from './rbac';
|
||||
import { stacksModule } from './stacks';
|
||||
|
||||
export const ngModule = angular
|
||||
.module('portainer.app.react.components', [
|
||||
@@ -66,6 +67,7 @@ export const ngModule = angular
|
||||
usersModule,
|
||||
activityLogsModule,
|
||||
rbacModule,
|
||||
stacksModule,
|
||||
])
|
||||
.component(
|
||||
'tagSelector',
|
||||
@@ -208,6 +210,7 @@ export const ngModule = angular
|
||||
'aria-label',
|
||||
'size',
|
||||
'loadingMessage',
|
||||
'getOptionValue',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
@@ -249,6 +252,7 @@ export const ngModule = angular
|
||||
'fileName',
|
||||
'placeholder',
|
||||
'showToolbar',
|
||||
'aria-label',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user