✨ (ci): Retire ephemeral runners after one task
Changes
6 files changed, +208 -10
MODIFY
README.md
+2 -1
@@ -92,7 +92,8 @@
92
92
labels, ordered by `needs` dependencies, and repository owners manage encrypted secrets and
93
93
variables that are delivered to runners, jobs support `needs` ordering and `strategy.matrix`, runs
94
94
can be cancelled or re-run from the UI, and a commit's CI result shows on its commit and merge-request
95
- pages. Non-push events, artifacts, and scoped/ephemeral runners are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
95
+ pages, and ephemeral runners are retired after one job. Non-push events, artifacts, and
96
+ repo/org-scoped runners are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
96
97
[architecture](docs/maintainers/ci-runners.md)
97
98
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
98
99
public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY
docs/admins/ci-runners.md
+3 -1
@@ -44,7 +44,9 @@
44
44
Registration tokens are **reusable** and **instance-scoped** (matching Gitea's global tokens): one
45
45
token can register any number of runners. Delete a token to stop it registering new runners; runners
46
46
already registered keep working (they authenticate with their own per-runner secret, not the
47
-registration token). Repo/org-scoped and ephemeral runners are later phases.
47
+registration token). An **ephemeral** runner (registered with `--ephemeral`) runs a single task and
48
+is then removed automatically — its credentials stop working after that one job. Repo/org-scoped
49
+runners are a later phase.
48
50
49
51
## Endpoints
50
52
MODIFY
docs/maintainers/ci-runners.md
+9 -3
@@ -136,7 +136,14 @@
136
136
matrices expand to one task per cell with a reduced payload; a non-matrix job stays single),
137
137
`MatrixNeedsTest` (a dependent waits for every cell of a needed matrix job, and one failed cell
138
138
cancels the dependent), `CommitCiStatusTest` (commit page + MR page show the aggregate badge, the
139
- commit-status API reflects failure, and a commit with no runs stays all-clear).
139
+ commit-status API reflects failure, and a commit with no runs stays all-clear),
140
+ `EphemeralRunnerTest` (an ephemeral runner is removed after its task — on completion and on
141
+ zombie-reclaim — and its credentials stop working).
142
+- **Ephemeral runners:** a runner registered with `ephemeral=true` is one-shot — once its single task
143
+ reaches a terminal state it is deleted (`ci_runner` row removed; the task's `runner_id` is `ON
144
+ DELETE SET NULL`), so its credentials stop working and it never gets a second task. This holds on
145
+ both completion (`TaskProgressService`) and timeout (`ZombieReclaimService` deletes rather than just
146
+ flagging OFFLINE). The stock `act_runner --ephemeral` client exits on its own after the job.
140
147
- **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
141
148
(`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
142
149
`action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
@@ -177,8 +184,7 @@
177
184
not. (`!`-negation within a single pattern list is also not supported.)
178
185
- **Matrix advanced options:** `include`/`exclude` and `fail-fast`/`max-parallel` are not honored
179
186
(plain dimension cross-product only).
180
-- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners,
181
- non-push events.
187
+- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped runners, non-push events.
182
188
183
189
## References
184
190
MODIFY
src/main/java/de/workaround/ci/TaskProgressService.java
+13 -1
@@ -37,6 +37,9 @@
37
37
@Inject
38
38
ActionLog.Repo logs;
39
39
40
+ @Inject
41
+ CiRunner.Repo runners;
42
+
40
43
/**
41
44
* Record the result the runner reports for a task and roll the owning run's status up.
42
45
*
@@ -67,7 +70,16 @@
67
70
if (status.isTerminal())
68
71
{
69
72
task.finishedAt = stoppedAt != null ? stoppedAt : Instant.now();
70
- runner.status = CiRunner.Status.IDLE;
73
+ if (runner.ephemeral)
74
+ {
75
+ // an ephemeral runner is one-shot: retire it after its single task (the client also exits)
76
+ task.runner = null;
77
+ runners.delete(runner);
78
+ }
79
+ else
80
+ {
81
+ runner.status = CiRunner.Status.IDLE;
82
+ }
71
83
}
72
84
rollUpRun(task.run);
73
85
return task;
MODIFY
src/main/java/de/workaround/ci/ZombieReclaimService.java
+18 -4
@@ -16,8 +16,9 @@
16
16
/**
17
17
* Fails tasks whose runner vanished mid-run (issue #2, phase 1). A claimed task carries a {@link
18
18
* ActionTask#deadline}; once it passes with the task still RUNNING, the runner is presumed gone, so
19
- * the task is marked FAILURE, its run rolled up, and the runner flagged OFFLINE. Runs on a schedule
20
- * ({@code gitshark.ci.zombie-reclaim-interval}); {@link #reclaim(Instant)} is the testable core.
19
+ * the task is marked FAILURE, its run rolled up, and the runner flagged OFFLINE (or deleted, if it is
20
+ * ephemeral). Runs on a schedule ({@code gitshark.ci.zombie-reclaim-interval}); {@link
21
+ * #reclaim(Instant)} is the testable core.
21
22
*/
22
23
@ApplicationScoped
23
24
public class ZombieReclaimService
@@ -28,6 +29,9 @@
28
29
ActionTask.Repo tasks;
29
30
30
31
@Inject
32
+ CiRunner.Repo runners;
33
+
34
+ @Inject
31
35
TaskProgressService progress;
32
36
33
37
@Scheduled(every = "{gitshark.ci.zombie-reclaim-interval}", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
@@ -49,9 +53,19 @@
49
53
{
50
54
task.status = ActionRun.Status.FAILURE;
51
55
task.finishedAt = now;
52
- if (task.runner != null)
56
+ CiRunner runner = task.runner;
57
+ if (runner != null)
53
58
{
54
- task.runner.status = CiRunner.Status.OFFLINE;
59
+ if (runner.ephemeral)
60
+ {
61
+ // an ephemeral runner is one-shot even when it dies mid-task — retire it, don't just flag it
62
+ task.runner = null;
63
+ runners.delete(runner);
64
+ }
65
+ else
66
+ {
67
+ runner.status = CiRunner.Status.OFFLINE;
68
+ }
55
69
}
56
70
progress.rollUpRun(task.run);
57
71
}
ADD
src/test/java/de/workaround/ci/EphemeralRunnerTest.java
+163 -0
@@ -0,0 +1,163 @@
1
+package de.workaround.ci;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.ci.proto.runner.v1.Result;
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.model.ActionRun;
11
+import de.workaround.model.ActionTask;
12
+import de.workaround.model.CiRunner;
13
+import de.workaround.model.Repository;
14
+import de.workaround.model.User;
15
+import io.quarkus.test.junit.QuarkusTest;
16
+import jakarta.inject.Inject;
17
+import jakarta.persistence.EntityManager;
18
+import jakarta.transaction.Transactional;
19
+
20
+import static org.junit.jupiter.api.Assertions.assertThrows;
21
+import static org.junit.jupiter.api.Assertions.assertTrue;
22
+
23
+/**
24
+ * Ephemeral runners (issue #2, phase 3): a runner registered {@code ephemeral} is retired after it
25
+ * finishes its single task, and its credentials no longer work.
26
+ */
27
+@QuarkusTest
28
+class EphemeralRunnerTest
29
+{
30
+ @Inject
31
+ RunnerRegistrationService runnerService;
32
+
33
+ @Inject
34
+ TaskDispatchService dispatch;
35
+
36
+ @Inject
37
+ TaskProgressService progress;
38
+
39
+ @Inject
40
+ GitRepositoryService repositories;
41
+
42
+ @Inject
43
+ ActionRun.Repo runs;
44
+
45
+ @Inject
46
+ ActionTask.Repo tasks;
47
+
48
+ @Inject
49
+ CiRunner.Repo runners;
50
+
51
+ @Inject
52
+ ZombieReclaimService reclaim;
53
+
54
+ @Inject
55
+ EntityManager em;
56
+
57
+ @Test
58
+ void ephemeralRunnerIsRetiredAfterItsTask()
59
+ {
60
+ RunnerRegistrationService.RegisteredRunner reg = registerEphemeral();
61
+ long seq = seedClaimedTask("ep-a", reg.runner().uuid);
62
+
63
+ progress.updateTask(reg.runner().uuid, reg.plaintext(), seq, Result.RESULT_SUCCESS, null, java.util.Map.of());
64
+
65
+ assertTrue(runners.findByUuid(reg.runner().uuid).isEmpty(), "ephemeral runner removed after its task");
66
+
67
+ // its credentials no longer authenticate
68
+ assertThrows(RunnerAuthenticationException.class,
69
+ () -> dispatch.fetch(reg.runner().uuid, reg.plaintext()));
70
+ }
71
+
72
+ @Test
73
+ void ephemeralRunnerIsRetiredWhenItsTaskIsReclaimed()
74
+ {
75
+ RunnerRegistrationService.RegisteredRunner reg = registerEphemeral();
76
+ seedOverdueClaimedTask("ep-b", reg.runner().uuid);
77
+
78
+ reclaim.reclaim(java.time.Instant.now());
79
+
80
+ assertTrue(runners.findByUuid(reg.runner().uuid).isEmpty(),
81
+ "ephemeral runner removed even when its task is reclaimed as a zombie");
82
+ }
83
+
84
+ private RunnerRegistrationService.RegisteredRunner registerEphemeral()
85
+ {
86
+ String token = runnerService.createRegistrationToken(persistUser("ep-admin-" + shortId())).plaintext();
87
+ return runnerService.register(token, "ep-runner", List.of(), "v4.0.0", true);
88
+ }
89
+
90
+ @Transactional
91
+ long seedClaimedTask(String repoName, String runnerUuid)
92
+ {
93
+ User owner = persistUser(repoName + "-" + shortId());
94
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
95
+ CiRunner runner = runners.findByUuid(runnerUuid).orElseThrow();
96
+
97
+ ActionRun run = new ActionRun();
98
+ run.repository = repo;
99
+ run.number = runs.maxNumber(repo) + 1;
100
+ run.workflowName = "CI";
101
+ run.workflowFile = ".forgejo/workflows/ci.yml";
102
+ run.event = "push";
103
+ run.ref = "refs/heads/main";
104
+ run.commitSha = "0000000000000000000000000000000000000000";
105
+ run.status = ActionRun.Status.RUNNING;
106
+ run.persist();
107
+
108
+ ActionTask task = new ActionTask();
109
+ task.run = run;
110
+ task.name = "build";
111
+ task.jobId = "build";
112
+ task.payload = "on: push";
113
+ task.runner = runner;
114
+ task.status = ActionRun.Status.RUNNING;
115
+ task.persist();
116
+ em.flush();
117
+ return task.seq;
118
+ }
119
+
120
+ @Transactional
121
+ void seedOverdueClaimedTask(String repoName, String runnerUuid)
122
+ {
123
+ User owner = persistUser(repoName + "-" + shortId());
124
+ Repository repo = repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
125
+ CiRunner runner = runners.findByUuid(runnerUuid).orElseThrow();
126
+
127
+ ActionRun run = new ActionRun();
128
+ run.repository = repo;
129
+ run.number = runs.maxNumber(repo) + 1;
130
+ run.workflowName = "CI";
131
+ run.workflowFile = ".forgejo/workflows/ci.yml";
132
+ run.event = "push";
133
+ run.ref = "refs/heads/main";
134
+ run.commitSha = "0000000000000000000000000000000000000000";
135
+ run.status = ActionRun.Status.RUNNING;
136
+ run.persist();
137
+
138
+ ActionTask task = new ActionTask();
139
+ task.run = run;
140
+ task.name = "build";
141
+ task.jobId = "build";
142
+ task.payload = "on: push";
143
+ task.runner = runner;
144
+ task.status = ActionRun.Status.RUNNING;
145
+ task.deadline = java.time.Instant.now().minusSeconds(300);
146
+ task.persist();
147
+ }
148
+
149
+ @Transactional
150
+ User persistUser(String name)
151
+ {
152
+ User user = new User();
153
+ user.oidcSub = name;
154
+ user.username = name;
155
+ user.persist();
156
+ return user;
157
+ }
158
+
159
+ private static String shortId()
160
+ {
161
+ return UUID.randomUUID().toString().substring(0, 8);
162
+ }
163
+}