feat: add workspace restart functionality to API (#25757)

This models restart as durable orchestration of existing stop and
start workspace builds instead of adding a new restart transition.
Keeping restart as two existing transitions preserves the current
build/provisioner model.

The child start build is created only after the parent stop build
succeeds, rather than being inserted immediately in a pending
state. That keeps `workspace_builds` aligned with actual
provisioner-ready work and avoids introducing a second
pending-build lifecycle that the provisioner and build acquisition
paths would need to understand.

Refs: https://linear.app/codercom/issue/PLAT-143
This commit is contained in:
George K
2026-07-07 09:18:30 -07:00
committed by GitHub
parent ba094c5706
commit 6af0f4d698
45 changed files with 4412 additions and 144 deletions
+126 -1
View File
@@ -375,6 +375,30 @@ func (api *API) postWorkspaceBuildsInternal(
codersdk.WorkspaceBuild,
error,
) {
if err := validateCreateWorkspaceBuildOnSuccess(createBuild); err != nil {
return codersdk.WorkspaceBuild{}, err
}
var childParameterValuesJSON json.RawMessage
if createBuild.OnSuccess != nil {
childParameterValues := createBuild.OnSuccess.RichParameterValues
if childParameterValues == nil {
childParameterValues = []codersdk.WorkspaceBuildParameter{}
}
var err error
childParameterValuesJSON, err = json.Marshal(childParameterValues)
if err != nil {
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
http.StatusInternalServerError,
codersdk.Response{
Message: "Internal error preparing follow-up workspace build parameters",
Detail: err.Error(),
},
)
}
}
transition := database.WorkspaceTransition(createBuild.Transition)
builder := wsbuilder.New(workspace, transition, *api.BuildUsageChecker.Load()).
Initiator(apiKey.UserID).
@@ -481,7 +505,62 @@ func (api *API) postWorkspaceBuildsInternal(
},
workspaceBuildBaggage,
)
return err
if err != nil {
return err
}
if createBuild.OnSuccess != nil {
onSuccessReq := createBuild.OnSuccess
// Reuse the parent build's timestamps so the orchestration row and
// the parent build agree on created_at.
_, err = tx.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
ID: uuid.New(),
CreatedAt: workspaceBuild.CreatedAt,
UpdatedAt: workspaceBuild.UpdatedAt,
ParentBuildID: workspaceBuild.ID,
ChildTransition: database.WorkspaceTransition(onSuccessReq.Transition),
ChildTemplateVersionID: uuid.NullUUID{
UUID: onSuccessReq.TemplateVersionID,
Valid: onSuccessReq.TemplateVersionID != uuid.Nil,
},
ChildTemplateVersionPresetID: uuid.NullUUID{
UUID: onSuccessReq.TemplateVersionPresetID,
Valid: onSuccessReq.TemplateVersionPresetID != uuid.Nil,
},
ChildRichParameterValues: childParameterValuesJSON,
ChildLogLevel: string(createBuild.LogLevel),
ChildReason: database.NullBuildReason{
BuildReason: database.BuildReason(createBuild.Reason),
Valid: createBuild.Reason != "",
},
})
if err != nil {
if dbauthz.IsNotAuthorizedError(err) {
detail := "Queuing the follow-up workspace build requires permission to start the workspace."
if onSuccessReq.TemplateVersionID != uuid.Nil {
detail = "Pinning a template version on the follow-up build requires template update permission. Omit template_version_id to use the active version."
}
return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
Message: "Unauthorized to queue follow-up workspace build.",
Detail: detail,
})
}
api.Logger.Error(ctx, "failed to queue follow-up workspace build",
slog.F("workspace_id", workspace.ID),
slog.F("workspace_build_id", workspaceBuild.ID),
slog.Error(err),
)
return httperror.NewResponseError(
http.StatusInternalServerError,
codersdk.Response{
Message: "Internal error queueing follow-up workspace build",
Detail: err.Error(),
},
)
}
}
return nil
}, nil)
if err != nil {
return codersdk.WorkspaceBuild{}, err
@@ -584,6 +663,48 @@ func (api *API) postWorkspaceBuildsInternal(
return apiBuild, nil
}
// validateCreateWorkspaceBuildOnSuccess enforces the subset of build options
// that currently has well-defined stop-then-start semantics.
func validateCreateWorkspaceBuildOnSuccess(createBuild codersdk.CreateWorkspaceBuildRequest) error {
onSuccess := createBuild.OnSuccess
if onSuccess == nil {
return nil
}
if createBuild.Transition != codersdk.WorkspaceTransitionStop {
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "OnSuccess is only permitted when stopping a workspace.",
})
}
if onSuccess.Transition != codersdk.WorkspaceTransitionStart {
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "OnSuccess transition must be start.",
})
}
if createBuild.DryRun {
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "OnSuccess cannot be set alongside DryRun.",
})
}
if createBuild.Orphan {
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "OnSuccess cannot be set alongside Orphan.",
})
}
if len(createBuild.ProvisionerState) > 0 {
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "OnSuccess cannot be set alongside ProvisionerState.",
})
}
if onSuccess.TemplateVersionPresetID != uuid.Nil && onSuccess.TemplateVersionID == uuid.Nil {
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "OnSuccess TemplateVersionPresetID requires TemplateVersionID.",
})
}
return nil
}
func (api *API) notifyWorkspaceUpdated(
ctx context.Context,
initiatorID uuid.UUID,
@@ -765,6 +886,10 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
Kind: wspubsub.WorkspaceEventKindStateChange,
WorkspaceID: workspace.ID,
})
err = wspubsub.PublishWorkspaceBuildOrchestrationWake(ctx, api.Pubsub)
if err != nil {
api.Logger.Warn(ctx, "failed to publish workspace build orchestration wake", slog.Error(err))
}
// Publish workspace build update to the all builds channel if the experiment is enabled.
if api.Experiments.Enabled(codersdk.ExperimentWorkspaceBuildUpdates) {