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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user