diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index ca3b24f6f..265ead4f0 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -7,6 +7,7 @@ import ( "os" "path" "strings" + "time" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/apikey" @@ -570,6 +571,13 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow log.Fatal().Err(err).Msg("failure during post init migrations") } + if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return recoverStaleDeployingStacks(tx) + }); err != nil { + log.Info().Err(err). + Msg("Error recovering stale deploying stacks") + } + return &http.Server{ AuthorizationService: authorizationService, ReverseTunnelService: reverseTunnelService, @@ -641,3 +649,39 @@ func main() { log.Info().Err(err).Msg("HTTP server exited") } } + +// recoverStaleDeployingStacks resets any stack that was left in the Deploying state +// (e.g. because the server was restarted mid-deployment) to the Error state so the +// user can retry. +func recoverStaleDeployingStacks(tx dataservices.DataStoreTx) error { + stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool { + return s.Status == portainer.StackStatusDeploying + }) + if err != nil { + return err + } + + for _, stack := range stacks { + stack.Status = portainer.StackStatusError + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusError, + Time: time.Now().Unix(), + Message: "Deployment interrupted by server restart", + }) + + if err := tx.Stack().Update(stack.ID, &stack); err != nil { + log.Warn().Err(err). + Int("stack_id", int(stack.ID)). + Str("context", "RecoverStaleDeployingStacks"). + Msg("Unable to recover stale deploying stack") + continue + } + log.Debug(). + Int("stack_id", int(stack.ID)). + Str("stack_name", stack.Name). + Str("context", "RecoverStaleDeployingStacks"). + Msg("Recovered stale deploying stack to error state") + } + + return nil +} diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 1e0c62746..a0759147d 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -147,7 +147,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, handler.FileService, handler.StackDeployer) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(composeStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, composeStackBuilder) stack, httpErr := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint) if httpErr != nil { return httpErr @@ -303,7 +303,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite handler.Scheduler, handler.StackDeployer) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(composeStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, composeStackBuilder) stack, httpErr := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint) if httpErr != nil { return httpErr @@ -408,7 +408,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, handler.FileService, handler.StackDeployer) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(composeStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, composeStackBuilder) stack, httpErr := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint) if httpErr != nil { return httpErr diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 4f598019d..37dca41e0 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -171,7 +171,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit } } - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, k8sStackBuilder) if _, err := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint); err != nil { return err } @@ -244,7 +244,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr handler.KubernetesDeployer, user) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, k8sStackBuilder) if _, err := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint); err != nil { return err } @@ -290,7 +290,7 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit handler.KubernetesDeployer, user) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, k8sStackBuilder) if _, err := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint); err != nil { return err } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 59e4cad74..3f36bc8f4 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -97,7 +97,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r handler.FileService, handler.StackDeployer) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(swarmStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, swarmStackBuilder) stack, httpErr := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint) if httpErr != nil { return httpErr @@ -239,7 +239,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, handler.Scheduler, handler.StackDeployer) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(swarmStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, swarmStackBuilder) stack, httpErr := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint) if httpErr != nil { return httpErr @@ -340,7 +340,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r handler.FileService, handler.StackDeployer) - stackBuilderDirector := stackbuilders.NewStackBuilderDirector(swarmStackBuilder) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(handler.DataStore, swarmStackBuilder) stack, httpErr := stackBuilderDirector.Build(context.TODO(), &stackPayload, endpoint) if httpErr != nil { return httpErr diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index 40c9902bf..33ddb400f 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -168,6 +168,10 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin return false, err } + if handler.DockerClientFactory == nil { + return isUniqueStackName, nil + } + dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil) if err != nil { return false, err diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index 55dd667d5..1c96d0461 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -5,8 +5,10 @@ import ( "errors" "fmt" "net/http" + "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" @@ -14,6 +16,8 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/rs/zerolog/log" ) // @id StackStart @@ -29,7 +33,7 @@ import ( // @failure 400 "Invalid request" // @failure 403 "Permission denied" // @failure 404 "Not found" -// @failure 409 "Stack name is not unique" +// @failure 409 "Stack is already active, deploying, or in error state" // @failure 500 "Server error" // @router /stacks/{id}/start [post] func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -54,6 +58,25 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http return httperror.BadRequest("Starting a kubernetes stack is not supported", err) } + // Check stack status before checking endpoint access to avoid unnecessary database + // calls in case of invalid stack status + switch stack.Status { + case portainer.StackStatusActive: + return httperror.Conflict("Unable to start stack", errors.New("Stack is already active")) + case portainer.StackStatusDeploying: + return httperror.Conflict("Unable to start stack", errors.New("Stack deployment is already in progress")) + case portainer.StackStatusError: + errMessage := "Stack is in error state" + if len(stack.DeploymentStatus) > 0 { + lastDeploymentStatus := stack.DeploymentStatus[len(stack.DeploymentStatus)-1] + if lastDeploymentStatus.Status == portainer.StackStatusError && lastDeploymentStatus.Message != "" { + errMessage = lastDeploymentStatus.Message + } + } + + return httperror.Conflict("Unable to start stack", errors.New(errMessage)) + } + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) if err != nil { return httperror.BadRequest("Invalid query parameter: endpointId", err) @@ -102,10 +125,6 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) } - if stack.Status == portainer.StackStatusActive { - return httperror.BadRequest("Stack is already active", errors.New("Stack is already active")) - } - if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" { deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) @@ -117,14 +136,29 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http stack.AutoUpdate.JobID = jobID } - err = handler.startStack(context.TODO(), stack, endpoint, securityContext) - if err != nil { + if err := handler.startStack(context.TODO(), stack, endpoint, securityContext); err != nil { + stack.Status = portainer.StackStatusError + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusError, + Time: time.Now().Unix(), + Message: err.Error(), + }) + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + log.Warn().Err(err).Str("context", "StackStart").Msg("Unable to update stack status after failed start attempt") + } + return httperror.InternalServerError("Unable to start stack", err) } stack.Status = portainer.StackStatusActive - err = handler.DataStore.Stack().Update(stack.ID, stack) - if err != nil { + stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusActive, Time: time.Now().Unix()}, + } + if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + return tx.Stack().Update(stack.ID, stack) + }); err != nil { return httperror.InternalServerError("Unable to update stack status", err) } diff --git a/api/http/handler/stacks/stack_start_test.go b/api/http/handler/stacks/stack_start_test.go new file mode 100644 index 000000000..b108d3b77 --- /dev/null +++ b/api/http/handler/stacks/stack_start_test.go @@ -0,0 +1,127 @@ +package stacks + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Stubs +type stubComposeStackManager struct { + portainer.ComposeStackManager + deployErr error +} + +func (s *stubComposeStackManager) NormalizeStackName(name string) string { return name } +func (s *stubComposeStackManager) Up(_ context.Context, _ *portainer.Stack, _ *portainer.Endpoint, _ portainer.ComposeUpOptions) error { + return s.deployErr +} + +func stackStartRequest(stackID portainer.StackID, endpointID portainer.EndpointID) *http.Request { + url := "/stacks/" + strconv.Itoa(int(stackID)) + "/start?endpointId=" + strconv.Itoa(int(endpointID)) + return mockCreateStackRequestWithSecurityContext(http.MethodPost, url, nil) +} + +func newStackStartHandler(t *testing.T) (*Handler, *datastore.Store) { + t.Helper() + _, store := datastore.MustNewTestStore(t, true, false) + h := NewHandler(testhelpers.NewTestRequestBouncer()) + h.DataStore = store + return h, store +} + +func TestStackStart_ActiveStack_ReturnsConflict(t *testing.T) { + h, store := newStackStartHandler(t) + stack := &portainer.Stack{ID: 1, Status: portainer.StackStatusActive} + require.NoError(t, store.Stack().Create(stack)) + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, 1)) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestStackStart_DeployingStack_ReturnsConflict(t *testing.T) { + h, store := newStackStartHandler(t) + stack := &portainer.Stack{ID: 1, Status: portainer.StackStatusDeploying} + require.NoError(t, store.Stack().Create(stack)) + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, 1)) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func TestStackStart_ErrorStack_ReturnsConflict(t *testing.T) { + h, store := newStackStartHandler(t) + stack := &portainer.Stack{ID: 1, Status: portainer.StackStatusError} + require.NoError(t, store.Stack().Create(stack)) + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, 1)) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +func newStartableStack(endpointID portainer.EndpointID) *portainer.Stack { + return &portainer.Stack{ + ID: 1, + EndpointID: endpointID, + Type: portainer.DockerComposeStack, + Name: "test-stack", + } +} + +func TestStackStart_StartSuccess_StackStatusSetToActive(t *testing.T) { + h, store := newStackStartHandler(t) + _, err := mockCreateUser(store) + require.NoError(t, err) + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err) + stack := newStartableStack(endpoint.ID) + require.NoError(t, store.Stack().Create(stack)) + h.ComposeStackManager = &stubComposeStackManager{} + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, endpoint.ID)) + + require.Equal(t, http.StatusOK, w.Code) + updated, err := store.Stack().Read(stack.ID) + require.NoError(t, err) + assert.Equal(t, portainer.StackStatusActive, updated.Status) + require.Len(t, updated.DeploymentStatus, 1) + assert.Equal(t, portainer.StackStatusActive, updated.DeploymentStatus[0].Status) +} + +func TestStackStart_StartFailure_StackStatusSetToError(t *testing.T) { + deployErr := errors.New("failed to pull image nginx:999") + h, store := newStackStartHandler(t) + _, err := mockCreateUser(store) + require.NoError(t, err) + endpoint, err := mockCreateEndpoint(store) + require.NoError(t, err) + stack := newStartableStack(endpoint.ID) + require.NoError(t, store.Stack().Create(stack)) + h.ComposeStackManager = &stubComposeStackManager{deployErr: deployErr} + + w := httptest.NewRecorder() + h.ServeHTTP(w, stackStartRequest(stack.ID, endpoint.ID)) + + require.Equal(t, http.StatusInternalServerError, w.Code) + updated, err := store.Stack().Read(stack.ID) + require.NoError(t, err) + assert.Equal(t, portainer.StackStatusError, updated.Status) + require.Len(t, updated.DeploymentStatus, 1) + lastEntry := updated.DeploymentStatus[0] + assert.Equal(t, portainer.StackStatusError, lastEntry.Status) + assert.Equal(t, deployErr.Error(), lastEntry.Message) +} diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index 11e069b58..fbadcf685 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -28,6 +28,7 @@ import ( // @failure 400 "Invalid request" // @failure 403 "Permission denied" // @failure 404 "Not found" +// @failure 409 "Conflict" // @failure 500 "Server error" // @router /stacks/{id}/stop [post] func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -95,6 +96,10 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe return httperror.BadRequest("Stack is already inactive", errors.New("Stack is already inactive")) } + if stack.Status == portainer.StackStatusDeploying { + return httperror.Conflict("Stack deployment is in progress", errors.New("stack deployment is in progress")) + } + // stop scheduler updates of the stack before stopping if stack.AutoUpdate != nil && stack.AutoUpdate.JobID != "" { deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index a8c407148..a5efd4281 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -172,6 +172,10 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req stack.UpdatedBy = user.Username stack.UpdateDate = time.Now().Unix() stack.Status = portainer.StackStatusActive + // TODO: move to async job when stack update becomes async + stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusActive, Time: time.Now().Unix()}, + } if err := tx.Stack().Update(stack.ID, stack); err != nil { return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err) diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index d82ea64ba..db8119fe3 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -194,6 +194,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) stack.UpdatedBy = user.Username stack.UpdateDate = time.Now().Unix() stack.Status = portainer.StackStatusActive + // TODO: move to async job when stack update becomes async + stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusActive, Time: time.Now().Unix()}, + } if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { return tx.Stack().Update(stack.ID, stack) diff --git a/api/stacks/stackbuilders/compose_git_builder.go b/api/stacks/stackbuilders/compose_git_builder.go index d93681c64..bcd7c361a 100644 --- a/api/stacks/stackbuilders/compose_git_builder.go +++ b/api/stacks/stackbuilders/compose_git_builder.go @@ -42,6 +42,8 @@ func (b *ComposeStackGitBuilder) SetUniqueInfo(payload *StackPayload) GitMethodS if b.hasError() { return b } + + b.GitMethodStackBuilder.SetUniqueInfo(payload) b.stack.Name = payload.Name b.stack.Type = portainer.DockerComposeStack b.stack.EntryPoint = payload.ComposeFile @@ -71,8 +73,3 @@ func (b *ComposeStackGitBuilder) Deploy(ctx context.Context, payload *StackPaylo return b.GitMethodStackBuilder.Deploy(ctx, payload, endpoint) } - -func (b *ComposeStackGitBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { - b.GitMethodStackBuilder.SetAutoUpdate(payload) - return b -} diff --git a/api/stacks/stackbuilders/director.go b/api/stacks/stackbuilders/director.go index 7703e2d82..a06c37c4c 100644 --- a/api/stacks/stackbuilders/director.go +++ b/api/stacks/stackbuilders/director.go @@ -3,19 +3,28 @@ package stackbuilders import ( "context" "errors" + "time" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + + "github.com/rs/zerolog/log" ) +type PostDeployHook func(context.Context, *portainer.Stack) error + type StackBuilderDirector struct { - builder any + builder any + dataStore dataservices.DataStore } -func NewStackBuilderDirector(b any) *StackBuilderDirector { +func NewStackBuilderDirector(dataStore dataservices.DataStore, b any) *StackBuilderDirector { return &StackBuilderDirector{ - builder: b, + builder: b, + dataStore: dataStore, } } @@ -23,32 +32,36 @@ func NewStackBuilderDirector(b any) *StackBuilderDirector { // created stack and any error encountered during the process. // The returned error is of type *httperror.HandlerError, which could be a BadRequest // or InternalServerError depending on the error encountered during the stack build process. +// +// For all stack types, the stack is saved to DB with Status=Deploying and returned +// immediately. Deployment runs in a background goroutine. The caller must poll +// GET /stacks/{id} to track completion. func (d *StackBuilderDirector) Build(ctx context.Context, payload *StackPayload, endpoint *portainer.Endpoint) (*portainer.Stack, *httperror.HandlerError) { - var ( - stack *portainer.Stack - err error - ) // To align with the flow of the actual service deployment tools, we save // the stack before the deployment. This allows us to track the stack // metadata and partially created resources. switch builder := d.builder.(type) { case GitMethodStackBuildProcess: - stack, err = builder.SetGeneralInfo(payload, endpoint). + stack, err := builder.SetGeneralInfo(payload, endpoint). SetUniqueInfo(payload). SetGitRepository(ctx, payload). SaveStack() if err != nil { + if errors.Is(err, httperrors.ErrUnauthorized) { + return nil, httperror.Forbidden("User not authorized to use git credential", err) + } return nil, httperror.InternalServerError("Failed to save stack via Git repository method", err) } - // Since AutoUpdate job for stack is created after a successful - // deployment, we need to update the stack with the new generated job ID - stack, err = builder.Deploy(ctx, payload, endpoint). - SetAutoUpdate(payload). - UpdateStack(stack) + d.spawnAsyncDeployment(ctx, stack.ID, func() error { + builder.Deploy(ctx, payload, endpoint) + return builder.Error() + }, builder.EnableAutoUpdate) + + return stack, nil case FileUploadMethodStackBuildProcess: - stack, err = builder.SetGeneralInfo(payload, endpoint). + stack, err := builder.SetGeneralInfo(payload, endpoint). SetUniqueInfo(payload). SetUploadedFile(payload). SaveStack() @@ -56,11 +69,15 @@ func (d *StackBuilderDirector) Build(ctx context.Context, payload *StackPayload, return nil, httperror.InternalServerError("Failed to save stack via File Upload method", err) } - builder.Deploy(ctx, payload, endpoint) - err = builder.Error() + d.spawnAsyncDeployment(ctx, stack.ID, func() error { + builder.Deploy(ctx, payload, endpoint) + return builder.Error() + }) + + return stack, nil case FileContentMethodStackBuildProcess: - stack, err = builder.SetGeneralInfo(payload, endpoint). + stack, err := builder.SetGeneralInfo(payload, endpoint). SetUniqueInfo(payload). SetFileContent(payload). SaveStack() @@ -68,11 +85,15 @@ func (d *StackBuilderDirector) Build(ctx context.Context, payload *StackPayload, return nil, httperror.InternalServerError("Failed to save stack via File Content method", err) } - builder.Deploy(ctx, payload, endpoint) - err = builder.Error() + d.spawnAsyncDeployment(ctx, stack.ID, func() error { + builder.Deploy(ctx, payload, endpoint) + return builder.Error() + }) + + return stack, nil case UrlMethodStackBuildProcess: - stack, err = builder.SetGeneralInfo(payload, endpoint). + stack, err := builder.SetGeneralInfo(payload, endpoint). SetUniqueInfo(payload). SetURL(payload). SaveStack() @@ -80,15 +101,60 @@ func (d *StackBuilderDirector) Build(ctx context.Context, payload *StackPayload, return nil, httperror.InternalServerError("Failed to save stack via URL method", err) } - builder.Deploy(ctx, payload, endpoint) - err = builder.Error() + d.spawnAsyncDeployment(ctx, stack.ID, func() error { + builder.Deploy(ctx, payload, endpoint) + return builder.Error() + }) + + return stack, nil default: return nil, httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository or url or file", errors.New(request.ErrInvalidQueryParameter)) } - if err != nil { - return nil, httperror.InternalServerError("Failed to deploy stack", err) - } - - return stack, nil +} + +// spawnAsyncDeployment runs the provided deploy function in a background goroutine +// and updates the stack status in the database upon completion. +func (d *StackBuilderDirector) spawnAsyncDeployment(ctx context.Context, stackID portainer.StackID, deploy func() error, hooks ...PostDeployHook) { + go func() { + deployErr := deploy() + + if err := d.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + stack, err := tx.Stack().Read(stackID) + if err != nil { + return err + } + + if deployErr != nil { + stack.Status = portainer.StackStatusError + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusError, + Time: time.Now().Unix(), + Message: deployErr.Error(), + }) + } else { + stack.Status = portainer.StackStatusActive + stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{ + Status: portainer.StackStatusActive, + Time: time.Now().Unix(), + }) + + for _, hook := range hooks { + if err := hook(ctx, stack); err != nil { + log.Error().Err(err). + Int("stack_id", int(stackID)). + Str("context", "StackBuilderDirector.spawnAsyncDeployment"). + Msg("Failed to run post-deployment hook") + } + } + } + + return tx.Stack().Update(stack.ID, stack) + }); err != nil { + log.Error().Err(err). + Int("stack_id", int(stackID)). + Str("context", "StackBuilderDirector.spawnAsyncDeployment"). + Msg("Failed to update stack status after async deployment") + } + }() } diff --git a/api/stacks/stackbuilders/director_test.go b/api/stacks/stackbuilders/director_test.go new file mode 100644 index 000000000..b18b19e15 --- /dev/null +++ b/api/stacks/stackbuilders/director_test.go @@ -0,0 +1,272 @@ +package stackbuilders + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/datastore" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Stubs + +type stubFileContentBuilder struct { + FileContentMethodStackBuildProcess + store *datastore.Store + savedStack *portainer.Stack + saveErr error + deployErr error +} + +func (s *stubFileContentBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) FileContentMethodStackBuildProcess { + s.savedStack.Status = portainer.StackStatusDeploying + s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: time.Now().Unix()}, + } + return s +} +func (s *stubFileContentBuilder) SetUniqueInfo(_ *StackPayload) FileContentMethodStackBuildProcess { + return s +} +func (s *stubFileContentBuilder) SetFileContent(_ *StackPayload) FileContentMethodStackBuildProcess { + return s +} +func (s *stubFileContentBuilder) SaveStack() (*portainer.Stack, error) { + if s.saveErr != nil { + return nil, s.saveErr + } + return s.savedStack, s.store.Stack().Create(s.savedStack) +} +func (s *stubFileContentBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) FileContentMethodStackBuildProcess { + return s +} +func (s *stubFileContentBuilder) Error() error { return s.deployErr } + +type stubFileUploadBuilder struct { + FileUploadMethodStackBuildProcess + store *datastore.Store + savedStack *portainer.Stack + saveErr error + deployErr error +} + +func (s *stubFileUploadBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) FileUploadMethodStackBuildProcess { + s.savedStack.Status = portainer.StackStatusDeploying + s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: time.Now().Unix()}, + } + return s +} +func (s *stubFileUploadBuilder) SetUniqueInfo(_ *StackPayload) FileUploadMethodStackBuildProcess { + return s +} +func (s *stubFileUploadBuilder) SetUploadedFile(_ *StackPayload) FileUploadMethodStackBuildProcess { + return s +} +func (s *stubFileUploadBuilder) SaveStack() (*portainer.Stack, error) { + if s.saveErr != nil { + return nil, s.saveErr + } + return s.savedStack, s.store.Stack().Create(s.savedStack) +} +func (s *stubFileUploadBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) FileUploadMethodStackBuildProcess { + return s +} +func (s *stubFileUploadBuilder) Error() error { return s.deployErr } + +type stubUrlBuilder struct { + UrlMethodStackBuildProcess + store *datastore.Store + savedStack *portainer.Stack + saveErr error + deployErr error +} + +func (s *stubUrlBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) UrlMethodStackBuildProcess { + s.savedStack.Status = portainer.StackStatusDeploying + s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: time.Now().Unix()}, + } + return s +} +func (s *stubUrlBuilder) SetUniqueInfo(_ *StackPayload) UrlMethodStackBuildProcess { return s } +func (s *stubUrlBuilder) SetURL(_ *StackPayload) UrlMethodStackBuildProcess { return s } +func (s *stubUrlBuilder) SaveStack() (*portainer.Stack, error) { + if s.saveErr != nil { + return nil, s.saveErr + } + return s.savedStack, s.store.Stack().Create(s.savedStack) +} +func (s *stubUrlBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) UrlMethodStackBuildProcess { + return s +} +func (s *stubUrlBuilder) Error() error { return s.deployErr } + +type stubGitBuilder struct { + GitMethodStackBuildProcess + store *datastore.Store + savedStack *portainer.Stack + saveErr error + deployErr error + hookCalled bool +} + +func (s *stubGitBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) GitMethodStackBuildProcess { + return s +} +func (s *stubGitBuilder) SetUniqueInfo(_ *StackPayload) GitMethodStackBuildProcess { return s } +func (s *stubGitBuilder) SetGitRepository(_ context.Context, _ *StackPayload) GitMethodStackBuildProcess { + return s +} +func (s *stubGitBuilder) SaveStack() (*portainer.Stack, error) { + if s.saveErr != nil { + return nil, s.saveErr + } + return s.savedStack, s.store.Stack().Create(s.savedStack) +} +func (s *stubGitBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) GitMethodStackBuildProcess { + return s +} +func (s *stubGitBuilder) Error() error { return s.deployErr } +func (s *stubGitBuilder) EnableAutoUpdate(_ context.Context, _ *portainer.Stack) error { + s.hookCalled = true + return nil +} + +// Helpers + +func waitForStackStatus(t *testing.T, store *datastore.Store, id portainer.StackID, wantStatus portainer.StackStatus) *portainer.Stack { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + stack, err := store.Stack().Read(id) + if err == nil && stack.Status == wantStatus { + return stack + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for stack %d to reach status %d", id, wantStatus) + return nil +} + +// Tests + +func TestDirector_Build_UnknownBuilder_ReturnsBadRequest(t *testing.T) { + director := NewStackBuilderDirector(nil, "not a builder") + + _, herr := director.Build(t.Context(), &StackPayload{}, &portainer.Endpoint{}) + + require.NotNil(t, herr) + assert.Equal(t, http.StatusBadRequest, herr.StatusCode) +} + +func TestDirector_Build_GitMethod_UnauthorizedCredential_ReturnsForbidden(t *testing.T) { + director := NewStackBuilderDirector(nil, &stubGitBuilder{saveErr: httperrors.ErrUnauthorized}) + + _, herr := director.Build(t.Context(), &StackPayload{}, &portainer.Endpoint{}) + + require.NotNil(t, herr) + assert.Equal(t, http.StatusForbidden, herr.StatusCode) +} + +func TestDirector_Build_GitMethod_SaveError_ReturnsInternalServerError(t *testing.T) { + director := NewStackBuilderDirector(nil, &stubGitBuilder{saveErr: errors.New("db error")}) + + _, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{}) + + require.NotNil(t, herr) + assert.Equal(t, http.StatusInternalServerError, herr.StatusCode) +} + +func TestDirector_SpawnAsync_DeploySuccess_UpdatesStackStatusToActive(t *testing.T) { + tc := []struct { + name string + builder func(store *datastore.Store, stack *portainer.Stack) any + }{ + { + name: "file content builder", + builder: func(store *datastore.Store, stack *portainer.Stack) any { + return &stubFileContentBuilder{store: store, savedStack: stack} + }, + }, + { + name: "file upload builder", + builder: func(store *datastore.Store, stack *portainer.Stack) any { + return &stubFileUploadBuilder{store: store, savedStack: stack} + }, + }, + { + name: "url builder", + builder: func(store *datastore.Store, stack *portainer.Stack) any { + return &stubUrlBuilder{store: store, savedStack: stack} + }, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + director := NewStackBuilderDirector(store, tt.builder(store, stack)) + _, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{}) + require.Nil(t, herr) + + updated := waitForStackStatus(t, store, stack.ID, portainer.StackStatusActive) + + assert.Equal(t, portainer.StackStatusActive, updated.Status) + require.Len(t, updated.DeploymentStatus, 2) + assert.Equal(t, portainer.StackStatusDeploying, updated.DeploymentStatus[0].Status) + assert.Equal(t, portainer.StackStatusActive, updated.DeploymentStatus[1].Status) + }) + } +} + +func TestDirector_SpawnAsync_DeployFailure_UpdatesStackStatusToError(t *testing.T) { + deployErr := errors.New("failed to pull image nginx:999") + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + director := NewStackBuilderDirector(store, &stubFileContentBuilder{store: store, savedStack: stack, deployErr: deployErr}) + _, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{}) + require.Nil(t, herr) + + updated := waitForStackStatus(t, store, stack.ID, portainer.StackStatusError) + + assert.Equal(t, portainer.StackStatusError, updated.Status) + require.Len(t, updated.DeploymentStatus, 2) + assert.Equal(t, portainer.StackStatusDeploying, updated.DeploymentStatus[0].Status) + lastEntry := updated.DeploymentStatus[1] + assert.Equal(t, portainer.StackStatusError, lastEntry.Status) + assert.Equal(t, deployErr.Error(), lastEntry.Message) +} + +func TestDirector_SpawnAsync_PostDeployHook_CalledOnSuccess(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + builder := &stubGitBuilder{store: store, savedStack: stack} + director := NewStackBuilderDirector(store, builder) + _, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{}) + require.Nil(t, herr) + + waitForStackStatus(t, store, stack.ID, portainer.StackStatusActive) + + assert.True(t, builder.hookCalled, "post-deploy hook should be called after a successful deployment") +} + +func TestDirector_SpawnAsync_PostDeployHook_NotCalledOnDeployFailure(t *testing.T) { + _, store := datastore.MustNewTestStore(t, true, false) + stack := &portainer.Stack{ID: 1} + builder := &stubGitBuilder{store: store, savedStack: stack, deployErr: errors.New("failed to deploy")} + director := NewStackBuilderDirector(store, builder) + _, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{}) + require.Nil(t, herr) + + waitForStackStatus(t, store, stack.ID, portainer.StackStatusError) + + assert.False(t, builder.hookCalled, "post-deploy hook should not be called after a failed deployment") +} diff --git a/api/stacks/stackbuilders/k8s_git_builder.go b/api/stacks/stackbuilders/k8s_git_builder.go index 4b491ff4c..26af9e2db 100644 --- a/api/stacks/stackbuilders/k8s_git_builder.go +++ b/api/stacks/stackbuilders/k8s_git_builder.go @@ -52,6 +52,7 @@ func (b *KubernetesStackGitBuilder) SetUniqueInfo(payload *StackPayload) GitMeth return b } + b.GitMethodStackBuilder.SetUniqueInfo(payload) b.stack.Type = portainer.KubernetesStack b.stack.Namespace = payload.Namespace b.stack.Name = payload.StackName @@ -93,12 +94,6 @@ func (b *KubernetesStackGitBuilder) Deploy(ctx context.Context, payload *StackPa return b.GitMethodStackBuilder.Deploy(ctx, payload, endpoint) } -func (b *KubernetesStackGitBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { - b.GitMethodStackBuilder.SetAutoUpdate(payload) - - return b -} - func (b *KubernetesStackGitBuilder) GetResponse() string { return b.deploymentConfiger.GetResponse() } diff --git a/api/stacks/stackbuilders/stack_file_content_builder.go b/api/stacks/stackbuilders/stack_file_content_builder.go index 5f058393d..42bf32029 100644 --- a/api/stacks/stackbuilders/stack_file_content_builder.go +++ b/api/stacks/stackbuilders/stack_file_content_builder.go @@ -31,8 +31,12 @@ func (b *FileContentMethodStackBuilder) SetGeneralInfo(payload *StackPayload, en stackID := b.dataStore.Stack().GetNextIdentifier() b.stack.ID = portainer.StackID(stackID) b.stack.EndpointID = endpoint.ID - b.stack.Status = portainer.StackStatusActive - b.stack.CreationDate = time.Now().Unix() + now := time.Now().Unix() + b.stack.Status = portainer.StackStatusDeploying + b.stack.CreationDate = now + b.stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: now}, + } return b } diff --git a/api/stacks/stackbuilders/stack_file_upload_builder.go b/api/stacks/stackbuilders/stack_file_upload_builder.go index cb3fbf54a..bbf9c4750 100644 --- a/api/stacks/stackbuilders/stack_file_upload_builder.go +++ b/api/stacks/stackbuilders/stack_file_upload_builder.go @@ -33,8 +33,12 @@ func (b *FileUploadMethodStackBuilder) SetGeneralInfo(payload *StackPayload, end stackID := b.dataStore.Stack().GetNextIdentifier() b.stack.ID = portainer.StackID(stackID) b.stack.EndpointID = endpoint.ID - b.stack.Status = portainer.StackStatusActive - b.stack.CreationDate = time.Now().Unix() + now := time.Now().Unix() + b.stack.Status = portainer.StackStatusDeploying + b.stack.CreationDate = now + b.stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: now}, + } return b } diff --git a/api/stacks/stackbuilders/stack_git_builder.go b/api/stacks/stackbuilders/stack_git_builder.go index eca183c88..1cbf0d921 100644 --- a/api/stacks/stackbuilders/stack_git_builder.go +++ b/api/stacks/stackbuilders/stack_git_builder.go @@ -7,7 +7,6 @@ import ( "time" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/filesystem" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/scheduler" @@ -28,10 +27,8 @@ type GitMethodStackBuildProcess interface { GetResponse() string // Set git repository configuration SetGitRepository(ctx context.Context, payload *StackPayload) GitMethodStackBuildProcess - // Set auto update setting - SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess - UpdateStack(stack *portainer.Stack) (*portainer.Stack, error) Error() error + EnableAutoUpdate(ctx context.Context, stack *portainer.Stack) error } type GitMethodStackBuilder struct { @@ -45,14 +42,19 @@ func (b *GitMethodStackBuilder) SetGeneralInfo(payload *StackPayload, endpoint * b.stack.ID = portainer.StackID(stackID) b.stack.EndpointID = endpoint.ID b.stack.AdditionalFiles = payload.AdditionalFiles - b.stack.Status = portainer.StackStatusActive - b.stack.CreationDate = time.Now().Unix() + now := time.Now().Unix() + b.stack.Status = portainer.StackStatusDeploying + b.stack.CreationDate = now + b.stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: now}, + } b.stack.AutoUpdate = payload.AutoUpdate return b } func (b *GitMethodStackBuilder) SetUniqueInfo(payload *StackPayload) GitMethodStackBuildProcess { + b.stack.AutoUpdate = payload.AutoUpdate return b } @@ -116,52 +118,25 @@ func (b *GitMethodStackBuilder) Deploy(ctx context.Context, payload *StackPayloa return b } -func (b *GitMethodStackBuilder) UpdateStack(stack *portainer.Stack) (*portainer.Stack, error) { - if b.hasError() { - return nil, b.err - } - - b.stack = stack - - // Ideally, we should replace b.dataStore with b.tx and manage the transaction - // at a higher layer. However, that would require significant changes to other - // logic unrelated to this builder. - // To keep this change focused and minimize the scope, we will retain b.dataStore - // and perform the update within a transaction here for now. - b.err = b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { - if err := tx.Stack().Update(b.stack.ID, b.stack); err != nil { - return fmt.Errorf("Unable to update the stack inside the database: %w", err) - } - - return nil - }) - - return b.stack, b.err -} - -func (b *GitMethodStackBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { - if b.hasError() { - return b - } - - if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { - jobID, err := deployments.StartAutoupdate(context.TODO(), b.stack.ID, - b.stack.AutoUpdate.Interval, - b.scheduler, - b.stackDeployer, - b.dataStore, - b.gitService) - if err != nil { - b.err = err - return b - } - - b.stack.AutoUpdate.JobID = jobID - } - - return b -} - func (b *GitMethodStackBuilder) GetResponse() string { return "" } + +func (b *GitMethodStackBuilder) EnableAutoUpdate(ctx context.Context, stack *portainer.Stack) error { + if stack.AutoUpdate == nil || stack.AutoUpdate.Interval == "" { + return nil + } + + jobID, err := deployments.StartAutoupdate(ctx, stack.ID, + stack.AutoUpdate.Interval, + b.scheduler, + b.stackDeployer, + b.dataStore, + b.gitService) + if err != nil { + return err + } + + stack.AutoUpdate.JobID = jobID + return nil +} diff --git a/api/stacks/stackbuilders/stack_url_builder.go b/api/stacks/stackbuilders/stack_url_builder.go index 8fe6f8893..0057937e6 100644 --- a/api/stacks/stackbuilders/stack_url_builder.go +++ b/api/stacks/stackbuilders/stack_url_builder.go @@ -31,8 +31,12 @@ func (b *UrlMethodStackBuilder) SetGeneralInfo(payload *StackPayload, endpoint * stackID := b.dataStore.Stack().GetNextIdentifier() b.stack.ID = portainer.StackID(stackID) b.stack.EndpointID = endpoint.ID - b.stack.Status = portainer.StackStatusActive - b.stack.CreationDate = time.Now().Unix() + now := time.Now().Unix() + b.stack.Status = portainer.StackStatusDeploying + b.stack.CreationDate = now + b.stack.DeploymentStatus = []portainer.StackDeploymentStatus{ + {Status: portainer.StackStatusDeploying, Time: now}, + } return b } diff --git a/api/stacks/stackbuilders/swarm_git_builder.go b/api/stacks/stackbuilders/swarm_git_builder.go index f8dd2e6e6..4945f2dd0 100644 --- a/api/stacks/stackbuilders/swarm_git_builder.go +++ b/api/stacks/stackbuilders/swarm_git_builder.go @@ -42,6 +42,8 @@ func (b *SwarmStackGitBuilder) SetUniqueInfo(payload *StackPayload) GitMethodSta if b.hasError() { return b } + + b.GitMethodStackBuilder.SetUniqueInfo(payload) b.stack.Name = payload.Name b.stack.Type = portainer.DockerSwarmStack b.stack.SwarmID = payload.SwarmID @@ -73,8 +75,3 @@ func (b *SwarmStackGitBuilder) Deploy(ctx context.Context, payload *StackPayload return b.GitMethodStackBuilder.Deploy(ctx, payload, endpoint) } - -func (b *SwarmStackGitBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { - b.GitMethodStackBuilder.SetAutoUpdate(payload) - return b -} diff --git a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts index dd30bdbba..d02068a85 100644 --- a/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts +++ b/app/react/common/stacks/queries/useCreateStack/useCreateStack.ts @@ -160,6 +160,8 @@ async function createStack(payload: CreateStackPayload) { resourceControl.Id ); } + + return stack; } function createActualStack(payload: CreateStackPayload) { diff --git a/app/react/common/stacks/types.ts b/app/react/common/stacks/types.ts index 4c2ffbe8f..1627b4b6f 100644 --- a/app/react/common/stacks/types.ts +++ b/app/react/common/stacks/types.ts @@ -27,6 +27,14 @@ export enum StackType { export enum StackStatus { Active = 1, Inactive, + Deploying, + Error, +} + +export interface StackDeploymentStatus { + Status: StackStatus; + Time: number; + Message?: string; } /** @@ -60,6 +68,7 @@ export interface Stack { Env: EnvVar[] | null; ResourceControl?: ResourceControlResponse; Status: StackStatus; + DeploymentStatus?: StackDeploymentStatus[]; ProjectPath: string; CreationDate: number; CreatedBy: string; diff --git a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx index a65654104..d85e7e6d1 100644 --- a/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx +++ b/app/react/docker/stacks/CreateView/CreateStackForm/CreateStackForm.tsx @@ -4,7 +4,6 @@ import { useState } from 'react'; import uuidv4 from 'uuid/v4'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { notifySuccess } from '@/portainer/services/notifications'; import { useCreateStack, CreateStackPayload, @@ -87,9 +86,13 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) { }); createStackMutation.mutate(payload, { - onSuccess: () => { - notifySuccess('Success', 'Stack successfully deployed'); - router.stateService.go('docker.stacks'); + onSuccess: (stack) => { + router.stateService.go('docker.stacks.stack', { + name: stack.Name, + id: stack.Id, + type: stack.Type, + regular: 'true', + }); }, }); } diff --git a/app/react/docker/stacks/ItemView/ItemView.tsx b/app/react/docker/stacks/ItemView/ItemView.tsx index 47b9a42ce..d28655170 100644 --- a/app/react/docker/stacks/ItemView/ItemView.tsx +++ b/app/react/docker/stacks/ItemView/ItemView.tsx @@ -5,11 +5,12 @@ import { useEffect } from 'react'; import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable'; import { AccessControlPanel } from '@/react/portainer/access-control'; import { useStack } from '@/react/common/stacks/queries/useStack'; -import { Stack, StackType } from '@/react/common/stacks/types'; +import { Stack, StackStatus, StackType } from '@/react/common/stacks/types'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; import { ResourceControlType } from '@/react/portainer/access-control/types'; import { queryKeys } from '@/react/common/stacks/queries/query-keys'; import { notifyError } from '@/portainer/services/notifications'; +import { useUpdateStackResourcesOnDeployment } from '@/react/docker/stacks/ItemView/useUpdateStackResourcesOnDeployment'; import { PageHeader } from '@@/PageHeader'; @@ -28,10 +29,16 @@ export function ItemView() { } = useParams(); const queryClient = useQueryClient(); - const stackQuery = useStack(stackId, { enabled: isRegular || isOrphaned }); + const stackQuery = useStack(stackId, { + enabled: isRegular || isOrphaned, + refetchInterval: (data) => + data?.Status === StackStatus.Deploying ? 3000 : false, + }); const stack = stackQuery.data; + useUpdateStackResourcesOnDeployment(stack); + const resourceControl = stack?.ResourceControl ? new ResourceControlViewModel(stack.ResourceControl) : undefined; diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx index c91e18aa1..df83dfbe4 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackActions.tsx @@ -50,13 +50,15 @@ export function StackActions({ deleteStackMutation.isLoading || detachFromGitMutation.isLoading; + const isDeploying = status === StackStatus.Deploying; + const stackId = stack.Id; return (
{isRegular && ( - {status === StackStatus.Active ? ( + {(status === StackStatus.Active || status === StackStatus.Error) && ( - ) : ( + )} + {status === StackStatus.Inactive && ( @@ -108,7 +91,7 @@ export function StackActions({ color="dangerlight" size="xsmall" onClick={() => handleDelete()} - disabled={isMutating} + disabled={isMutating || isDeploying} data-cy="stack-delete-btn" > Delete this stack @@ -158,6 +141,22 @@ export function StackActions({
); + function handleStart() { + startStackMutation.mutate( + { id: stackId, environmentId }, + { + onError(err) { + notifyError('Failure', err as Error, 'Unable to start stack'); + router.stateService.reload(); + }, + onSuccess() { + notifySuccess('Success', `Stack ${stack.Name} started successfully`); + router.stateService.reload(); + }, + } + ); + } + async function handleStop() { const confirmed = await confirm({ title: 'Are you sure?', diff --git a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx index c753fc7b5..36ebefed9 100644 --- a/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx +++ b/app/react/docker/stacks/ItemView/StackInfoTab/StackInfoTab.tsx @@ -1,11 +1,17 @@ import { useState } from 'react'; -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, Loader2 } from 'lucide-react'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { Stack, StackStatus, StackType } from '@/react/common/stacks/types'; +import { + Stack, + StackDeploymentStatus, + StackStatus, + StackType, +} from '@/react/common/stacks/types'; import { Authorized } from '@/react/hooks/useUser'; import { InfoPanel } from '@/react/portainer/gitops/InfoPanel'; +import { Alert } from '@@/Alert'; import { Icon } from '@@/Icon'; import { Button } from '@@/buttons'; import { FormSection } from '@@/form-components/FormSection'; @@ -56,6 +62,13 @@ export function StackInfoTab({ isOrphaned={isOrphaned || isOrphanedRunning} /> + {stack && ( + + )} +
{stackName} @@ -158,6 +171,48 @@ export function StackInfoTab({ ); } +function DeploymentStatusSection({ + status, + deploymentStatus, +}: { + status: StackStatus; + deploymentStatus?: StackDeploymentStatus[]; +}) { + if (status === StackStatus.Deploying) { + return ( + +
+

+ + Deployment in progress... +

+
+
+ ); + } + + if (status === StackStatus.Error) { + const errorMessage = getLastDeploymentError(deploymentStatus); + return ( + +
+ {errorMessage || 'Deployment failed.'} +
+
+ ); + } + + return null; +} + +function getLastDeploymentError( + deploymentStatus?: StackDeploymentStatus[] +): string | undefined { + if (!deploymentStatus?.length) return undefined; + const last = deploymentStatus[deploymentStatus.length - 1]; + return last.Status === StackStatus.Error ? last.Message : undefined; +} + function ExternalOrphanedWarning({ isExternal, isOrphaned, diff --git a/app/react/docker/stacks/ItemView/useUpdateStackResourcesOnDeployment.ts b/app/react/docker/stacks/ItemView/useUpdateStackResourcesOnDeployment.ts new file mode 100644 index 000000000..64e276d54 --- /dev/null +++ b/app/react/docker/stacks/ItemView/useUpdateStackResourcesOnDeployment.ts @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { Stack, StackStatus } from '@/react/common/stacks/types'; +import { queryKeys as dockerQueryKeys } from '@/react/docker/queries/utils'; + +/** + * Hook to invalidate related Docker queries when a stack finishes deploying. + * + * This encapsulates the previous pattern of keeping a ref to the previous + * stack status and invalidating the docker root query for the environment + * when the stack transitions from Deploying -> not Deploying. + */ +export function useUpdateStackResourcesOnDeployment(stack?: Stack) { + const queryClient = useQueryClient(); + const prevStatusRef = useRef(undefined); + + const status = stack?.Status; + const endpointId = stack?.EndpointId; + const id = stack?.Id; + + useEffect(() => { + if (!id) { + return; + } + + const prev = prevStatusRef.current; + prevStatusRef.current = status; + + if ( + prev === StackStatus.Deploying && + status !== StackStatus.Deploying && + endpointId !== undefined + ) { + queryClient.invalidateQueries(dockerQueryKeys.root(endpointId)); + } + }, [status, endpointId, id, queryClient]); +} + +export default useUpdateStackResourcesOnDeployment; diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx index 5d0bcd65e..78e464535 100644 --- a/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx +++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx @@ -16,7 +16,12 @@ import { DecoratedStack } from '../types'; import { columnHelper } from './helper'; -const filterOptions = ['Active Stacks', 'Inactive Stacks'] as const; +const filterOptions = [ + 'Active Stacks', + 'Inactive Stacks', + 'Deploying Stacks', + 'Error Stacks', +] as const; type FilterOption = (typeof filterOptions)[number]; @@ -43,7 +48,11 @@ export const name = columnHelper.accessor('Name', { (stack.Status === StackStatus.Active && filterValue.includes('Active Stacks')) || (stack.Status === StackStatus.Inactive && - filterValue.includes('Inactive Stacks')) + filterValue.includes('Inactive Stacks')) || + (stack.Status === StackStatus.Deploying && + filterValue.includes('Deploying Stacks')) || + (stack.Status === StackStatus.Error && + filterValue.includes('Error Stacks')) ); }, meta: { @@ -57,11 +66,21 @@ function NameCell({ return ( <> - {isRegularStack(item) && item.Status === 2 && ( + {isRegularStack(item) && item.Status === StackStatus.Inactive && ( Inactive )} + {isRegularStack(item) && item.Status === StackStatus.Deploying && ( + + Deploying... + + )} + {isRegularStack(item) && item.Status === StackStatus.Error && ( + + Error + + )} ); } diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx index 4598a90e5..c9dc4ccd7 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/CronJobsDatatable.tsx @@ -100,9 +100,7 @@ export function CronJobsDatatable() { withColumnFilters(tableState.columnFilters, tableState.setColumnFilters) )} getRowCanExpand={(row) => (row.original.Jobs ?? []).length > 0} - renderSubRow={(row) => ( - - )} + renderSubRow={(row) => } /> ); }