diff --git a/models/actions/runner.go b/models/actions/runner.go index 84398b143b..f5d40ca7d6 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -62,6 +62,8 @@ type ActionRunner struct { AgentLabels []string `xorm:"TEXT"` // Store if this is a runner that only ever get one single job assigned Ephemeral bool `xorm:"ephemeral NOT NULL DEFAULT false"` + // Store if this runner is disabled and should not pick up new jobs + IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"` Created timeutil.TimeStamp `xorm:"created"` Updated timeutil.TimeStamp `xorm:"updated"` @@ -199,6 +201,7 @@ type FindRunnerOptions struct { Sort string Filter string IsOnline optional.Option[bool] + IsDisabled optional.Option[bool] WithAvailable bool // not only runners belong to, but also runners can be used } @@ -239,6 +242,10 @@ func (opts FindRunnerOptions) ToConds() builder.Cond { cond = cond.And(builder.Lte{"last_online": time.Now().Add(-RunnerOfflineTime).Unix()}) } } + + if opts.IsDisabled.Has() { + cond = cond.And(builder.Eq{"is_disabled": opts.IsDisabled.Value()}) + } return cond } @@ -297,6 +304,20 @@ func UpdateRunner(ctx context.Context, r *ActionRunner, cols ...string) error { return err } +func SetRunnerDisabled(ctx context.Context, runner *ActionRunner, isDisabled bool) error { + if runner.IsDisabled == isDisabled { + return nil + } + + return db.WithTx(ctx, func(ctx context.Context) error { + runner.IsDisabled = isDisabled + if err := UpdateRunner(ctx, runner, "is_disabled"); err != nil { + return err + } + return IncreaseTaskVersion(ctx, runner.OwnerID, runner.RepoID) + }) +} + // DeleteRunner deletes a runner by given ID. func DeleteRunner(ctx context.Context, id int64) error { if _, err := GetRunnerByID(ctx, id); err != nil { diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index ab14e8a8e7..c1d448577c 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -401,6 +401,7 @@ func prepareMigrationTasks() []*migration { newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness), newMigration(325, "Fix missed repo_id when migrate attachments", v1_26.FixMissedRepoIDWhenMigrateAttachments), newMigration(326, "Migrate commit status target URL to use run ID and job ID", v1_26.FixCommitStatusTargetURLToUseRunAndJobID), + newMigration(327, "Add disabled state to action runners", v1_26.AddDisabledToActionRunner), } return preparedMigrations } diff --git a/models/migrations/v1_26/v327.go b/models/migrations/v1_26/v327.go new file mode 100644 index 0000000000..51af567650 --- /dev/null +++ b/models/migrations/v1_26/v327.go @@ -0,0 +1,17 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +func AddDisabledToActionRunner(x *xorm.Engine) error { + type ActionRunner struct { + IsDisabled bool `xorm:"is_disabled NOT NULL DEFAULT false"` + } + + _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRunner)) + return err +} diff --git a/models/migrations/v1_26/v327_test.go b/models/migrations/v1_26/v327_test.go new file mode 100644 index 0000000000..971707be4f --- /dev/null +++ b/models/migrations/v1_26/v327_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/require" +) + +func Test_AddDisabledToActionRunner(t *testing.T) { + type ActionRunner struct { + ID int64 `xorm:"pk autoincr"` + Name string + } + + x, deferable := base.PrepareTestEnv(t, 0, new(ActionRunner)) + defer deferable() + + _, err := x.Insert(&ActionRunner{Name: "runner"}) + require.NoError(t, err) + + require.NoError(t, AddDisabledToActionRunner(x)) + + var isDisabled bool + has, err := x.SQL("SELECT is_disabled FROM action_runner WHERE id = ?", 1).Get(&isDisabled) + require.NoError(t, err) + require.True(t, has) + require.False(t, isDisabled) +} diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 86bf4959d1..92ca9bccce 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -196,10 +196,18 @@ type ActionRunner struct { Name string `json:"name"` Status string `json:"status"` Busy bool `json:"busy"` + Disabled bool `json:"disabled"` Ephemeral bool `json:"ephemeral"` Labels []*ActionRunnerLabel `json:"labels"` } +// EditActionRunnerOption represents the editable fields for a runner. +// swagger:model +type EditActionRunnerOption struct { + // required: true + Disabled *bool `json:"disabled"` +} + // ActionRunnersResponse returns Runners type ActionRunnersResponse struct { Entries []*ActionRunner `json:"runners"` diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 27695a8f5a..5a5148a146 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -3644,6 +3644,7 @@ "actions.runners.id": "ID", "actions.runners.name": "Name", "actions.runners.owner_type": "Type", + "actions.runners.availability": "Availability", "actions.runners.description": "Description", "actions.runners.labels": "Labels", "actions.runners.last_online": "Last Online Time", @@ -3659,6 +3660,12 @@ "actions.runners.update_runner": "Update Changes", "actions.runners.update_runner_success": "Runner updated successfully", "actions.runners.update_runner_failed": "Failed to update runner", + "actions.runners.enable_runner": "Enable this runner", + "actions.runners.enable_runner_success": "Runner enabled successfully", + "actions.runners.enable_runner_failed": "Failed to enable runner", + "actions.runners.disable_runner": "Disable this runner", + "actions.runners.disable_runner_success": "Runner disabled successfully", + "actions.runners.disable_runner_failed": "Failed to disable runner", "actions.runners.delete_runner": "Delete this runner", "actions.runners.delete_runner_success": "Runner deleted successfully", "actions.runners.delete_runner_failed": "Failed to delete runner", diff --git a/routers/api/actions/runner/runner.go b/routers/api/actions/runner/runner.go index 49d1b13262..190dc69744 100644 --- a/routers/api/actions/runner/runner.go +++ b/routers/api/actions/runner/runner.go @@ -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 { diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go index f5aaea2c32..93983f6c7e 100644 --- a/routers/api/v1/admin/runners.go +++ b/routers/api/v1/admin/runners.go @@ -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")) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e95e597932..560894b798 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 687e9fcbfb..18ed602ddb 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -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 // --- diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 2d28e7ae86..d7e51b8046 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -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 diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go index e9834aff9f..b2e07cf218 100644 --- a/routers/api/v1/shared/runners.go +++ b/routers/api/v1/shared/runners.go @@ -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) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 310839374b..f66cef61df 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -225,6 +225,9 @@ type swaggerParameterBodies struct { // in:body UpdateVariableOption api.UpdateVariableOption + // in:body + EditActionRunnerOption api.EditActionRunnerOption + // in:body LockIssueOption api.LockIssueOption } diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go index 77b0f59c1e..667bdb36fe 100644 --- a/routers/api/v1/user/runners.go +++ b/routers/api/v1/user/runners.go @@ -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")) +} diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 8e8557305b..988d2d0a99 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -317,7 +317,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) { } hasOnlineRunner := false for _, runner := range runners { - if runner.CanMatchLabels(job.RunsOn) { + if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { hasOnlineRunner = true break } diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 577dad822c..8a4e93fe82 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -321,6 +321,47 @@ func RunnerDeletePost(ctx *context.Context) { ctx.JSONRedirect(successRedirectTo) } +func RunnerUpdatePost(ctx *context.Context) { + rCtx, err := getRunnersCtx(ctx) + if err != nil { + ctx.ServerError("getRunnersCtx", err) + return + } + + runner := findActionsRunner(ctx, rCtx) + if ctx.Written() { + return + } + + if !runner.EditableInContext(rCtx.OwnerID, rCtx.RepoID) { + ctx.NotFound(util.NewPermissionDeniedErrorf("no permission to edit this runner")) + return + } + + isDisabled := ctx.FormOptionalBool("disabled") + if !isDisabled.Has() { + ctx.HTTPError(http.StatusBadRequest, "missing 'disabled' parameter") + return + } + + successKey := "actions.runners.enable_runner_success" + failedKey := "actions.runners.enable_runner_failed" + if isDisabled.Value() { + successKey = "actions.runners.disable_runner_success" + failedKey = "actions.runners.disable_runner_failed" + } + + if err := actions_model.SetRunnerDisabled(ctx, runner, isDisabled.Value()); err != nil { + log.Warn("RunnerUpdatePost.SetRunnerDisabled failed: %v, url: %s", err, ctx.Req.URL) + ctx.Flash.Error(ctx.Tr(failedKey)) + ctx.JSONRedirect("") + return + } + + ctx.Flash.Success(ctx.Tr(successKey)) + ctx.JSONRedirect("") +} + func RedirectToDefaultSetting(ctx *context.Context) { ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners") } diff --git a/routers/web/web.go b/routers/web/web.go index 95a54b5244..8da7609994 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -493,6 +493,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("", shared_actions.Runners) m.Combo("/{runnerid}").Get(shared_actions.RunnersEdit). Post(web.Bind(forms.EditRunnerForm{}), shared_actions.RunnersEditPost) + m.Post("/{runnerid}/update-runner", shared_actions.RunnerUpdatePost) m.Post("/{runnerid}/delete", shared_actions.RunnerDeletePost) m.Post("/reset_registration_token", shared_actions.ResetRunnerRegistrationToken) }) diff --git a/services/actions/interface.go b/services/actions/interface.go index b1725550e1..15e6381052 100644 --- a/services/actions/interface.go +++ b/services/actions/interface.go @@ -31,6 +31,8 @@ type API interface { GetRunner(*context.APIContext) // DeleteRunner delete runner DeleteRunner(*context.APIContext) + // UpdateRunner update runner + UpdateRunner(*context.APIContext) // ListWorkflowJobs list jobs ListWorkflowJobs(*context.APIContext) // ListWorkflowRuns list runs diff --git a/services/actions/task.go b/services/actions/task.go index cf2164f456..a21b600998 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -24,6 +24,10 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv actionTask *actions_model.ActionTask ) + if runner.IsDisabled { + return nil, false, nil + } + if runner.Ephemeral { var task actions_model.ActionTask has, err := db.GetEngine(ctx).Where("runner_id = ?", runner.ID).Get(&task) diff --git a/services/convert/convert.go b/services/convert/convert.go index 8bef63f7cd..d9aea7d7fa 100644 --- a/services/convert/convert.go +++ b/services/convert/convert.go @@ -521,6 +521,7 @@ func ToActionRunner(ctx context.Context, runner *actions_model.ActionRunner) *ap Name: runner.Name, Status: apiStatus, Busy: status == runnerv1.RunnerStatus_RUNNER_STATUS_ACTIVE, + Disabled: runner.IsDisabled, Ephemeral: runner.Ephemeral, Labels: labels, } diff --git a/templates/shared/actions/runner_edit.tmpl b/templates/shared/actions/runner_edit.tmpl index dbf4104fe5..4753711c2f 100644 --- a/templates/shared/actions/runner_edit.tmpl +++ b/templates/shared/actions/runner_edit.tmpl @@ -10,6 +10,16 @@ {{.Runner.StatusLocaleName ctx.Locale}} +
+ + + {{if .Runner.IsDisabled}} + {{ctx.Locale.Tr "disabled"}} + {{else}} + {{ctx.Locale.Tr "enabled"}} + {{end}} + +
{{if .Runner.LastOnline}}{{DateUtils.TimeSince .Runner.LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} @@ -39,6 +49,9 @@
+
diff --git a/templates/shared/actions/runner_list.tmpl b/templates/shared/actions/runner_list.tmpl index 9c541c95ee..90eb4591d7 100644 --- a/templates/shared/actions/runner_list.tmpl +++ b/templates/shared/actions/runner_list.tmpl @@ -66,7 +66,10 @@ {{range .Runners}} - {{.StatusLocaleName ctx.Locale}} + + {{.StatusLocaleName ctx.Locale}} + {{if .IsDisabled}}{{ctx.Locale.Tr "actions.runners.disabled"}}{{end}} + {{.ID}}

{{.Name}}

{{if .Version}}{{.Version}}{{else}}{{ctx.Locale.Tr "unknown"}}{{end}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index c9f2e96858..fab51203d1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -76,6 +76,14 @@ ], "summary": "Get all runners", "operationId": "getAdminRunners", + "parameters": [ + { + "type": "boolean", + "description": "filter by disabled status (true or false)", + "name": "disabled", + "in": "query" + } + ], "responses": { "200": { "$ref": "#/definitions/ActionRunnersResponse" @@ -166,6 +174,49 @@ "$ref": "#/responses/notFound" } } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update a global runner", + "operationId": "updateAdminRunner", + "parameters": [ + { + "type": "string", + "description": "id of the runner", + "name": "runner_id", + "in": "path", + "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" + } + } } }, "/admin/actions/runs": { @@ -1947,6 +1998,12 @@ "name": "org", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "filter by disabled status (true or false)", + "name": "disabled", + "in": "query" } ], "responses": { @@ -2062,6 +2119,56 @@ "$ref": "#/responses/notFound" } } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Update an org-level runner", + "operationId": "updateOrgRunner", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the runner", + "name": "runner_id", + "in": "path", + "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" + } + } } }, "/orgs/{org}/actions/runs": { @@ -4872,6 +4979,12 @@ "name": "repo", "in": "path", "required": true + }, + { + "type": "boolean", + "description": "filter by disabled status (true or false)", + "name": "disabled", + "in": "query" } ], "responses": { @@ -4928,7 +5041,7 @@ "tags": [ "repository" ], - "summary": "Get an repo-level runner", + "summary": "Get a repo-level runner", "operationId": "getRepoRunner", "parameters": [ { @@ -4972,7 +5085,7 @@ "tags": [ "repository" ], - "summary": "Delete an repo-level runner", + "summary": "Delete a repo-level runner", "operationId": "deleteRepoRunner", "parameters": [ { @@ -5008,6 +5121,63 @@ "$ref": "#/responses/notFound" } } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Update a repo-level runner", + "operationId": "updateRepoRunner", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "id of the runner", + "name": "runner_id", + "in": "path", + "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" + } + } } }, "/repos/{owner}/{repo}/actions/runs": { @@ -18441,6 +18611,14 @@ ], "summary": "Get user-level runners", "operationId": "getUserRunners", + "parameters": [ + { + "type": "boolean", + "description": "filter by disabled status (true or false)", + "name": "disabled", + "in": "query" + } + ], "responses": { "200": { "$ref": "#/definitions/ActionRunnersResponse" @@ -18479,7 +18657,7 @@ "tags": [ "user" ], - "summary": "Get an user-level runner", + "summary": "Get a user-level runner", "operationId": "getUserRunner", "parameters": [ { @@ -18509,7 +18687,7 @@ "tags": [ "user" ], - "summary": "Delete an user-level runner", + "summary": "Delete a user-level runner", "operationId": "deleteUserRunner", "parameters": [ { @@ -18531,6 +18709,49 @@ "$ref": "#/responses/notFound" } } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update a user-level runner", + "operationId": "updateUserRunner", + "parameters": [ + { + "type": "string", + "description": "id of the runner", + "name": "runner_id", + "in": "path", + "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" + } + } } }, "/user/actions/runs": { @@ -21115,6 +21336,10 @@ "type": "boolean", "x-go-name": "Busy" }, + "disabled": { + "type": "boolean", + "x-go-name": "Disabled" + }, "ephemeral": { "type": "boolean", "x-go-name": "Ephemeral" @@ -24205,6 +24430,20 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditActionRunnerOption": { + "type": "object", + "title": "EditActionRunnerOption represents the editable fields for a runner.", + "required": [ + "disabled" + ], + "properties": { + "disabled": { + "type": "boolean", + "x-go-name": "Disabled" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditAttachmentOptions": { "description": "EditAttachmentOptions options for editing attachments", "type": "object", diff --git a/tests/integration/actions_job_test.go b/tests/integration/actions_job_test.go index 4f4456a4e5..3da290f1d3 100644 --- a/tests/integration/actions_job_test.go +++ b/tests/integration/actions_job_test.go @@ -27,6 +27,7 @@ import ( runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "connectrpc.com/connect" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestJobWithNeeds(t *testing.T) { @@ -349,6 +350,122 @@ jobs: }) } +func TestRunnerDisableEnable(t *testing.T) { + onGiteaRun(t, func(t *testing.T, _ *url.URL) { + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + session := loginUser(t, user2.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser) + + t.Run("BasicDisableEnable", func(t *testing.T) { + testData := prepareRunnerDisableEnableTest(t, user2, token, "actions-runner-disable-enable", "mock-runner", "runner-disable-enable") + + task1 := testData.runner.fetchTask(t) + require.NotNil(t, task1) + + triggerRunnerDisableEnableRun(t, user2, token, testData.repo, "second-push.txt") + + req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + testData.runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + testData.runner.fetchNoTask(t, 2*time.Second) + + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + task2 := testData.runner.fetchTask(t, 5*time.Second) + require.NotNil(t, task2) + testData.runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + }) + + t.Run("TasksVersionPath", func(t *testing.T) { + testData := prepareRunnerDisableEnableTest(t, user2, token, "actions-runner-version-path", "mock-runner-version-path", "runner-version-path") + + var firstVersion int64 + var task1 *runnerv1.Task + ddl := time.Now().Add(5 * time.Second) + for time.Now().Before(ddl) { + task1, firstVersion = testData.runner.fetchTaskOnce(t, 0) + if task1 != nil { + break + } + time.Sleep(200 * time.Millisecond) + } + require.NotNil(t, task1, "expected to receive first task") + require.NotZero(t, firstVersion, "response TasksVersion should be set") + + // Trigger a second run so there is a pending job after we re-enable the runner + triggerRunnerDisableEnableRun(t, user2, token, testData.repo, "second-push.txt") + time.Sleep(500 * time.Millisecond) // allow workflow run to be created + + req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + testData.runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + + // Fetch with the version we had before disable. Server has bumped version on disable, + // so we enter PickTask with a re-loaded runner (disabled) and get no task. + taskAfterDisable, _ := testData.runner.fetchTaskOnce(t, firstVersion) + assert.Nil(t, taskAfterDisable, "disabled runner must not receive a task when sending previous TasksVersion") + + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/%d", user2.Name, testData.repo.Name, testData.runnerID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + + task2 := testData.runner.fetchTask(t, 5*time.Second) + require.NotNil(t, task2, "after re-enable runner should receive tasks again") + testData.runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) + }) + }) +} + +type runnerDisableEnableTestData struct { + repo *api.Repository + runner *mockRunner + runnerID int64 +} + +func prepareRunnerDisableEnableTest(t *testing.T, user *user_model.User, authToken, repoName, runnerName, workflowName string) *runnerDisableEnableTestData { + t.Helper() + + apiRepo := createActionsTestRepo(t, authToken, repoName, false) + runner := newMockRunner() + runner.registerAsRepoRunner(t, user.Name, apiRepo.Name, runnerName, []string{"ubuntu-latest"}, false) + + wfTreePath := fmt.Sprintf(".gitea/workflows/%s.yml", workflowName) + wfContent := fmt.Sprintf(`name: %s +on: [push] +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo %s +`, workflowName, workflowName) + opts := getWorkflowCreateFileOptions(user, apiRepo.DefaultBranch, "create workflow", wfContent) + createWorkflowFile(t, authToken, user.Name, apiRepo.Name, wfTreePath, opts) + + return &runnerDisableEnableTestData{ + repo: apiRepo, + runner: runner, + runnerID: getRepoRunnerID(t, authToken, user.Name, apiRepo.Name), + } +} + +func triggerRunnerDisableEnableRun(t *testing.T, user *user_model.User, authToken string, repo *api.Repository, treePath string) { + t.Helper() + opts := getWorkflowCreateFileOptions(user, repo.DefaultBranch, "second push", "second run") + createWorkflowFile(t, authToken, user.Name, repo.Name, treePath, opts) +} + +func getRepoRunnerID(t *testing.T, authToken, ownerName, repoName string) int64 { + t.Helper() + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners", ownerName, repoName)).AddTokenAuth(authToken) + resp := MakeRequest(t, req, http.StatusOK) + runnerList := api.ActionRunnersResponse{} + DecodeJSON(t, resp, &runnerList) + require.Len(t, runnerList.Entries, 1) + return runnerList.Entries[0].ID +} + func TestActionsGiteaContext(t *testing.T) { onGiteaRun(t, func(t *testing.T, u *url.URL) { user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/tests/integration/actions_runner_modify_test.go b/tests/integration/actions_runner_modify_test.go index 8a65dd625f..eb5206f73d 100644 --- a/tests/integration/actions_runner_modify_test.go +++ b/tests/integration/actions_runner_modify_test.go @@ -60,17 +60,36 @@ func TestActionsRunnerModify(t *testing.T) { sess.MakeRequest(t, req, expectedStatus) } + doDisable := func(t *testing.T, sess *TestSession, baseURL string, id int64, expectedStatus int) { + req := NewRequest(t, "POST", fmt.Sprintf("%s/%d/update-runner?disabled=true", baseURL, id)) + sess.MakeRequest(t, req, expectedStatus) + } + + doEnable := func(t *testing.T, sess *TestSession, baseURL string, id int64, expectedStatus int) { + req := NewRequest(t, "POST", fmt.Sprintf("%s/%d/update-runner?disabled=false", baseURL, id)) + sess.MakeRequest(t, req, expectedStatus) + } + assertDenied := func(t *testing.T, sess *TestSession, baseURL string, id int64) { doUpdate(t, sess, baseURL, id, "ChangedDescription", http.StatusNotFound) + doDisable(t, sess, baseURL, id, http.StatusNotFound) + doEnable(t, sess, baseURL, id, http.StatusNotFound) doDelete(t, sess, baseURL, id, http.StatusNotFound) v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id}) assert.Empty(t, v.Description) + assert.False(t, v.IsDisabled) } assertSuccess := func(t *testing.T, sess *TestSession, baseURL string, id int64) { doUpdate(t, sess, baseURL, id, "ChangedDescription", http.StatusSeeOther) v := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id}) assert.Equal(t, "ChangedDescription", v.Description) + doDisable(t, sess, baseURL, id, http.StatusOK) + v = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id}) + assert.True(t, v.IsDisabled) + doEnable(t, sess, baseURL, id, http.StatusOK) + v = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunner{ID: id}) + assert.False(t, v.IsDisabled) doDelete(t, sess, baseURL, id, http.StatusOK) unittest.AssertNotExistsBean(t, &actions_model.ActionRunner{ID: id}) } diff --git a/tests/integration/actions_runner_test.go b/tests/integration/actions_runner_test.go index 3c48ce2409..9ac2fb6e13 100644 --- a/tests/integration/actions_runner_test.go +++ b/tests/integration/actions_runner_test.go @@ -19,6 +19,7 @@ import ( "code.gitea.io/actions-proto-go/runner/v1/runnerv1connect" "connectrpc.com/connect" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -113,12 +114,8 @@ func (r *mockRunner) tryFetchTask(t *testing.T, timeout ...time.Duration) *runne ddl := time.Now().Add(fetchTimeout) var task *runnerv1.Task for time.Now().Before(ddl) { - resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{ - TasksVersion: 0, - })) - assert.NoError(t, err) - if resp.Msg.Task != nil { - task = resp.Msg.Task + task, _ = r.fetchTaskOnce(t, 0) + if task != nil { break } time.Sleep(200 * time.Millisecond) @@ -127,6 +124,17 @@ func (r *mockRunner) tryFetchTask(t *testing.T, timeout ...time.Duration) *runne return task } +// fetchTaskOnce performs a single FetchTask request with the given TasksVersion +// and returns the task (if any) and the TasksVersion from the response. +// Used to verify the production path where the runner sends the current version. +func (r *mockRunner) fetchTaskOnce(t *testing.T, tasksVersion int64) (*runnerv1.Task, int64) { + resp, err := r.client.runnerServiceClient.FetchTask(t.Context(), connect.NewRequest(&runnerv1.FetchTaskRequest{ + TasksVersion: tasksVersion, + })) + require.NoError(t, err) + return resp.Msg.Task, resp.Msg.TasksVersion +} + type mockTaskOutcome struct { result runnerv1.Result outputs map[string]string diff --git a/tests/integration/api_actions_runner_test.go b/tests/integration/api_actions_runner_test.go index fb9ba5b0c2..74788ec32e 100644 --- a/tests/integration/api_actions_runner_test.go +++ b/tests/integration/api_actions_runner_test.go @@ -24,6 +24,12 @@ func TestAPIActionsRunner(t *testing.T) { t.Run("RepoRunner", testActionsRunnerRepo) } +func newRunnerUpdateRequest(t *testing.T, url string, disabled bool) *RequestWrapper { + return NewRequestWithJSON(t, http.MethodPatch, url, api.EditActionRunnerOption{ + Disabled: &disabled, + }) +} + func testActionsRunnerAdmin(t *testing.T) { defer tests.PrepareTestEnv(t)() adminUsername := "user1" @@ -45,22 +51,39 @@ func testActionsRunnerAdmin(t *testing.T) { require.NotEqual(t, -1, idx) expectedRunner := runnerList.Entries[idx] assert.Equal(t, "runner_to_be_deleted", expectedRunner.Name) + assert.False(t, expectedRunner.Disabled) assert.False(t, expectedRunner.Ephemeral) assert.Len(t, expectedRunner.Labels, 2) assert.Equal(t, "runner_to_be_deleted", expectedRunner.Labels[0].Name) assert.Equal(t, "linux", expectedRunner.Labels[1].Name) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token) + runnerResp := MakeRequest(t, req, http.StatusOK) + disabledRunner := api.ActionRunner{} + DecodeJSON(t, runnerResp, &disabledRunner) + assert.True(t, disabledRunner.Disabled) + + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/admin/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + // Verify all returned runners can be requested and deleted for _, runnerEntry := range runnerList.Entries { // Verify get the runner by id req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/admin/actions/runners/%d", runnerEntry.ID)).AddTokenAuth(token) - runnerResp := MakeRequest(t, req, http.StatusOK) + runnerResp = MakeRequest(t, req, http.StatusOK) runner := api.ActionRunner{} DecodeJSON(t, runnerResp, &runner) assert.Equal(t, runnerEntry.Name, runner.Name) assert.Equal(t, runnerEntry.ID, runner.ID) + assert.Equal(t, runnerEntry.Disabled, runner.Disabled) assert.Equal(t, runnerEntry.Ephemeral, runner.Ephemeral) assert.ElementsMatch(t, runnerEntry.Labels, runner.Labels) @@ -93,6 +116,7 @@ func testActionsRunnerUser(t *testing.T) { assert.Len(t, runnerList.Entries, 1) assert.Equal(t, "runner_to_be_deleted-user", runnerList.Entries[0].Name) assert.Equal(t, int64(34346), runnerList.Entries[0].ID) + assert.False(t, runnerList.Entries[0].Disabled) assert.False(t, runnerList.Entries[0].Ephemeral) assert.Len(t, runnerList.Entries[0].Labels, 2) assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name) @@ -107,11 +131,26 @@ func testActionsRunnerUser(t *testing.T) { assert.Equal(t, "runner_to_be_deleted-user", runner.Name) assert.Equal(t, int64(34346), runner.ID) + assert.False(t, runner.Disabled) assert.False(t, runner.Ephemeral) assert.Len(t, runner.Labels, 2) assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name) assert.Equal(t, "linux", runner.Labels[1].Name) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token) + runnerResp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, runnerResp, &runner) + assert.True(t, runner.Disabled) + + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + // Verify delete the runner by id req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) @@ -136,6 +175,7 @@ func testActionsRunnerOwner(t *testing.T) { assert.Equal(t, "runner_to_be_deleted-org", runner.Name) assert.Equal(t, int64(34347), runner.ID) + assert.False(t, runner.Disabled) assert.False(t, runner.Ephemeral) assert.Len(t, runner.Labels, 2) assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name) @@ -165,6 +205,7 @@ func testActionsRunnerOwner(t *testing.T) { require.NotNil(t, expectedRunner) assert.Equal(t, "runner_to_be_deleted-org", expectedRunner.Name) assert.Equal(t, int64(34347), expectedRunner.ID) + assert.False(t, expectedRunner.Disabled) assert.False(t, expectedRunner.Ephemeral) assert.Len(t, expectedRunner.Labels, 2) assert.Equal(t, "runner_to_be_deleted", expectedRunner.Labels[0].Name) @@ -179,11 +220,26 @@ func testActionsRunnerOwner(t *testing.T) { assert.Equal(t, "runner_to_be_deleted-org", runner.Name) assert.Equal(t, int64(34347), runner.ID) + assert.False(t, runner.Disabled) assert.False(t, runner.Ephemeral) assert.Len(t, runner.Labels, 2) assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name) assert.Equal(t, "linux", runner.Labels[1].Name) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token) + runnerResp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, runnerResp, &runner) + assert.True(t, runner.Disabled) + + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + // Verify delete the runner by id req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) @@ -202,6 +258,22 @@ func testActionsRunnerOwner(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) }) + t.Run("DisableReadScopeForbidden", func(t *testing.T) { + userUsername := "user2" + token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization) + + req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("UpdateReadScopeForbidden", func(t *testing.T) { + userUsername := "user2" + token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization) + + req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", 34347), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + t.Run("GetRepoScopeForbidden", func(t *testing.T) { userUsername := "user2" token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository) @@ -244,6 +316,7 @@ func testActionsRunnerRepo(t *testing.T) { assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name) assert.Equal(t, int64(34348), runner.ID) + assert.False(t, runner.Disabled) assert.False(t, runner.Ephemeral) assert.Len(t, runner.Labels, 2) assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name) @@ -269,6 +342,7 @@ func testActionsRunnerRepo(t *testing.T) { assert.Len(t, runnerList.Entries, 1) assert.Equal(t, "runner_to_be_deleted-repo1", runnerList.Entries[0].Name) assert.Equal(t, int64(34348), runnerList.Entries[0].ID) + assert.False(t, runnerList.Entries[0].Disabled) assert.False(t, runnerList.Entries[0].Ephemeral) assert.Len(t, runnerList.Entries[0].Labels, 2) assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name) @@ -283,11 +357,26 @@ func testActionsRunnerRepo(t *testing.T) { assert.Equal(t, "runner_to_be_deleted-repo1", runner.Name) assert.Equal(t, int64(34348), runner.ID) + assert.False(t, runner.Disabled) assert.False(t, runner.Ephemeral) assert.Len(t, runner.Labels, 2) assert.Equal(t, "runner_to_be_deleted", runner.Labels[0].Name) assert.Equal(t, "linux", runner.Labels[1].Name) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token) + runnerResp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, runnerResp, &runner) + assert.True(t, runner.Disabled) + + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + req = newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusOK) + // Verify delete the runner by id req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) @@ -306,6 +395,22 @@ func testActionsRunnerRepo(t *testing.T) { MakeRequest(t, req, http.StatusForbidden) }) + t.Run("DisableReadScopeForbidden", func(t *testing.T) { + userUsername := "user2" + token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository) + + req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348), true).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("UpdateReadScopeForbidden", func(t *testing.T) { + userUsername := "user2" + token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadRepository) + + req := newRunnerUpdateRequest(t, fmt.Sprintf("/api/v1/repos/user2/repo1/actions/runners/%d", 34348), false).AddTokenAuth(token) + MakeRequest(t, req, http.StatusForbidden) + }) + t.Run("GetOrganizationScopeForbidden", func(t *testing.T) { userUsername := "user2" token := getUserToken(t, userUsername, auth_model.AccessTokenScopeReadOrganization)