Skip to content

Commit a36e2c7

Browse files
authored
fix: add the ability to cancel ongoing activities (#2756)
1 parent 9ac1291 commit a36e2c7

23 files changed

Lines changed: 579 additions & 73 deletions

backend/api/handlers/activities.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"net/url"
1010
"strconv"
11+
"strings"
1112
"time"
1213

1314
"github.com/danielgtaylor/huma/v2"
@@ -64,6 +65,16 @@ type StreamActivitiesInput struct {
6465
Limit int `query:"limit" default:"50" doc:"Initial snapshot limit"`
6566
}
6667

68+
type CancelActivityInput struct {
69+
EnvironmentID string `path:"id" doc:"Environment ID"`
70+
ActivityID string `path:"activityId" doc:"Activity ID"`
71+
RequestedBy string `query:"requestedBy" doc:"Display name to attribute the cancellation to (used when proxying to a remote environment)"`
72+
}
73+
74+
type CancelActivityOutput struct {
75+
Body base.ApiResponse[activity.Activity]
76+
}
77+
6778
func RegisterActivities(api huma.API, activityService *services.ActivityService, environmentService *services.EnvironmentService) {
6879
h := &ActivityHandler{
6980
activityService: activityService,
@@ -112,6 +123,20 @@ func RegisterActivities(api huma.API, activityService *services.ActivityService,
112123
Middlewares: humamw.RequirePermission(api, authz.PermActivitiesRead),
113124
}, h.StreamActivities)
114125

126+
huma.Register(api, huma.Operation{
127+
OperationID: "cancel-activity",
128+
Method: http.MethodPost,
129+
Path: "/environments/{id}/activities/{activityId}/cancel",
130+
Summary: "Cancel a background activity",
131+
Description: "Request cancellation of a running or queued background activity",
132+
Tags: []string{"Activities"},
133+
Security: []map[string][]string{
134+
{"BearerAuth": {}},
135+
{"ApiKeyAuth": {}},
136+
},
137+
Middlewares: humamw.RequirePermission(api, authz.PermActivitiesCancel),
138+
}, h.CancelActivity)
139+
115140
huma.Register(api, huma.Operation{
116141
OperationID: "clear-activity-history",
117142
Method: http.MethodDelete,
@@ -308,6 +333,70 @@ func (h *ActivityHandler) ClearHistory(ctx context.Context, input *ClearActivity
308333
}, nil
309334
}
310335

336+
func (h *ActivityHandler) CancelActivity(ctx context.Context, input *CancelActivityInput) (*CancelActivityOutput, error) {
337+
if input.EnvironmentID != "0" {
338+
return h.proxyCancelActivityInternal(ctx, input)
339+
}
340+
if h.activityService == nil {
341+
return nil, huma.Error500InternalServerError("service not available")
342+
}
343+
if input.ActivityID == "" {
344+
return nil, huma.Error400BadRequest("activity id is required")
345+
}
346+
347+
requestedBy := h.cancelRequestedByInternal(ctx, input.RequestedBy)
348+
cancelled, err := h.activityService.CancelActivity(ctx, input.EnvironmentID, input.ActivityID, requestedBy)
349+
if err != nil {
350+
switch {
351+
case errors.Is(err, gorm.ErrRecordNotFound):
352+
return nil, huma.Error404NotFound("activity not found")
353+
case errors.Is(err, services.ErrActivityNotCancelable):
354+
return nil, huma.Error409Conflict("activity is not running")
355+
default:
356+
return nil, huma.Error500InternalServerError(err.Error())
357+
}
358+
}
359+
h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, cancelled)
360+
361+
return &CancelActivityOutput{
362+
Body: base.ApiResponse[activity.Activity]{
363+
Success: true,
364+
Data: *cancelled,
365+
},
366+
}, nil
367+
}
368+
369+
func (h *ActivityHandler) proxyCancelActivityInternal(ctx context.Context, input *CancelActivityInput) (*CancelActivityOutput, error) {
370+
if h.environmentService == nil {
371+
return nil, huma.Error500InternalServerError("environment service not available")
372+
}
373+
path := fmt.Sprintf("/api/environments/0/activities/%s/cancel", url.PathEscape(input.ActivityID))
374+
if requestedBy := h.cancelRequestedByInternal(ctx, input.RequestedBy); requestedBy != "" {
375+
path += "?requestedBy=" + url.QueryEscape(requestedBy)
376+
}
377+
out, err := proxyRemoteJSONInternal[base.ApiResponse[activity.Activity]](ctx, h.environmentService, input.EnvironmentID, http.MethodPost, path, nil)
378+
if err != nil {
379+
return nil, err
380+
}
381+
h.applyActivitySourceLabelInternal(ctx, input.EnvironmentID, &out.Data)
382+
return &CancelActivityOutput{Body: *out}, nil
383+
}
384+
385+
// cancelRequestedByInternal resolves a human-readable name for the cancellation
386+
// audit message, preferring the authenticated user and falling back to a name
387+
// forwarded from a proxying controller.
388+
func (h *ActivityHandler) cancelRequestedByInternal(ctx context.Context, forwarded string) string {
389+
if user, ok := humamw.GetCurrentUserFromContext(ctx); ok && user != nil {
390+
if user.DisplayName != nil && strings.TrimSpace(*user.DisplayName) != "" {
391+
return strings.TrimSpace(*user.DisplayName)
392+
}
393+
if name := strings.TrimSpace(user.Username); name != "" {
394+
return name
395+
}
396+
}
397+
return strings.TrimSpace(forwarded)
398+
}
399+
311400
func (h *ActivityHandler) streamRemoteActivitySnapshotsInternal(
312401
ctx context.Context,
313402
input *StreamActivitiesInput,

backend/api/handlers/containers.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -625,7 +625,7 @@ func (h *ContainerHandler) StartContainer(ctx context.Context, input *ContainerA
625625
}
626626

627627
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
628-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID})
628+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStart, "container", input.ContainerID, input.ContainerID, user, "Starting container", "Container start requested", models.JSON{"containerID": input.ContainerID})
629629
if err := h.containerService.StartContainer(runtimeCtx, input.ContainerID, *user); err != nil {
630630
activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container started", err)
631631
return nil, huma.Error500InternalServerError((&common.ContainerStartError{Err: err}).Error())
@@ -651,7 +651,7 @@ func (h *ContainerHandler) StopContainer(ctx context.Context, input *ContainerAc
651651
}
652652

653653
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
654-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID})
654+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerStop, "container", input.ContainerID, input.ContainerID, user, "Stopping container", "Container stop requested", models.JSON{"containerID": input.ContainerID})
655655
if err := h.containerService.StopContainer(runtimeCtx, input.ContainerID, *user); err != nil {
656656
activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container stopped", err)
657657
return nil, huma.Error500InternalServerError((&common.ContainerStopError{Err: err}).Error())
@@ -677,7 +677,7 @@ func (h *ContainerHandler) RestartContainer(ctx context.Context, input *Containe
677677
}
678678

679679
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
680-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID})
680+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRestart, "container", input.ContainerID, input.ContainerID, user, "Restarting container", "Container restart requested", models.JSON{"containerID": input.ContainerID})
681681
if err := h.containerService.RestartContainer(runtimeCtx, input.ContainerID, *user); err != nil {
682682
activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container restarted", err)
683683
return nil, huma.Error500InternalServerError((&common.ContainerRestartError{Err: err}).Error())
@@ -703,7 +703,7 @@ func (h *ContainerHandler) RedeployContainer(ctx context.Context, input *Contain
703703
}
704704

705705
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
706-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID})
706+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerRedeploy, "container", input.ContainerID, input.ContainerID, user, "Starting redeploy", "Container redeploy requested", models.JSON{"containerID": input.ContainerID})
707707
activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Redeploying container")
708708
redeployCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter)
709709
newContainerID, err := h.containerService.RedeployContainer(redeployCtx, input.ContainerID, *user)
@@ -752,7 +752,7 @@ func (h *ContainerHandler) DeleteContainer(ctx context.Context, input *DeleteCon
752752
}
753753

754754
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
755-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes})
755+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeContainerDelete, "container", input.ContainerID, input.ContainerID, user, "Deleting container", "Container delete requested", models.JSON{"containerID": input.ContainerID, "force": input.Force, "removeVolumes": input.RemoveVolumes})
756756
if err := h.containerService.DeleteContainer(runtimeCtx, input.ContainerID, input.Force, input.RemoveVolumes, *user); err != nil {
757757
activitylib.CompleteHandlerActivity(runtimeCtx, h.activityService, activityID, "Container deleted", err)
758758
return nil, huma.Error500InternalServerError((&common.ContainerDeleteError{Err: err}).Error())

backend/api/handlers/images.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,7 @@ func (h *ImageHandler) PullImage(ctx context.Context, input *PullImageInput) (*h
453453

454454
runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx)
455455
rawWriter := humaCtx.BodyWriter()
456-
activityID := activitylib.StartHandlerActivityForUser(
456+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(
457457
runtimeCtx,
458458
h.activityService,
459459
input.EnvironmentID,
@@ -512,7 +512,7 @@ func (h *ImageHandler) BuildImage(ctx context.Context, input *BuildImageInput) (
512512
if strings.TrimSpace(resourceName) == "" {
513513
resourceName = input.Body.ContextDir
514514
}
515-
activityID := activitylib.StartHandlerActivityForUser(
515+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(
516516
runtimeCtx,
517517
h.activityService,
518518
input.EnvironmentID,

backend/api/handlers/networks.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ func (h *NetworkHandler) CreateNetwork(ctx context.Context, input *CreateNetwork
302302
"action": "create_network",
303303
"driver": input.Body.Options.Driver,
304304
},
305-
}, func() error {
305+
}, func(runtimeCtx context.Context) error {
306306
var createErr error
307307
response, createErr = h.networkService.CreateNetwork(runtimeCtx, input.Body.Name, dockerOptions, *user)
308308
return createErr
@@ -447,7 +447,7 @@ func (h *NetworkHandler) DeleteNetwork(ctx context.Context, input *DeleteNetwork
447447
Metadata: models.JSON{
448448
"action": "remove_network",
449449
},
450-
}, func() error {
450+
}, func(runtimeCtx context.Context) error {
451451
return h.networkService.RemoveNetwork(runtimeCtx, input.NetworkID, *user)
452452
})
453453
if err != nil {
@@ -473,7 +473,7 @@ func (h *NetworkHandler) PruneNetworks(ctx context.Context, input *PruneNetworks
473473
Message: "Pruning unused networks",
474474
SuccessMessage: "Networks pruned successfully",
475475
Metadata: models.JSON{"action": "prune_networks"},
476-
}, func() error {
476+
}, func(runtimeCtx context.Context) error {
477477
var pruneErr error
478478
report, pruneErr = h.networkService.PruneNetworks(runtimeCtx)
479479
return pruneErr

backend/api/handlers/projects.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject
602602

603603
runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx)
604604
rawWriter := humaCtx.BodyWriter()
605-
activityID := activitylib.StartHandlerActivityForUser(
605+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(
606606
runtimeCtx,
607607
h.activityService,
608608
input.EnvironmentID,
@@ -658,7 +658,7 @@ func (h *ProjectHandler) DownProject(ctx context.Context, input *DownProjectInpu
658658
}
659659

660660
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
661-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID})
661+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDown, "project", input.ProjectID, input.ProjectID, user, "Stopping project", "Project stop requested", models.JSON{"projectID": input.ProjectID})
662662
activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Stopping project")
663663
downCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter)
664664
if err := h.projectService.DownProject(downCtx, input.ProjectID, *user); err != nil {
@@ -708,7 +708,7 @@ func (h *ProjectHandler) CreateProject(ctx context.Context, input *CreateProject
708708
Message: "Creating project",
709709
SuccessMessage: "Project created successfully",
710710
Metadata: models.JSON{"action": "create_project"},
711-
}, func() error {
711+
}, func(runtimeCtx context.Context) error {
712712
var createErr error
713713
proj, createErr = h.projectService.CreateProject(runtimeCtx, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user)
714714
return createErr
@@ -865,7 +865,7 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro
865865
}
866866

867867
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
868-
activityID := activitylib.StartHandlerActivityForUser(
868+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(
869869
runtimeCtx,
870870
h.activityService,
871871
input.EnvironmentID,
@@ -929,7 +929,7 @@ func (h *ProjectHandler) DestroyProject(ctx context.Context, input *DestroyProje
929929
}
930930

931931
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
932-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes})
932+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectDestroy, "project", input.ProjectID, input.ProjectID, user, "Destroying project", "Project destroy requested", models.JSON{"projectID": input.ProjectID, "removeFiles": removeFiles, "removeVolumes": removeVolumes})
933933
activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Destroying project")
934934
destroyCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter)
935935
if err := h.projectService.DestroyProject(destroyCtx, input.ProjectID, removeFiles, removeVolumes, *user); err != nil {
@@ -978,7 +978,7 @@ func (h *ProjectHandler) UpdateProject(ctx context.Context, input *UpdateProject
978978
Message: "Updating project",
979979
SuccessMessage: "Project updated successfully",
980980
Metadata: models.JSON{"action": "update_project", "projectID": input.ProjectID},
981-
}, func() error {
981+
}, func(runtimeCtx context.Context) error {
982982
_, updateErr := h.projectService.UpdateProject(runtimeCtx, input.ProjectID, input.Body.Name, input.Body.ComposeContent, input.Body.EnvContent, *user)
983983
return updateErr
984984
})
@@ -1031,7 +1031,7 @@ func (h *ProjectHandler) UpdateProjectInclude(ctx context.Context, input *Update
10311031
"projectID": input.ProjectID,
10321032
"relativePath": input.Body.RelativePath,
10331033
},
1034-
}, func() error {
1034+
}, func(runtimeCtx context.Context) error {
10351035
return h.projectService.UpdateProjectIncludeFile(runtimeCtx, input.ProjectID, input.Body.RelativePath, input.Body.Content, *user)
10361036
})
10371037
if err != nil {
@@ -1068,7 +1068,7 @@ func (h *ProjectHandler) RestartProject(ctx context.Context, input *RestartProje
10681068
}
10691069

10701070
runtimeCtx := utils.ActivityRuntimeContext(ctx, h.appCtx)
1071-
activityID := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID})
1071+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(runtimeCtx, h.activityService, input.EnvironmentID, models.ActivityTypeProjectRestart, "project", input.ProjectID, input.ProjectID, user, "Restarting project", "Project restart requested", models.JSON{"projectID": input.ProjectID})
10721072
activityWriter := activitylib.NewWriter(runtimeCtx, h.activityService, activityID, io.Discard, "Restarting project")
10731073
restartCtx := context.WithValue(runtimeCtx, projects.ProgressWriterKey{}, activityWriter)
10741074
if err := h.projectService.RestartProject(restartCtx, input.ProjectID, *user); err != nil {
@@ -1174,7 +1174,7 @@ func (h *ProjectHandler) PullProjectImages(ctx context.Context, input *PullProje
11741174

11751175
runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx)
11761176
rawWriter := humaCtx.BodyWriter()
1177-
activityID := activitylib.StartHandlerActivityForUser(
1177+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(
11781178
runtimeCtx,
11791179
h.activityService,
11801180
input.EnvironmentID,
@@ -1246,7 +1246,7 @@ func (h *ProjectHandler) BuildProjectImages(ctx context.Context, input *BuildPro
12461246

12471247
runtimeCtx := utils.ActivityRuntimeContext(humaCtx.Context(), h.appCtx)
12481248
rawWriter := humaCtx.BodyWriter()
1249-
activityID := activitylib.StartHandlerActivityForUser(
1249+
activityID, runtimeCtx := activitylib.StartHandlerActivityForUser(
12501250
runtimeCtx,
12511251
h.activityService,
12521252
input.EnvironmentID,

0 commit comments

Comments
 (0)