gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (ci-runners): Add runner registration via runner.v1 Connect protocol

a6f34bfc237ec53d9cea341777c677a46ff9cec1 · Michael Hainz · 2026-07-16T17:46:07Z

Changes

29 files changed, +1449 -3

MODIFY README.md +7 -0
diff --git a/README.md b/README.md
index 8f271b4..117f195 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 39141dc..26bd534 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
new file mode 100644
index 0000000..a7d59ef
--- /dev/null
+++ b/docs/admins/ci-runners.md
@@ -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
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index 563f9d5..3cd897b 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -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
diff --git a/docs/admins/persistent-data.md b/docs/admins/persistent-data.md
index 33a8283..fbdd276 100644
--- a/docs/admins/persistent-data.md
+++ b/docs/admins/persistent-data.md
@@ -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
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
new file mode 100644
index 0000000..5b4d03b
--- /dev/null
+++ b/docs/maintainers/ci-runners.md
@@ -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
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
new file mode 100644
index 0000000..683325a
--- /dev/null
+++ b/docs/users/ci-runners.md
@@ -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
diff --git a/pom.xml b/pom.xml
index 38ee007..448e0d4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -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
diff --git a/src/main/java/de/workaround/account/AdminAccess.java b/src/main/java/de/workaround/account/AdminAccess.java
new file mode 100644
index 0000000..4ab3dce
--- /dev/null
+++ b/src/main/java/de/workaround/account/AdminAccess.java
@@ -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
diff --git a/src/main/java/de/workaround/account/CurrentUser.java b/src/main/java/de/workaround/account/CurrentUser.java
index 43f4f54..31474c7 100644
--- a/src/main/java/de/workaround/account/CurrentUser.java
+++ b/src/main/java/de/workaround/account/CurrentUser.java
@@ -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
diff --git a/src/main/java/de/workaround/ci/AdminRunnerResource.java b/src/main/java/de/workaround/ci/AdminRunnerResource.java
new file mode 100644
index 0000000..9ad7978
--- /dev/null
+++ b/src/main/java/de/workaround/ci/AdminRunnerResource.java
@@ -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
diff --git a/src/main/java/de/workaround/ci/ConnectRunnerResource.java b/src/main/java/de/workaround/ci/ConnectRunnerResource.java
new file mode 100644
index 0000000..41b41b2
--- /dev/null
+++ b/src/main/java/de/workaround/ci/ConnectRunnerResource.java
@@ -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
diff --git a/src/main/java/de/workaround/ci/InvalidRegistrationTokenException.java b/src/main/java/de/workaround/ci/InvalidRegistrationTokenException.java
new file mode 100644
index 0000000..46425b5
--- /dev/null
+++ b/src/main/java/de/workaround/ci/InvalidRegistrationTokenException.java
@@ -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
diff --git a/src/main/java/de/workaround/ci/RunnerAuthenticationException.java b/src/main/java/de/workaround/ci/RunnerAuthenticationException.java
new file mode 100644
index 0000000..96360d6
--- /dev/null
+++ b/src/main/java/de/workaround/ci/RunnerAuthenticationException.java
@@ -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
diff --git a/src/main/java/de/workaround/ci/RunnerRegistrationService.java b/src/main/java/de/workaround/ci/RunnerRegistrationService.java
new file mode 100644
index 0000000..83e8786
--- /dev/null
+++ b/src/main/java/de/workaround/ci/RunnerRegistrationService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/CiRunner.java b/src/main/java/de/workaround/model/CiRunner.java
new file mode 100644
index 0000000..5ca38f4
--- /dev/null
+++ b/src/main/java/de/workaround/model/CiRunner.java
@@ -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
diff --git a/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java b/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
new file mode 100644
index 0000000..d22e7eb
--- /dev/null
+++ b/src/main/java/de/workaround/model/CiRunnerRegistrationToken.java
@@ -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
diff --git a/src/main/proto/ping/v1/messages.proto b/src/main/proto/ping/v1/messages.proto
new file mode 100644
index 0000000..d8a0f25
--- /dev/null
+++ b/src/main/proto/ping/v1/messages.proto
@@ -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
diff --git a/src/main/proto/ping/v1/services.proto b/src/main/proto/ping/v1/services.proto
new file mode 100644
index 0000000..f29848d
--- /dev/null
+++ b/src/main/proto/ping/v1/services.proto
@@ -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
diff --git a/src/main/proto/runner/v1/messages.proto b/src/main/proto/runner/v1/messages.proto
new file mode 100644
index 0000000..fb271a4
--- /dev/null
+++ b/src/main/proto/runner/v1/messages.proto
@@ -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
diff --git a/src/main/proto/runner/v1/services.proto b/src/main/proto/runner/v1/services.proto
new file mode 100644
index 0000000..4c18cba
--- /dev/null
+++ b/src/main/proto/runner/v1/services.proto
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 231e1c4..df329cf 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/db/migration/V19__ci_runners.sql b/src/main/resources/db/migration/V19__ci_runners.sql
new file mode 100644
index 0000000..94ffe5e
--- /dev/null
+++ b/src/main/resources/db/migration/V19__ci_runners.sql
@@ -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
diff --git a/src/main/resources/templates/AdminRunnerResource/runners.html b/src/main/resources/templates/AdminRunnerResource/runners.html
new file mode 100644
index 0000000..a414663
--- /dev/null
+++ b/src/main/resources/templates/AdminRunnerResource/runners.html
@@ -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 &lt;this instance URL&gt; --token &lt;registration token&gt;</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
diff --git a/src/main/resources/templates/AdminRunnerResource/tokenCreated.html b/src/main/resources/templates/AdminRunnerResource/tokenCreated.html
new file mode 100644
index 0000000..87de8e1
--- /dev/null
+++ b/src/main/resources/templates/AdminRunnerResource/tokenCreated.html
@@ -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 &lt;this instance URL&gt; --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
diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html
index 318abff..72d9995 100644
--- a/src/main/resources/templates/layout.html
+++ b/src/main/resources/templates/layout.html
@@ -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
diff --git a/src/test/java/de/workaround/account/AdminAccessTest.java b/src/test/java/de/workaround/account/AdminAccessTest.java
new file mode 100644
index 0000000..52a5663
--- /dev/null
+++ b/src/test/java/de/workaround/account/AdminAccessTest.java
@@ -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
diff --git a/src/test/java/de/workaround/ci/ConnectRunnerResourceTest.java b/src/test/java/de/workaround/ci/ConnectRunnerResourceTest.java
new file mode 100644
index 0000000..07655a1
--- /dev/null
+++ b/src/test/java/de/workaround/ci/ConnectRunnerResourceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/ci/RunnerRegistrationServiceTest.java b/src/test/java/de/workaround/ci/RunnerRegistrationServiceTest.java
new file mode 100644
index 0000000..e9f56bf
--- /dev/null
+++ b/src/test/java/de/workaround/ci/RunnerRegistrationServiceTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog