Skip to content

Commit 73f640f

Browse files
Fix ephemeral runner deletion (#34447)
* repository deletion, delete ephemeral runners with active tasks as well skips regular cleanup * user deletion, delete ephemeral runners with active tasks as well skips regular cleanup * delete ephemeral runners once status changes to done * You no longer see used ephemeral runners after the task is done * if you see one the cron job takes care of it
1 parent 28dec9a commit 73f640f

File tree

8 files changed

+172
-12
lines changed

8 files changed

+172
-12
lines changed

models/actions/runner.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package actions
55

66
import (
77
"context"
8+
"errors"
89
"fmt"
910
"strings"
1011
"time"
@@ -298,6 +299,23 @@ func DeleteRunner(ctx context.Context, id int64) error {
298299
return err
299300
}
300301

302+
// DeleteEphemeralRunner deletes a ephemeral runner by given ID.
303+
func DeleteEphemeralRunner(ctx context.Context, id int64) error {
304+
runner, err := GetRunnerByID(ctx, id)
305+
if err != nil {
306+
if errors.Is(err, util.ErrNotExist) {
307+
return nil
308+
}
309+
return err
310+
}
311+
if !runner.Ephemeral {
312+
return nil
313+
}
314+
315+
_, err = db.DeleteByID[ActionRunner](ctx, id)
316+
return err
317+
}
318+
301319
// CreateRunner creates new runner.
302320
func CreateRunner(ctx context.Context, t *ActionRunner) error {
303321
if t.OwnerID != 0 && t.RepoID != 0 {

models/actions/task.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,11 @@ func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
336336
sess.Cols(cols...)
337337
}
338338
_, err := sess.Update(task)
339+
340+
// Automatically delete the ephemeral runner if the task is done
341+
if err == nil && task.Status.IsDone() && util.SliceContainsString(cols, "status") {
342+
return DeleteEphemeralRunner(ctx, task.RunnerID)
343+
}
339344
return err
340345
}
341346

models/fixtures/action_runner.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,14 @@
3838
repo_id: 0
3939
description: "This runner is going to be deleted"
4040
agent_labels: '["runner_to_be_deleted","linux"]'
41+
-
42+
id: 34350
43+
name: runner_to_be_deleted-org-ephemeral
44+
uuid: 3FF231BD-FBB7-4E4B-9602-E6F28363EF20
45+
token_hash: 3FF231BD-FBB7-4E4B-9602-E6F28363EF20
46+
ephemeral: true
47+
version: "1.0.0"
48+
owner_id: 3
49+
repo_id: 0
50+
description: "This runner is going to be deleted"
51+
agent_labels: '["runner_to_be_deleted","linux"]'

models/fixtures/action_task.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,26 @@
117117
log_length: 707
118118
log_size: 90179
119119
log_expired: 0
120+
-
121+
id: 52
122+
job_id: 196
123+
attempt: 1
124+
runner_id: 34350
125+
status: 6 # running
126+
started: 1683636528
127+
stopped: 1683636626
128+
repo_id: 4
129+
owner_id: 1
130+
commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0
131+
is_fork_pull_request: 0
132+
token_hash: f8d3962425466b6709b9ac51446f93260c54afe8e7b6d3686e34f991fb8a8953822b0deed86fe41a103f34bc48dbc4784222
133+
token_salt: ffffffffff
134+
token_last_eight: ffffffff
135+
log_filename: artifact-test2/2f/47.log
136+
log_in_storage: 1
137+
log_length: 707
138+
log_size: 90179
139+
log_expired: 0
120140
-
121141
id: 53
122142
job_id: 198

services/actions/cleanup.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,22 @@ func CleanupEphemeralRunners(ctx context.Context) error {
155155
return nil
156156
}
157157

158+
// CleanupEphemeralRunnersByPickedTaskOfRepo removes all ephemeral runners that have active/finished tasks on the given repository
159+
func CleanupEphemeralRunnersByPickedTaskOfRepo(ctx context.Context, repoID int64) error {
160+
subQuery := builder.Select("`action_runner`.id").
161+
From(builder.Select("*").From("`action_runner`"), "`action_runner`"). // mysql needs this redundant subquery
162+
Join("INNER", "`action_task`", "`action_task`.`runner_id` = `action_runner`.`id`").
163+
Where(builder.And(builder.Eq{"`action_runner`.`ephemeral`": true}, builder.Eq{"`action_task`.`repo_id`": repoID}))
164+
b := builder.Delete(builder.In("id", subQuery)).From("`action_runner`")
165+
res, err := db.GetEngine(ctx).Exec(b)
166+
if err != nil {
167+
return fmt.Errorf("find runners: %w", err)
168+
}
169+
affected, _ := res.RowsAffected()
170+
log.Info("Removed %d runners", affected)
171+
return nil
172+
}
173+
158174
// DeleteRun deletes workflow run, including all logs and artifacts.
159175
func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error {
160176
if !run.Status.IsDone() {

services/repository/delete.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"code.gitea.io/gitea/modules/lfs"
2828
"code.gitea.io/gitea/modules/log"
2929
"code.gitea.io/gitea/modules/storage"
30+
actions_service "code.gitea.io/gitea/services/actions"
3031
asymkey_service "code.gitea.io/gitea/services/asymkey"
3132

3233
"xorm.io/builder"
@@ -133,6 +134,14 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID
133134
return err
134135
}
135136

137+
// CleanupEphemeralRunnersByPickedTaskOfRepo deletes ephemeral global/org/user that have started any task of this repo
138+
// The cannot pick a second task hardening for ephemeral runners expect that task objects remain available until runner deletion
139+
// This method will delete affected ephemeral global/org/user runners
140+
// &actions_model.ActionRunner{RepoID: repoID} does only handle ephemeral repository runners
141+
if err := actions_service.CleanupEphemeralRunnersByPickedTaskOfRepo(ctx, repoID); err != nil {
142+
return fmt.Errorf("cleanupEphemeralRunners: %w", err)
143+
}
144+
136145
if err := db.DeleteBeans(ctx,
137146
&access_model.Access{RepoID: repo.ID},
138147
&activities_model.Action{RepoID: repo.ID},

tests/integration/api_actions_runner_test.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ func testActionsRunnerAdmin(t *testing.T) {
4141
runnerList := api.ActionRunnersResponse{}
4242
DecodeJSON(t, runnerListResp, &runnerList)
4343

44-
assert.Len(t, runnerList.Entries, 4)
45-
4644
idx := slices.IndexFunc(runnerList.Entries, func(e *api.ActionRunner) bool { return e.ID == 34349 })
4745
require.NotEqual(t, -1, idx)
4846
expectedRunner := runnerList.Entries[idx]
@@ -160,16 +158,20 @@ func testActionsRunnerOwner(t *testing.T) {
160158
runnerList := api.ActionRunnersResponse{}
161159
DecodeJSON(t, runnerListResp, &runnerList)
162160

163-
assert.Len(t, runnerList.Entries, 1)
164-
assert.Equal(t, "runner_to_be_deleted-org", runnerList.Entries[0].Name)
165-
assert.Equal(t, int64(34347), runnerList.Entries[0].ID)
166-
assert.False(t, runnerList.Entries[0].Ephemeral)
167-
assert.Len(t, runnerList.Entries[0].Labels, 2)
168-
assert.Equal(t, "runner_to_be_deleted", runnerList.Entries[0].Labels[0].Name)
169-
assert.Equal(t, "linux", runnerList.Entries[0].Labels[1].Name)
161+
idx := slices.IndexFunc(runnerList.Entries, func(e *api.ActionRunner) bool { return e.ID == 34347 })
162+
require.NotEqual(t, -1, idx)
163+
expectedRunner := runnerList.Entries[idx]
164+
165+
require.NotNil(t, expectedRunner)
166+
assert.Equal(t, "runner_to_be_deleted-org", expectedRunner.Name)
167+
assert.Equal(t, int64(34347), expectedRunner.ID)
168+
assert.False(t, expectedRunner.Ephemeral)
169+
assert.Len(t, expectedRunner.Labels, 2)
170+
assert.Equal(t, "runner_to_be_deleted", expectedRunner.Labels[0].Name)
171+
assert.Equal(t, "linux", expectedRunner.Labels[1].Name)
170172

171173
// Verify get the runner by id
172-
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
174+
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
173175
runnerResp := MakeRequest(t, req, http.StatusOK)
174176

175177
runner := api.ActionRunner{}
@@ -183,11 +185,11 @@ func testActionsRunnerOwner(t *testing.T) {
183185
assert.Equal(t, "linux", runner.Labels[1].Name)
184186

185187
// Verify delete the runner by id
186-
req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
188+
req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
187189
MakeRequest(t, req, http.StatusNoContent)
188190

189191
// Verify runner deletion
190-
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", runnerList.Entries[0].ID)).AddTokenAuth(token)
192+
req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/org3/actions/runners/%d", expectedRunner.ID)).AddTokenAuth(token)
191193
MakeRequest(t, req, http.StatusNotFound)
192194
})
193195

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package integration
5+
6+
import (
7+
"testing"
8+
9+
actions_model "code.gitea.io/gitea/models/actions"
10+
"code.gitea.io/gitea/models/unittest"
11+
user_model "code.gitea.io/gitea/models/user"
12+
"code.gitea.io/gitea/modules/util"
13+
repo_service "code.gitea.io/gitea/services/repository"
14+
user_service "code.gitea.io/gitea/services/user"
15+
"code.gitea.io/gitea/tests"
16+
17+
"github.com/stretchr/testify/assert"
18+
)
19+
20+
func TestEphemeralActionsRunnerDeletion(t *testing.T) {
21+
t.Run("ByTaskCompletion", testEphemeralActionsRunnerDeletionByTaskCompletion)
22+
t.Run("ByRepository", testEphemeralActionsRunnerDeletionByRepository)
23+
t.Run("ByUser", testEphemeralActionsRunnerDeletionByUser)
24+
}
25+
26+
// Test that the ephemeral runner is deleted when the task is finished
27+
func testEphemeralActionsRunnerDeletionByTaskCompletion(t *testing.T) {
28+
defer tests.PrepareTestEnv(t)()
29+
30+
_, err := actions_model.GetRunnerByID(t.Context(), 34350)
31+
assert.NoError(t, err)
32+
33+
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 52})
34+
assert.Equal(t, actions_model.StatusRunning, task.Status)
35+
36+
task.Status = actions_model.StatusSuccess
37+
err = actions_model.UpdateTask(t.Context(), task, "status")
38+
assert.NoError(t, err)
39+
40+
_, err = actions_model.GetRunnerByID(t.Context(), 34350)
41+
assert.ErrorIs(t, err, util.ErrNotExist)
42+
}
43+
44+
func testEphemeralActionsRunnerDeletionByRepository(t *testing.T) {
45+
defer tests.PrepareTestEnv(t)()
46+
47+
_, err := actions_model.GetRunnerByID(t.Context(), 34350)
48+
assert.NoError(t, err)
49+
50+
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 52})
51+
assert.Equal(t, actions_model.StatusRunning, task.Status)
52+
53+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
54+
55+
err = repo_service.DeleteRepositoryDirectly(t.Context(), user, task.RepoID, true)
56+
assert.NoError(t, err)
57+
58+
_, err = actions_model.GetRunnerByID(t.Context(), 34350)
59+
assert.ErrorIs(t, err, util.ErrNotExist)
60+
}
61+
62+
// Test that the ephemeral runner is deleted when a user is deleted
63+
func testEphemeralActionsRunnerDeletionByUser(t *testing.T) {
64+
defer tests.PrepareTestEnv(t)()
65+
66+
_, err := actions_model.GetRunnerByID(t.Context(), 34350)
67+
assert.NoError(t, err)
68+
69+
task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 52})
70+
assert.Equal(t, actions_model.StatusRunning, task.Status)
71+
72+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
73+
74+
err = user_service.DeleteUser(t.Context(), user, true)
75+
assert.NoError(t, err)
76+
77+
_, err = actions_model.GetRunnerByID(t.Context(), 34350)
78+
assert.ErrorIs(t, err, util.ErrNotExist)
79+
}

0 commit comments

Comments
 (0)