refactor(stack): move stack update into transaction [BE-12244] (#1324)
This commit is contained in:
53
api/http/handler/stacks/stack_test_helper.go
Normal file
53
api/http/handler/stacks/stack_test_helper.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
func mockCreateUser(store *datastore.Store) (*portainer.User, error) {
|
||||
user := &portainer.User{ID: 1, Username: "testUser", Role: portainer.AdministratorRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
|
||||
err := store.User().Create(user)
|
||||
return user, err
|
||||
}
|
||||
|
||||
func mockCreateEndpoint(store *datastore.Store) (*portainer.Endpoint, error) {
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: 1,
|
||||
Name: "testEndpoint",
|
||||
SecuritySettings: portainer.EndpointSecuritySettings{
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowVolumeBrowserForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
AllowDeviceMappingForRegularUsers: true,
|
||||
AllowStackManagementForRegularUsers: true,
|
||||
AllowContainerCapabilitiesForRegularUsers: true,
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
EnableHostManagementFeatures: true,
|
||||
},
|
||||
}
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
|
||||
return endpoint, err
|
||||
}
|
||||
|
||||
func mockCreateStackRequestWithSecurityContext(method, target string, body io.Reader) *http.Request {
|
||||
req := httptest.NewRequest(method,
|
||||
target,
|
||||
body)
|
||||
|
||||
ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: portainer.UserID(1),
|
||||
})
|
||||
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
@@ -78,13 +79,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 +86,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 +171,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 +191,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)
|
||||
@@ -213,10 +228,9 @@ 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,
|
||||
@@ -243,7 +257,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)
|
||||
@@ -280,10 +294,9 @@ 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,
|
||||
|
||||
419
api/http/handler/stacks/stack_update_test.go
Normal file
419
api/http/handler/stacks/stack_update_test.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_updateStackInTx(t *testing.T) {
|
||||
t.Run("Transaction commits successfully - changes are persisted", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}},
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
|
||||
// Execute updateStackInTx within a successful transaction
|
||||
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err, "transction should succeed")
|
||||
|
||||
// Verify the stack was updated in the database (transaction committed)
|
||||
stackAfterCommit, err := setup.store.Stack().Read(setup.stack.ID)
|
||||
require.NoError(t, err, "should be able to read stack after commit")
|
||||
require.NotNil(t, stackAfterCommit)
|
||||
require.Equal(t, "BAR", stackAfterCommit.Env[0].Value, "stack env variable should be updated")
|
||||
})
|
||||
|
||||
t.Run("Transaction rollback on error - changes not persisted", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}},
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
|
||||
// Execute updateStackInTx within a transaction that we force to fail
|
||||
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
updatedStack, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// Verify changes are visible within the transaction
|
||||
assert.NotNil(t, updatedStack)
|
||||
assert.Equal(t, setup.user.Username, updatedStack.UpdatedBy)
|
||||
assert.NotZero(t, updatedStack.UpdateDate)
|
||||
|
||||
// Force the transaction to fail by returning an error
|
||||
return errors.New("forced transaction failure")
|
||||
})
|
||||
|
||||
// Verify the transaction failed
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "forced transaction failure")
|
||||
|
||||
// Verify the stack was NOT updated in the database (transaction rolled back)
|
||||
stackAfterRollback, err := setup.store.Stack().Read(setup.stack.ID)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, stackAfterRollback.Env, "stack env variable should remain unchanged after rollback")
|
||||
})
|
||||
|
||||
t.Run("Error: Stack not found returns NotFound httperror", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
setup.req.URL.Path = "/stacks/9999" // Non-existent stack ID
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, 9999, setup.endpoint.ID)
|
||||
return handlerErr
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound")
|
||||
assert.Contains(t, handlerErr.Message, "Unable to find a stack", "error message should mention stack")
|
||||
})
|
||||
|
||||
t.Run("Error: Endpoint not found returns NotFound httperror", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, 2999) // Non-existent endpoint ID
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound")
|
||||
assert.Contains(t, handlerErr.Message, "Unable to find the environment", "error message should mention environment")
|
||||
})
|
||||
|
||||
t.Run("Error: user cannot access the stack", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
originalUser, err := setup.store.User().Read(setup.user.ID)
|
||||
require.NoError(t, err, "error reading user")
|
||||
|
||||
// Modify the user's role to restrict access
|
||||
originalUser.Role = portainer.StandardUserRole
|
||||
err = setup.store.User().Update(originalUser.ID, originalUser)
|
||||
require.NoError(t, err, "error updating user role")
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode, "should return 403 Forbidden")
|
||||
assert.Contains(t, handlerErr.Message, "Access denied", "error message should mention access")
|
||||
})
|
||||
|
||||
t.Run("Error: user not found", func(t *testing.T) {
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Type: portainer.DockerComposeStack,
|
||||
}
|
||||
setup := setupUpdateStackInTxTest(t, stack, payload)
|
||||
err := setup.store.User().Delete(setup.user.ID) // Delete the user to simulate "user not found"
|
||||
require.NoError(t, err, "error deleting user")
|
||||
|
||||
var handlerErr *httperror.HandlerError
|
||||
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NotNil(t, handlerErr, "handler error should be set")
|
||||
assert.Equal(t, http.StatusInternalServerError, handlerErr.StatusCode, "should return 500 Internal Server Error")
|
||||
assert.Contains(t, handlerErr.Message, "Unable to verify user authorizations to validate stack access", "error message should mention user authorizations")
|
||||
})
|
||||
}
|
||||
|
||||
func TestStackUpdate(t *testing.T) {
|
||||
t.Helper()
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
testDataPath := filepath.Join(t.TempDir())
|
||||
fileService, err := filesystem.NewService(testDataPath, "")
|
||||
require.NoError(t, err, "error init file service")
|
||||
|
||||
// Create test user
|
||||
_, err = mockCreateUser(store)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
// Create test endpoint
|
||||
endpoint, err := mockCreateEndpoint(store)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
|
||||
// Create test stack
|
||||
stack := &portainer.Stack{
|
||||
ID: 1,
|
||||
Name: "test-stack-1",
|
||||
EntryPoint: "docker-compose.yml",
|
||||
EndpointID: endpoint.ID,
|
||||
ProjectPath: fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", 1),
|
||||
Type: portainer.DockerSwarmStack,
|
||||
}
|
||||
|
||||
err = store.Stack().Create(stack)
|
||||
require.NoError(t, err, "error creating stack")
|
||||
|
||||
// Create resource control for the stack
|
||||
resourceControl := &portainer.ResourceControl{
|
||||
ID: portainer.ResourceControlID(stack.ID),
|
||||
ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name),
|
||||
Type: portainer.StackResourceControl,
|
||||
AdministratorsOnly: false,
|
||||
}
|
||||
err = store.ResourceControl().Create(resourceControl)
|
||||
require.NoError(t, err, "error creating resource control")
|
||||
|
||||
// Store initial stack file
|
||||
_, err = fileService.StoreStackFileFromBytes(
|
||||
strconv.Itoa(int(stack.ID)),
|
||||
stack.EntryPoint,
|
||||
[]byte("version: '3'\nservices:\n web:\n image: nginx:v1"),
|
||||
)
|
||||
require.NoError(t, err, "error storing stack file")
|
||||
|
||||
// Create handler
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
handler.FileService = fileService
|
||||
handler.StackDeployer = testStackDeployer{}
|
||||
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||
handler.SwarmStackManager = swarmStackManager{}
|
||||
|
||||
payload := &updateComposeStackPayload{
|
||||
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
|
||||
}
|
||||
// Create mock request with security context
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Endpoint is not provided in query param nor header", func(t *testing.T) {
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/%d", stack.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "expected status BadRequest when endpoint is not provided")
|
||||
})
|
||||
|
||||
t.Run("Stack doesn't exist", func(t *testing.T) {
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/test-stack-1?endpointId=%d", endpoint.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "expected status NotFound when stack doesn't exist")
|
||||
})
|
||||
|
||||
t.Run("Update stack successfully", func(t *testing.T) {
|
||||
fips.InitFIPS(false)
|
||||
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, "expected status OK when stack is updated successfully")
|
||||
var stackResponse portainer.Stack
|
||||
err = json.NewDecoder(rec.Body).Decode(&stackResponse)
|
||||
require.NoError(t, err, "error decoding response body")
|
||||
require.NotZero(t, stackResponse.UpdateDate, "stack update date should be set")
|
||||
})
|
||||
}
|
||||
|
||||
// setupUpdateStackInTxTest creates a fresh test environment for each subtest
|
||||
type updateStackInTxTestSetup struct {
|
||||
store *datastore.Store
|
||||
fileService portainer.FileService
|
||||
handler *Handler
|
||||
user *portainer.User
|
||||
endpoint *portainer.Endpoint
|
||||
stack *portainer.Stack
|
||||
resourceControl *portainer.ResourceControl
|
||||
jsonPayload []byte
|
||||
req *http.Request
|
||||
}
|
||||
|
||||
func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *updateComposeStackPayload) *updateStackInTxTestSetup {
|
||||
t.Helper()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
testDataPath := filepath.Join(t.TempDir())
|
||||
fileService, err := filesystem.NewService(testDataPath, "")
|
||||
require.NoError(t, err, "error init file service")
|
||||
|
||||
// Create test user
|
||||
user, err := mockCreateUser(store)
|
||||
require.NoError(t, err, "error creating user")
|
||||
|
||||
// Create test endpoint
|
||||
endpoint, err := mockCreateEndpoint(store)
|
||||
require.NoError(t, err, "error creating endpoint")
|
||||
|
||||
// Create test stack
|
||||
stack.EndpointID = endpoint.ID
|
||||
stack.ProjectPath = fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", stack.ID)
|
||||
|
||||
err = store.Stack().Create(stack)
|
||||
require.NoError(t, err, "error creating stack")
|
||||
|
||||
// Create resource control for the stack
|
||||
resourceControl := &portainer.ResourceControl{
|
||||
ID: portainer.ResourceControlID(stack.ID),
|
||||
ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name),
|
||||
Type: portainer.StackResourceControl,
|
||||
AdministratorsOnly: false,
|
||||
}
|
||||
err = store.ResourceControl().Create(resourceControl)
|
||||
require.NoError(t, err, "error creating resource control")
|
||||
|
||||
// Store initial stack file
|
||||
_, err = fileService.StoreStackFileFromBytes(
|
||||
strconv.Itoa(int(stack.ID)),
|
||||
stack.EntryPoint,
|
||||
[]byte("version: '3'\nservices:\n web:\n image: nginx:v1"),
|
||||
)
|
||||
require.NoError(t, err, "error storing stack file")
|
||||
|
||||
// Create handler
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer())
|
||||
handler.DataStore = store
|
||||
handler.FileService = fileService
|
||||
handler.StackDeployer = testStackDeployer{}
|
||||
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||
|
||||
// Create mock request with security context
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
req := mockCreateStackRequestWithSecurityContext(
|
||||
http.MethodPut,
|
||||
fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID),
|
||||
bytes.NewBuffer(jsonPayload),
|
||||
)
|
||||
|
||||
return &updateStackInTxTestSetup{
|
||||
store: store,
|
||||
fileService: fileService,
|
||||
handler: handler,
|
||||
user: user,
|
||||
endpoint: endpoint,
|
||||
stack: stack,
|
||||
resourceControl: resourceControl,
|
||||
jsonPayload: jsonPayload,
|
||||
req: req,
|
||||
}
|
||||
}
|
||||
|
||||
type swarmStackManager struct {
|
||||
portainer.SwarmStackManager
|
||||
}
|
||||
|
||||
func (manager swarmStackManager) NormalizeStackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
type testStackDeployer struct {
|
||||
deployments.StackDeployer
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (testStackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
|
||||
return nil
|
||||
}
|
||||
12
api/internal/testhelpers/user_activity_service.go
Normal file
12
api/internal/testhelpers/user_activity_service.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package testhelpers
|
||||
|
||||
type userActivityService struct {
|
||||
}
|
||||
|
||||
func NewUserActivityService() *userActivityService {
|
||||
return &userActivityService{}
|
||||
}
|
||||
|
||||
func (service *userActivityService) LogUserActivity(username string, context string, action string, payload []byte) error {
|
||||
return nil
|
||||
}
|
||||
@@ -25,12 +25,19 @@ type ComposeStackDeploymentConfig struct {
|
||||
}
|
||||
|
||||
func CreateComposeStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
|
||||
user, err := dataStore.User().Read(securityContext.UserID)
|
||||
return CreateComposeStackDeploymentConfigTx(dataStore, securityContext, stack, endpoint, fileService, deployer, forcePullImage, forceCreate)
|
||||
}
|
||||
|
||||
// Alternate function that works within a transaction
|
||||
// We didn't update the original function to use a transaction because it would be a breaking change for many other files.
|
||||
// Let's do this only where necessary for now. This is also planed to be refactored in the future, but not prioritized right now.
|
||||
func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
|
||||
user, err := tx.User().Read(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load user information from the database: %w", err)
|
||||
}
|
||||
|
||||
registries, err := dataStore.Registry().ReadAll()
|
||||
registries, err := tx.Registry().ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err)
|
||||
}
|
||||
|
||||
@@ -24,12 +24,19 @@ type SwarmStackDeploymentConfig struct {
|
||||
}
|
||||
|
||||
func CreateSwarmStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, prune bool, pullImage bool) (*SwarmStackDeploymentConfig, error) {
|
||||
user, err := dataStore.User().Read(securityContext.UserID)
|
||||
return CreateSwarmStackDeploymentConfigTx(dataStore, securityContext, stack, endpoint, fileService, deployer, prune, pullImage)
|
||||
}
|
||||
|
||||
// Alternate function that works within a transaction
|
||||
// We didn't update the original function to use a transaction because it would be a breaking change for many other files.
|
||||
// Let's do this only where necessary for now. This is also planed to be refactored in the future, but not prioritized right now.
|
||||
func CreateSwarmStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, prune bool, pullImage bool) (*SwarmStackDeploymentConfig, error) {
|
||||
user, err := tx.User().Read(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load user information from the database: %w", err)
|
||||
}
|
||||
|
||||
registries, err := dataStore.Registry().ReadAll()
|
||||
registries, err := tx.Registry().ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user