✨ (ci): Scope runners to a repository
Changes
8 files changed, +206 -8
MODIFY
docs/admins/ci-runners.md
+7 -4
@@ -45,8 +45,10 @@
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
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
+is then removed automatically — its credentials stop working after that one job. A runner can also be
49
+**scoped to a single repository** (it then only runs that repo's jobs); the enforcement is in place,
50
+though minting a repo-scoped registration token from the UI is still to come (the admin page mints
51
+instance-wide tokens today). Org scope is a later phase.
50
52
51
53
## Endpoints
52
54
@@ -89,7 +91,7 @@
89
91
| Table | Contents |
90
92
|---|---|
91
93
| `ci_runner_registration_token` | Reusable registration tokens: `token_hash`, `created_by_id`, `created_at`, `last_used`. |
92
-| `ci_runner` | Registered runners: `uuid` (the `x-runner-uuid` value), `token_hash`, `name`, `labels` (comma-joined), `version`, `status` (`IDLE`/`ACTIVE`/`OFFLINE`/`UNSPECIFIED`), `ephemeral`, `last_seen`, `created_at`. |
94
+| `ci_runner` | Registered runners: `uuid` (the `x-runner-uuid` value), `token_hash`, `name`, `labels` (comma-joined), `version`, `status` (`IDLE`/`ACTIVE`/`OFFLINE`/`UNSPECIFIED`), `ephemeral`, `repository_id` (scope; null = instance-wide), `last_seen`, `created_at`. |
93
95
| `action_run` | One workflow run per repository: `number` (per-repo sequential), `workflow_name`, `workflow_file`, `event`, `ref`, `commit_sha`, `triggered_by_id`, `status` (`PENDING`/`RUNNING`/`SUCCESS`/`FAILURE`/`CANCELLED`), timestamps. Deleted with its repository. |
94
96
| `action_task` | One job within a run: `seq` (surrogate int64 id handed to runners), `run_id`, `name`, `runs_on` (comma-joined labels for runner matching, empty = any), `needs` (comma-joined dependency job names), `outputs` (JSON of the job's reported outputs), `job_id` (workflow job key; a matrix job's cells share it), `payload`, `runner_id` (the claiming runner, null while pending), `status`, `log_length` (durable log-row count = UpdateLog resume offset), `deadline` (zombie timeout), timestamps. Deleted with its run. |
95
97
| `action_log` | One log row of a task: `task_id`, `line_index` (0-based), `content`, `timestamp`. Deleted with its task. |
@@ -100,7 +102,8 @@
100
102
(`V24__action_task_seq.sql` adds `action_task.seq`, `V25__action_task_runs_on.sql` adds
101
103
`action_task.runs_on`, `V26__action_secrets_variables.sql` adds `action_secret`/`action_variable`,
102
104
`V27__action_task_needs.sql` adds `action_task.needs`, `V28__action_task_outputs.sql` adds
103
-`action_task.outputs`, `V29__action_task_job_id.sql` adds `action_task.job_id`).
105
+`action_task.outputs`, `V29__action_task_job_id.sql` adds `action_task.job_id`,
106
+`V30__ci_runner_scope.sql` adds the `repository_id` scope to `ci_runner`/`ci_runner_registration_token`).
104
107
Secrets are stored encrypted and require `GITSHARK_SECRET_KEY` to be set (same key as push mirrors);
105
108
without it, secrets cannot be decrypted and are omitted from what a runner receives.
106
109
The `ci_runner*` tables hold no repository data (losing them only forces re-registration); the
MODIFY
docs/maintainers/ci-runners.md
+11 -2
@@ -100,6 +100,11 @@
100
100
accumulated into `action_task.outputs` (JSON) and echoed back as `sent_outputs`; dispatch delivers a
101
101
needed job's outputs to its dependents as `needs.<job>.outputs`. `ActionOutputs` (de)serializes the
102
102
JSON, fail-safe to an empty map on a bad value.
103
+- **Repo-scoped runners:** a registration token (and the runners it creates) may carry a
104
+ `repository_id` (`ci_runner_registration_token`/`ci_runner`, migration `V30`); null = instance-scope
105
+ (any repository). Dispatch's `scopeAllows` check hands a scoped runner only its repository's tasks,
106
+ while an instance runner still serves any. Scoped rows cascade-delete with the repository.
107
+ (Generating a scoped token still needs a UI — the admin page only mints instance tokens today.)
103
108
- **Label matching:** a task carries its job's `runs-on` labels (`action_task.runs_on`, parsed at
104
109
ingest). Dispatch scans PENDING tasks oldest-first and claims the first whose labels are all
105
110
advertised by the fetching runner (empty `runs-on` = any runner); an incompatible task is left for a
@@ -138,7 +143,8 @@
138
143
cancels the dependent), `CommitCiStatusTest` (commit page + MR page show the aggregate badge, the
139
144
commit-status API reflects failure, and a commit with no runs stays all-clear),
140
145
`EphemeralRunnerTest` (an ephemeral runner is removed after its task — on completion and on
141
- zombie-reclaim — and its credentials stop working).
146
+ zombie-reclaim — and its credentials stop working), `ScopedRunnerTest` (a repo-scoped runner skips
147
+ other repos' tasks and idles when only they have work; an instance runner claims across repos).
142
148
- **Ephemeral runners:** a runner registered with `ephemeral=true` is one-shot — once its single task
143
149
reaches a terminal state it is deleted (`ci_runner` row removed; the task's `runner_id` is `ON
144
150
DELETE SET NULL`), so its credentials stop working and it never gets a second task. This holds on
@@ -184,7 +190,10 @@
184
190
not. (`!`-negation within a single pattern list is also not supported.)
185
191
- **Matrix advanced options:** `include`/`exclude` and `fail-fast`/`max-parallel` are not honored
186
192
(plain dimension cross-product only).
187
-- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped runners, non-push events.
193
+- **Scoped-runner token UI & org scope:** dispatch enforces repo scope, but there's no page yet to
194
+ mint a repo-scoped registration token (admin UI mints instance tokens only), and org scope isn't
195
+ modelled.
196
+- **Later phases:** artifacts (`ACTIONS_RESULTS_URL`), non-push events.
188
197
189
198
## References
190
199
MODIFY
src/main/java/de/workaround/ci/RunnerRegistrationService.java
+10 -0
@@ -12,6 +12,7 @@
12
12
13
13
import de.workaround.model.CiRunner;
14
14
import de.workaround.model.CiRunnerRegistrationToken;
15
+import de.workaround.model.Repository;
15
16
import de.workaround.model.User;
16
17
import jakarta.enterprise.context.ApplicationScoped;
17
18
import jakarta.inject.Inject;
@@ -48,10 +49,18 @@
48
49
@Transactional
49
50
public CreatedRegistrationToken createRegistrationToken(User admin)
50
51
{
52
+ return createRegistrationToken(admin, null);
53
+ }
54
+
55
+ /** Create a registration token scoped to {@code repository} (null = instance-scope, any repository). */
56
+ @Transactional
57
+ public CreatedRegistrationToken createRegistrationToken(User admin, Repository repository)
58
+ {
51
59
String plaintext = REGISTRATION_PREFIX + randomSecret();
52
60
CiRunnerRegistrationToken token = new CiRunnerRegistrationToken();
53
61
token.tokenHash = hash(plaintext);
54
62
token.createdBy = admin;
63
+ token.repository = repository;
55
64
token.persist();
56
65
return new CreatedRegistrationToken(token, plaintext);
57
66
}
@@ -72,6 +81,7 @@
72
81
runner.labels = joinLabels(labels);
73
82
runner.version = version;
74
83
runner.ephemeral = ephemeral;
84
+ runner.repository = registration.repository;
75
85
runner.status = CiRunner.Status.IDLE;
76
86
runner.lastSeen = Instant.now();
77
87
runner.persist();
MODIFY
src/main/java/de/workaround/ci/TaskDispatchService.java
+8 -1
@@ -170,7 +170,8 @@
170
170
continue;
171
171
}
172
172
UUID id = (UUID) candidate[0];
173
- if (!needsSatisfied(tasks.findById(id)))
173
+ ActionTask candidateTask = tasks.findById(id);
174
+ if (!scopeAllows(runner, candidateTask) || !needsSatisfied(candidateTask))
174
175
{
175
176
continue;
176
177
}
@@ -187,6 +188,12 @@
187
188
return Optional.empty();
188
189
}
189
190
191
+ /** An instance-scoped runner (no repository) serves any task; a repo-scoped runner only its repository's. */
192
+ private static boolean scopeAllows(CiRunner runner, ActionTask task)
193
+ {
194
+ return runner.repository == null || runner.repository.id.equals(task.run.repository.id);
195
+ }
196
+
190
197
/** A task is dispatchable only once every job it needs (in the same run) has succeeded. */
191
198
private boolean needsSatisfied(ActionTask task)
192
199
{
MODIFY
src/main/java/de/workaround/model/CiRunner.java
+7 -1
@@ -16,13 +16,15 @@
16
16
import jakarta.persistence.GeneratedValue;
17
17
import jakarta.persistence.GenerationType;
18
18
import jakarta.persistence.Id;
19
+import jakarta.persistence.ManyToOne;
19
20
import jakarta.persistence.Table;
20
21
21
22
/**
22
23
* A CI/CD runner registered against this instance via the Forgejo/Gitea runner.v1 Connect protocol.
23
24
* The runner authenticates every post-registration call with {@link #uuid} + a secret whose SHA-256
24
25
* hash is kept in {@link #tokenHash}; the plaintext secret is returned to the runner only once, at
25
- * registration. Instance-scope only in phase 1 (no repo/org scoping yet).
26
+ * registration. A runner may be scoped to a single {@link #repository} (null means instance-scope,
27
+ * serving any repository).
26
28
*/
27
29
@Entity
28
30
@Table(name = "ci_runner")
@@ -50,6 +52,10 @@
50
52
51
53
public boolean ephemeral;
52
54
55
+ /** Repository this runner is scoped to; null means instance-scope (any repository). */
56
+ @ManyToOne
57
+ public Repository repository;
58
+
53
59
public Instant lastSeen;
54
60
55
61
public Instant createdAt = Instant.now();
MODIFY
src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
+4 -0
@@ -37,6 +37,10 @@
37
37
@ManyToOne
38
38
public User createdBy;
39
39
40
+ /** Repository this token (and the runners it creates) is scoped to; null means instance-scope. */
41
+ @ManyToOne
42
+ public Repository repository;
43
+
40
44
public Instant createdAt = Instant.now();
41
45
42
46
public Instant lastUsed;
ADD
src/main/resources/db/migration/V30__ci_runner_scope.sql
+11 -0
@@ -0,0 +1,11 @@
1
+-- Repo-scoped runners (issue #2, phase 3).
2
+--
3
+-- A registration token and the runners it creates may be scoped to a single repository; a NULL
4
+-- repository means instance scope (any repository), the existing behaviour. A scoped runner only
5
+-- receives tasks from its repository. Scoped rows are removed with the repository.
6
+
7
+alter table ci_runner_registration_token
8
+ add column repository_id uuid references repositories (id) on delete cascade;
9
+
10
+alter table ci_runner
11
+ add column repository_id uuid references repositories (id) on delete cascade;
ADD
src/test/java/de/workaround/ci/ScopedRunnerTest.java
+148 -0
@@ -0,0 +1,148 @@
1
+package de.workaround.ci;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import org.junit.jupiter.api.Test;
8
+
9
+import de.workaround.git.GitRepositoryService;
10
+import de.workaround.model.ActionRun;
11
+import de.workaround.model.ActionTask;
12
+import de.workaround.model.Repository;
13
+import de.workaround.model.User;
14
+import io.quarkus.test.junit.QuarkusTest;
15
+import jakarta.inject.Inject;
16
+import jakarta.transaction.Transactional;
17
+
18
+import static org.junit.jupiter.api.Assertions.assertEquals;
19
+import static org.junit.jupiter.api.Assertions.assertTrue;
20
+
21
+/**
22
+ * Repo-scoped runners (issue #2, phase 3): a runner registered for one repository only claims that
23
+ * repository's tasks; an instance-scoped runner claims any.
24
+ */
25
+@QuarkusTest
26
+class ScopedRunnerTest
27
+{
28
+ @Inject
29
+ RunnerRegistrationService runnerService;
30
+
31
+ @Inject
32
+ TaskDispatchService dispatch;
33
+
34
+ @Inject
35
+ GitRepositoryService repositories;
36
+
37
+ @Inject
38
+ ActionRun.Repo runs;
39
+
40
+ @Inject
41
+ ActionTask.Repo tasks;
42
+
43
+ @Test
44
+ void repoScopedRunnerSkipsOtherReposTasks()
45
+ {
46
+ User admin = persistUser("sr-admin-" + shortId());
47
+ Repository repoA = newRepo("sr-a");
48
+ Repository repoB = newRepo("sr-b");
49
+ // repoB's task is older, so without scoping it would be picked first
50
+ seedPendingTask(repoB, "b-build", 20);
51
+ UUID aTask = seedPendingTask(repoA, "a-build", 10);
52
+
53
+ RunnerRegistrationService.RegisteredRunner scoped = registerScoped(admin, repoA);
54
+ ActionTask claimed = dispatch.fetch(scoped.runner().uuid, scoped.plaintext()).task().orElseThrow();
55
+
56
+ assertEquals(aTask, claimed.id, "scoped runner claims its own repo's task, not the older other-repo one");
57
+ }
58
+
59
+ @Test
60
+ void repoScopedRunnerGetsNothingWhenOnlyOtherReposHaveWork()
61
+ {
62
+ User admin = persistUser("sr-admin2-" + shortId());
63
+ Repository repoA = newRepo("sr-c");
64
+ Repository repoB = newRepo("sr-d");
65
+ seedPendingTask(repoB, "b-build", 10);
66
+
67
+ RunnerRegistrationService.RegisteredRunner scoped = registerScoped(admin, repoA);
68
+ assertTrue(dispatch.fetch(scoped.runner().uuid, scoped.plaintext()).task().isEmpty());
69
+ }
70
+
71
+ @Test
72
+ void instanceRunnerClaimsAnyRepo()
73
+ {
74
+ User admin = persistUser("sr-admin3-" + shortId());
75
+ Repository repoB = newRepo("sr-e");
76
+ // dispatch's queue is global; backdate far so this is the oldest pending task and is claimed
77
+ // deterministically regardless of other tests' committed rows
78
+ UUID bTask = seedPendingTask(repoB, "b-build", 86_400);
79
+
80
+ String token = runnerService.createRegistrationToken(admin).plaintext();
81
+ RunnerRegistrationService.RegisteredRunner instance = runnerService.register(token, "inst", List.of(),
82
+ "v4.0.0", false);
83
+ assertEquals(bTask, dispatch.fetch(instance.runner().uuid, instance.plaintext()).task().orElseThrow().id,
84
+ "instance runner claims across repos");
85
+ }
86
+
87
+ private RunnerRegistrationService.RegisteredRunner registerScoped(User admin, Repository repo)
88
+ {
89
+ String token = runnerService.createRegistrationToken(admin, repo).plaintext();
90
+ return runnerService.register(token, "scoped", List.of(), "v4.0.0", false);
91
+ }
92
+
93
+ private Repository newRepo(String name)
94
+ {
95
+ return createRepo(persistUser(name + "-owner-" + shortId()), name);
96
+ }
97
+
98
+ @Transactional
99
+ Repository createRepo(User owner, String name)
100
+ {
101
+ return repositories.create(owner, name, Repository.Visibility.PUBLIC, null);
102
+ }
103
+
104
+ private UUID seedPendingTask(Repository repository, String jobName, int secondsAgo)
105
+ {
106
+ return seedPendingTask(repository, jobName, secondsAgo, "");
107
+ }
108
+
109
+ @Transactional
110
+ UUID seedPendingTask(Repository repository, String jobName, int secondsAgo, String runsOn)
111
+ {
112
+ Repository repo = repositories.find(repository.ownerHandle(), repository.name).orElseThrow();
113
+ ActionRun run = new ActionRun();
114
+ run.repository = repo;
115
+ run.number = runs.maxNumber(repo) + 1;
116
+ run.workflowName = "CI";
117
+ run.workflowFile = ".forgejo/workflows/ci.yml";
118
+ run.event = "push";
119
+ run.ref = "refs/heads/main";
120
+ run.commitSha = "0000000000000000000000000000000000000000";
121
+ run.persist();
122
+
123
+ ActionTask task = new ActionTask();
124
+ task.run = run;
125
+ task.name = jobName;
126
+ task.jobId = jobName;
127
+ task.runsOn = runsOn;
128
+ task.payload = "on: push";
129
+ task.createdAt = Instant.now().minusSeconds(secondsAgo);
130
+ task.persist();
131
+ return task.id;
132
+ }
133
+
134
+ @Transactional
135
+ User persistUser(String name)
136
+ {
137
+ User user = new User();
138
+ user.oidcSub = name;
139
+ user.username = name;
140
+ user.persist();
141
+ return user;
142
+ }
143
+
144
+ private static String shortId()
145
+ {
146
+ return UUID.randomUUID().toString().substring(0, 8);
147
+ }
148
+}