Compare commits

..

53 Commits

Author SHA1 Message Date
Oscar Zhou
4f4e8b5af9 fix(snapshot): prevent from returning SnapshotRaw data [BE-12431] (#1442) 2025-11-27 09:01:01 +13:00
Devon Steenberg
5058b40871 chore(version): bump to v2.36.0 (#1434) 2025-11-25 11:09:49 +13:00
Chaim Lev-Ari
5d847b59b2 feat(analytics): remove matomo dependency [BE-12404] (#1431) 2025-11-24 16:30:03 +02:00
Oscar Zhou
c8d44b9416 fix(edgestack): external label on k8s application deployed by edgestack [BE-12318] (#1428) 2025-11-22 09:04:31 +13:00
Oscar Zhou
14d67d1ec7 fix(edgestack): external label on k8s application deployed by edgestack [BE-12318] (#1385) 2025-11-21 12:44:42 +13:00
Hannah Cooper
6866faf4fe Update bug_report to include 2.33.4 (#1420) 2025-11-20 13:06:07 +13:00
Viktor Pettersson
567d628a52 fix(edge-stacks): inconsistent edge stack count BE-12285 (#1382) 2025-11-20 10:56:38 +13:00
Chaim Lev-Ari
a3eab75405 refactor(registries): remove superfluous useEffect in PrivateRegistryFieldset [BE-12408] (#1396) 2025-11-19 08:12:11 +02:00
Chaim Lev-Ari
566f6b067c fix(environments): fix podman auto onboarding script [BE-12327] (#1395) 2025-11-18 14:30:23 +02:00
Chaim Lev-Ari
e73d07281c fix(endpoints): Change syntax for multi-line commands in Windows (#1355)
Co-authored-by: Shawn <host@shawnsg.dev>
2025-11-18 08:48:32 +02:00
Steven Kang
e59d4dea77 fix: CVE-2024-25621 - develop [R8S-639] (#1412) 2025-11-18 17:34:10 +13:00
Steven Kang
4ca5370b86 fix: CVE-2025-47913 - develop [R8S-638] (#1401) 2025-11-18 16:28:14 +13:00
Devon Steenberg
e831971dd1 fix(docker): bump docker max api version [BE-12399] (#1392) 2025-11-18 11:27:16 +13:00
Steven Kang
99d996dde9 fix: CVE-2025-47906 and CVE-2025-47910 - develop [R8S-618] (#1389) 2025-11-18 08:57:00 +13:00
Malcolm Lockyer
712d31b416 fix(agent): for iamra and ecr login, detect errors and retry [be-12284] (#1362) 2025-11-17 11:51:09 +13:00
Steven Kang
0394855b2f feat: reorder environment creation types (#1359) 2025-11-17 10:09:19 +13:00
Chaim Lev-Ari
9024b021ee feat(environments): deprecate openamt [BE-12359] (#1390) 2025-11-16 09:55:00 +02:00
Chaim Lev-Ari
8071641179 refactor(stacks): convert editor to tab (#1374) 2025-11-12 15:44:13 +02:00
Chaim Lev-Ari
0075374241 fix(ui/datatables): show selected filter values [BE-11301] (#1387) 2025-11-12 15:21:17 +02:00
Chaim Lev-Ari
c35ddc8c76 feat(git): hide user/pass for save creds [BE-10953] (#1376) 2025-11-12 15:20:20 +02:00
Oscar Zhou
4b4aef7ef8 fix(stack): apply new stack manual redeployment filed name to regular stack [BE-12384] (#1375) 2025-11-12 09:17:57 +13:00
Copilot
6db4a62e01 Fix swagger enum issues causing duplicate constants in generated code (#1373)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: deviantony <5485061+deviantony@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
2025-11-12 08:45:08 +13:00
Chaim Lev-Ari
db394b6145 feat(logs): filter activity logs by envs and users [BE-12275] (#1383) 2025-11-11 14:49:26 +02:00
Chaim Lev-Ari
53e7704724 feat(stacks): allow to rename stacks [BE-12317] (#1339) 2025-11-09 09:39:29 +02:00
Chaim Lev-Ari
f607c7c271 reactor(stacks): migrate deploy git to react [BE-12382] (#1372) 2025-11-09 09:36:06 +02:00
Oscar Zhou
48c689e5d6 fix(registry): custom registry configure page doesn't reflect actual setting [BE-12385] (#1378) 2025-11-08 10:13:00 +13:00
Oscar Zhou
2f2251ff33 fix(registry): pulling private image from registry fails despite credential is valid [BE-12237] (#1303) 2025-11-08 10:12:17 +13:00
Devon Steenberg
29254d1a66 fix(proxy): replace Director with Rewrite field [BE-12328] (#1358) 2025-11-05 10:57:01 +13:00
Chaim Lev-Ari
19cbae1732 feat(registries): check dockerhub credentials [BE-12329] (#1338) 2025-11-04 18:46:37 +02:00
Chaim Lev-Ari
73ad27640c refactor(stacks): migrate duplication form to react [BE-12353] (#1357) 2025-11-04 18:44:54 +02:00
Chaim Lev-Ari
1be96e1bd1 fix(telemetry): update privacy policy url [BE-12350] (#1348) 2025-11-04 14:25:03 +02:00
Chaim Lev-Ari
a9834be2ff fix(widget): remove fixed margin on button [BE-12344] (#1346) 2025-11-04 14:24:26 +02:00
Chaim Lev-Ari
d8ab86d86f fix(templates): keep icon to their border size [BE-12349] (#1343) 2025-11-04 14:23:56 +02:00
Chaim Lev-Ari
3f1bd8e290 fix(ui): fix warnings in client-side tests [BE-12351] (#1342) 2025-11-04 14:23:11 +02:00
Chaim Lev-Ari
34a7d75e10 fix(edge-scripts): add podman auto onboarding script [BE-12327] (#1333) 2025-11-04 14:21:37 +02:00
Oscar Zhou
ae53de42df fix(stack): stack prune service does not persist [BE-12314] (#1323) 2025-11-03 12:22:04 +13:00
Oscar Zhou
b70321a0aa fix(edgestack): unify gitops update flow [BE-12184] (#1110) 2025-11-01 20:20:51 +13:00
Oscar Zhou
0ff39f9a61 refactor(stack): move stack update into transaction [BE-12244] (#1324) 2025-10-31 17:19:56 +13:00
Ali
876ba0fa0f fix: add titles to truncated text [r8s-610] (#1331)
Small behavioral change
2025-10-30 16:43:15 +13:00
Hannah Cooper
c7c65d2f97 Update bug_report to include 2.33.3 (#1352) 2025-10-30 15:18:48 +13:00
andres-portainer
736f7e198f fix(CVE-2025-62725): upgrade github.com/docker/compose/v2 to v2.40.2 BE-12352 (#1345) 2025-10-29 18:17:46 -03:00
Viktor Pettersson
8cb3589fb8 chore(go.mod): pin github.com/robfig/cron/v3 to v3.0.1 due to lack of maintenance BE-12226 (#1334) 2025-10-24 10:00:09 +13:00
Chaim Lev-Ari
56530d8791 fix(sidebar): add copyright icon to CE (#1325) 2025-10-23 18:14:09 +03:00
Chaim Lev-Ari
da6b0e3dcc refactor(registries): convert docker hub form to react (#1335) 2025-10-23 17:00:49 +03:00
Steven Kang
eb02f99cae feat: crds support [r8s-580] (#1254)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-10-23 11:07:03 +13:00
Chaim Lev-Ari
cb0efae81c chore(gitops): upgrade parse-duration dep [r8s-608] (#1328) 2025-10-22 13:20:20 +03:00
Viktor Pettersson
e5f98e6145 test(scheduler): use synctest to cut execution time by 95% BE-12226 (#1330) 2025-10-22 10:48:12 +13:00
Devon Steenberg
8a23007ad2 fix(deps): update github.com/container/image/v5 dep [BE-12212] (#1313) 2025-10-20 15:47:46 +13:00
Oscar Zhou
592b196848 fix(registry): selecting one item checked all items in registry access table [BE-12036] (#1318) 2025-10-20 12:55:32 +13:00
Ali
8eb273e54b docs(kubernetes): update Helm install docs link to /user/kubernetes/applications/manifest/helm [R8S-601] (#1317)
Minor docs change
2025-10-20 09:33:07 +13:00
Ali
78c7e752f9 chore(build): fix relative paths for make dev [r8s-588] (#1314) 2025-10-17 10:40:23 +13:00
Hannah Cooper
7c51a3b5ff Update bug report to include 2.35.0 (#1310) 2025-10-16 12:18:34 +13:00
Viktor Pettersson
3e77db4cee chore(version): bump to v2.35.0 (#1304) 2025-10-15 15:35:33 +13:00
273 changed files with 14370 additions and 2041 deletions

View File

@@ -94,7 +94,10 @@ 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.35.0'
- '2.34.0'
- '2.33.4'
- '2.33.3'
- '2.33.2'
- '2.33.1'
- '2.33.0'

View File

@@ -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

View File

@@ -614,7 +614,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.35.0",
"KubectlShellImage": "portainer/kubectl-shell:2.36.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.36.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -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

View File

@@ -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

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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).

View File

@@ -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")

View 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)

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.35.0
// @version 2.36.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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))
}

View 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
}

View 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")
})
}

View 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)
}

View File

@@ -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 {

View File

@@ -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
}

View 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
}

View File

@@ -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
})
}

View 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")
}

View File

@@ -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"`
}

View File

@@ -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, "")

View File

@@ -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)
}
}
}

View File

@@ -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)
}
})

View File

@@ -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")

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"
`

View File

@@ -347,6 +347,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 +358,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"`
}
@@ -1782,7 +1794,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.35.0"
APIVersion = "2.36.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

View File

@@ -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()
})
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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) {}

View File

@@ -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);
});
}

View File

@@ -12,7 +12,6 @@ pr-icon {
.icon {
color: currentColor;
margin: 0;
display: inline-block;
font-size: var(--icon-size);
height: var(--icon-size);

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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']>;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,
])

View File

@@ -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',
},
};

View File

@@ -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);

View File

@@ -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>

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}
};
},
};
});

View File

@@ -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,
};
});
}

View File

@@ -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>

View File

@@ -1,12 +0,0 @@
angular.module('portainer.app').component('stackDuplicationForm', {
templateUrl: './stack-duplication-form.html',
controller: 'StackDuplicationFormController',
bindings: {
onDuplicate: '&',
onMigrate: '&',
endpoints: '<',
groups: '<',
currentEndpointId: '<',
yamlError: '<',
},
});

View File

@@ -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;

View File

@@ -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'));
}

View File

@@ -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;

View File

@@ -10,6 +10,7 @@ import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
import { RefField } from '@/react/portainer/gitops/RefField';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
import { StackRedeployGitForm } from '@/react/portainer/gitops/StackRedeployGitForm/StackRedeployGitForm';
export const gitFormModule = angular
.module('portainer.app.components.forms.git', [])
@@ -79,4 +80,12 @@ export const gitFormModule = angular
.component(
'timeWindowDisplay',
r2a(withReactQuery(withUIRouter(TimeWindowDisplay)), [])
)
.component(
'stackRedeployGitForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(StackRedeployGitForm))), [
'model',
'stack',
'endpoint',
])
).name;

View File

@@ -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',
@@ -249,6 +251,7 @@ export const ngModule = angular
'fileName',
'placeholder',
'showToolbar',
'aria-label',
])
)
.component(

View File

@@ -6,6 +6,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { RepositoriesDatatable } from '@/react/portainer/registries/repositories/ListView/RepositoriesDatatable';
import { TagsDatatable } from '@/react/portainer/registries/repositories/ItemView/TagsDatatable/TagsDatatable';
import { GitlabProjectTable } from '@/react/portainer/registries/CreateView/GitlabProjectsTable/GitlabProjectsTable';
import { RegistryFormDockerhub } from '@/react/portainer/registries/CreateView/RegistryFormDockerhub/RegistryFormDockerhub';
export const registriesModule = angular
.module('portainer.app.react.components.registries', [])
@@ -25,4 +26,14 @@ export const registriesModule = angular
.component(
'gitlabProjectSelector',
r2a(GitlabProjectTable, ['dataset', 'onChange', 'value'])
)
.component(
'registryFormDockerhub',
r2a(withReactQuery(RegistryFormDockerhub), [
'initialValues',
'onSubmit',
'submitLabel',
'isLoading',
'nameIsUsed',
])
).name;

View File

@@ -0,0 +1,35 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { StackDuplicationForm } from '@/react/common/stacks/ItemView/StackDuplicationForm/StackDuplicationForm';
import { StackEditorTab } from '@/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
export const stacksModule = angular
.module('portainer.app.react.components.stacks', [])
.component(
'stackDuplicationForm',
r2a(withUIRouter(withReactQuery(StackDuplicationForm)), [
'yamlError',
'currentEnvironmentId',
'originalFileContent',
'stack',
])
)
.component(
'stackEditorTab',
r2a(withUIRouter(withCurrentUser(StackEditorTab)), [
'stackType',
'composeSyntaxMaxVersion',
'stackId',
'versions',
'isOrphaned',
'onSubmit',
'initialValues',
'containerNames',
'originalContainerNames',
'onSubmitSettled',
])
).name;

View File

@@ -64,12 +64,13 @@
action-in-progress="$ctrl.state.actionInProgress"
reset-defaults="$ctrl.useDefaultGitlabConfiguration"
></registry-form-gitlab>
<registry-form-dockerhub
ng-if="$ctrl.model.Type === $ctrl.RegistryTypes.DOCKERHUB"
model="$ctrl.model"
form-action="$ctrl.createRegistry"
form-action-label="Add registry"
action-in-progress="$ctrl.state.actionInProgress"
initial-values="$ctrl.model"
on-submit="($ctrl.onSubmitDocker)"
submit-label="'Add registry'"
is-loading="$ctrl.state.actionInProgress"
name-is-used="($ctrl.nameIsUsed)"
></registry-form-dockerhub>
</form>

View File

@@ -17,6 +17,7 @@ class CreateRegistryController {
};
this.createRegistry = this.createRegistry.bind(this);
this.onSubmitDocker = this.onSubmitDocker.bind(this);
this.getRegistries = this.getRegistries.bind(this);
this.nameIsUsed = this.nameIsUsed.bind(this);
this.retrieveGitlabRegistries = this.retrieveGitlabRegistries.bind(this);
@@ -143,6 +144,15 @@ class CreateRegistryController {
});
}
/**
*
* @param {import('@/react/portainer/registries/CreateView/RegistryFormDockerhub/RegistryFormDockerhub').RegistryFormDockerhubValues} model
*/
onSubmitDocker(model) {
this.model = model;
this.createRegistry();
}
nameIsUsed(name) {
return _.includes(this.registriesNames, name);
}

View File

@@ -16,7 +16,6 @@ function StackFactory($resource, API_ENDPOINT_STACKS) {
associate: { method: 'PUT', params: { id: '@id', swarmId: '@swarmId', endpointId: '@endpointId', orphanedRunning: '@orphanedRunning', action: 'associate' } },
remove: { method: 'DELETE', params: { id: '@id', external: '@external', endpointId: '@endpointId' } },
getStackFile: { method: 'GET', params: { id: '@id', action: 'file' } },
migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true },
start: { method: 'POST', params: { id: '@id', action: 'start', endpointId: '@endpointId' } },
stop: { method: 'POST', params: { id: '@id', action: 'stop', endpointId: '@endpointId' } },
updateGit: { method: 'PUT', params: { id: '@id', action: 'git', subaction: 'redeploy' } },

View File

@@ -48,42 +48,6 @@ angular.module('portainer.app').factory('StackService', [
return deferred.promise;
};
service.migrateSwarmStack = function (stack, targetEndpointId, newName) {
var deferred = $q.defer();
SwarmService.swarm(targetEndpointId)
.then(function success(data) {
var swarm = data;
if (swarm.ID === stack.SwarmId) {
deferred.reject({ msg: 'Target environment is located in the same Swarm cluster as the current environment', err: null });
return;
}
return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.ID, Name: newName }).$promise;
})
.then(function success() {
deferred.resolve();
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to migrate stack', err: err });
});
return deferred.promise;
};
service.migrateComposeStack = function (stack, targetEndpointId, newName) {
var deferred = $q.defer();
Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, Name: newName })
.$promise.then(function success() {
deferred.resolve();
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to migrate stack', err: err });
});
return deferred.promise;
};
service.stacks = function (compose, swarm, endpointId, includeOrphanedStacks = false) {
var deferred = $q.defer();
@@ -263,7 +227,7 @@ angular.module('portainer.app').factory('StackService', [
return deferred.promise;
};
service.updateStack = function (stack, stackFile, env, prune, pullImage) {
service.updateStack = function (stack, stackFile, env, prune, repullImageAndRedeploy) {
return Stack.update(
{ endpointId: stack.EndpointId },
{
@@ -271,7 +235,7 @@ angular.module('portainer.app').factory('StackService', [
StackFileContent: stackFile,
Env: env,
Prune: prune,
PullImage: pullImage,
RepullImageAndRedeploy: repullImageAndRedeploy,
}
).$promise;
};
@@ -410,11 +374,6 @@ angular.module('portainer.app').factory('StackService', [
return deferred.promise;
};
service.duplicateStack = function duplicateStack(name, stackFileContent, env, endpointId, type) {
var action = type === 1 ? service.createSwarmStackFromFileContent : service.createComposeStackFromFileContent;
return action(name, stackFileContent, env, endpointId);
};
async function kubernetesDeployAsync(endpointId, method, payload) {
try {
await Stack.create({ endpointId: endpointId }, { method, type: 'kubernetes', ...payload }).$promise;

View File

@@ -4,8 +4,4 @@ export const MaxDockerAPIVersionKey = 'maxDockerAPIVersion' as const;
export type DockerAPIVersionType = number;
// this is the version we are using with the generated API types
export const MAX_DOCKER_API_VERSION: DockerAPIVersionType = 1.41;
// https://docs.docker.com/engine/api/#api-version-matrix
// Docker 26 = API 1.45
export const LATEST_DOCKER_API_VERSION: DockerAPIVersionType = 1.45;
export const MAX_DOCKER_API_VERSION: DockerAPIVersionType = 1.44;

View File

@@ -1,4 +1,4 @@
import { SystemVersion } from 'docker-types/generated/1.41';
import { SystemVersion } from 'docker-types/generated/1.44';
import Axios, { InternalAxiosRequestConfig } from 'axios';
import { setupCache, buildMemoryStorage } from 'axios-cache-interceptor';

View File

@@ -98,7 +98,7 @@
<input type="checkbox" name="toggle_enableTelemetry" ng-model="formValues.enableTelemetry" data-cy="init-enableTelemetry" />
<span class="text-muted small"
>Allow collection of anonymous statistics. You can find more information about this in our
<a class="hyperlink" href="https://www.portainer.io/documentation/in-app-analytics-and-privacy-policy/" target="_blank">privacy policy</a>.</span
<a class="hyperlink" href="{{ privacyPolicyUrl }}" target="_blank">privacy policy</a>.</span
>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { restoreOptions } from '@/react/portainer/init/InitAdminView/restore-options';
import { privacyPolicyUrl } from '@/react/portainer/settings/SettingsView/ApplicationSettingsPanel/EnableTelemetryField';
angular.module('portainer.app').controller('InitAdminController', [
'$scope',
@@ -14,6 +15,8 @@ angular.module('portainer.app').controller('InitAdminController', [
function ($scope, $state, Notifications, Authentication, StateManager, SettingsService, UserService, BackupService, StatusService) {
$scope.restoreOptions = restoreOptions;
$scope.privacyPolicyUrl = privacyPolicyUrl;
$scope.uploadBackup = uploadBackup;
$scope.logo = StateManager.getState().application.logo;

View File

@@ -119,14 +119,13 @@
endpoint="applicationState.endpoint"
>
</stack-redeploy-git-form>
<stack-duplication-form
ng-if="regular && endpoints.length > 0"
endpoints="endpoints"
groups="groups"
current-endpoint-id="endpoint.Id"
on-duplicate="duplicateStack(name, endpointId)"
on-migrate="migrateStack(name, endpointId)"
ng-if="stack && regular"
current-environment-id="endpoint.Id"
yaml-error="state.yamlError"
stack="stack"
original-file-content="stackFileContent"
>
</stack-duplication-form>
</div>
@@ -139,96 +138,19 @@
<pr-icon icon="'edit-2'" class-name="'mr-1'"></pr-icon>
Editor
</uib-tab-heading>
<form class="form-horizontal" ng-if="state.showEditorTab" style="margin-top: 10px" name="stackUpdateForm">
<div class="form-group">
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px" ng-if="stackType == STACK_TYPES.DockerCompose && composeSyntaxMaxVersion == 2">
This stack will be deployed using the equivalent of <code>docker compose</code>. Only Compose file format version <b>2</b> is supported at the moment.
</span>
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px" ng-if="stackType == STACK_TYPES.DockerCompose && composeSyntaxMaxVersion > 2">
This stack will be deployed using <code>docker compose</code>.
</span>
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>
<!-- opacity-0 with &nbsp; fixes the layout shift causing tooltips to go over hovered text -->
<div class="col-sm-12" ng-class="{ 'opacity-100': state.yamlError, 'opacity-0': !state.yamlError }">
<span class="text-danger small">{{ state.yamlError || '&nbsp;' }}</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
read-only="orphaned"
identifier="stack-editor"
text-tip="Define or paste the content of your docker compose file here"
yml="true"
on-change="(editorUpdate)"
value="stackFileContent"
schema="dockerComposeSchema"
></code-editor>
</div>
</div>
<!-- environment-variables -->
<div ng-if="stack">
<stack-environment-variables-panel
values="formValues.Env"
on-change="(handleEnvVarChange)"
show-help-message="true"
is-foldable="true"
></stack-environment-variables-panel>
</div>
<!-- !environment-variables -->
<!-- webhook -->
<div ng-if="isAdmin && applicationState.endpoint.type !== 4">
<div class="form-section-title"> Webhooks</div>
<por-switch-field
name="EnableWebhook"
checked="formValues.EnableWebhook"
label-class="'col-sm-2'"
tooltip="'Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack.'"
label="'Create a Stack webhook'"
feature-id="'stack-webhook'"
></por-switch-field>
</div>
<!-- !webhook -->
<!-- options -->
<div ng-if="stack.Type === 1 && applicationState.endpoint.apiVersion >= 1.27" authorization="PortainerStackUpdate">
<div class="col-sm-12 form-section-title"> Options </div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
name="prune"
checked="formValues.Prune"
tooltip="'Prune services that are no longer referenced.'"
label-class="'col-sm-2'"
label="'Prune services'"
on-change="(onPruneChange)"
></por-switch-field>
</div>
</div>
</div>
<!-- !options -->
<div authorization="PortainerStackUpdate">
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-sm btn-primary"
ng-disabled="state.actionInProgress || state.yamlError || !stackUpdateForm.$valid || !stackFileContent || orphaned"
ng-click="deployStack()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Update the stack</span>
<span ng-show="state.actionInProgress">Deployment in progress...</span>
</button>
</div>
</div>
</div>
</form>
<stack-editor-tab
ng-if="state.showEditorTab"
stack-type="stackType"
compose-syntax-max-version="composeSyntaxMaxVersion"
is-orphaned="orphaned"
initial-values="editorTabInitialValues"
container-names="containerNames"
original-container-names="originalContainerNames"
versions="state.versions"
stack-id="stack.Id"
on-submit="(deployStack)"
on-submit-settled="(onEditorSubmitSettled)"
></stack-editor-tab>
</uib-tab>
<!-- !tab-file -->
</uib-tabset>

View File

@@ -1,14 +1,12 @@
import { ResourceControlType } from '@/react/portainer/access-control/types';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { StackStatus, StackType } from '@/react/common/stacks/types';
import { extractContainerNames } from '@/portainer/helpers/stackHelper';
import { extractContainerNames } from '@/react/docker/stacks/ItemView/container-names';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { confirm, confirmDelete, confirmWebEditorDiscard } from '@@/modals/confirm';
import { confirm, confirmDelete } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { getDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
angular.module('portainer.app').controller('StackController', [
'$async',
@@ -26,7 +24,6 @@ angular.module('portainer.app').controller('StackController', [
'TaskHelper',
'Notifications',
'FormHelper',
'GroupService',
'StackHelper',
'ResourceControlService',
'Authentication',
@@ -48,7 +45,6 @@ angular.module('portainer.app').controller('StackController', [
TaskHelper,
Notifications,
FormHelper,
GroupService,
StackHelper,
ResourceControlService,
Authentication,
@@ -72,80 +68,16 @@ angular.module('portainer.app').controller('StackController', [
migrationInProgress: false,
showEditorTab: false,
yamlError: false,
isEditorDirty: false,
};
$scope.formValues = {
Prune: false,
Endpoint: null,
AccessControlData: new AccessControlFormData(),
Env: [],
};
$window.onbeforeunload = () => {
if ($scope.stackFileContent && $scope.state.isEditorDirty) {
return '';
}
};
$scope.$on('$destroy', function () {
$scope.state.isEditorDirty = false;
});
$scope.handleEnvVarChange = handleEnvVarChange;
function handleEnvVarChange(value) {
$scope.formValues.Env = value;
}
$scope.onEnableWebhookChange = function (enable) {
$scope.$evalAsync(() => {
$scope.formValues.EnableWebhook = enable;
});
};
$scope.onPruneChange = function (enable) {
$scope.$evalAsync(() => {
$scope.formValues.Prune = enable;
});
};
$scope.duplicateStack = function duplicateStack(name, targetEndpointId) {
var stack = $scope.stack;
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
return StackService.duplicateStack(name, $scope.stackFileContent, env, targetEndpointId, stack.Type).then(onDuplicationSuccess).catch(notifyOnError);
function onDuplicationSuccess() {
Notifications.success('Success', 'Stack successfully duplicated');
$state.go('docker.stacks', {}, { reload: true });
}
function notifyOnError(err) {
Notifications.error('Failure', err, 'Unable to duplicate stack');
}
};
$scope.showEditor = function () {
$scope.state.showEditorTab = true;
};
$scope.migrateStack = function (name, endpointId) {
return $q(async function (resolve) {
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
message:
'This action will deploy a new instance of this stack on the target environment, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.',
confirmButton: buildConfirmButton('Migrate', 'danger'),
});
if (!confirmed) {
return resolve();
}
return resolve(migrateStack(name, endpointId));
});
};
$scope.removeStack = function () {
confirmDelete('Do you want to remove the stack? Associated services will be removed as well').then((confirmed) => {
if (!confirmed) {
@@ -161,40 +93,14 @@ angular.module('portainer.app').controller('StackController', [
return;
}
$scope.deployStack();
deployStack({
stackFileContent: $scope.stackFileContent,
environmentVariables: FormHelper.removeInvalidEnvVars($scope.stack.Env),
prune: false,
});
});
};
function migrateStack(name, targetEndpointId) {
const stack = $scope.stack;
let migrateRequest = StackService.migrateSwarmStack;
if (stack.Type === 2) {
migrateRequest = StackService.migrateComposeStack;
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, we can pass
// the current endpoint identifier as a part of the migrate request. It will be used if
// the EndpointID property is not defined on the stack.
if (!stack.EndpointId) {
stack.EndpointId = endpoint.Id;
}
$scope.state.migrationInProgress = true;
return migrateRequest(stack, targetEndpointId, name)
.then(function success() {
Notifications.success('Stack successfully migrated', stack.Name);
$state.go('docker.stacks', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to migrate stack');
})
.finally(function final() {
$scope.state.migrationInProgress = false;
});
}
function deleteStack() {
var endpointId = +$state.params.endpointId;
var stack = $scope.stack;
@@ -234,16 +140,29 @@ angular.module('portainer.app').controller('StackController', [
});
};
$scope.deployStack = function () {
$scope.onEditorSubmit = function () {
$scope.state.actionInProgress = true;
};
$scope.onEditorSubmitSettled = function () {
$scope.state.actionInProgress = false;
};
/**
* Deploy a stack
* @param {Object} stack
* @param {string} stack.stackFileContent - The stack file content to deploy
* @param {import('@@/form-components/EnvironmentVariablesFieldset').EnvVarValues} stack.environmentVariables - Array of environment variables
* @param {boolean} stack.prune - Whether to prune services that are no longer referenced
* @returns {void}
*/
function deployStack({ stackFileContent, environmentVariables, prune }) {
const stack = $scope.stack;
const isSwarmStack = stack.Type === 1;
confirmStackUpdate('Do you want to force an update of the stack?', isSwarmStack).then(function (result) {
if (!result) {
return;
}
var stackFile = $scope.stackFileContent;
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
var prune = $scope.formValues.Prune;
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, we can pass
@@ -254,10 +173,9 @@ angular.module('portainer.app').controller('StackController', [
}
$scope.state.actionInProgress = true;
StackService.updateStack(stack, stackFile, env, prune, result.pullImage)
StackService.updateStack(stack, stackFileContent, environmentVariables, prune, result.pullImage)
.then(function success() {
Notifications.success('Success', 'Stack successfully deployed');
$scope.state.isEditorDirty = false;
$state.reload();
})
.catch(function error(err) {
@@ -267,15 +185,7 @@ angular.module('portainer.app').controller('StackController', [
$scope.state.actionInProgress = false;
});
});
};
$scope.editorUpdate = function (value) {
if ($scope.stackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== value.replace(/(\r\n|\n|\r)/gm, '')) {
$scope.state.isEditorDirty = true;
$scope.stackFileContent = value;
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames, $scope.state.originalContainerNames);
}
};
}
$scope.stopStack = stopStack;
function stopStack() {
@@ -322,27 +232,17 @@ angular.module('portainer.app').controller('StackController', [
return $async(async () => {
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
getEnvironments()
.then(function success(data) {
$scope.endpoints = data.value;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve environments');
});
$q.all({
stack: StackService.stack(id),
groups: GroupService.groups(),
containers: ContainerService.containers(endpoint.Id, true),
})
.then(function success(data) {
var stack = data.stack;
$scope.groups = data.groups;
$scope.stack = stack;
$scope.containerNames = ContainerHelper.getContainerNames(data.containers);
$scope.formValues.Env = $scope.stack.Env;
$scope.formValues.Prune = !!($scope.stack.Option && $scope.stack.Option.Prune);
let resourcesPromise = Promise.resolve({});
if (!stack.Status || stack.Status === 1) {
resourcesPromise = stack.Type === 1 ? retrieveSwarmStackResources(stack.Name, agentProxy) : retrieveComposeStackResources(stack.Name);
@@ -369,9 +269,17 @@ angular.module('portainer.app').controller('StackController', [
if (isSwarm && $scope.stack.Status === StackStatus.Active) {
assignSwarmStackResources(data.resources, agentProxy);
}
$scope.state.originalContainerNames = extractContainerNames($scope.stackFileContent);
$scope.state.originalContainerNames = extractContainerNames(data.stackFile);
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames, $scope.state.originalContainerNames);
$scope.state.yamlError = StackHelper.validateYAML(data.stackFile, $scope.containerNames, $scope.state.originalContainerNames);
$scope.editorTabInitialValues = {
stackFileContent: data.stackFile,
environmentVariables: $scope.stack.Env,
enableWebhook: !!$scope.stack.Webhook,
webhookId: $scope.stack.Webhook,
prune: !!($scope.stack.Option && $scope.stack.Option.Prune),
};
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve stack details');
@@ -448,12 +356,6 @@ angular.module('portainer.app').controller('StackController', [
});
}
this.uiCanExit = async function () {
if ($scope.stackFileContent && $scope.state.isEditorDirty) {
return confirmWebEditorDiscard();
}
};
async function canManageStacks() {
return endpoint.SecuritySettings.allowStackManagementForRegularUsers || Authentication.isAdmin();
}
@@ -492,12 +394,6 @@ angular.module('portainer.app').controller('StackController', [
}
$scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion;
try {
$scope.dockerComposeSchema = await getDockerComposeSchema();
} catch (err) {
Notifications.error('Failure', err, 'Unable to load schema validation for editor');
}
}
initView();

View File

@@ -32,10 +32,10 @@ test('submit button should be disabled when name or image is missing', async ()
expect(button).toBeVisible();
expect(button).toBeDisabled();
const nameInput = getByLabelText(/name/i);
const nameInput = getByLabelText(/name/i, { selector: 'input' });
await userEvent.type(nameInput, 'name');
const imageInput = getByLabelText(/image/i);
const imageInput = getByLabelText(/image/i, { selector: 'input' });
await userEvent.type(imageInput, 'image');
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();

View File

@@ -0,0 +1,196 @@
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { EnvSelector, getEnvironmentOptions } from './EnvSelector';
describe('EnvSelector', () => {
it('should render when environment options are available', async () => {
const mockEnvironments: Environment[] = [
{
Id: 1,
Name: 'Environment 1',
GroupId: 1,
} as Environment,
{
Id: 2,
Name: 'Environment 2',
GroupId: 1,
} as Environment,
];
const mockGroups: EnvironmentGroup[] = [
{
Id: 1,
Name: 'Unassigned',
} as EnvironmentGroup,
];
renderComponent({
environments: mockEnvironments,
groups: mockGroups,
});
await waitFor(() => {
// render select
const select = screen.getByRole('combobox');
expect(select).toBeVisible();
// placeholder text
expect(screen.getByText('Select an environment')).toBeVisible();
// no error displayed
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// data-cy
expect(
screen.getByTestId('stack-duplicate-environment-select')
).toBeInTheDocument();
});
});
it('should return null when no environment options exist', async () => {
const { container } = renderComponent();
await waitFor(() => {
expect(container.firstChild).toBeNull();
});
});
it('should display FormError when error prop is provided', async () => {
const mockEnvironments: Environment[] = [
{
Id: 1,
Name: 'Environment 1',
GroupId: 1,
} as Environment,
];
const mockGroups: EnvironmentGroup[] = [
{
Id: 1,
Name: 'Group 1',
} as EnvironmentGroup,
];
const error = 'Environment is required';
renderComponent({
environments: mockEnvironments,
groups: mockGroups,
error,
});
await waitFor(() => {
expect(screen.getByRole('alert', { name: error })).toBeVisible();
});
});
function renderComponent({
environments = [],
groups = [],
onChange = vi.fn(),
error,
}: {
environments?: Environment[];
groups?: EnvironmentGroup[];
onChange?: (value: number | undefined) => void;
error?: string;
} = {}) {
const Component = withTestQueryProvider(EnvSelector);
server.use(
http.get('/api/endpoints', () => HttpResponse.json(environments)),
http.get('/api/endpoint_groups', () => HttpResponse.json(groups))
);
return render(
<Component value={undefined} onChange={onChange} error={error} />
);
}
});
describe('getEnvironmentOptions', () => {
it('should return empty array when no data provided', () => {
expect(getEnvironmentOptions([], [])).toEqual([]);
expect(
getEnvironmentOptions(
[
{
Id: 1,
Name: 'Group 1',
} as EnvironmentGroup,
],
[]
)
).toEqual([]);
});
it('should exclude current environment when currentEnvironmentId is provided', () => {
const groups: EnvironmentGroup[] = [
{ Id: 1, Name: 'Group 1' } as EnvironmentGroup,
];
const environments: Environment[] = [
{ Id: 1, Name: 'Env 1', GroupId: 1 } as Environment,
{ Id: 2, Name: 'Env 2', GroupId: 1 } as Environment,
];
const result = getEnvironmentOptions(groups, environments, 1);
expect(result).toHaveLength(1);
expect(result[0].options).toHaveLength(1);
expect(result[0].options[0]).toEqual({ label: 'Env 2', value: 2 });
});
it('should group environments by GroupId with correct structure', () => {
const groups: EnvironmentGroup[] = [
{ Id: 1, Name: 'Group 1' } as EnvironmentGroup,
{ Id: 2, Name: 'Group 2' } as EnvironmentGroup,
];
const environments: Environment[] = [
{ Id: 1, Name: 'Env 1', GroupId: 1 } as Environment,
{ Id: 2, Name: 'Env 2', GroupId: 1 } as Environment,
{ Id: 3, Name: 'Env 3', GroupId: 2 } as Environment,
];
const result = getEnvironmentOptions(groups, environments);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
label: 'Group 1',
options: [
{ label: 'Env 1', value: 1 },
{ label: 'Env 2', value: 2 },
],
});
expect(result[1]).toEqual({
label: 'Group 2',
options: [{ label: 'Env 3', value: 3 }],
});
});
it('should create "Unassigned" group for GroupId = 1', () => {
const environments: Environment[] = [
{ Id: 1, Name: 'Env 1', GroupId: 1 } as Environment,
];
const result = getEnvironmentOptions([], environments);
expect(result[0].label).toBe('Unassigned');
expect(result[0].options[0]).toEqual({ label: 'Env 1', value: 1 });
});
it('should throw error if group is missing for non-unassigned GroupId', () => {
const environments: Environment[] = [
{ Id: 1, Name: 'Env 1', GroupId: 2 } as Environment,
];
expect(() => getEnvironmentOptions([], environments)).toThrow(
'Missing group with id 2'
);
});
});

View File

@@ -0,0 +1,99 @@
import { useMemo } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import {
PortainerSelect,
GroupOption,
} from '@@/form-components/PortainerSelect';
import { FormError } from '@@/form-components/FormError';
export function EnvSelector({
value,
onChange,
error,
}: {
value: number | undefined;
onChange: (value: number | undefined) => void;
error?: string;
}) {
const envsQuery = useEnvironmentList();
const groupsQuery = useGroups();
const environmentOptions = useMemo(() => {
if (!envsQuery.environments || !groupsQuery.data) {
return [];
}
return getEnvironmentOptions(groupsQuery.data, envsQuery.environments);
}, [envsQuery.environments, groupsQuery.data]);
if (!environmentOptions.length) {
return null;
}
return (
<div className="form-group">
<PortainerSelect
value={value}
onChange={onChange}
options={environmentOptions}
placeholder="Select an environment"
data-cy="stack-duplicate-environment-select"
/>
{error && (
<div className="col-sm-12">
<FormError>{error}</FormError>
</div>
)}
</div>
);
}
/**
* Transforms environments and groups into grouped options for PortainerSelect
*/
export function getEnvironmentOptions(
groups: EnvironmentGroup[],
environments: Environment[],
currentEnvironmentId?: number
): GroupOption<number>[] {
if (!groups || !environments) {
return [];
}
// Group environments by their GroupId
const groupedEnvironments = environments.reduce<
Record<number, Array<{ label: string; value: number }>>
>((acc, environment) => {
if (environment.Id === currentEnvironmentId) {
return acc;
}
const groupId = environment.GroupId;
if (!acc[groupId]) {
acc[groupId] = [];
}
acc[groupId].push({
label: environment.Name,
value: environment.Id,
});
return acc;
}, {});
return Object.entries(groupedEnvironments).map(([groupId, envOptions]) => {
const parsedGroupId = parseInt(groupId, 10);
const group = groups.find((g) => g.Id === parsedGroupId);
if (!group && parsedGroupId !== 1) {
throw new Error(`Missing group with id ${groupId}`);
}
return {
label: group?.Name || 'Unassigned',
options: envOptions,
};
});
}

View File

@@ -0,0 +1,117 @@
import { render, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { UIRouterContext, UIRouterReact } from '@uirouter/react';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { Stack, StackType } from '../../types';
import { StackDuplicationForm } from './StackDuplicationForm';
it('should render Widget with title and inner component', async () => {
const { getByText } = renderComponent();
expect(getByText('Stack duplication / migration')).toBeVisible();
await waitFor(() => {
expect(
getByText('This feature allows you to duplicate or migrate this stack.')
).toBeVisible();
});
});
it('should initialize form with empty name and no selected environment', async () => {
const { getByPlaceholderText, getByRole } = renderComponent();
await waitFor(() => {
expect(
getByPlaceholderText('Stack name (optional for migration)')
).toHaveValue('');
expect(getByRole('button', { name: /migrate/i })).toBeDisabled();
expect(getByRole('button', { name: /duplicate/i })).toBeDisabled();
});
});
it('should display rename help text', async () => {
const { getByText } = renderComponent();
await waitFor(() => {
expect(
getByText(
'To rename the stack, choose the same environment when migrating.'
)
).toBeVisible();
});
});
function createMockStack(overrides?: Partial<Stack>): Stack {
return {
Id: 1,
Name: 'test-stack',
Type: StackType.DockerCompose,
EndpointId: 1,
SwarmId: '',
EntryPoint: 'docker-compose.yml',
Env: [{ name: 'VAR1', value: 'value1' }],
Status: 1,
ProjectPath: '/data/compose/1',
CreationDate: Date.now(),
CreatedBy: 'admin',
UpdateDate: Date.now(),
UpdatedBy: 'admin',
FromAppTemplate: false,
IsComposeFormat: true,
SupportRelativePath: false,
FilesystemPath: '/data/compose/1',
StackFileVersion: '3.7',
PreviousDeploymentInfo: null,
...overrides,
};
}
function renderComponent({
stack = createMockStack(),
currentEnvironmentId = 1,
yamlError,
originalFileContent = 'version: "3"\nservices:\n app:\n image: nginx',
}: {
stack?: Stack;
currentEnvironmentId?: number;
yamlError?: string;
originalFileContent?: string;
} = {}) {
const mockEnvironments: Environment[] = [
{ Id: 1, Name: 'Current Environment', GroupId: 1 } as Environment,
{ Id: 2, Name: 'Target Environment', GroupId: 1 } as Environment,
];
const mockGroups: EnvironmentGroup[] = [
{ Id: 1, Name: 'Unassigned' } as EnvironmentGroup,
];
server.use(
http.get('/api/endpoints', () => HttpResponse.json(mockEnvironments)),
http.get('/api/endpoint_groups', () => HttpResponse.json(mockGroups))
);
const mockRouter = {
stateService: {
go: vi.fn(),
},
} as unknown as UIRouterReact;
const Component = withTestQueryProvider(() => (
<UIRouterContext.Provider value={mockRouter}>
<StackDuplicationForm
stack={stack}
currentEnvironmentId={currentEnvironmentId}
yamlError={yamlError}
originalFileContent={originalFileContent}
/>
</UIRouterContext.Provider>
));
return { ...render(<Component />), mockRouter };
}

View File

@@ -0,0 +1,169 @@
import { Formik } from 'formik';
import { Copy } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { WidgetBody } from '@@/Widget/WidgetBody';
import { WidgetTitle } from '@@/Widget/WidgetTitle';
import { validateForm } from '@@/form-components/validate-form';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { Stack } from '../../types';
import { FormSubmitValues } from './StackDuplicationForm.types';
import { StackDuplicationFormInner } from './StackDuplicationFormInner';
import {
getBaseValidationSchema,
getDuplicateValidationSchema,
getMigrateValidationSchema,
} from './StackDuplicationForm.validation';
import { useDuplicateStackMutation } from './useDuplicateStackMutation';
import { useMigrateStackMutation } from './useMigrateStackMutation';
interface StackDuplicationFormProps {
currentEnvironmentId: number;
yamlError?: string;
originalFileContent: string;
stack: Stack;
}
export function StackDuplicationForm({
yamlError,
originalFileContent,
currentEnvironmentId,
stack,
}: StackDuplicationFormProps) {
const router = useRouter();
const duplicateMutation = useDuplicateStackMutation();
const migrateMutation = useMigrateStackMutation();
const initialValues: FormSubmitValues = {
environmentId: undefined,
newName: '',
actionType: 'migrate', // Default value, will be set by button clicks
};
return (
<Widget>
<WidgetTitle title="Stack duplication / migration" icon={Copy} />
<WidgetBody>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validateOnMount
validationSchema={getBaseValidationSchema()}
>
<StackDuplicationFormInner
yamlError={yamlError}
currentEnvironmentId={currentEnvironmentId}
currentStackName={stack.Name}
/>
</Formik>
</WidgetBody>
</Widget>
);
async function handleSubmit(values: FormSubmitValues) {
const { actionType, environmentId, newName } = values;
switch (actionType) {
case 'duplicate':
await handleDuplicate(environmentId!, newName);
break;
case 'migrate':
await handleMigrate(environmentId!, newName);
break;
default:
break;
}
}
async function handleDuplicate(environmentId: number, name: string) {
const schema = getDuplicateValidationSchema();
const errors = await validateForm(() => schema, { environmentId, name });
if (errors) {
notifyError(
'Validation Error',
undefined,
'Please fix the errors and try again.'
);
return;
}
duplicateMutation.mutate(
{
fileContent: originalFileContent,
name,
type: stack.Type,
env: stack.Env,
targetEnvironmentId: environmentId,
},
{
onSuccess() {
notifySuccess('Success', 'Stack successfully duplicated');
router.stateService.go('docker.stacks', {}, { reload: true });
},
onError(error) {
notifyError('Failure', error as Error, 'Unable to duplicate stack');
},
}
);
}
async function handleMigrate(
environmentId: number,
name: string | undefined
) {
const confirmed = await confirm({
title: 'Are you sure?',
modalType: ModalType.Warn,
message:
'This action will deploy a new instance of this stack on the target environment, please note that this does NOT relocate the content of any persistent volumes that may be attached to this stack.',
confirmButton: buildConfirmButton('Migrate', 'danger'),
});
if (!confirmed) {
return;
}
const schema = getMigrateValidationSchema(stack.Name, currentEnvironmentId);
const errors = await validateForm(() => schema, {
environmentId,
name,
});
if (errors) {
notifyError(
'Validation Error',
undefined,
'Please fix the errors and try again.'
);
return;
}
migrateMutation.mutate(
{
name,
stackType: stack.Type,
fromEnvId: currentEnvironmentId,
id: stack.Id,
targetEnvId: environmentId,
fromSwarmId: stack.SwarmId,
},
{
onSuccess() {
notifySuccess('Stack successfully migrated', name || stack.Name);
router.stateService.go('docker.stacks', {}, { reload: true });
},
onError(error) {
notifyError('Failure', error as Error, 'Unable to migrate stack');
},
}
);
}
}

View File

@@ -0,0 +1,10 @@
export interface FormValues {
environmentId: number | undefined;
newName: string;
}
export type ActionType = 'duplicate' | 'migrate';
export interface FormSubmitValues extends FormValues {
actionType: ActionType;
}

View File

@@ -0,0 +1,320 @@
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import type { AnySchema } from 'yup';
import {
getDuplicateValidationSchema,
getMigrateValidationSchema,
useValidation,
} from './StackDuplicationForm.validation';
import { FormSubmitValues } from './StackDuplicationForm.types';
describe('getDuplicateValidationSchema', () => {
const schema = getDuplicateValidationSchema();
describe('name validation', () => {
it.each([
{
name: '',
environmentId: 2,
error: 'Stack name is required',
scenario: 'should fail with empty name',
},
{
name: 'mystack123',
environmentId: 2,
error: '',
scenario: 'should be valid with lowercase alphanumeric',
},
{
name: 'my_stack',
environmentId: 2,
error: '',
scenario: 'should be valid with underscores',
},
{
name: 'my-stack',
environmentId: 2,
error: '',
scenario: 'should be valid with hyphens',
},
{
name: 'my_stack-123',
environmentId: 2,
error: '',
scenario: 'should be valid with underscores and hyphens',
},
{
name: 'MyStack',
environmentId: 2,
error:
"Stack name must consist of lower case alphanumeric characters, '_' or '-'",
scenario: 'should fail with uppercase letters',
},
{
name: 'my stack',
environmentId: 2,
error:
"Stack name must consist of lower case alphanumeric characters, '_' or '-'",
scenario: 'should fail with spaces',
},
{
name: 'my@stack',
environmentId: 2,
error:
"Stack name must consist of lower case alphanumeric characters, '_' or '-'",
scenario: 'should fail with special characters',
},
])('$scenario', async ({ name, environmentId, error }) => {
const promise = schema.validate({ name, environmentId });
if (error) {
await expect(promise).rejects.toThrow(error);
} else {
await expect(promise).resolves.toBeTruthy();
}
});
});
describe('environmentId validation', () => {
testEnvironmentIdValidation(schema);
});
});
describe('getMigrateValidationSchema', () => {
const currentStackName = 'test-stack';
const currentEnvironmentId = 1;
const schema = getMigrateValidationSchema(
currentStackName,
currentEnvironmentId
);
describe('name validation (optional)', () => {
it.each([
{
name: '',
environmentId: 2,
error: '',
scenario: 'should be valid with empty string',
},
{
name: undefined,
environmentId: 2,
error: '',
scenario: 'should be valid with undefined',
},
{
name: 'mystack',
environmentId: 2,
error: '',
scenario: 'should be valid with lowercase alphanumeric',
},
{
name: 'my_stack-123',
environmentId: 2,
error: '',
scenario: 'should be valid with underscores and hyphens',
},
{
name: 'MyStack',
environmentId: 2,
error:
"Stack name must consist of lower case alphanumeric characters, '_' or '-'",
scenario: 'should fail with uppercase letters',
},
{
name: 'my@stack',
environmentId: 2,
error:
"Stack name must consist of lower case alphanumeric characters, '_' or '-'",
scenario: 'should fail with special characters',
},
])('$scenario', async ({ name, environmentId, error }) => {
const promise = schema.validate({ name, environmentId });
if (error) {
await expect(promise).rejects.toThrow(error);
} else {
await expect(promise).resolves.toBeTruthy();
}
});
});
describe('environmentId validation', () => {
testEnvironmentIdValidation(schema);
});
describe('rename validation (same environment)', () => {
it.each([
{
name: currentStackName,
environmentId: currentEnvironmentId,
error: "Can't rename to the same name",
scenario: 'should fail when renaming to same name',
},
{
name: '',
environmentId: currentEnvironmentId,
error: 'Stack name is required when renaming',
scenario: 'should fail when renaming with empty name',
},
{
name: undefined,
environmentId: currentEnvironmentId,
error: 'Stack name is required when renaming',
scenario: 'should fail when renaming with undefined name',
},
{
name: 'new-stack-name',
environmentId: currentEnvironmentId,
error: '',
scenario: 'should be valid when renaming to different name',
},
])('$scenario', async ({ name, environmentId, error }) => {
const promise = schema.validate({ name, environmentId });
if (error) {
await expect(promise).rejects.toThrow(error);
} else {
await expect(promise).resolves.toBeTruthy();
}
});
});
describe('migrate validation (different environment)', () => {
it.each([
{
name: '',
environmentId: 2,
error: '',
scenario: 'should be valid when migrating with empty name',
},
{
name: undefined,
environmentId: 2,
error: '',
scenario: 'should be valid when migrating with undefined name',
},
{
name: currentStackName,
environmentId: 2,
error: '',
scenario:
'should be valid when migrating with same name to different environment',
},
{
name: 'new-stack-name',
environmentId: 2,
error: '',
scenario:
'should be valid when migrating with different name to different environment',
},
])('$scenario', async ({ name, environmentId, error }) => {
const promise = schema.validate({ name, environmentId });
if (error) {
await expect(promise).rejects.toThrow(error);
} else {
await expect(promise).resolves.toBeTruthy();
}
});
});
});
describe('useValidation', () => {
const currentStackName = 'test-stack';
const currentEnvironmentId = 1;
it('should start with both migrate and duplicate as false', () => {
const { result } = renderHook(() =>
useValidation({
values: {
environmentId: undefined,
newName: '',
actionType: 'migrate',
},
currentStackName,
currentEnvironmentId,
})
);
expect(result.current.migrate).toBe(false);
expect(result.current.duplicate).toBe(false);
});
describe('reactive updates', () => {
it('should revalidate when environmentId changes', async () => {
const { result, rerender } = renderHook(
({ values }: { values: FormSubmitValues }) =>
useValidation({ values, currentStackName, currentEnvironmentId }),
{
initialProps: {
values: {
environmentId: undefined as number | undefined,
newName: 'mystack',
actionType: 'duplicate' as const,
},
},
}
);
await waitFor(() => {
expect(result.current.duplicate).toBe(false);
});
rerender({
values: {
environmentId: 2,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
expect(result.current.duplicate).toBe(true);
});
});
it('should revalidate when newName changes', async () => {
const { result, rerender } = renderHook(
({ values }: { values: FormSubmitValues }) =>
useValidation({ values, currentStackName, currentEnvironmentId }),
{
initialProps: {
values: {
environmentId: 2,
newName: '',
actionType: 'duplicate' as const,
},
},
}
);
await waitFor(() => {
expect(result.current.duplicate).toBe(false);
});
rerender({
values: {
environmentId: 2,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
expect(result.current.duplicate).toBe(true);
});
});
});
});
function testEnvironmentIdValidation(schema: AnySchema) {
it('should require environmentId', async () => {
await expect(
schema.validate({ name: 'mystack', environmentId: undefined })
).rejects.toThrow('Target environment must be selected');
});
it('should accept valid environmentId', async () => {
await expect(
schema.validate({ name: 'mystack', environmentId: 2 })
).resolves.toBeTruthy();
});
}

View File

@@ -0,0 +1,133 @@
import { object, string, number } from 'yup';
import { useEffect, useState } from 'react';
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
import { validateForm } from '@@/form-components/validate-form';
import { FormSubmitValues } from './StackDuplicationForm.types';
/**
* since this form has two actions, we need to manage separate validation state. Ideally we would use separate forms
*/
export function useValidation({
values,
currentStackName,
currentEnvironmentId,
}: {
values: FormSubmitValues;
currentStackName: string;
currentEnvironmentId: number;
}) {
const [validState, setValidState] = useState({
migrate: false,
duplicate: false,
});
useEffect(() => {
async function validateSchemas() {
const migrateSchema = getMigrateValidationSchema(
currentStackName,
currentEnvironmentId
);
const migrateErrors = await validateForm(() => migrateSchema, {
environmentId: values.environmentId || undefined,
name: values.newName,
});
setValidState((state) => ({ ...state, migrate: !migrateErrors }));
}
validateSchemas();
}, [
values.environmentId,
values.newName,
currentStackName,
currentEnvironmentId,
]);
useEffect(() => {
async function validateSchema() {
const duplicateSchema = getDuplicateValidationSchema();
const duplicateErrors = await validateForm(() => duplicateSchema, {
environmentId: values.environmentId || undefined,
name: values.newName,
});
setValidState((state) => ({ ...state, duplicate: !duplicateErrors }));
}
validateSchema();
}, [values.environmentId, values.newName]);
return validState;
}
const regexp = new RegExp(STACK_NAME_VALIDATION_REGEX);
const baseNameValidation = string().test(
'valid-format-if-provided',
"Stack name must consist of lower case alphanumeric characters, '_' or '-'",
(value) => !value || regexp.test(value)
);
const baseEnvValidation = number().required(
'Target environment must be selected'
);
export function getBaseValidationSchema() {
return object({
name: baseNameValidation,
environmentId: baseEnvValidation,
});
}
export function getDuplicateValidationSchema() {
return object({
name: baseNameValidation.required('Stack name is required'),
environmentId: baseEnvValidation,
});
}
export function getMigrateValidationSchema(
currentStackName?: string,
currentEnvironmentId?: number
) {
return object({
name: baseNameValidation
.test(
'required-for-rename',
'Stack name is required when renaming',
function validate(value) {
const { environmentId } = this.parent;
// If renaming (same environment), name is required
if (
currentEnvironmentId !== undefined &&
environmentId === currentEnvironmentId
) {
return !!value && value.length > 0;
}
// For migration to different environment, name is optional
return true;
}
)
.test(
'not-same-name-for-rename',
"Can't rename to the same name",
function validate(value) {
const { environmentId } = this.parent;
// If renaming (same environment) and name matches current stack name, reject
if (
currentStackName &&
currentEnvironmentId !== undefined &&
environmentId === currentEnvironmentId &&
value === currentStackName
) {
return false;
}
return true;
}
),
environmentId: baseEnvValidation,
});
}

View File

@@ -0,0 +1,533 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Formik } from 'formik';
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { Environment } from '@/react/portainer/environments/types';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { StackDuplicationFormInner } from './StackDuplicationFormInner';
import { FormSubmitValues } from './StackDuplicationForm.types';
describe('StackDuplicationFormInner', () => {
describe('initial rendering', () => {
it('should render form', async () => {
renderFormInner();
expect(
screen.getByText(
'This feature allows you to duplicate or migrate this stack.'
)
).toBeVisible();
await waitFor(() => {
const input = screen.getByPlaceholderText(
'Stack name (optional for migration)'
);
expect(input).toBeVisible();
});
expect(screen.getByRole('button', { name: /migrate/i })).toBeVisible();
expect(screen.getByRole('button', { name: /duplicate/i })).toBeVisible();
await waitFor(() => {
expect(
screen.getByTestId('stack-duplicate-name-input')
).toBeInTheDocument();
expect(screen.getByTestId('stack-migrate-button')).toBeInTheDocument();
expect(
screen.getByTestId('stack-duplicate-button')
).toBeInTheDocument();
});
});
});
describe('button states - migrate', () => {
it('should disable Migrate button when no environment is selected', async () => {
const { getByRole } = renderFormInner();
await waitFor(() => {
const migrateButton = getByRole('button', { name: /migrate/i });
expect(migrateButton).toBeDisabled();
});
});
it('should enable Migrate button when valid environment is selected', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: 2,
newName: '',
actionType: 'migrate',
},
});
await waitFor(() => {
const migrateButton = getByRole('button', { name: /migrate/i });
expect(migrateButton).toBeEnabled();
});
});
it('should show "Rename" button when environmentId matches current environment', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: 1,
newName: '',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
await waitFor(() => {
const renameButton = getByRole('button', { name: /rename/i });
expect(renameButton).toBeVisible();
});
});
it('should show "Migrate" button text when environmentId differs from current environment', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: 2,
newName: '',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
await waitFor(() => {
expect(getByRole('button', { name: 'Migrate' })).toBeVisible();
});
});
});
describe('button states - duplicate', () => {
it('should disable Duplicate button when name is empty', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: 2,
newName: '',
actionType: 'duplicate',
},
});
await waitFor(() => {
const duplicateButton = getByRole('button', { name: /duplicate/i });
expect(duplicateButton).toBeDisabled();
});
});
it('should disable Duplicate button when no environment is selected', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: undefined,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
const duplicateButton = getByRole('button', { name: /duplicate/i });
expect(duplicateButton).toBeDisabled();
});
});
it('should disable Duplicate button when yamlError is present', async () => {
const { getByRole } = renderFormInner({
yamlError: 'Invalid YAML',
initialValues: {
environmentId: 2,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
const duplicateButton = getByRole('button', { name: /duplicate/i });
expect(duplicateButton).toBeDisabled();
});
});
it('should enable Duplicate button when valid name and environment selected and no yamlError', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: 2,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
const duplicateButton = getByRole('button', { name: /duplicate/i });
expect(duplicateButton).toBeEnabled();
});
});
});
describe('form interactions', () => {
it('should update newName field when user types', async () => {
const { getByPlaceholderText } = renderFormInner();
const user = userEvent.setup();
await waitFor(() => {
const input = getByPlaceholderText(
'Stack name (optional for migration)'
);
expect(input).toBeVisible();
});
const input = getByPlaceholderText('Stack name (optional for migration)');
await user.type(input, 'mystack');
expect(input).toHaveValue('mystack');
});
it('should display FormError for newName when validation error exists', async () => {
const { getByText } = renderFormInner();
// Formik with validation schema will show errors
// This test demonstrates the error display mechanism
// In a real scenario, validation would trigger after user interaction
await waitFor(() => {
const form = getByText(
'This feature allows you to duplicate or migrate this stack.'
);
expect(form).toBeVisible();
});
});
});
describe('action handlers', () => {
it('should call onSubmit with actionType "migrate" when Migrate button clicked', async () => {
const onSubmit = vi.fn();
const { getByRole } = renderFormInner({
onSubmit,
initialValues: {
environmentId: 2,
newName: '',
actionType: 'migrate',
},
});
const user = userEvent.setup();
await waitFor(() => {
const migrateButton = getByRole('button', { name: /migrate/i });
expect(migrateButton).toBeEnabled();
});
const migrateButton = getByRole('button', { name: /migrate/i });
await user.click(migrateButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
actionType: 'migrate',
environmentId: 2,
newName: '',
}),
expect.anything()
);
});
});
it('should call onSubmit with actionType "duplicate" when Duplicate button clicked', async () => {
const onSubmit = vi.fn();
const { getByRole } = renderFormInner({
onSubmit,
initialValues: {
environmentId: 2,
newName: 'mystack',
actionType: 'duplicate',
},
});
const user = userEvent.setup();
await waitFor(() => {
const duplicateButton = getByRole('button', { name: /duplicate/i });
expect(duplicateButton).toBeEnabled();
});
const duplicateButton = getByRole('button', { name: /duplicate/i });
await user.click(duplicateButton);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
actionType: 'duplicate',
environmentId: 2,
newName: 'mystack',
}),
expect.anything()
);
});
});
});
describe('YAML error display', () => {
it('should display yamlError when environment is selected and error exists', async () => {
const yamlError = 'Invalid YAML format';
renderFormInner({
yamlError,
initialValues: {
environmentId: 2,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
const errorElement = screen.getByRole('alert', { name: 'Yaml Error' });
expect(errorElement).toBeVisible();
});
});
it('should not display yamlError when no environment is selected', async () => {
const yamlError = 'Invalid YAML format';
renderFormInner({
yamlError,
initialValues: {
environmentId: undefined,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
expect(screen.queryByRole('alert', { name: 'Yaml Error' })).toBeNull();
});
});
it('should not display yamlError when no error exists', async () => {
renderFormInner({
initialValues: {
environmentId: 2,
newName: 'mystack',
actionType: 'duplicate',
},
});
await waitFor(() => {
expect(screen.queryByRole('alert', { name: 'Yaml Error' })).toBeNull();
});
});
});
describe('rename functionality', () => {
it('should display rename help text', () => {
const { getByText } = renderFormInner();
expect(
getByText(
'To rename the stack, choose the same environment when migrating.'
)
).toBeVisible();
});
it('should disable rename when current environment selected with empty name', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: 1,
newName: '',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeDisabled();
});
});
it('should enable rename when current environment selected with valid name', async () => {
const { getByRole } = renderFormInner({
initialValues: {
environmentId: 1,
newName: 'newname',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeEnabled();
});
});
it('should show "Renaming in progress..." loading text when renaming', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise(() => {})); // Never resolves
const { getByRole } = renderFormInner({
onSubmit,
initialValues: {
environmentId: 1,
newName: 'newname',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
const user = userEvent.setup();
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeEnabled();
});
const renameButton = getByRole('button', { name: 'Rename' });
await user.click(renameButton);
await waitFor(() => {
expect(
getByRole('button', { name: /renaming in progress/i })
).toBeInTheDocument();
});
});
it('should show "Migration in progress..." loading text when migrating to different environment', async () => {
const onSubmit = vi.fn().mockImplementation(() => new Promise(() => {})); // Never resolves
const { getByRole } = renderFormInner({
onSubmit,
initialValues: {
environmentId: 2,
newName: '',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
const user = userEvent.setup();
await waitFor(() => {
const migrateButton = getByRole('button', { name: 'Migrate' });
expect(migrateButton).toBeEnabled();
});
const migrateButton = getByRole('button', { name: 'Migrate' });
await user.click(migrateButton);
await waitFor(() => {
expect(
getByRole('button', { name: /migration in progress/i })
).toBeInTheDocument();
});
});
it('should disable rename button when renaming to the same name', async () => {
const currentStackName = 'test-stack';
const { getByRole } = renderFormInner({
currentStackName,
initialValues: {
environmentId: 1,
newName: currentStackName,
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeDisabled();
});
});
it('should disable rename button when user types the same name', async () => {
const currentStackName = 'test-stack';
const { getByRole, getByPlaceholderText } = renderFormInner({
currentStackName,
initialValues: {
environmentId: 1,
newName: '',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
const user = userEvent.setup();
// Initially rename button should be disabled with empty name
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeDisabled();
});
// Type a valid different name
const input = getByPlaceholderText('Stack name (optional for migration)');
await user.type(input, 'newname');
// Button should now be enabled
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeEnabled();
});
// Clear and type the same name as current stack
await user.clear(input);
await user.type(input, currentStackName);
// Now the button should be disabled again
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeDisabled();
});
});
it('should enable rename button when renaming to a different name', async () => {
const currentStackName = 'test-stack';
const { getByRole } = renderFormInner({
currentStackName,
initialValues: {
environmentId: 1,
newName: 'new-stack-name',
actionType: 'migrate',
},
currentEnvironmentId: 1,
});
await waitFor(() => {
const renameButton = getByRole('button', { name: 'Rename' });
expect(renameButton).toBeEnabled();
});
});
});
});
function renderFormInner({
yamlError,
currentEnvironmentId = 1,
currentStackName = 'test-stack',
onSubmit = vi.fn(),
initialValues = {
environmentId: undefined,
newName: '',
actionType: 'migrate' as const,
},
}: {
yamlError?: string;
currentEnvironmentId?: number;
currentStackName?: string;
onSubmit?: (values: FormSubmitValues) => void | Promise<void>;
initialValues?: FormSubmitValues;
} = {}) {
const mockEnvironments: Environment[] = [
{ Id: 1, Name: 'Current Environment', GroupId: 1 } as Environment,
{ Id: 2, Name: 'Target Environment', GroupId: 1 } as Environment,
];
const mockGroups: EnvironmentGroup[] = [
{ Id: 1, Name: 'Unassigned' } as EnvironmentGroup,
];
server.use(
http.get('/api/endpoints', () => HttpResponse.json(mockEnvironments)),
http.get('/api/endpoint_groups', () => HttpResponse.json(mockGroups))
);
const Component = withTestQueryProvider(() => (
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<StackDuplicationFormInner
yamlError={yamlError}
currentEnvironmentId={currentEnvironmentId}
currentStackName={currentStackName}
/>
</Formik>
));
return render(<Component />);
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { Field, Form, useFormikContext } from 'formik';
import { Copy, ArrowRight } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { Input } from '@@/form-components/Input';
import { FormError } from '@@/form-components/FormError';
import { TextTip } from '@@/Tip/TextTip';
import { FormSubmitValues, ActionType } from './StackDuplicationForm.types';
import { useValidation } from './StackDuplicationForm.validation';
import { EnvSelector } from './EnvSelector';
interface Props {
yamlError?: string;
currentEnvironmentId: EnvironmentId;
currentStackName: string;
}
export function StackDuplicationFormInner({
yamlError,
currentEnvironmentId,
currentStackName,
}: Props) {
const { values, errors, setFieldValue, submitForm, isSubmitting } =
useFormikContext<FormSubmitValues>();
const validState = useValidation({
values,
currentStackName,
currentEnvironmentId,
});
const [actionType, setActionType] = useState<ActionType | null>(null);
const isEnvSelected = !!values.environmentId;
async function handleAction(type: ActionType) {
setActionType(type);
// Set the actionType in form values before submitting
await setFieldValue('actionType', type);
await submitForm();
}
const isMigrateInProgress = isSubmitting && actionType === 'migrate';
const isDuplicateInProgress = isSubmitting && actionType === 'duplicate';
const isMigrateDisabled = isSubmitting || !validState.migrate;
const isDuplicateDisabled =
isSubmitting || !validState.duplicate || !!yamlError;
return (
<Form>
<TextTip color="blue">
<p>This feature allows you to duplicate or migrate this stack. </p>
<p>To rename the stack, choose the same environment when migrating.</p>
</TextTip>
<div className="form-group">
<Field
as={Input}
type="text"
placeholder="Stack name (optional for migration)"
aria-label="Stack name"
name="newName"
data-cy="stack-duplicate-name-input"
/>
{errors.newName && (
<div className="col-sm-12">
<FormError>{errors.newName}</FormError>
</div>
)}
</div>
<EnvSelector
onChange={(value) => setFieldValue('environmentId', value)}
value={values.environmentId}
error={errors.environmentId}
/>
<div className="inline-flex gap-2">
<LoadingButton
type="button"
color="primary"
size="small"
disabled={isMigrateDisabled}
isLoading={isMigrateInProgress}
loadingText={
values.environmentId === currentEnvironmentId
? 'Renaming in progress...'
: 'Migration in progress...'
}
onClick={() => handleAction('migrate')}
icon={ArrowRight}
data-cy="stack-migrate-button"
className="!ml-0"
>
{values.environmentId === currentEnvironmentId ? 'Rename' : 'Migrate'}
</LoadingButton>
<LoadingButton
type="button"
color="primary"
size="small"
disabled={isDuplicateDisabled}
isLoading={isDuplicateInProgress}
loadingText="Duplication in progress..."
onClick={() => handleAction('duplicate')}
icon={Copy}
data-cy="stack-duplicate-button"
>
Duplicate
</LoadingButton>
</div>
{yamlError && isEnvSelected && (
<div className="form-group" role="alert" aria-label="Yaml Error">
<div>
<span className="text-danger small">{yamlError}</span>
</div>
</div>
)}
</Form>
);
}

View File

@@ -0,0 +1,277 @@
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { StackType } from '../../types';
import { duplicateStack } from './useDuplicateStackMutation';
type StackRequestBody = {
name: string;
swarmID: string;
env?: Array<{ name: string; value: string }>;
};
describe('Swarm stack duplication', () => {
it('should call getSwarm with targetEnvironmentId for Swarm stacks', async () => {
const swarmId = 'swarm123';
let swarmRequestCalled = false;
server.use(
http.get('/api/endpoints/:id/docker/swarm', ({ params }) => {
swarmRequestCalled = true;
expect(params.id).toBe('2');
return HttpResponse.json({ ID: swarmId });
}),
http.post('/api/stacks/create/:type/:method', async () =>
HttpResponse.json({ Id: 123 })
)
);
await duplicateStack({
name: 'test-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerSwarm,
env: [{ name: 'VAR1', value: 'value1' }],
});
expect(swarmRequestCalled).toBe(true);
});
it('should call createSwarmStackFromFileContent with correct parameters', async () => {
const swarmId = 'swarm123';
let stackRequestBody: undefined | StackRequestBody;
server.use(
http.get('/api/endpoints/:id/docker/swarm', () =>
HttpResponse.json({ ID: swarmId })
),
http.post('/api/stacks/create/:type/:method', async ({ request }) => {
stackRequestBody = (await request.json()) as StackRequestBody;
return HttpResponse.json({ Id: 123 });
})
);
const fileContent = 'version: "3"\nservices:\n app:\n image: nginx';
const env = [{ name: 'VAR1', value: 'value1' }];
await duplicateStack({
name: 'test-stack',
fileContent,
targetEnvironmentId: 2,
type: StackType.DockerSwarm,
env,
});
expect(stackRequestBody).toBeDefined();
expect(stackRequestBody?.name).toBe('test-stack');
expect(stackRequestBody?.swarmID).toBe(swarmId);
});
it('should throw error if Swarm ID is missing', async () => {
server.use(
http.get('/api/endpoints/:id/docker/swarm', () =>
HttpResponse.json({ ID: '' })
)
);
await expect(
duplicateStack({
name: 'test-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerSwarm,
})
).rejects.toThrow('Swarm ID is required');
});
it('should pass swarmID from getSwarm response', async () => {
const swarmId = 'custom-swarm-id-456';
let stackRequestBody: StackRequestBody | undefined;
server.use(
http.get('/api/endpoints/:id/docker/swarm', () =>
HttpResponse.json({ ID: swarmId })
),
http.post('/api/stacks/create/:type/:method', async ({ request }) => {
stackRequestBody = (await request.json()) as StackRequestBody;
return HttpResponse.json({ Id: 123 });
})
);
await duplicateStack({
name: 'test-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerSwarm,
});
expect(stackRequestBody?.swarmID).toBe(swarmId);
});
it('should pass environmentId, name, stackFileContent, env', async () => {
const swarmId = 'swarm123';
let stackRequestBody: StackRequestBody | undefined;
server.use(
http.get('/api/endpoints/:id/docker/swarm', () =>
HttpResponse.json({ ID: swarmId })
),
http.post('/api/stacks/create/:type/:method', async ({ request }) => {
stackRequestBody = (await request.json()) as StackRequestBody;
return HttpResponse.json({ Id: 123 });
})
);
const fileContent = 'version: "3"\nservices:\n app:\n image: nginx';
const env = [
{ name: 'VAR1', value: 'value1' },
{ name: 'VAR2', value: 'value2' },
];
await duplicateStack({
name: 'my-swarm-stack',
fileContent,
targetEnvironmentId: 3,
type: StackType.DockerSwarm,
env,
});
expect(stackRequestBody).toBeDefined();
expect(stackRequestBody?.name).toBe('my-swarm-stack');
expect(stackRequestBody?.env).toEqual(env);
});
});
describe('Standalone stack duplication', () => {
it('should call createStandaloneStackFromFileContent for non-Swarm stacks', async () => {
let stackRequestBody: StackRequestBody | undefined;
server.use(
http.post('/api/stacks/create/:type/:method', async ({ request }) => {
stackRequestBody = (await request.json()) as StackRequestBody;
return HttpResponse.json({ Id: 123 });
})
);
await duplicateStack({
name: 'standalone-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerCompose,
});
expect(stackRequestBody).toBeDefined();
expect(stackRequestBody?.name).toBe('standalone-stack');
});
it('should pass environmentId, name, stackFileContent, env', async () => {
let stackRequestBody: StackRequestBody | undefined;
server.use(
http.post('/api/stacks/create/:type/:method', async ({ request }) => {
stackRequestBody = (await request.json()) as StackRequestBody;
return HttpResponse.json({ Id: 123 });
})
);
const fileContent = 'version: "3"\nservices:\n web:\n image: nginx';
const env = [{ name: 'PORT', value: '8080' }];
await duplicateStack({
name: 'compose-stack',
fileContent,
targetEnvironmentId: 5,
type: StackType.DockerCompose,
env,
});
expect(stackRequestBody).toBeDefined();
expect(stackRequestBody?.name).toBe('compose-stack');
expect(stackRequestBody?.env).toEqual(env);
});
it('should not call getSwarm for standalone stacks', async () => {
let swarmRequestCalled = false;
server.use(
http.get('/api/endpoints/:id/docker/swarm', () => {
swarmRequestCalled = true;
return HttpResponse.json({ ID: 'swarm123' });
}),
http.post('/api/stacks/create/:type/:method', () =>
HttpResponse.json({ Id: 123 })
)
);
await duplicateStack({
name: 'standalone-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerCompose,
});
expect(swarmRequestCalled).toBe(false);
});
});
describe('error handling', () => {
it('should propagate errors from getSwarm', async () => {
server.use(
http.get('/api/endpoints/:id/docker/swarm', () =>
HttpResponse.json({ message: 'Swarm not found' }, { status: 404 })
)
);
await expect(
duplicateStack({
name: 'test-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerSwarm,
})
).rejects.toThrow();
});
it('should propagate errors from createSwarmStackFromFileContent', async () => {
server.use(
http.get('/api/endpoints/:id/docker/swarm', () =>
HttpResponse.json({ ID: 'swarm123' })
),
http.post('/api/stacks/create/:type/:method', () =>
HttpResponse.json({ message: 'Stack creation failed' }, { status: 500 })
)
);
await expect(
duplicateStack({
name: 'test-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerSwarm,
})
).rejects.toThrow();
});
it('should propagate errors from createStandaloneStackFromFileContent', async () => {
server.use(
http.post('/api/stacks/create/:type/:method', () =>
HttpResponse.json(
{ message: 'Stack name already exists' },
{ status: 409 }
)
)
);
await expect(
duplicateStack({
name: 'existing-stack',
fileContent: 'version: "3"\nservices:\n app:\n image: nginx',
targetEnvironmentId: 2,
type: StackType.DockerCompose,
})
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,51 @@
import { useMutation } from '@tanstack/react-query';
import { getSwarm } from '@/react/docker/proxy/queries/useSwarm';
import { Pair } from '@/react/portainer/settings/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { createStandaloneStackFromFileContent } from '../../queries/useCreateStack/createStandaloneStackFromFileContent';
import { createSwarmStackFromFileContent } from '../../queries/useCreateStack/createSwarmStackFromFileContent';
import { StackType } from '../../types';
export function useDuplicateStackMutation() {
return useMutation({
mutationFn: duplicateStack,
});
}
export async function duplicateStack({
name,
fileContent,
targetEnvironmentId,
type,
env,
}: {
name: string;
fileContent: string;
targetEnvironmentId: EnvironmentId;
type: StackType;
env?: Array<Pair>;
}) {
if (type === StackType.DockerSwarm) {
const swarm = await getSwarm(targetEnvironmentId);
if (!swarm.ID) {
throw new Error('Swarm ID is required to duplicate a Swarm stack');
}
return createSwarmStackFromFileContent({
environmentId: targetEnvironmentId,
name,
stackFileContent: fileContent,
swarmID: swarm.ID,
env,
});
}
return createStandaloneStackFromFileContent({
environmentId: targetEnvironmentId,
name,
stackFileContent: fileContent,
env,
});
}

Some files were not shown because too many files have changed in this diff Show More