✨ (ci-runners): Add runner registration via runner.v1 Connect protocol
Changes
29 files changed, +1449 -3
MODIFY
README.md
+7 -0
@@ -73,6 +73,13 @@
73
73
- **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
74
74
exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
75
75
activities from (see below)
76
+- **CI/CD runners** — *phase 1: registration only.* git-shark implements the server side of the
77
+ Forgejo/Gitea `runner.v1` Connect protocol under `/api/actions`, so a stock `forgejo-runner` /
78
+ `act_runner` registers and reports in unchanged. Instance admins (handles in
79
+ `GITSHARK_ADMIN_HANDLES`) generate reusable registration tokens and manage runners at
80
+ `/admin/runners`; secrets are stored hashed. Workflow execution (fetching and running jobs) is a
81
+ follow-up phase. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
82
+ [architecture](docs/maintainers/ci-runners.md)
76
83
77
84
## Federation (ForgeFed)
78
85
MODIFY
docs/README.md
+8 -0
@@ -31,6 +31,8 @@
31
31
- **[AI clients (MCP)](users/mcp.md)** — connect Claude Code, Claude Desktop, or
32
32
any MCP client to your instance: token setup, client configuration, available
33
33
tools.
34
+- **[CI/CD runners](users/ci-runners.md)** — what runners are, what admins can do
35
+ today (register runners), and what workflow execution is still coming.
34
36
35
37
### For admins
36
38
@@ -53,6 +55,9 @@
53
55
- **[Organisations](admins/organisations.md)** — shared handle namespace, owner
54
56
resolution, role semantics, endpoints, and the `organisations` /
55
57
`organisation_members` tables (no configuration needed).
58
+- **[CI/CD runners](admins/ci-runners.md)** — register Forgejo/Gitea runners via
59
+ the `runner.v1` Connect endpoints: admin handles, registration tokens, the
60
+ `/api/actions` paths, reverse-proxy notes, and the runner tables.
56
61
57
62
### For maintainers
58
63
@@ -67,6 +72,9 @@
67
72
implemented, the decisions behind it, what works and what is still missing.
68
73
- **[Push mirrors architecture](maintainers/push-mirrors.md)** — trigger flow,
69
74
queue design, credential encryption, and SSH decisions behind push mirroring.
75
+- **[CI/CD runner protocol](maintainers/ci-runners.md)** — how the Forgejo/Gitea
76
+ `runner.v1` server side is built (Connect-over-JAX-RS, protobuf codegen), the
77
+ decisions behind it, and the works/gaps list toward full workflow execution.
70
78
71
79
## Where else to look
72
80
ADD
docs/admins/ci-runners.md
+102 -0
@@ -0,0 +1,102 @@
1
+# CI/CD runners
2
+
3
+git-shark runs repository workflows on **external runners** rather than shipping its own runner
4
+binary. It implements the server side of the Forgejo/Gitea runner protocol (`runner.v1`), so a
5
+stock [`forgejo-runner`](https://forgejo.org/docs/latest/admin/actions/) or Gitea `act_runner`
6
+registers and works against git-shark unchanged.
7
+
8
+> **Phase 1 (this release):** runner **registration and presence** only — an admin generates a
9
+> registration token, runners register and appear in the admin UI, and `Register`/`Declare`/`Ping`
10
+> are served. **Workflow execution (fetching and running jobs) is not implemented yet** — that is a
11
+> follow-up phase. Registering a runner today is useful for verifying connectivity and the token
12
+> flow; it will not receive jobs until the run loop lands.
13
+
14
+## Becoming an admin
15
+
16
+There is no admin role in the database yet. An admin is any logged-in user whose handle is listed in
17
+`GITSHARK_ADMIN_HANDLES` (comma-separated). Empty (the default) means the instance has no admins and
18
+`/admin/*` is closed to everyone.
19
+
20
+```yaml
21
+ environment:
22
+ GITSHARK_ADMIN_HANDLES: alice,bob
23
+```
24
+
25
+Admins get a **CI runners** entry in the Account menu, linking to `/admin/runners`.
26
+
27
+## Registering a runner
28
+
29
+1. As an admin, open **Account → CI runners** (`/admin/runners`) and click **Generate registration
30
+ token**. The token is shown **once** — copy it.
31
+2. On the runner host, register against this instance:
32
+
33
+ ```
34
+ forgejo-runner register --no-interactive \
35
+ --instance https://gitshark.example.com \
36
+ --token <registration token>
37
+ ```
38
+
39
+ `--instance` is the public origin of your git-shark deployment (the same URL users browse to);
40
+ the runner reaches the protocol under `/api/actions`.
41
+3. Start the runner (`forgejo-runner daemon`). It calls `Declare`, and appears in the admin UI with
42
+ its version, labels, and last-seen time.
43
+
44
+Registration tokens are **reusable** and **instance-scoped** (matching Gitea's global tokens): one
45
+token can register any number of runners. Delete a token to stop it registering new runners; runners
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.
48
+
49
+## Endpoints
50
+
51
+The runner protocol is Connect RPC (unary): a plain HTTP `POST` to
52
+`/{package}.{Service}/{Method}` whose body is a serialized protobuf message and whose `200` response
53
+body is the serialized response message (`Content-Type: application/proto`). All are served under
54
+`/api/actions` and are **public** (no OIDC) — runners authenticate with their own credentials:
55
+
56
+| Method | Path | Auth |
57
+|---|---|---|
58
+| `Ping` | `POST /api/actions/ping.v1.PingService/Ping` | none (health check) |
59
+| `Register` | `POST /api/actions/runner.v1.RunnerService/Register` | registration token in request body |
60
+| `Declare` | `POST /api/actions/runner.v1.RunnerService/Declare` | `x-runner-uuid` + `x-runner-token` headers |
61
+
62
+`FetchTask`, `UpdateTask`, and `UpdateLog` exist in the protocol but are **not served yet** (phase 2+).
63
+
64
+## Reverse-proxy requirements
65
+
66
+Connect unary RPC is ordinary HTTP/1.1 `POST` with a binary body — no HTTP/2, no gRPC, no extra
67
+port. Any proxy that already fronts git-shark works, provided it:
68
+
69
+- forwards the `/api/actions/*` paths to the app unchanged;
70
+- preserves the `application/proto` request/response bodies (do **not** let the proxy buffer,
71
+ transcode, or gzip-rewrite them — pass through as-is);
72
+- forwards the `x-runner-uuid` and `x-runner-token` request headers.
73
+
74
+No new listener or TLS config beyond what [Getting Started](getting-started.md) already sets up.
75
+
76
+## Security
77
+
78
+- **Registration tokens and per-runner secrets** are stored only as SHA-256 hashes; each plaintext
79
+ is shown exactly once at creation and never again (same model as personal access tokens).
80
+- **Runner hosts are trusted infrastructure.** When the run loop and secrets delivery arrive, job
81
+ secrets will be sent to whichever runner picks up a task; do not register runners you do not
82
+ control. Fork-PR workflows with secrets are out of scope for now.
83
+- Only handles in `GITSHARK_ADMIN_HANDLES` can generate tokens or see/delete runners.
84
+
85
+## Tables
86
+
87
+| Table | Contents |
88
+|---|---|
89
+| `ci_runner_registration_token` | Reusable registration tokens: `token_hash`, `created_by_id`, `created_at`, `last_used`. |
90
+| `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`. |
91
+
92
+Both are introduced by migration `V19__ci_runners.sql`. They hold no repository data; losing them
93
+only means runners must re-register.
94
+
95
+## Troubleshooting
96
+
97
+| Symptom | Likely cause |
98
+|---|---|
99
+| `register` fails with `401`/`unauthenticated` | Registration token wrong, or deleted in the admin UI. Generate a fresh one. |
100
+| Runner registers but never runs anything | Expected in phase 1 — job execution is not implemented yet. |
101
+| `Declare` returns `401` after a working `Register` | Proxy is stripping `x-runner-uuid` / `x-runner-token`; forward them. |
102
+| Runner cannot reach the instance | `--instance` must be the public origin; the runner appends `/api/actions` itself. |
MODIFY
docs/admins/getting-started.md
+1 -0
@@ -342,6 +342,7 @@
342
342
| `GITSHARK_FEDERATION_PEER_ALLOWLIST` | — | — | Comma-separated peer hosts (empty denies all) |
343
343
| `GITSHARK_FEDERATION_MAX_ATTEMPTS` | — | `8` | Outbound delivery retry cap |
344
344
| `GITSHARK_FEDERATION_DEV_ALLOW_INSECURE` | — | `false` | Dev only: allow http/loopback peers |
345
+| `GITSHARK_ADMIN_HANDLES` | — | — | Comma-separated handles allowed into `/admin/*` (CI runner management); empty means no admins (see [CI runners](ci-runners.md)) |
345
346
346
347
### Optional: push mirrors
347
348
MODIFY
docs/admins/persistent-data.md
+1 -1
@@ -12,7 +12,7 @@
12
12
13
13
| Store | Configured by | Default (in container) | Contents | If you lose it |
14
14
|---|---|---|---|---|
15
-| PostgreSQL | `QUARKUS_DATASOURCE_*` | external service | Users, repository records, issues, merge requests, comments, SSH public keys, access-token hashes, push-mirror and federation state. Also each avatar's and repository image's content type and update timestamp — but **not** the image bytes. | Everything except the raw git objects and images. Total loss. |
15
+| PostgreSQL | `QUARKUS_DATASOURCE_*` | external service | Users, repository records, issues, merge requests, comments, SSH public keys, access-token hashes, push-mirror and federation state, CI runner registration-token and runner records (hashed secrets). Also each avatar's and repository image's content type and update timestamp — but **not** the image bytes. | Everything except the raw git objects and images. Total loss. |
16
16
| Repositories | `GITSHARK_STORAGE_ROOT` | `data/repositories` | The bare git repositories (all commits, branches, tags). | All hosted code. The DB rows survive but point at nothing. |
17
17
| Avatars | `GITSHARK_AVATAR_ROOT` | `data/avatars` | Uploaded profile pictures, one file per user, named by user UUID. | Profile pictures render as broken images (the DB still says the user has one, but `GET /users/{username}/avatar` returns 404). Users must re-upload. |
18
18
| Repository images | `GITSHARK_REPO_IMAGE_ROOT` | `data/repo-images` | Uploaded per-repository images, one file per repository, named by repository UUID. | Repository images render as broken images (the DB still says the repo has one, but `GET /repos/{owner}/{name}/image` returns 404); repos fall back to the owner's avatar once the DB row is also cleared. Owners must re-upload. |
ADD
docs/maintainers/ci-runners.md
+71 -0
@@ -0,0 +1,71 @@
1
+# CI/CD runner protocol — implementation
2
+
3
+git-shark does **not** ship its own runner. It implements the server side of the Forgejo/Gitea
4
+`runner.v1` protocol so stock `forgejo-runner` / `act_runner` binaries work unchanged. This document
5
+covers how the server side is built and what is / isn't done.
6
+
7
+## Component map
8
+
9
+| Component | File | Role |
10
+|---|---|---|
11
+| Proto definitions | `src/main/proto/{ping,runner}/v1/*.proto` | MIT-licensed copies of `gitea.com/gitea/actions-proto-def`, with `java_package`/`java_multiple_files` options added. |
12
+| Generated messages | `de.workaround.ci.proto.*` (build output) | Protobuf message classes generated by `protobuf-maven-plugin` at `generate-sources`. |
13
+| Connect endpoint | `ci/ConnectRunnerResource.java` | JAX-RS resource serving the Connect unary RPCs under `/api/actions`. |
14
+| Registration/presence | `ci/RunnerRegistrationService.java` | Token issue, runner register/declare/authenticate, list/delete. |
15
+| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Persisted state (migration `V19`). |
16
+| Admin UI | `ci/AdminRunnerResource.java` + `templates/AdminRunnerResource/` | Token generation, runner list, deletion. |
17
+| Admin gate | `account/AdminAccess.java` | Config-driven instance-admin check. |
18
+
19
+## Key decisions (and why)
20
+
21
+- **Reuse the Forgejo/Gitea runner, don't write our own.** The wire protocol is small, open, and
22
+ MIT-licensed; the runner ecosystem and GHA-compatible workflow format come for free. The real cost
23
+ is server-side workflow semantics (trigger/DAG/matrix), not the transport — see the issue for the
24
+ full rationale.
25
+- **Connect unary over plain JAX-RS, not `quarkus-grpc`.** Connect unary RPC is just an HTTP `POST`
26
+ with a serialized-protobuf body and a serialized-protobuf `200` body. We only need the generated
27
+ *message* classes; serving them from ordinary JAX-RS resources avoids pulling in a gRPC server
28
+ (extra listener, HTTP/2) we don't use. Hence `protobuf-maven-plugin` (messages only) rather than
29
+ the gRPC codegen. Errors use the Connect JSON error shape (`{"code","message"}`) with the matching
30
+ HTTP status.
31
+- **Two secret types, hashed at rest.** A reusable, instance-scoped *registration token* (admin
32
+ issues it; runner presents it once in the `Register` body) and a permanent per-runner *secret*
33
+ (minted in `Register`, sent to the runner exactly once, presented in `x-runner-token` thereafter).
34
+ Only SHA-256 hashes are stored — same model as `AccessToken`.
35
+- **Instance-scope, config-based admin.** No admin role in the schema yet; `AdminAccess` reads
36
+ `gitshark.admin.handles`. Deliberately minimal so phase 1 doesn't drag a roles migration with it.
37
+- **`uuid` distinct from the DB primary key.** The runner echoes `uuid` in headers; keeping it
38
+ separate from the internal `UUID id` means the runner never learns our primary key.
39
+
40
+## What works today
41
+
42
+- `protobuf-maven-plugin` generates the `ping.v1` + `runner.v1` message classes at build time.
43
+- `Ping` health check.
44
+- `Register`: validates the registration token, creates a `ci_runner`, returns the per-runner secret
45
+ once. Registration tokens are reusable.
46
+- `Declare`: authenticates by `uuid` + secret, refreshes version/labels/`last_seen`, returns the
47
+ runner.
48
+- Admin UI: generate/delete registration tokens, list/delete runners; gated by `AdminAccess` and the
49
+ `/admin/*` authenticated policy.
50
+- Tests: `RunnerRegistrationServiceTest` (service), `ConnectRunnerResourceTest` (protobuf-over-HTTP
51
+ round-trip for Ping/Register/Declare + auth failures), `AdminAccessTest` (admin gate).
52
+
53
+## What still needs to be implemented
54
+
55
+- **Run loop:** `FetchTask` (long-poll with `tasks_version`), `UpdateTask`, `UpdateLog` (offset /
56
+ `ack_index` resume). Not served yet.
57
+- **Tables:** `action_run`, `action_task`, `action_log` (or log-blob storage).
58
+- **Workflow pipeline:** parse `.forgejo/workflows/*.yml` at the pushed head, evaluate `on:` triggers
59
+ (push only for the MVP), expand a single job into a Task payload. No `needs`/`matrix` yet.
60
+- **Run UI:** per-repository run list + run detail with live per-step status and logs.
61
+- **Task state machine:** timeout / zombie handling when a runner vanishes mid-task.
62
+- **Real-runner integration test:** protocol round-trip against an actual `forgejo-runner` container
63
+ (the current endpoint test uses a hand-built protobuf client, not the binary).
64
+- **Later phases:** secrets/variables delivery, label-based matching, concurrency/cancellation,
65
+ artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and ephemeral runners, commit/MR status.
66
+
67
+## References
68
+
69
+- Protos: <https://gitea.com/gitea/actions-proto-def> (MIT)
70
+- Gitea Actions design (server/runner split): <https://docs.gitea.com/usage/actions/design>
71
+- Forgejo ↔ Gitea shared-protocol confirmation: <https://code.forgejo.org/forgejo/runner/issues/525>
ADD
docs/users/ci-runners.md
+24 -0
@@ -0,0 +1,24 @@
1
+# CI/CD runners
2
+
3
+git-shark can run repository workflows on CI/CD **runners**. It speaks the Forgejo/Gitea runner
4
+protocol, so the standard `forgejo-runner` (or Gitea `act_runner`) connects to it directly, and
5
+workflows use the familiar GitHub-Actions-compatible YAML format in `.forgejo/workflows/`.
6
+
7
+> **Available today:** an **instance administrator** can register runners against this instance and
8
+> see them listed. **Running workflows is not enabled yet** — pushing a workflow file does not yet
9
+> start a job. This page will grow as workflow execution, logs, and per-repository run views land.
10
+
11
+## What you can do now
12
+
13
+- **If you are an instance admin**, you manage runners under **Account → CI runners**. See the
14
+ [admin guide](../admins/ci-runners.md) for generating a registration token and connecting a runner.
15
+- **If you are not an admin**, there is nothing to configure yet. Runner management is
16
+ instance-wide and admin-only in this phase; per-repository controls and workflow authoring arrive
17
+ in later phases.
18
+
19
+## What's coming
20
+
21
+- Workflows in `.forgejo/workflows/*.yml` triggered on push, with live per-step logs and results in
22
+ the repository UI.
23
+- Repository-level secrets and variables, `needs`/`matrix`, and run cancellation/re-run.
24
+- Artifacts and commit/merge-request status integration.
MODIFY
pom.xml
+35 -0
@@ -20,6 +20,9 @@
20
20
<bouncycastle.version>1.84</bouncycastle.version>
21
21
<commonmark.version>0.24.0</commonmark.version>
22
22
<quarkus-mcp-server.version>1.13.1</quarkus-mcp-server.version>
23
+ <protobuf.version>3.25.5</protobuf.version>
24
+ <os-maven-plugin.version>1.7.1</os-maven-plugin.version>
25
+ <protobuf-maven-plugin.version>0.6.1</protobuf-maven-plugin.version>
23
26
<skipITs>true</skipITs>
24
27
<surefire-plugin.version>3.5.6</surefire-plugin.version>
25
28
</properties>
@@ -67,6 +70,11 @@
67
70
<artifactId>quarkus-hibernate-panache-next</artifactId>
68
71
</dependency>
69
72
<dependency>
73
+ <groupId>com.google.protobuf</groupId>
74
+ <artifactId>protobuf-java</artifactId>
75
+ <version>${protobuf.version}</version>
76
+ </dependency>
77
+ <dependency>
70
78
<groupId>io.quarkus</groupId>
71
79
<artifactId>quarkus-undertow</artifactId>
72
80
</dependency>
@@ -163,6 +171,14 @@
163
171
</dependencies>
164
172
165
173
<build>
174
+ <!-- Resolves ${os.detected.classifier} so protobuf-maven-plugin can fetch the right protoc binary -->
175
+ <extensions>
176
+ <extension>
177
+ <groupId>kr.motd.maven</groupId>
178
+ <artifactId>os-maven-plugin</artifactId>
179
+ <version>${os-maven-plugin.version}</version>
180
+ </extension>
181
+ </extensions>
166
182
<plugins>
167
183
<plugin>
168
184
<groupId>${quarkus.platform.group-id}</groupId>
@@ -170,6 +186,25 @@
170
186
<version>${quarkus.platform.version}</version>
171
187
<extensions>true</extensions>
172
188
</plugin>
189
+ <!-- Generate Java message classes for the Forgejo/Gitea runner.v1 + ping.v1 Connect protocol
190
+ (MIT protos under src/main/proto). Only message types are generated; the Connect unary
191
+ endpoints are served by plain JAX-RS resources reading/writing serialized protobuf. -->
192
+ <plugin>
193
+ <groupId>org.xolstice.maven.plugins</groupId>
194
+ <artifactId>protobuf-maven-plugin</artifactId>
195
+ <version>${protobuf-maven-plugin.version}</version>
196
+ <configuration>
197
+ <protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
198
+ <protoSourceRoot>${project.basedir}/src/main/proto</protoSourceRoot>
199
+ </configuration>
200
+ <executions>
201
+ <execution>
202
+ <goals>
203
+ <goal>compile</goal>
204
+ </goals>
205
+ </execution>
206
+ </executions>
207
+ </plugin>
173
208
<plugin>
174
209
<artifactId>maven-compiler-plugin</artifactId>
175
210
<version>${compiler-plugin.version}</version>
ADD
src/main/java/de/workaround/account/AdminAccess.java
+41 -0
@@ -0,0 +1,41 @@
1
+package de.workaround.account;
2
+
3
+import java.util.List;
4
+import java.util.Optional;
5
+
6
+import org.eclipse.microprofile.config.inject.ConfigProperty;
7
+
8
+import de.workaround.git.ForbiddenOperationException;
9
+import de.workaround.model.User;
10
+import jakarta.enterprise.context.ApplicationScoped;
11
+
12
+/**
13
+ * Instance-level administration gate. There is no admin role in the schema yet (phase 1): an admin
14
+ * is simply a logged-in user whose handle appears in {@code gitshark.admin.handles}
15
+ * (env {@code GITSHARK_ADMIN_HANDLES}, comma-separated). Empty list means the instance has no admins
16
+ * and every admin-only page is closed.
17
+ */
18
+@ApplicationScoped
19
+public class AdminAccess
20
+{
21
+ @ConfigProperty(name = "gitshark.admin.handles")
22
+ Optional<List<String>> adminHandles;
23
+
24
+ public boolean isAdmin(User user)
25
+ {
26
+ if (user == null || user.username == null)
27
+ {
28
+ return false;
29
+ }
30
+ return adminHandles.orElse(List.of()).contains(user.username);
31
+ }
32
+
33
+ /** Throw {@link ForbiddenOperationException} unless the user is an instance admin. */
34
+ public void require(User user)
35
+ {
36
+ if (!isAdmin(user))
37
+ {
38
+ throw new ForbiddenOperationException("Instance administrator access required");
39
+ }
40
+ }
41
+}
MODIFY
src/main/java/de/workaround/account/CurrentUser.java
+9 -0
@@ -22,6 +22,9 @@
22
22
@Inject
23
23
UserProvisioningService provisioning;
24
24
25
+ @Inject
26
+ AdminAccess adminAccess;
27
+
25
28
private User cached;
26
29
27
30
/** Cheap auth check for templates — no user provisioning, just the identity state. */
@@ -53,6 +56,12 @@
53
56
return user == null ? "" : user.contentWidth.cssClass;
54
57
}
55
58
59
+ /** Whether the logged-in user is an instance admin; drives the admin nav entry. */
60
+ public boolean isAdmin()
61
+ {
62
+ return adminAccess.isAdmin(get());
63
+ }
64
+
56
65
public User require()
57
66
{
58
67
User user = get();
ADD
src/main/java/de/workaround/ci/AdminRunnerResource.java
+88 -0
@@ -0,0 +1,88 @@
1
+package de.workaround.ci;
2
+
3
+import java.net.URI;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import de.workaround.account.AdminAccess;
8
+import de.workaround.account.CurrentUser;
9
+import de.workaround.model.CiRunner;
10
+import de.workaround.model.CiRunnerRegistrationToken;
11
+import io.quarkus.qute.CheckedTemplate;
12
+import io.quarkus.qute.TemplateInstance;
13
+import jakarta.inject.Inject;
14
+import jakarta.ws.rs.GET;
15
+import jakarta.ws.rs.POST;
16
+import jakarta.ws.rs.Path;
17
+import jakarta.ws.rs.PathParam;
18
+import jakarta.ws.rs.Produces;
19
+import jakarta.ws.rs.core.MediaType;
20
+import jakarta.ws.rs.core.Response;
21
+
22
+/**
23
+ * Instance-admin UI for CI/CD runners: generate registration tokens, list registered runners and
24
+ * their presence, and delete either. Every handler is gated by {@link AdminAccess}; the routes are
25
+ * additionally behind the {@code /admin/*} authenticated policy so anonymous requests never reach here.
26
+ */
27
+@Path("/admin/runners")
28
+@Produces(MediaType.TEXT_HTML)
29
+public class AdminRunnerResource
30
+{
31
+ @CheckedTemplate
32
+ static class Templates
33
+ {
34
+ static native TemplateInstance runners(List<CiRunner> runners, List<CiRunnerRegistrationToken> tokens);
35
+
36
+ static native TemplateInstance tokenCreated(String plaintext);
37
+ }
38
+
39
+ @Inject
40
+ CurrentUser currentUser;
41
+
42
+ @Inject
43
+ AdminAccess adminAccess;
44
+
45
+ @Inject
46
+ RunnerRegistrationService runnerService;
47
+
48
+ @GET
49
+ public TemplateInstance list()
50
+ {
51
+ requireAdmin();
52
+ return Templates.runners(runnerService.list(), runnerService.listRegistrationTokens());
53
+ }
54
+
55
+ @POST
56
+ @Path("/tokens")
57
+ public TemplateInstance createToken()
58
+ {
59
+ requireAdmin();
60
+ RunnerRegistrationService.CreatedRegistrationToken created =
61
+ runnerService.createRegistrationToken(currentUser.require());
62
+ return Templates.tokenCreated(created.plaintext());
63
+ }
64
+
65
+ @POST
66
+ @Path("/tokens/{id}/delete")
67
+ public Response deleteToken(@PathParam("id") UUID id)
68
+ {
69
+ requireAdmin();
70
+ runnerService.deleteRegistrationToken(id);
71
+ return Response.seeOther(URI.create("/admin/runners")).build();
72
+ }
73
+
74
+ @POST
75
+ @Path("/{id}/delete")
76
+ public Response deleteRunner(@PathParam("id") UUID id)
77
+ {
78
+ requireAdmin();
79
+ runnerService.delete(id);
80
+ return Response.seeOther(URI.create("/admin/runners")).build();
81
+ }
82
+
83
+ private void requireAdmin()
84
+ {
85
+ adminAccess.require(currentUser.require());
86
+ }
87
+
88
+}
ADD
src/main/java/de/workaround/ci/ConnectRunnerResource.java
+148 -0
@@ -0,0 +1,148 @@
1
+package de.workaround.ci;
2
+
3
+import java.util.Arrays;
4
+import java.util.List;
5
+
6
+import com.google.protobuf.InvalidProtocolBufferException;
7
+
8
+import de.workaround.ci.proto.ping.v1.PingRequest;
9
+import de.workaround.ci.proto.ping.v1.PingResponse;
10
+import de.workaround.ci.proto.runner.v1.DeclareRequest;
11
+import de.workaround.ci.proto.runner.v1.DeclareResponse;
12
+import de.workaround.ci.proto.runner.v1.RegisterRequest;
13
+import de.workaround.ci.proto.runner.v1.RegisterResponse;
14
+import de.workaround.ci.proto.runner.v1.Runner;
15
+import de.workaround.ci.proto.runner.v1.RunnerStatus;
16
+import de.workaround.model.CiRunner;
17
+import jakarta.inject.Inject;
18
+import jakarta.ws.rs.Consumes;
19
+import jakarta.ws.rs.HeaderParam;
20
+import jakarta.ws.rs.POST;
21
+import jakarta.ws.rs.Path;
22
+import jakarta.ws.rs.Produces;
23
+import jakarta.ws.rs.core.Response;
24
+
25
+/**
26
+ * Server side of the Forgejo/Gitea <code>runner.v1</code> Connect protocol (phase 1: Ping, Register,
27
+ * Declare). Connect unary RPC is a plain HTTP POST to <code>/{package}.{Service}/{Method}</code>
28
+ * whose body is the serialized request message and whose 200 response body is the serialized
29
+ * response message ({@code application/proto}). Stock <code>forgejo-runner</code> / <code>act_runner</code>
30
+ * point their <code>instance</code> at this origin and reach these paths under <code>/api/actions</code>.
31
+ * <p>
32
+ * Errors follow the Connect wire format: a non-200 status with a small JSON body
33
+ * <code>{"code": "...", "message": "..."}</code>. Post-registration calls authenticate with the
34
+ * <code>x-runner-uuid</code> / <code>x-runner-token</code> headers the runner stores after Register.
35
+ */
36
+@Path("/api/actions")
37
+@Consumes(ConnectRunnerResource.PROTO)
38
+@Produces(ConnectRunnerResource.PROTO)
39
+public class ConnectRunnerResource
40
+{
41
+ static final String PROTO = "application/proto";
42
+
43
+ @Inject
44
+ RunnerRegistrationService runnerService;
45
+
46
+ @POST
47
+ @Path("/ping.v1.PingService/Ping")
48
+ public Response ping(byte[] body) throws InvalidProtocolBufferException
49
+ {
50
+ PingRequest request = PingRequest.parseFrom(body);
51
+ PingResponse response = PingResponse.newBuilder()
52
+ .setData("Hello, " + request.getData())
53
+ .build();
54
+ return ok(response.toByteArray());
55
+ }
56
+
57
+ @POST
58
+ @Path("/runner.v1.RunnerService/Register")
59
+ public Response register(byte[] body) throws InvalidProtocolBufferException
60
+ {
61
+ RegisterRequest request = RegisterRequest.parseFrom(body);
62
+ try
63
+ {
64
+ RunnerRegistrationService.RegisteredRunner reg = runnerService.register(request.getToken(),
65
+ request.getName(), request.getLabelsList(), request.getVersion(), request.getEphemeral());
66
+ Runner runner = toProto(reg.runner(), reg.plaintext());
67
+ return ok(RegisterResponse.newBuilder().setRunner(runner).build().toByteArray());
68
+ }
69
+ catch (InvalidRegistrationTokenException e)
70
+ {
71
+ return connectError(Response.Status.UNAUTHORIZED, "unauthenticated", e.getMessage());
72
+ }
73
+ }
74
+
75
+ @POST
76
+ @Path("/runner.v1.RunnerService/Declare")
77
+ public Response declare(@HeaderParam("x-runner-uuid") String uuid, @HeaderParam("x-runner-token") String token,
78
+ byte[] body) throws InvalidProtocolBufferException
79
+ {
80
+ DeclareRequest request = DeclareRequest.parseFrom(body);
81
+ try
82
+ {
83
+ CiRunner runner = runnerService.declare(uuid, token, request.getVersion(), request.getLabelsList());
84
+ return ok(DeclareResponse.newBuilder().setRunner(toProto(runner, null)).build().toByteArray());
85
+ }
86
+ catch (RunnerAuthenticationException e)
87
+ {
88
+ return connectError(Response.Status.UNAUTHORIZED, "unauthenticated", e.getMessage());
89
+ }
90
+ }
91
+
92
+ private static Runner toProto(CiRunner runner, String plaintextSecret)
93
+ {
94
+ Runner.Builder builder = Runner.newBuilder()
95
+ .setUuid(runner.uuid)
96
+ .setName(runner.name)
97
+ .setStatus(toProtoStatus(runner.status))
98
+ .setEphemeral(runner.ephemeral)
99
+ .addAllLabels(splitLabels(runner.labels));
100
+ if (runner.version != null)
101
+ {
102
+ builder.setVersion(runner.version);
103
+ }
104
+ // The runner secret is delivered only in the Register response; Declare must not resend it.
105
+ if (plaintextSecret != null)
106
+ {
107
+ builder.setToken(plaintextSecret);
108
+ }
109
+ return builder.build();
110
+ }
111
+
112
+ private static RunnerStatus toProtoStatus(CiRunner.Status status)
113
+ {
114
+ return switch (status)
115
+ {
116
+ case IDLE -> RunnerStatus.RUNNER_STATUS_IDLE;
117
+ case ACTIVE -> RunnerStatus.RUNNER_STATUS_ACTIVE;
118
+ case OFFLINE -> RunnerStatus.RUNNER_STATUS_OFFLINE;
119
+ case UNSPECIFIED -> RunnerStatus.RUNNER_STATUS_UNSPECIFIED;
120
+ };
121
+ }
122
+
123
+ private static List<String> splitLabels(String labels)
124
+ {
125
+ if (labels == null || labels.isBlank())
126
+ {
127
+ return List.of();
128
+ }
129
+ return Arrays.stream(labels.split(",")).filter(s -> !s.isBlank()).toList();
130
+ }
131
+
132
+ private static Response ok(byte[] payload)
133
+ {
134
+ return Response.ok(payload, PROTO).build();
135
+ }
136
+
137
+ private static Response connectError(Response.Status status, String code, String message)
138
+ {
139
+ String json = "{\"code\":\"" + code + "\",\"message\":\"" + escapeJson(message) + "\"}";
140
+ return Response.status(status).type("application/json").entity(json).build();
141
+ }
142
+
143
+ private static String escapeJson(String value)
144
+ {
145
+ return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"");
146
+ }
147
+
148
+}
ADD
src/main/java/de/workaround/ci/InvalidRegistrationTokenException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.ci;
2
+
3
+/** Thrown when a runner presents a registration token that does not match any stored token. */
4
+public class InvalidRegistrationTokenException extends RuntimeException
5
+{
6
+ public InvalidRegistrationTokenException(String message)
7
+ {
8
+ super(message);
9
+ }
10
+}
ADD
src/main/java/de/workaround/ci/RunnerAuthenticationException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.ci;
2
+
3
+/** Thrown when a post-registration call presents an unknown or mismatched runner uuid/token pair. */
4
+public class RunnerAuthenticationException extends RuntimeException
5
+{
6
+ public RunnerAuthenticationException(String message)
7
+ {
8
+ super(message);
9
+ }
10
+}
ADD
src/main/java/de/workaround/ci/RunnerRegistrationService.java
+157 -0
@@ -0,0 +1,157 @@
1
+package de.workaround.ci;
2
+
3
+import java.nio.charset.StandardCharsets;
4
+import java.security.MessageDigest;
5
+import java.security.NoSuchAlgorithmException;
6
+import java.security.SecureRandom;
7
+import java.time.Instant;
8
+import java.util.Base64;
9
+import java.util.HexFormat;
10
+import java.util.List;
11
+import java.util.UUID;
12
+
13
+import de.workaround.model.CiRunner;
14
+import de.workaround.model.CiRunnerRegistrationToken;
15
+import de.workaround.model.User;
16
+import jakarta.enterprise.context.ApplicationScoped;
17
+import jakarta.inject.Inject;
18
+import jakarta.transaction.Transactional;
19
+
20
+/**
21
+ * Registration and presence of CI/CD runners (Forgejo/Gitea runner.v1 protocol, phase 1).
22
+ * <p>
23
+ * Two secrets are involved, both stored only as SHA-256 hashes and shown in plaintext exactly once:
24
+ * a shared <em>registration token</em> an admin generates, and a per-runner <em>secret</em> minted
25
+ * during {@link #register}. Registration tokens are reusable (Gitea instance-token semantics).
26
+ */
27
+@ApplicationScoped
28
+public class RunnerRegistrationService
29
+{
30
+ private static final String REGISTRATION_PREFIX = "gsr_";
31
+
32
+ private static final String RUNNER_TOKEN_PREFIX = "gsrt_";
33
+
34
+ @Inject
35
+ CiRunner.Repo runners;
36
+
37
+ @Inject
38
+ CiRunnerRegistrationToken.Repo registrationTokens;
39
+
40
+ public record CreatedRegistrationToken(CiRunnerRegistrationToken token, String plaintext)
41
+ {
42
+ }
43
+
44
+ public record RegisteredRunner(CiRunner runner, String plaintext)
45
+ {
46
+ }
47
+
48
+ @Transactional
49
+ public CreatedRegistrationToken createRegistrationToken(User admin)
50
+ {
51
+ String plaintext = REGISTRATION_PREFIX + randomSecret();
52
+ CiRunnerRegistrationToken token = new CiRunnerRegistrationToken();
53
+ token.tokenHash = hash(plaintext);
54
+ token.createdBy = admin;
55
+ token.persist();
56
+ return new CreatedRegistrationToken(token, plaintext);
57
+ }
58
+
59
+ @Transactional
60
+ public RegisteredRunner register(String registrationPlaintext, String name, List<String> labels, String version,
61
+ boolean ephemeral)
62
+ {
63
+ CiRunnerRegistrationToken registration = registrationTokens.findByTokenHash(hash(registrationPlaintext))
64
+ .orElseThrow(() -> new InvalidRegistrationTokenException("Unknown runner registration token"));
65
+ registration.lastUsed = Instant.now();
66
+
67
+ String secret = RUNNER_TOKEN_PREFIX + randomSecret();
68
+ CiRunner runner = new CiRunner();
69
+ runner.uuid = UUID.randomUUID().toString();
70
+ runner.tokenHash = hash(secret);
71
+ runner.name = name;
72
+ runner.labels = joinLabels(labels);
73
+ runner.version = version;
74
+ runner.ephemeral = ephemeral;
75
+ runner.status = CiRunner.Status.IDLE;
76
+ runner.lastSeen = Instant.now();
77
+ runner.persist();
78
+ return new RegisteredRunner(runner, secret);
79
+ }
80
+
81
+ /**
82
+ * Authenticate a runner by its uuid + secret and refresh its declared version/labels. Called for
83
+ * the Declare RPC (and, going forward, every authenticated post-registration call).
84
+ */
85
+ @Transactional
86
+ public CiRunner declare(String uuid, String tokenPlaintext, String version, List<String> labels)
87
+ {
88
+ CiRunner runner = authenticate(uuid, tokenPlaintext);
89
+ runner.version = version;
90
+ runner.labels = joinLabels(labels);
91
+ runner.status = CiRunner.Status.IDLE;
92
+ runner.lastSeen = Instant.now();
93
+ return runner;
94
+ }
95
+
96
+ /** Resolve a runner from its credentials or throw {@link RunnerAuthenticationException}. */
97
+ @Transactional
98
+ public CiRunner authenticate(String uuid, String tokenPlaintext)
99
+ {
100
+ if (uuid == null || tokenPlaintext == null)
101
+ {
102
+ throw new RunnerAuthenticationException("Missing runner credentials");
103
+ }
104
+ return runners.findByUuidAndTokenHash(uuid, hash(tokenPlaintext))
105
+ .orElseThrow(() -> new RunnerAuthenticationException("Unknown runner uuid/token"));
106
+ }
107
+
108
+ public List<CiRunner> list()
109
+ {
110
+ return runners.listNewestFirst();
111
+ }
112
+
113
+ public List<CiRunnerRegistrationToken> listRegistrationTokens()
114
+ {
115
+ return registrationTokens.listNewestFirst();
116
+ }
117
+
118
+ @Transactional
119
+ public void delete(UUID id)
120
+ {
121
+ runners.deleteById(id);
122
+ }
123
+
124
+ @Transactional
125
+ public void deleteRegistrationToken(UUID id)
126
+ {
127
+ registrationTokens.deleteById(id);
128
+ }
129
+
130
+ private static String joinLabels(List<String> labels)
131
+ {
132
+ return labels == null ? "" : String.join(",", labels);
133
+ }
134
+
135
+ private static String randomSecret()
136
+ {
137
+ byte[] bytes = new byte[32];
138
+ // created per call: a SecureRandom held in a bean field would be snapshotted into the native
139
+ // image heap, which GraalVM rejects (same rationale as AccessTokenService)
140
+ new SecureRandom().nextBytes(bytes);
141
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
142
+ }
143
+
144
+ public String hash(String plaintext)
145
+ {
146
+ try
147
+ {
148
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
149
+ return HexFormat.of().formatHex(digest.digest(plaintext.getBytes(StandardCharsets.UTF_8)));
150
+ }
151
+ catch (NoSuchAlgorithmException e)
152
+ {
153
+ throw new IllegalStateException("SHA-256 unavailable", e);
154
+ }
155
+ }
156
+
157
+}
ADD
src/main/java/de/workaround/model/CiRunner.java
+77 -0
@@ -0,0 +1,77 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.EnumType;
15
+import jakarta.persistence.Enumerated;
16
+import jakarta.persistence.GeneratedValue;
17
+import jakarta.persistence.GenerationType;
18
+import jakarta.persistence.Id;
19
+import jakarta.persistence.Table;
20
+
21
+/**
22
+ * A CI/CD runner registered against this instance via the Forgejo/Gitea runner.v1 Connect protocol.
23
+ * The runner authenticates every post-registration call with {@link #uuid} + a secret whose SHA-256
24
+ * 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
+ */
27
+@Entity
28
+@Table(name = "ci_runner")
29
+public class CiRunner implements PanacheEntity.Managed
30
+{
31
+ @Id
32
+ @GeneratedValue(strategy = GenerationType.UUID)
33
+ public UUID id;
34
+
35
+ // The identifier the runner echoes in the x-runner-uuid header; distinct from the DB primary key
36
+ // so the runner never learns our internal id.
37
+ public String uuid;
38
+
39
+ public String tokenHash;
40
+
41
+ public String name;
42
+
43
+ // Comma-joined labels advertised via Register/Declare; empty string means none.
44
+ public String labels = "";
45
+
46
+ public String version;
47
+
48
+ @Enumerated(EnumType.STRING)
49
+ public Status status = Status.IDLE;
50
+
51
+ public boolean ephemeral;
52
+
53
+ public Instant lastSeen;
54
+
55
+ public Instant createdAt = Instant.now();
56
+
57
+ public enum Status
58
+ {
59
+ UNSPECIFIED,
60
+ IDLE,
61
+ ACTIVE,
62
+ OFFLINE
63
+ }
64
+
65
+ public interface Repo extends PanacheRepository.Managed<CiRunner, UUID>
66
+ {
67
+ @Find
68
+ Optional<CiRunner> findByUuid(String uuid);
69
+
70
+ @Find
71
+ Optional<CiRunner> findByUuidAndTokenHash(String uuid, String tokenHash);
72
+
73
+ @HQL("order by createdAt desc")
74
+ List<CiRunner> listNewestFirst();
75
+ }
76
+
77
+}
ADD
src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
+53 -0
@@ -0,0 +1,53 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.GeneratedValue;
15
+import jakarta.persistence.GenerationType;
16
+import jakarta.persistence.Id;
17
+import jakarta.persistence.ManyToOne;
18
+import jakarta.persistence.Table;
19
+
20
+/**
21
+ * A shared registration token an admin hands to {@code forgejo-runner register}. Instance-scoped
22
+ * and reusable (matching Gitea's global registration tokens): a runner presents it once in the
23
+ * Register RPC body and thereafter authenticates with its own per-runner secret. Only the SHA-256
24
+ * hash of the plaintext is stored; the plaintext is shown to the admin exactly once at creation.
25
+ */
26
+@Entity
27
+@Table(name = "ci_runner_registration_token")
28
+public class CiRunnerRegistrationToken implements PanacheEntity.Managed
29
+{
30
+ @Id
31
+ @GeneratedValue(strategy = GenerationType.UUID)
32
+ public UUID id;
33
+
34
+ public String tokenHash;
35
+
36
+ // The admin who generated the token; nullable so deleting the user leaves the token standing.
37
+ @ManyToOne
38
+ public User createdBy;
39
+
40
+ public Instant createdAt = Instant.now();
41
+
42
+ public Instant lastUsed;
43
+
44
+ public interface Repo extends PanacheRepository.Managed<CiRunnerRegistrationToken, UUID>
45
+ {
46
+ @Find
47
+ Optional<CiRunnerRegistrationToken> findByTokenHash(String tokenHash);
48
+
49
+ @HQL("order by createdAt desc")
50
+ List<CiRunnerRegistrationToken> listNewestFirst();
51
+ }
52
+
53
+}
ADD
src/main/proto/ping/v1/messages.proto
+16 -0
@@ -0,0 +1,16 @@
1
+// Copied from gitea.com/gitea/actions-proto-def (MIT License, Copyright (c) 2022 The Gitea Authors).
2
+// Only the java_package / java_multiple_files options are added; message shapes are unmodified.
3
+syntax = "proto3";
4
+
5
+package ping.v1;
6
+
7
+option java_package = "de.workaround.ci.proto.ping.v1";
8
+option java_multiple_files = true;
9
+
10
+message PingRequest {
11
+ string data = 1;
12
+}
13
+
14
+message PingResponse {
15
+ string data = 1;
16
+}
ADD
src/main/proto/ping/v1/services.proto
+14 -0
@@ -0,0 +1,14 @@
1
+// Copied from gitea.com/gitea/actions-proto-def (MIT License, Copyright (c) 2022 The Gitea Authors).
2
+// Only the java_package / java_multiple_files options are added; message shapes are unmodified.
3
+syntax = "proto3";
4
+
5
+package ping.v1;
6
+
7
+option java_package = "de.workaround.ci.proto.ping.v1";
8
+option java_multiple_files = true;
9
+
10
+import "ping/v1/messages.proto";
11
+
12
+service PingService {
13
+ rpc Ping(PingRequest) returns (PingResponse);
14
+}
ADD
src/main/proto/runner/v1/messages.proto
+139 -0
@@ -0,0 +1,139 @@
1
+// Copied from gitea.com/gitea/actions-proto-def (MIT License, Copyright (c) 2022 The Gitea Authors).
2
+// Only the java_package / java_multiple_files options are added; message shapes are unmodified.
3
+syntax = "proto3";
4
+
5
+package runner.v1;
6
+
7
+option java_package = "de.workaround.ci.proto.runner.v1";
8
+option java_multiple_files = true;
9
+
10
+import "google/protobuf/struct.proto";
11
+import "google/protobuf/timestamp.proto";
12
+
13
+message RegisterRequest {
14
+ string name = 1;
15
+ string token = 2;
16
+ repeated string agent_labels = 3 [deprecated = true];
17
+ repeated string custom_labels = 4 [deprecated = true];
18
+ string version = 5;
19
+ repeated string labels = 6;
20
+ bool ephemeral = 7;
21
+ repeated string capabilities = 8; // Capability flags the runner advertises (e.g. "cancelling").
22
+}
23
+
24
+message RegisterResponse {
25
+ Runner runner = 1;
26
+}
27
+
28
+message DeclareRequest {
29
+ string version = 1;
30
+ repeated string labels = 2;
31
+ repeated string capabilities = 3; // Capability flags the runner advertises (e.g. "cancelling").
32
+}
33
+
34
+message DeclareResponse {
35
+ Runner runner = 1;
36
+}
37
+
38
+message FetchTaskRequest {
39
+ int64 tasks_version = 1; // Runner use `tasks_version` to compare with Gitea and detemine whether new tasks may exist.
40
+}
41
+
42
+message FetchTaskResponse {
43
+ Task task = 1;
44
+ int64 tasks_version = 2; // Gitea informs the Runner of the latest version of tasks through `tasks_version`.
45
+}
46
+
47
+message UpdateTaskRequest {
48
+ TaskState state = 1;
49
+ map<string, string> outputs = 2; // The outputs of the task. Since the outputs may be large, the client does not need to send all outputs every time, only the unsent outputs.
50
+}
51
+
52
+message UpdateTaskResponse {
53
+ TaskState state = 1;
54
+ repeated string sent_outputs = 2; // The keys of the outputs that have been sent, not only the ones that have been sent this time, but also those that have been sent before.
55
+}
56
+
57
+message UpdateLogRequest {
58
+ int64 task_id = 1;
59
+ int64 index = 2; // The actual index of the first line.
60
+ repeated LogRow rows = 3;
61
+ bool no_more = 4; // No more logs.
62
+}
63
+
64
+message UpdateLogResponse {
65
+ int64 ack_index = 1; // If all lines are received, should be index + length(lines).
66
+}
67
+
68
+// Runner Payload
69
+message Runner {
70
+ int64 id = 1;
71
+ string uuid = 2;
72
+ string token = 3;
73
+ string name = 4;
74
+ RunnerStatus status = 5;
75
+ repeated string agent_labels = 6 [deprecated = true];
76
+ repeated string custom_labels = 7 [deprecated = true];
77
+ string version = 8;
78
+ repeated string labels = 9;
79
+ bool ephemeral = 10;
80
+}
81
+
82
+// RunnerStatus runner all status
83
+enum RunnerStatus {
84
+ RUNNER_STATUS_UNSPECIFIED = 0;
85
+ RUNNER_STATUS_IDLE = 1;
86
+ RUNNER_STATUS_ACTIVE = 2;
87
+ RUNNER_STATUS_OFFLINE = 3;
88
+}
89
+
90
+// The result of a task or a step, see https://docs.github.com/en/actions/learn-github-actions/contexts#jobs-context .
91
+enum Result {
92
+ RESULT_UNSPECIFIED = 0;
93
+ RESULT_SUCCESS = 1;
94
+ RESULT_FAILURE = 2;
95
+ RESULT_CANCELLED = 3;
96
+ RESULT_SKIPPED = 4;
97
+}
98
+
99
+// Task represents a task.
100
+message Task {
101
+ int64 id = 1; // A unique number for each workflow run, unlike run_id or job_id, task_id never be reused.
102
+ optional bytes workflow_payload = 2; // The content of the expanded workflow yaml file.
103
+ optional google.protobuf.Struct context = 3; // See https://docs.github.com/en/actions/learn-github-actions/contexts#github-context .
104
+ map<string, string> secrets = 4; // See https://docs.github.com/en/actions/learn-github-actions/contexts#secrets-context .
105
+ string machine = 5 [deprecated = true]; // Unused.
106
+ map<string, TaskNeed> needs = 6; // See https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context .
107
+ map<string, string> vars = 7; // See https://docs.github.com/en/actions/learn-github-actions/contexts#vars-context .
108
+}
109
+
110
+// TaskNeed represents a task need.
111
+message TaskNeed {
112
+ map<string, string> outputs = 1; // The set of outputs of a job that the current job depends on.
113
+ Result result = 2; // The result of a job that the current job depends on. Possible values are success, failure, cancelled, or skipped.
114
+}
115
+
116
+// TaskState represents the state of a task.
117
+message TaskState {
118
+ int64 id = 1;
119
+ Result result = 2;
120
+ google.protobuf.Timestamp started_at = 3;
121
+ google.protobuf.Timestamp stopped_at = 4;
122
+ repeated StepState steps = 5;
123
+}
124
+
125
+// TaskState represents the state of a step.
126
+message StepState {
127
+ int64 id = 1;
128
+ Result result = 2;
129
+ google.protobuf.Timestamp started_at = 3;
130
+ google.protobuf.Timestamp stopped_at = 4;
131
+ int64 log_index = 5; // Where the first line log of the step.
132
+ int64 log_length = 6; // How many logs the step has.
133
+}
134
+
135
+// LogRow represents a row of logs.
136
+message LogRow {
137
+ google.protobuf.Timestamp time = 1;
138
+ string content = 2;
139
+}
ADD
src/main/proto/runner/v1/services.proto
+23 -0
@@ -0,0 +1,23 @@
1
+// Copied from gitea.com/gitea/actions-proto-def (MIT License, Copyright (c) 2022 The Gitea Authors).
2
+// Only the java_package / java_multiple_files options are added; message shapes are unmodified.
3
+syntax = "proto3";
4
+
5
+package runner.v1;
6
+
7
+option java_package = "de.workaround.ci.proto.runner.v1";
8
+option java_multiple_files = true;
9
+
10
+import "runner/v1/messages.proto";
11
+
12
+service RunnerService {
13
+ // Register register a new runner in server.
14
+ rpc Register(RegisterRequest) returns (RegisterResponse) {}
15
+ // Declare declare runner's version and labels to Gitea before starting fetching task.
16
+ rpc Declare(DeclareRequest) returns (DeclareResponse) {}
17
+ // FetchTask requests the next available task for execution.
18
+ rpc FetchTask(FetchTaskRequest) returns (FetchTaskResponse) {}
19
+ // UpdateTask updates the task status.
20
+ rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse) {}
21
+ // UpdateLog uploads log of the task.
22
+ rpc UpdateLog(UpdateLogRequest) returns (UpdateLogResponse) {}
23
+}
MODIFY
src/main/resources/application.properties
+8 -2
@@ -74,10 +74,16 @@
74
74
%dev.gitshark.dev.seed-data=true
75
75
%dev.gitshark.dev.adopt-username=true
76
76
77
-# Pages that require a logged-in user (git transport does its own auth)
78
-quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login,/onboarding,/following,/following/*
77
+# Pages that require a logged-in user (git transport does its own auth). The runner Connect
78
+# endpoints under /api/actions/* stay public: runners authenticate with their own uuid/token.
79
+quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login,/onboarding,/following,/following/*,/admin,/admin/*
79
80
quarkus.http.auth.permission.authenticated.policy=authenticated
80
81
82
+# Instance administrators: comma-separated handles allowed into /admin/* (runner management, etc.).
83
+# Empty in production unless set; dev/test grant the seeded alice for convenience.
84
+gitshark.admin.handles=${GITSHARK_ADMIN_HANDLES:}
85
+%dev,test.gitshark.admin.handles=alice
86
+
81
87
# Native image: JGit holds static Random/lazy state that must not be build-time initialized
82
88
quarkus.native.additional-build-args=--initialize-at-run-time=org.eclipse.jgit,--initialize-at-run-time=org.apache.sshd.common.random,--initialize-at-run-time=org.apache.sshd.common.util.security.bouncycastle.BouncyCastleEncryptedPrivateKeyInfoDecryptor
83
89
# Register BouncyCastle at build time: SSHD's runtime registrar (Security.addProvider via reflection)
ADD
src/main/resources/db/migration/V19__ci_runners.sql
+34 -0
@@ -0,0 +1,34 @@
1
+-- CI/CD runners (issue #2, phase 1 MVP): registration + presence only. The run loop
2
+-- (action_run/action_task/action_log) arrives in later phases.
3
+--
4
+-- Runners speak the Forgejo/Gitea runner.v1 Connect protocol. A runner registers once with a
5
+-- shared registration token and receives a permanent per-runner uuid + secret; only the SHA-256
6
+-- hash of each secret is stored (same model as access_tokens).
7
+
8
+create table ci_runner_registration_token
9
+(
10
+ id uuid primary key,
11
+ token_hash varchar(128) not null unique,
12
+ created_by_id uuid references users (id) on delete set null,
13
+ created_at timestamptz not null default now(),
14
+ last_used timestamptz
15
+);
16
+
17
+create table ci_runner
18
+(
19
+ id uuid primary key,
20
+ -- The uuid the runner presents in the x-runner-uuid header on every post-registration call.
21
+ uuid varchar(64) not null unique,
22
+ token_hash varchar(128) not null,
23
+ name text not null,
24
+ -- Comma-joined label set advertised via Register/Declare (empty string when none).
25
+ labels text not null default '',
26
+ version text,
27
+ status varchar(32) not null default 'IDLE'
28
+ check (status in ('UNSPECIFIED', 'IDLE', 'ACTIVE', 'OFFLINE')),
29
+ ephemeral boolean not null default false,
30
+ last_seen timestamptz,
31
+ created_at timestamptz not null default now()
32
+);
33
+
34
+create index idx_ci_runner_uuid on ci_runner (uuid);
ADD
src/main/resources/templates/AdminRunnerResource/runners.html
+54 -0
@@ -0,0 +1,54 @@
1
+{#include layout}
2
+{#title}CI runners – git-shark{/title}
3
+<h1>CI runners</h1>
4
+
5
+<p>Runners execute repository workflows. git-shark speaks the Forgejo/Gitea runner protocol, so a
6
+stock <code>forgejo-runner</code> or <code>act_runner</code> registers against this instance
7
+unchanged. Generate a registration token below, then run:</p>
8
+<pre class="token-line"><code>forgejo-runner register --no-interactive --instance <this instance URL> --token <registration token></code></pre>
9
+
10
+<h2>Registered runners</h2>
11
+<table>
12
+ <tr><th>Name</th><th>Status</th><th>Labels</th><th>Version</th><th>Last seen</th><th class="actions"></th></tr>
13
+ {#for runner in runners}
14
+ <tr>
15
+ <td>{runner.name}</td>
16
+ <td>{runner.status}</td>
17
+ <td>{runner.labels ?: '—'}</td>
18
+ <td>{runner.version ?: '—'}</td>
19
+ <td>{runner.lastSeen ?: 'never'}</td>
20
+ <td class="actions">
21
+ <form class="inline" method="post" action="/admin/runners/{runner.id}/delete">
22
+ <button class="btn btn-danger btn-sm">Delete</button>
23
+ </form>
24
+ </td>
25
+ </tr>
26
+ {#else}
27
+ <tr><td colspan="6">No runners registered yet.</td></tr>
28
+ {/for}
29
+</table>
30
+
31
+<h2>Registration tokens</h2>
32
+<p>Registration tokens are reusable and instance-scoped. Delete a token to stop it registering new
33
+runners; runners already registered keep working.</p>
34
+<table>
35
+ <tr><th>Created</th><th>Created by</th><th>Last used</th><th class="actions"></th></tr>
36
+ {#for token in tokens}
37
+ <tr>
38
+ <td>{token.createdAt}</td>
39
+ <td>{#if token.createdBy}{token.createdBy.username}{#else}—{/if}</td>
40
+ <td>{token.lastUsed ?: 'never'}</td>
41
+ <td class="actions">
42
+ <form class="inline" method="post" action="/admin/runners/tokens/{token.id}/delete">
43
+ <button class="btn btn-danger btn-sm">Delete</button>
44
+ </form>
45
+ </td>
46
+ </tr>
47
+ {#else}
48
+ <tr><td colspan="4">No registration tokens yet.</td></tr>
49
+ {/for}
50
+</table>
51
+<form method="post" action="/admin/runners/tokens">
52
+ <button class="btn btn-primary">Generate registration token</button>
53
+</form>
54
+{/include}
ADD
src/main/resources/templates/AdminRunnerResource/tokenCreated.html
+9 -0
@@ -0,0 +1,9 @@
1
+{#include layout}
2
+{#title}Registration token created – git-shark{/title}
3
+<h1>Registration token created</h1>
4
+<p>Copy this runner registration token now — it will not be shown again:</p>
5
+<pre class="token-line"><code>{plaintext}</code><button type="button" class="btn-icon copy-btn" data-copy="{plaintext}" aria-label="Copy registration token" title="Copy">⧉</button></pre>
6
+<p>Register a runner with it:</p>
7
+<pre class="token-line"><code>forgejo-runner register --no-interactive --instance <this instance URL> --token {plaintext}</code></pre>
8
+<p><a class="btn btn-secondary" href="/admin/runners">Back to runners</a></p>
9
+{/include}
MODIFY
src/main/resources/templates/layout.html
+1 -0
@@ -24,6 +24,7 @@
24
24
<a href="/settings/profile">{#avatar user=cdi:currentUser.get /} Profile</a>
25
25
<a href="/settings/keys">SSH keys</a>
26
26
<a href="/settings/tokens">Access tokens</a>
27
+ {#if cdi:currentUser.isAdmin}<a href="/admin/runners">CI runners</a>{/if}
27
28
<form class="logout" method="post" action="/logout"><button type="submit">Logout</button></form>
28
29
</div>
29
30
</details>
ADD
src/test/java/de/workaround/account/AdminAccessTest.java
+43 -0
@@ -0,0 +1,43 @@
1
+package de.workaround.account;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import de.workaround.git.ForbiddenOperationException;
6
+import de.workaround.model.User;
7
+import io.quarkus.test.junit.QuarkusTest;
8
+import jakarta.inject.Inject;
9
+
10
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
11
+import static org.junit.jupiter.api.Assertions.assertFalse;
12
+import static org.junit.jupiter.api.Assertions.assertThrows;
13
+import static org.junit.jupiter.api.Assertions.assertTrue;
14
+
15
+@QuarkusTest
16
+class AdminAccessTest
17
+{
18
+ @Inject
19
+ AdminAccess adminAccess;
20
+
21
+ @Test
22
+ void configuredHandleIsAdmin()
23
+ {
24
+ // test profile sets gitshark.admin.handles=alice
25
+ assertTrue(adminAccess.isAdmin(user("alice")));
26
+ assertDoesNotThrow(() -> adminAccess.require(user("alice")));
27
+ }
28
+
29
+ @Test
30
+ void otherHandlesAreNotAdmin()
31
+ {
32
+ assertFalse(adminAccess.isAdmin(user("bob")));
33
+ assertFalse(adminAccess.isAdmin(null));
34
+ assertThrows(ForbiddenOperationException.class, () -> adminAccess.require(user("bob")));
35
+ }
36
+
37
+ private static User user(String username)
38
+ {
39
+ User user = new User();
40
+ user.username = username;
41
+ return user;
42
+ }
43
+}
ADD
src/test/java/de/workaround/ci/ConnectRunnerResourceTest.java
+145 -0
@@ -0,0 +1,145 @@
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 com.google.protobuf.InvalidProtocolBufferException;
9
+
10
+import de.workaround.ci.proto.ping.v1.PingRequest;
11
+import de.workaround.ci.proto.ping.v1.PingResponse;
12
+import de.workaround.ci.proto.runner.v1.DeclareRequest;
13
+import de.workaround.ci.proto.runner.v1.DeclareResponse;
14
+import de.workaround.ci.proto.runner.v1.RegisterRequest;
15
+import de.workaround.ci.proto.runner.v1.RegisterResponse;
16
+import de.workaround.ci.proto.runner.v1.RunnerStatus;
17
+import de.workaround.model.User;
18
+import io.quarkus.test.junit.QuarkusTest;
19
+import jakarta.inject.Inject;
20
+import jakarta.transaction.Transactional;
21
+
22
+import static io.restassured.RestAssured.given;
23
+import static org.junit.jupiter.api.Assertions.assertEquals;
24
+import static org.junit.jupiter.api.Assertions.assertFalse;
25
+import static org.junit.jupiter.api.Assertions.assertTrue;
26
+
27
+/**
28
+ * Drives the runner.v1 Connect endpoints exactly as a stock forgejo-runner would: serialized
29
+ * protobuf over HTTP POST to {@code /api/actions/...}. A real runner binary round-trip is deferred
30
+ * to a later phase; here a hand-built protobuf client stands in for it.
31
+ */
32
+@QuarkusTest
33
+class ConnectRunnerResourceTest
34
+{
35
+ private static final String PROTO = "application/proto";
36
+
37
+ @Inject
38
+ RunnerRegistrationService runnerService;
39
+
40
+ @Inject
41
+ User.Repo userRepo;
42
+
43
+ @Test
44
+ void pingIsAnswered() throws InvalidProtocolBufferException
45
+ {
46
+ byte[] responseBytes = given()
47
+ .contentType(PROTO)
48
+ .body(PingRequest.newBuilder().setData("shark").build().toByteArray())
49
+ .when().post("/api/actions/ping.v1.PingService/Ping")
50
+ .then().statusCode(200)
51
+ .extract().asByteArray();
52
+
53
+ PingResponse response = PingResponse.parseFrom(responseBytes);
54
+ assertEquals("Hello, shark", response.getData());
55
+ }
56
+
57
+ @Test
58
+ void registerThenDeclareOverConnectRpc() throws InvalidProtocolBufferException
59
+ {
60
+ String regToken = mintRegistrationToken();
61
+
62
+ byte[] registerBytes = given()
63
+ .contentType(PROTO)
64
+ .body(RegisterRequest.newBuilder()
65
+ .setToken(regToken)
66
+ .setName("connect-runner")
67
+ .addLabels("ubuntu-latest")
68
+ .setVersion("v4.0.0")
69
+ .build().toByteArray())
70
+ .when().post("/api/actions/runner.v1.RunnerService/Register")
71
+ .then().statusCode(200)
72
+ .extract().asByteArray();
73
+
74
+ RegisterResponse register = RegisterResponse.parseFrom(registerBytes);
75
+ assertFalse(register.getRunner().getUuid().isBlank(), "runner assigned a uuid");
76
+ assertFalse(register.getRunner().getToken().isBlank(), "runner secret delivered once");
77
+ assertEquals(RunnerStatus.RUNNER_STATUS_IDLE, register.getRunner().getStatus());
78
+ assertEquals(List.of("ubuntu-latest"), register.getRunner().getLabelsList());
79
+
80
+ String uuid = register.getRunner().getUuid();
81
+ String secret = register.getRunner().getToken();
82
+
83
+ byte[] declareBytes = given()
84
+ .contentType(PROTO)
85
+ .header("x-runner-uuid", uuid)
86
+ .header("x-runner-token", secret)
87
+ .body(DeclareRequest.newBuilder()
88
+ .setVersion("v4.1.0")
89
+ .addLabels("ubuntu-latest")
90
+ .addLabels("arm64")
91
+ .build().toByteArray())
92
+ .when().post("/api/actions/runner.v1.RunnerService/Declare")
93
+ .then().statusCode(200)
94
+ .extract().asByteArray();
95
+
96
+ DeclareResponse declare = DeclareResponse.parseFrom(declareBytes);
97
+ assertEquals("v4.1.0", declare.getRunner().getVersion());
98
+ assertEquals(List.of("ubuntu-latest", "arm64"), declare.getRunner().getLabelsList());
99
+ assertTrue(declare.getRunner().getToken().isEmpty(), "Declare must not resend the secret");
100
+ }
101
+
102
+ @Test
103
+ void registerWithBadTokenReturnsUnauthenticated()
104
+ {
105
+ given()
106
+ .contentType(PROTO)
107
+ .body(RegisterRequest.newBuilder().setToken("gsr_bogus").setName("x").build().toByteArray())
108
+ .when().post("/api/actions/runner.v1.RunnerService/Register")
109
+ .then().statusCode(401);
110
+ }
111
+
112
+ @Test
113
+ void declareWithBadTokenReturnsUnauthenticated()
114
+ {
115
+ given()
116
+ .contentType(PROTO)
117
+ .header("x-runner-uuid", "does-not-exist")
118
+ .header("x-runner-token", "gsrt_bogus")
119
+ .body(DeclareRequest.newBuilder().setVersion("v1").build().toByteArray())
120
+ .when().post("/api/actions/runner.v1.RunnerService/Declare")
121
+ .then().statusCode(401);
122
+ }
123
+
124
+ private String mintRegistrationToken()
125
+ {
126
+ return runnerService.createRegistrationToken(persistAdmin()).plaintext();
127
+ }
128
+
129
+ @Transactional
130
+ User persistAdmin()
131
+ {
132
+ String name = "runner-admin-" + UUID.randomUUID();
133
+ User existing = userRepo.findByOidcSubOptional(name).orElse(null);
134
+ if (existing != null)
135
+ {
136
+ return existing;
137
+ }
138
+ User user = new User();
139
+ user.oidcSub = name;
140
+ user.username = name;
141
+ user.persist();
142
+ return user;
143
+ }
144
+
145
+}
ADD
src/test/java/de/workaround/ci/RunnerRegistrationServiceTest.java
+121 -0
@@ -0,0 +1,121 @@
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.model.CiRunner;
9
+import de.workaround.model.User;
10
+import io.quarkus.test.TestTransaction;
11
+import io.quarkus.test.junit.QuarkusTest;
12
+import jakarta.inject.Inject;
13
+
14
+import static org.junit.jupiter.api.Assertions.assertEquals;
15
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
16
+import static org.junit.jupiter.api.Assertions.assertNotNull;
17
+import static org.junit.jupiter.api.Assertions.assertNull;
18
+import static org.junit.jupiter.api.Assertions.assertThrows;
19
+
20
+@QuarkusTest
21
+class RunnerRegistrationServiceTest
22
+{
23
+ @Inject
24
+ RunnerRegistrationService service;
25
+
26
+ @Inject
27
+ CiRunner.Repo runners;
28
+
29
+ @Test
30
+ @TestTransaction
31
+ void registrationTokenReturnsPlaintextOnceAndStoresOnlyHash()
32
+ {
33
+ User admin = persistUser();
34
+
35
+ RunnerRegistrationService.CreatedRegistrationToken created = service.createRegistrationToken(admin);
36
+
37
+ assertNotNull(created.plaintext());
38
+ assertNotEquals(created.plaintext(), created.token().tokenHash, "plaintext must never be persisted");
39
+ assertEquals(service.hash(created.plaintext()), created.token().tokenHash);
40
+ }
41
+
42
+ @Test
43
+ @TestTransaction
44
+ void registerWithValidTokenCreatesIdleRunnerAndReturnsSecretOnce()
45
+ {
46
+ User admin = persistUser();
47
+ String regToken = service.createRegistrationToken(admin).plaintext();
48
+
49
+ RunnerRegistrationService.RegisteredRunner reg =
50
+ service.register(regToken, "builder-1", List.of("ubuntu-latest", "docker"), "v4.0.0", false);
51
+
52
+ CiRunner runner = reg.runner();
53
+ assertEquals(CiRunner.Status.IDLE, runner.status);
54
+ assertEquals("builder-1", runner.name);
55
+ assertEquals("ubuntu-latest,docker", runner.labels);
56
+ assertNotNull(runner.uuid);
57
+ assertNotNull(reg.plaintext());
58
+ assertNotEquals(reg.plaintext(), runner.tokenHash, "runner secret plaintext must never be persisted");
59
+ assertEquals(service.hash(reg.plaintext()), runner.tokenHash);
60
+ }
61
+
62
+ @Test
63
+ @TestTransaction
64
+ void registerWithUnknownTokenIsRejected()
65
+ {
66
+ assertThrows(InvalidRegistrationTokenException.class,
67
+ () -> service.register("gsr_nope", "x", List.of(), "v1", false));
68
+ }
69
+
70
+ @Test
71
+ @TestTransaction
72
+ void declareWithValidCredentialsUpdatesVersionAndLabels()
73
+ {
74
+ User admin = persistUser();
75
+ String regToken = service.createRegistrationToken(admin).plaintext();
76
+ RunnerRegistrationService.RegisteredRunner reg = service.register(regToken, "b", List.of("old"), "v1", false);
77
+
78
+ CiRunner declared = service.declare(reg.runner().uuid, reg.plaintext(), "v4.1.0", List.of("ubuntu", "arm64"));
79
+
80
+ assertEquals("v4.1.0", declared.version);
81
+ assertEquals("ubuntu,arm64", declared.labels);
82
+ assertNotNull(declared.lastSeen);
83
+ }
84
+
85
+ @Test
86
+ @TestTransaction
87
+ void declareWithWrongTokenIsRejected()
88
+ {
89
+ User admin = persistUser();
90
+ String regToken = service.createRegistrationToken(admin).plaintext();
91
+ RunnerRegistrationService.RegisteredRunner reg = service.register(regToken, "b", List.of(), "v1", false);
92
+
93
+ assertThrows(RunnerAuthenticationException.class,
94
+ () -> service.declare(reg.runner().uuid, "gsrt_wrong", "v2", List.of()));
95
+ }
96
+
97
+ @Test
98
+ @TestTransaction
99
+ void deleteRemovesRunner()
100
+ {
101
+ User admin = persistUser();
102
+ String regToken = service.createRegistrationToken(admin).plaintext();
103
+ RunnerRegistrationService.RegisteredRunner reg = service.register(regToken, "b", List.of(), "v1", false);
104
+ UUID id = reg.runner().id;
105
+
106
+ service.delete(id);
107
+
108
+ assertNull(runners.findById(id));
109
+ }
110
+
111
+ private static User persistUser()
112
+ {
113
+ String name = "admin-" + UUID.randomUUID();
114
+ User user = new User();
115
+ user.oidcSub = "sub-" + name;
116
+ user.username = name;
117
+ user.persist();
118
+ return user;
119
+ }
120
+
121
+}