Feature: Add per-runner “Disable/Pause” (#36776)
This PR adds per-runner disable/enable support for Gitea Actions so a registered runner can be paused from picking up new jobs without unregistering. Disabled runners stay registered and online but are excluded from new task assignment; running tasks are allowed to finish. Re-enabling restores pickup, and runner list/get responses now expose disabled state. Also added an endpoint for testing http://localhost:3000/devtest/runner-edit/enable <img width="1509" height="701" alt="Bildschirmfoto 2026-02-27 um 22 13 24" src="https://github.com/user-attachments/assets/5328eda9-e59c-46b6-b398-f436e50ee3da" /> Fixes: https://github.com/go-gitea/gitea/issues/36767
This commit is contained in:
@@ -156,10 +156,16 @@ func (s *Service) FetchTask(
|
||||
}
|
||||
|
||||
if tasksVersion != latestVersion {
|
||||
// Re-load runner from DB so task assignment uses current IsDisabled state
|
||||
// (avoids race where disable commits while this request still has stale runner).
|
||||
freshRunner, err := actions_model.GetRunnerByUUID(ctx, runner.UUID)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "get runner: %v", err)
|
||||
}
|
||||
// if the task version in request is not equal to the version in db,
|
||||
// it means there may still be some tasks that haven't been assigned.
|
||||
// try to pick a task for the runner that send the request.
|
||||
if t, ok, err := actions_service.PickTask(ctx, runner); err != nil {
|
||||
if t, ok, err := actions_service.PickTask(ctx, freshRunner); err != nil {
|
||||
log.Error("pick task failed: %v", err)
|
||||
return nil, status.Errorf(codes.Internal, "pick task: %v", err)
|
||||
} else if ok {
|
||||
|
||||
@@ -32,6 +32,12 @@ func ListRunners(ctx *context.APIContext) {
|
||||
// summary: Get all runners
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: disabled
|
||||
// in: query
|
||||
// description: filter by disabled status (true or false)
|
||||
// type: boolean
|
||||
// required: false
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunnersResponse"
|
||||
@@ -87,3 +93,34 @@ func DeleteRunner(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.DeleteRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// UpdateRunner update a global runner
|
||||
func UpdateRunner(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /admin/actions/runners/{runner_id} admin updateAdminRunner
|
||||
// ---
|
||||
// summary: Update a global runner
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditActionRunnerOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunner"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
shared.UpdateRunner(ctx, 0, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
@@ -916,6 +916,7 @@ func Routes() *web.Router {
|
||||
m.Post("/registration-token", reqToken(), reqOwnerCheck, act.CreateRegistrationToken)
|
||||
m.Get("/{runner_id}", reqToken(), reqOwnerCheck, act.GetRunner)
|
||||
m.Delete("/{runner_id}", reqToken(), reqOwnerCheck, act.DeleteRunner)
|
||||
m.Patch("/{runner_id}", reqToken(), reqOwnerCheck, bind(api.EditActionRunnerOption{}), act.UpdateRunner)
|
||||
})
|
||||
m.Get("/runs", reqToken(), reqReaderCheck, act.ListWorkflowRuns)
|
||||
m.Get("/jobs", reqToken(), reqReaderCheck, act.ListWorkflowJobs)
|
||||
@@ -1043,6 +1044,7 @@ func Routes() *web.Router {
|
||||
m.Post("/registration-token", reqToken(), user.CreateRegistrationToken)
|
||||
m.Get("/{runner_id}", reqToken(), user.GetRunner)
|
||||
m.Delete("/{runner_id}", reqToken(), user.DeleteRunner)
|
||||
m.Patch("/{runner_id}", reqToken(), bind(api.EditActionRunnerOption{}), user.UpdateRunner)
|
||||
})
|
||||
|
||||
m.Get("/runs", reqToken(), user.ListWorkflowRuns)
|
||||
@@ -1728,6 +1730,7 @@ func Routes() *web.Router {
|
||||
m.Post("/registration-token", admin.CreateRegistrationToken)
|
||||
m.Get("/{runner_id}", admin.GetRunner)
|
||||
m.Delete("/{runner_id}", admin.DeleteRunner)
|
||||
m.Patch("/{runner_id}", bind(api.EditActionRunnerOption{}), admin.UpdateRunner)
|
||||
})
|
||||
m.Get("/runs", admin.ListWorkflowRuns)
|
||||
m.Get("/jobs", admin.ListWorkflowJobs)
|
||||
|
||||
@@ -485,6 +485,11 @@ func (Action) ListRunners(ctx *context.APIContext) {
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: disabled
|
||||
// in: query
|
||||
// description: filter by disabled status (true or false)
|
||||
// type: boolean
|
||||
// required: false
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunnersResponse"
|
||||
@@ -551,6 +556,42 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
|
||||
shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// UpdateRunner update an org-level runner
|
||||
func (Action) UpdateRunner(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /orgs/{org}/actions/runners/{runner_id} organization updateOrgRunner
|
||||
// ---
|
||||
// summary: Update an org-level runner
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: org
|
||||
// in: path
|
||||
// description: name of the organization
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditActionRunnerOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunner"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
shared.UpdateRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /orgs/{org}/actions/jobs organization getOrgWorkflowJobs
|
||||
// ---
|
||||
|
||||
@@ -554,6 +554,11 @@ func (Action) ListRunners(ctx *context.APIContext) {
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: disabled
|
||||
// in: query
|
||||
// description: filter by disabled status (true or false)
|
||||
// type: boolean
|
||||
// required: false
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunnersResponse"
|
||||
@@ -564,11 +569,11 @@ func (Action) ListRunners(ctx *context.APIContext) {
|
||||
shared.ListRunners(ctx, 0, ctx.Repo.Repository.ID)
|
||||
}
|
||||
|
||||
// GetRunner get an repo-level runner
|
||||
// GetRunner get a repo-level runner
|
||||
func (Action) GetRunner(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/runners/{runner_id} repository getRepoRunner
|
||||
// ---
|
||||
// summary: Get an repo-level runner
|
||||
// summary: Get a repo-level runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
@@ -597,11 +602,11 @@ func (Action) GetRunner(ctx *context.APIContext) {
|
||||
shared.GetRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// DeleteRunner delete an repo-level runner
|
||||
// DeleteRunner delete a repo-level runner
|
||||
func (Action) DeleteRunner(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /repos/{owner}/{repo}/actions/runners/{runner_id} repository deleteRepoRunner
|
||||
// ---
|
||||
// summary: Delete an repo-level runner
|
||||
// summary: Delete a repo-level runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
@@ -630,6 +635,47 @@ func (Action) DeleteRunner(ctx *context.APIContext) {
|
||||
shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// UpdateRunner update a repo-level runner
|
||||
func (Action) UpdateRunner(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /repos/{owner}/{repo}/actions/runners/{runner_id} repository updateRepoRunner
|
||||
// ---
|
||||
// summary: Update a repo-level runner
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: owner
|
||||
// in: path
|
||||
// description: owner of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: repo
|
||||
// in: path
|
||||
// description: name of the repo
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditActionRunnerOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunner"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
shared.UpdateRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// GetWorkflowRunJobs Lists all jobs for a workflow run.
|
||||
func (Action) ListWorkflowJobs(ctx *context.APIContext) {
|
||||
// swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
api "code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/convert"
|
||||
@@ -46,11 +47,13 @@ func ListRunners(ctx *context.APIContext, ownerID, repoID int64) {
|
||||
if ownerID != 0 && repoID != 0 {
|
||||
setting.PanicInDevOrTesting("ownerID and repoID should not be both set")
|
||||
}
|
||||
runners, total, err := db.FindAndCount[actions_model.ActionRunner](ctx, &actions_model.FindRunnerOptions{
|
||||
opts := &actions_model.FindRunnerOptions{
|
||||
OwnerID: ownerID,
|
||||
RepoID: repoID,
|
||||
ListOptions: utils.GetListOptions(ctx),
|
||||
})
|
||||
}
|
||||
opts.IsDisabled = ctx.FormOptionalBool("disabled")
|
||||
runners, total, err := db.FindAndCount[actions_model.ActionRunner](ctx, opts)
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
@@ -125,3 +128,23 @@ func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
|
||||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func UpdateRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) {
|
||||
runner, ok := getRunnerByID(ctx, ownerID, repoID, runnerID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*api.EditActionRunnerOption)
|
||||
if form.Disabled == nil {
|
||||
ctx.APIError(http.StatusUnprocessableEntity, "[Disabled]: Required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := actions_model.SetRunnerDisabled(ctx, runner, *form.Disabled); err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
}
|
||||
|
||||
GetRunner(ctx, ownerID, repoID, runnerID)
|
||||
}
|
||||
|
||||
@@ -225,6 +225,9 @@ type swaggerParameterBodies struct {
|
||||
// in:body
|
||||
UpdateVariableOption api.UpdateVariableOption
|
||||
|
||||
// in:body
|
||||
EditActionRunnerOption api.EditActionRunnerOption
|
||||
|
||||
// in:body
|
||||
LockIssueOption api.LockIssueOption
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ func ListRunners(ctx *context.APIContext) {
|
||||
// summary: Get user-level runners
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: disabled
|
||||
// in: query
|
||||
// description: filter by disabled status (true or false)
|
||||
// type: boolean
|
||||
// required: false
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunnersResponse"
|
||||
@@ -42,11 +48,11 @@ func ListRunners(ctx *context.APIContext) {
|
||||
shared.ListRunners(ctx, ctx.Doer.ID, 0)
|
||||
}
|
||||
|
||||
// GetRunner get an user-level runner
|
||||
// GetRunner get a user-level runner
|
||||
func GetRunner(ctx *context.APIContext) {
|
||||
// swagger:operation GET /user/actions/runners/{runner_id} user getUserRunner
|
||||
// ---
|
||||
// summary: Get an user-level runner
|
||||
// summary: Get a user-level runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
@@ -65,11 +71,11 @@ func GetRunner(ctx *context.APIContext) {
|
||||
shared.GetRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// DeleteRunner delete an user-level runner
|
||||
// DeleteRunner delete a user-level runner
|
||||
func DeleteRunner(ctx *context.APIContext) {
|
||||
// swagger:operation DELETE /user/actions/runners/{runner_id} user deleteUserRunner
|
||||
// ---
|
||||
// summary: Delete an user-level runner
|
||||
// summary: Delete a user-level runner
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
@@ -87,3 +93,34 @@ func DeleteRunner(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
shared.DeleteRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
// UpdateRunner update a user-level runner
|
||||
func UpdateRunner(ctx *context.APIContext) {
|
||||
// swagger:operation PATCH /user/actions/runners/{runner_id} user updateUserRunner
|
||||
// ---
|
||||
// summary: Update a user-level runner
|
||||
// consumes:
|
||||
// - application/json
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: runner_id
|
||||
// in: path
|
||||
// description: id of the runner
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/EditActionRunnerOption"
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/definitions/ActionRunner"
|
||||
// "400":
|
||||
// "$ref": "#/responses/error"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "422":
|
||||
// "$ref": "#/responses/validationError"
|
||||
shared.UpdateRunner(ctx, ctx.Doer.ID, 0, ctx.PathParamInt64("runner_id"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user