@@ -581,10 +581,24 @@ func (api *API) notifyWorkspaceUpdated(
581
581
// @Produce json
582
582
// @Tags Builds
583
583
// @Param workspacebuild path string true "Workspace build ID"
584
+ // @Param expect_status query string false "Expected status of the job. If expect_status is supplied, the request will be rejected with 412 Precondition Failed if the job doesn't match the state when performing the cancellation." Enums(running, pending)
584
585
// @Success 200 {object} codersdk.Response
585
586
// @Router /workspacebuilds/{workspacebuild}/cancel [patch]
586
587
func (api * API ) patchCancelWorkspaceBuild (rw http.ResponseWriter , r * http.Request ) {
587
588
ctx := r .Context ()
589
+
590
+ var expectStatus database.ProvisionerJobStatus
591
+ expectStatusParam := r .URL .Query ().Get ("expect_status" )
592
+ if expectStatusParam != "" {
593
+ if expectStatusParam != "running" && expectStatusParam != "pending" {
594
+ httpapi .Write (ctx , rw , http .StatusBadRequest , codersdk.Response {
595
+ Message : fmt .Sprintf ("Invalid expect_status %q. Only 'running' or 'pending' are allowed." , expectStatusParam ),
596
+ })
597
+ return
598
+ }
599
+ expectStatus = database .ProvisionerJobStatus (expectStatusParam )
600
+ }
601
+
588
602
workspaceBuild := httpmw .WorkspaceBuildParam (r )
589
603
workspace , err := api .Database .GetWorkspaceByID (ctx , workspaceBuild .WorkspaceID )
590
604
if err != nil {
@@ -594,58 +608,78 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
594
608
return
595
609
}
596
610
597
- valid , err := api .verifyUserCanCancelWorkspaceBuilds (ctx , httpmw .APIKey (r ).UserID , workspace .TemplateID )
598
- if err != nil {
599
- httpapi .Write (ctx , rw , http .StatusInternalServerError , codersdk.Response {
600
- Message : "Internal error verifying permission to cancel workspace build." ,
601
- Detail : err .Error (),
602
- })
603
- return
604
- }
605
- if ! valid {
606
- httpapi .Write (ctx , rw , http .StatusForbidden , codersdk.Response {
607
- Message : "User is not allowed to cancel workspace builds. Owner role is required." ,
608
- })
609
- return
611
+ code := http .StatusInternalServerError
612
+ resp := codersdk.Response {
613
+ Message : "Internal error canceling workspace build." ,
610
614
}
615
+ err = api .Database .InTx (func (db database.Store ) error {
616
+ valid , err := verifyUserCanCancelWorkspaceBuilds (ctx , db , httpmw .APIKey (r ).UserID , workspace .TemplateID , expectStatus )
617
+ if err != nil {
618
+ code = http .StatusInternalServerError
619
+ resp .Message = "Internal error verifying permission to cancel workspace build."
620
+ resp .Detail = err .Error ()
611
621
612
- job , err := api .Database .GetProvisionerJobByID (ctx , workspaceBuild .JobID )
613
- if err != nil {
614
- httpapi .Write (ctx , rw , http .StatusInternalServerError , codersdk.Response {
615
- Message : "Internal error fetching provisioner job." ,
616
- Detail : err .Error (),
617
- })
618
- return
619
- }
620
- if job .CompletedAt .Valid {
621
- httpapi .Write (ctx , rw , http .StatusBadRequest , codersdk.Response {
622
- Message : "Job has already completed!" ,
623
- })
624
- return
625
- }
626
- if job .CanceledAt .Valid {
627
- httpapi .Write (ctx , rw , http .StatusBadRequest , codersdk.Response {
628
- Message : "Job has already been marked as canceled!" ,
622
+ return xerrors .Errorf ("verify user can cancel workspace builds: %w" , err )
623
+ }
624
+ if ! valid {
625
+ code = http .StatusForbidden
626
+ resp .Message = "User is not allowed to cancel workspace builds. Owner role is required."
627
+
628
+ return xerrors .New ("user is not allowed to cancel workspace builds" )
629
+ }
630
+
631
+ job , err := db .GetProvisionerJobByIDForUpdate (ctx , workspaceBuild .JobID )
632
+ if err != nil {
633
+ code = http .StatusInternalServerError
634
+ resp .Message = "Internal error fetching provisioner job."
635
+ resp .Detail = err .Error ()
636
+
637
+ return xerrors .Errorf ("get provisioner job: %w" , err )
638
+ }
639
+ if job .CompletedAt .Valid {
640
+ code = http .StatusBadRequest
641
+ resp .Message = "Job has already completed!"
642
+
643
+ return xerrors .New ("job has already completed" )
644
+ }
645
+ if job .CanceledAt .Valid {
646
+ code = http .StatusBadRequest
647
+ resp .Message = "Job has already been marked as canceled!"
648
+
649
+ return xerrors .New ("job has already been marked as canceled" )
650
+ }
651
+
652
+ if expectStatus != "" && job .JobStatus != expectStatus {
653
+ code = http .StatusPreconditionFailed
654
+ resp .Message = "Job is not in the expected state."
655
+
656
+ return xerrors .Errorf ("job is not in the expected state: expected: %q, got %q" , expectStatus , job .JobStatus )
657
+ }
658
+
659
+ err = db .UpdateProvisionerJobWithCancelByID (ctx , database.UpdateProvisionerJobWithCancelByIDParams {
660
+ ID : job .ID ,
661
+ CanceledAt : sql.NullTime {
662
+ Time : dbtime .Now (),
663
+ Valid : true ,
664
+ },
665
+ CompletedAt : sql.NullTime {
666
+ Time : dbtime .Now (),
667
+ // If the job is running, don't mark it completed!
668
+ Valid : ! job .WorkerID .Valid ,
669
+ },
629
670
})
630
- return
631
- }
632
- err = api .Database .UpdateProvisionerJobWithCancelByID (ctx , database.UpdateProvisionerJobWithCancelByIDParams {
633
- ID : job .ID ,
634
- CanceledAt : sql.NullTime {
635
- Time : dbtime .Now (),
636
- Valid : true ,
637
- },
638
- CompletedAt : sql.NullTime {
639
- Time : dbtime .Now (),
640
- // If the job is running, don't mark it completed!
641
- Valid : ! job .WorkerID .Valid ,
642
- },
643
- })
671
+ if err != nil {
672
+ code = http .StatusInternalServerError
673
+ resp .Message = "Internal error updating provisioner job."
674
+ resp .Detail = err .Error ()
675
+
676
+ return xerrors .Errorf ("update provisioner job: %w" , err )
677
+ }
678
+
679
+ return nil
680
+ }, nil )
644
681
if err != nil {
645
- httpapi .Write (ctx , rw , http .StatusInternalServerError , codersdk.Response {
646
- Message : "Internal error updating provisioner job." ,
647
- Detail : err .Error (),
648
- })
682
+ httpapi .Write (ctx , rw , code , resp )
649
683
return
650
684
}
651
685
@@ -659,8 +693,14 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
659
693
})
660
694
}
661
695
662
- func (api * API ) verifyUserCanCancelWorkspaceBuilds (ctx context.Context , userID uuid.UUID , templateID uuid.UUID ) (bool , error ) {
663
- template , err := api .Database .GetTemplateByID (ctx , templateID )
696
+ func verifyUserCanCancelWorkspaceBuilds (ctx context.Context , store database.Store , userID uuid.UUID , templateID uuid.UUID , jobStatus database.ProvisionerJobStatus ) (bool , error ) {
697
+ // If the jobStatus is pending, we always allow cancellation regardless of
698
+ // the template setting as it's non-destructive to Terraform resources.
699
+ if jobStatus == database .ProvisionerJobStatusPending {
700
+ return true , nil
701
+ }
702
+
703
+ template , err := store .GetTemplateByID (ctx , templateID )
664
704
if err != nil {
665
705
return false , xerrors .New ("no template exists for this workspace" )
666
706
}
@@ -669,7 +709,7 @@ func (api *API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, userID u
669
709
return true , nil // all users can cancel workspace builds
670
710
}
671
711
672
- user , err := api . Database .GetUserByID (ctx , userID )
712
+ user , err := store .GetUserByID (ctx , userID )
673
713
if err != nil {
674
714
return false , xerrors .New ("user does not exist" )
675
715
}
0 commit comments