commit 5024bde91c84d3b4a6cf311547601d9145044180
parent 1d4ab12fa21063f35d29cd930ee5db37521d9742
Author: Arjoonn Sharma <arjoonn@midpathsoftware.com>
Date: Fri, 22 May 2026 10:36:14 +0000
runner example (!16)
Reviewed-on: https://gitea.midpathsoftware.com/midpath/jayporeci/pulls/16
Co-authored-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>
Co-committed-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>
Diffstat:
18 files changed, 993 insertions(+), 576 deletions(-)
diff --git a/ARCH.md b/ARCH.md
@@ -1,367 +0,0 @@
-# JCI Architecture
-
-JCI is a local-first CI system. CI results are stored directly inside the git repository
-as regular git objects under custom refs, so they travel with the repo on push/pull.
-`git jci server` and `git jci runner` extend this with a webhook-driven, distributed
-CI execution layer — the server is a thin coordination point; all actual CI work happens
-inside runner-managed Docker containers.
-
----
-
-## Repository layout
-
-```
-cmd/git-jci/main.go entry point, CLI dispatch
-internal/jci/
- run.go execute CI for a commit
- git.go low-level git plumbing helpers
- web.go HTTP server + SPA (single-file, inline)
- push.go push CI refs to a remote
- pull.go fetch CI refs from a remote
- prune.go delete old CI refs locally or on a remote
- cron.go cron subcommands: ls, sync
- server.go coordination server (webhook + runner poll)
- runner.go runner (Docker job dispatch)
-www_jci/ project website (separate, not embedded in the binary)
-```
-
----
-
-## Ref layout inside git
-
-```
-refs/jci-runs/<commit>/<run-id> every run result, including one-off manual runs
-```
-
-Every run — whether triggered manually, by cron, or by the distributed runner — gets a
-unique run ID (`<unix_timestamp>-<4_random_hex_chars>`), so multiple runs on the same
-commit are stored independently and none overwrite another.
-
-Each ref points to a git **commit** whose **tree** holds the CI artefacts:
-
-```
-status.txt "ok" | "err" | "running"
-run.output.txt combined stdout+stderr from .jci/run.sh
-index.html standalone HTML view of the run (generated only if the job
- did not produce its own index.html)
-<anything else> files written by the CI script to $JCI_OUTPUT_DIR
-```
-
-Because these are normal git objects, `git push origin 'refs/jci-runs/*:refs/jci-runs/*'`
-is all it takes to share results with a team.
-
----
-
-## CI execution flow (`git jci run`)
-
-```
-1. GetCurrentCommit() resolve HEAD → commit hash
-2. generateRunID() timestamp + 2 random bytes → unique run ID
-3. check .jci/run.sh exists
-4. mkdir .jci/<commit>/ temporary output directory
-5. write status.txt = "running"
-6. exec bash .jci/run.sh working dir = output dir
- env: JCI_COMMIT, JCI_REPO_ROOT, JCI_OUTPUT_DIR
- stdout+stderr captured to run.output.txt
-7. write status.txt = "ok"|"err"
-8. generateIndexHTML() only if index.html was NOT written by the job
-9. StoreTree() git hash-object each file → git mktree → git commit-tree
- → git update-ref refs/jci-runs/<commit>/<runID>
-10. rm -rf .jci/<commit>/ clean up temp dir
-```
-
-The CI script receives three environment variables and should write any extra
-artefacts into `$JCI_OUTPUT_DIR`. Whatever exists in that directory when the
-script exits is committed to git.
-
----
-
-## Web UI (`git jci web [port]`)
-
-A minimal three-panel SPA served entirely from a single Go handler.
-No external assets; the HTML/CSS/JS is embedded inline in `showMainPage()`.
-
-```
-GET /api/branches list local branch names
-GET /api/commits?branch=&page= paginated commit list with CI status per commit
-GET /api/commit/<hash> commit detail + file list (latest run for that commit)
-GET /api/commit/<hash>/<runId> same but for a specific run
-
-GET /jci/<commit>/<runId>/<file>/raw serve file from refs/jci-runs/<commit>/<runId>
-GET /jci/<commit>/<file>/raw serve file from the latest run for <commit>
-GET /jci/... (other) serve SPA shell (JS handles routing)
-GET / serve SPA shell
-```
-
-The UI keeps a client-side page index for infinite-scroll commit loading
-(`commitsPageSize = 100` per page).
-
----
-
-## Cron integration (`git jci cron`)
-
-**`cron ls`** — shows what is configured in `.jci/crontab` and what is currently
-installed in the user's system crontab.
-
-**`cron sync`** — idempotent sync from `.jci/crontab` → system crontab:
-
-```
-1. parse .jci/crontab 5-field schedule + optional branch:X name:Y
-2. crontab -l read current system crontab
-3. strip lines containing # JCI:<sha256(repoRoot)>
-4. append new lines, one per entry:
- <schedule> cd <repoRoot> && [git checkout <branch> &&] git-jci run # JCI:<id> [<name>]
-5. crontab - install new crontab
-```
-
-Each repo is identified by `sha256(absolute_path)` so entries from different
-repos never collide.
-
----
-
-## Push / Pull
-
-```
-git jci push [remote] discover all local refs/jci-runs/* not on remote → git push each
-git jci pull [remote] git fetch refs/jci-runs/*:refs/jci-runs/*
-```
-
----
-
-## Prune
-
-Removes CI refs to reclaim space. Works on local repo or a remote.
-
-```
-git jci prune [--older-than=<duration>] [--commit]
-git jci prune --on-remote=<remote> [--older-than=<duration>] [--commit]
-```
-
-Duration format: `30d`, `2w`, `6m`, `4h`, or any Go duration string.
-Without `--commit` the command is a dry run.
-After local deletion it runs `git gc --prune=now` to actually free objects.
-
----
-
-## Distributed CI (`git jci server` / `git jci runner`)
-
-Both commands are added to the existing `git-jci` binary and run in the foreground,
-managed by Docker or systemd.
-
-```
-Gitea ──webhook──▶ jci server ◀──poll── jci runner (Docker container)
- │ │
- │ Gitea API │ docker socket
- ▼ ▼
- Gitea job container
- (set status) (git-jci run → git-jci push → Gitea)
-```
-
-### Server (`git jci server`)
-
-#### Configuration (env vars)
-
-```
-GITEA_HOST=gitea.example.com
-GITEA_USER=gitea_service_user
-GITEA_TOKEN=<token> # must have permission to create/delete scoped tokens
-GITEA_WEBHOOK_SECRET=<hmac secret>
-RUNNER_SECRET=<shared secret for runners>
-JCI_MAX_JOBS=4 # max concurrent jobs assignable to a single runner
-JCI_JOB_TIMEOUT=60m # server-wide job timeout (default: 60 minutes)
-```
-
-#### SQLite schema
-
-```sql
--- Known runners; auto-created on first poll.
-CREATE TABLE runners (
- runner_id TEXT PRIMARY KEY,
- last_seen DATETIME NOT NULL
-);
-
--- Active (pending or running) jobs only.
--- Completed/timed-out jobs are deleted immediately.
-CREATE TABLE jobs (
- job_id TEXT PRIMARY KEY, -- UUID
- repo_owner TEXT NOT NULL,
- repo_name TEXT NOT NULL,
- commit_sha TEXT NOT NULL, -- idempotency key
- runner_id TEXT, -- NULL = unassigned
- assigned_at DATETIME,
- expires_at DATETIME, -- assigned_at + JCI_JOB_TIMEOUT
- gitea_token TEXT NOT NULL, -- one-time scoped token for this job
- status_cache TEXT, -- last known status ("pending"|"running"|"success"|"failure")
- cache_until DATETIME -- when status_cache expires (15s TTL)
-);
-```
-
-#### Startup check
-
-On startup the server verifies that `GITEA_TOKEN` has permission to create and delete
-per-repo tokens via the Gitea API. If the check fails, the server exits with a clear
-error. No silent degradation.
-
-#### Webhook handling (`POST /webhook`)
-
-1. Verify HMAC-SHA256 signature using `GITEA_WEBHOOK_SECRET`. Return 400 on failure.
-2. Extract `repo.owner`, `repo.name`, `commit_sha` (from `after` field on push events).
-3. Check `jobs` table for an existing active job with the same `commit_sha`. If found,
- respond 200 and drop — no duplicate jobs.
-4. Insert a new job row with `runner_id = NULL`, `status_cache = "pending"`.
-5. Respond 200 immediately.
-
-#### Runner poll endpoint (`POST /poll`)
-
-Request body: `{ "runner_id": "...", "secret": "..." }`
-
-1. Verify `secret == RUNNER_SECRET`. Return 403 on failure.
-2. Upsert runner row (`runner_id`, `last_seen = now()`). This is auto-registration.
-3. Count active jobs already assigned to this runner.
- - If count >= `JCI_MAX_JOBS`: respond 429 with `Retry-After: 5`.
-4. Poll Gitea for cached status of each assigned job (see *Status polling* below).
- Completed jobs are deleted from the DB.
-5. Pick one unassigned job from the queue (FIFO per-repo, round-robin across repos).
- If none: respond 200 with `{ "job": null }`.
-6. For the selected job:
- a. Create a fresh scoped Gitea token (scoped to that repo, expiry = `now + JCI_JOB_TIMEOUT`).
- b. Set `runner_id`, `assigned_at`, `expires_at`, store token in `jobs.gitea_token`.
- c. Set Gitea commit status to `"pending"`.
-7. Respond 200 with the job payload:
-
-```json
-{
- "job": {
- "job_id": "...",
- "clone_url": "https://<user>:<token>@gitea.example.com/owner/repo",
- "commit_sha": "...",
- "repo_owner": "...",
- "repo_name": "..."
- }
-}
-```
-
-#### Status polling (server → Gitea)
-
-The server checks job status by reading `refs/jci-runs/<commit>/<runID>` on Gitea and
-inspecting `status.txt` in that ref's tree.
-
-- Results are cached per-job for **15 seconds** (`jobs.cache_until`).
-- A check is triggered every time the assigned runner calls `/poll`.
-- A final check is performed at `jobs.expires_at` (60-minute timeout); if still
- unresolved, the job is deleted and Gitea status is set to `"failure"`.
-- On `status.txt = "ok"` or `"err"` the server:
- 1. Sets Gitea commit status to `"success"` or `"failure"`.
- 2. Deletes the one-time token via the Gitea API.
- 3. Deletes the job row from SQLite.
-
-#### Token cleanup
-
-Two-layer cleanup for the one-time Gitea token:
-1. **Gitea-native expiry**: token created with hard expiry at `assigned_at + 60m`.
-2. **Server-side deletion**: explicit delete via Gitea API on job completion or timeout.
-
-This ensures the token cannot be used beyond 60 minutes even if the server crashes.
-
----
-
-### Runner (`git jci runner`)
-
-#### Configuration (env vars)
-
-```
-JCI_SERVER=https://jci.example.com
-JCI_RUNNER_SECRET=<shared secret>
-```
-
-#### Persistent state
-
-A SQLite database at a fixed path on a mounted volume:
-
-```sql
-CREATE TABLE identity (
- runner_id TEXT PRIMARY KEY -- generated once, reused across restarts
-);
-```
-
-#### Poll loop
-
-```
-every 5s + random jitter (0–2s):
- POST /poll → { runner_id, secret }
- if 429: wait Retry-After seconds, then continue
- if job == null: continue
- if job != null: dispatch(job) # non-blocking; runner returns to poll immediately
-```
-
-#### Job dispatch
-
-For each received job the runner:
-
-1. **Pulls the `git-jci` binary** from a known location (e.g. mounted at
- `/usr/local/bin/git-jci` in the runner container) to inject into the job container.
-2. **Starts a detached job container**:
- ```
- docker run --rm -d \
- -v /usr/local/bin/git-jci:/usr/local/bin/git-jci:ro \
- -e JCI_COMMIT=<commit_sha> \
- --label jci-job=y \
- --label jci-job-timeout=60m \
- <image from job config> \
- /bin/sh -c "
- git clone --depth=1 --branch <commit> <clone_url> /repo &&
- cd /repo &&
- git-jci run
- git-jci push # always runs, even if run failed, to commit status.txt
- "
- ```
- `clone_url` embeds the one-time credentials, so `git push` (via `git-jci push`)
- is transparent — `origin` is already set correctly by the clone.
-3. **Tracks the container ID** in memory (not persisted — runner crash means the
- container runs to completion or is reaped by Docker's own restart policy).
-
-The job container never talks to the `jci server` directly.
-
-#### Container reaping
-
-The runner periodically checks for containers labelled `jci-job=y` that have been
-running longer than the configured timeout and kills them via `docker rm -f`. This
-prevents capacity leaks if a job container hangs.
-
----
-
-## End-to-end distributed flow
-
-```
-1. Dev pushes to Gitea
-2. Gitea sends webhook → POST /webhook on jci server
-3. Server verifies HMAC, deduplicates on commit SHA, inserts job (status=pending)
-4. Runner polls → POST /poll
-5. Server creates scoped one-time Gitea token, assigns job, sets Gitea status = "pending"
-6. Server responds with job payload (clone URL with embedded token)
-7. Runner starts job container (detached), returns to poll loop
-8. Job container: clone → git-jci run → git-jci push (pushes refs/jci-runs/* to Gitea)
-9. Runner polls again (5s + jitter)
-10. Server checks Gitea for status.txt in refs/jci-runs/<commit>/<runID> (15s cache)
-11. Server sees "ok"/"err" → sets Gitea commit status → deletes token → deletes job row
-12. Runner poll returns 429 if JCI_MAX_JOBS reached; backs off via Retry-After
-```
-
----
-
-## Key design constraints
-
-- **No external dependencies** — pure Go stdlib + git CLI + SQLite (driver only for
- server/runner). No separate storage service.
-- **Results live in the repo** — CI artefacts are normal git objects under
- `refs/jci-runs/*`; they travel with the repo on push/pull.
-- **Runner is pull-only** — server never initiates contact with a runner; no runner
- address is stored.
-- **All Gitea API interaction is server-only** — runner uses only plain `git` commands
- (clone/push); the coordination layer is opaque to it.
-- **One-time credentials per job** — scoped to one repo, expire in 60 minutes via both
- Gitea-native expiry and explicit server-side deletion.
-- **Stateless job containers** — no volumes; crash of a container loses only that run.
-- **Artefacts are arbitrary files** — anything written to `$JCI_OUTPUT_DIR` is stored.
-- **Single binary** — `git-jci` placed on `$PATH` becomes available as `git jci`.
-- **Server runs in foreground** — managed by Docker or systemd; no self-daemonization.
diff --git a/README.md b/README.md
@@ -13,6 +13,7 @@
- [`git jci server`](#git-jci-server)
- [`git jci runner`](#git-jci-runner)
- [End-to-end flow](#end-to-end-flow)
+- [Design constraints](#design-constraints)
- [How it works](#how-it-works)
- [Viewing results](#viewing-results)
- [Sharing results with teammates](#sharing-results-with-teammates)
@@ -33,6 +34,7 @@
- [Downstream Trigger](#downstream-trigger)
- [Jekyll Netlify](#jekyll-netlify)
- [Docusaurus S3](#docusaurus-s3)
+ - [With Server And Runner](#with-server-and-runner)
---
@@ -108,7 +110,7 @@ Your `run.sh` script receives these environment variables:
| `JCI_REPO_ROOT` | Absolute path to the repository root |
| `JCI_OUTPUT_DIR` | Absolute path to the output directory for this run |
-All three are always set. The script's initial working directory is `JCI_REPO_ROOT`. Any files written to `JCI_OUTPUT_DIR` become CI artifacts , browsable and downloadable via `git jci web`. The output directory is a temporary directory that is cleaned up after its contents are stored in git.
+All three are always set. The script's initial working directory is `JCI_OUTPUT_DIR`. Any files written to `JCI_OUTPUT_DIR` become CI artifacts , browsable and downloadable via `git jci web`. The output directory is a temporary directory that is cleaned up after its contents are stored in git.
> **Non-zero exit = failure.** If `run.sh` exits with a non-zero code, the run is marked as failed (`✗ err`). The artifacts are still saved and viewable.
@@ -179,9 +181,9 @@ queues jobs in a local SQLite database, and hands them to runners on demand.
|---|---|
| `GITEA_HOST` | Gitea hostname (e.g. `gitea.example.com`) |
| `GITEA_USER` | Service account username |
-| `GITEA_TOKEN` | Token with permission to create/delete scoped tokens |
+| `GITEA_TOKEN` | Token with read access to the target repos |
| `GITEA_WEBHOOK_SECRET` | HMAC secret shared with the Gitea webhook |
-| `RUNNER_SECRET` | Shared secret runners must present when polling |
+| `JCI_RUNNER_SECRET` | Shared secret runners must present when polling |
| `JCI_MAX_JOBS` | Max concurrent jobs per runner (default: `4`) |
| `JCI_JOB_TIMEOUT` | Server-wide job timeout (default: `60m`) |
@@ -192,11 +194,15 @@ queues jobs in a local SQLite database, and hands them to runners on demand.
| `POST /webhook` | Receives Gitea push events; verifies HMAC, enqueues job |
| `POST /poll` | Runner poll: returns next job or `null`; auto-registers new runners |
-On startup the server verifies that `GITEA_TOKEN` can create and delete per-repo
-tokens. If the check fails it exits immediately with a clear error.
+On startup the server verifies that `GITEA_TOKEN` is valid by performing a repo
+search. If the check fails it exits immediately with a clear error.
-Each job gets a short-lived, repo-scoped Gitea token (hard expiry at
-`assigned_at + JCI_JOB_TIMEOUT`, plus explicit server-side deletion on completion).
+> **Note — per-job scoped tokens:** The architecture calls for creating a short-lived,
+> repo-scoped token per job (so that each job container only has access to one repo
+> for the duration of that job). This is not yet implemented because the relevant
+> Gitea API endpoints are still unstable. For now the server embeds the long-lived
+> `GITEA_TOKEN` directly in the clone URL. Per-job token creation and revocation will
+> be added once the Gitea API stabilises.
### `git jci runner`
@@ -208,18 +214,22 @@ Runs in the foreground alongside a Docker daemon. Polls the server every 5 s
| Variable | Description |
|---|---|
| `JCI_SERVER` | Server URL (e.g. `https://jci.example.com`) |
-| `JCI_RUNNER_SECRET` | Shared secret matching `RUNNER_SECRET` on the server |
+| `JCI_RUNNER_SECRET` | Shared secret matching `JCI_RUNNER_SECRET` on the server |
The runner stores its identity in a SQLite database on a mounted volume so the
same runner ID is reused across restarts.
**Job execution** — for each job the runner:
-1. Starts a detached Docker container that clones the repo and runs `git-jci run`
- then `git-jci push` (always, even on failure, to commit `status.txt`).
+1. Starts a detached Docker container that does a full clone, checks out the target
+ commit, runs `git jci run`, then `git jci push` (always, even on failure, to
+ commit `status.txt`).
2. Returns to the poll loop immediately — containers run in the background.
-3. Periodically kills containers labelled `jci-job=y` that exceed the timeout,
- preventing capacity leaks from hung jobs.
+3. Containers are **not** started with `--rm`. This is intentional: exited containers
+ remain on the host so you can run `docker logs <id>` or `docker cp` to
+ investigate a failed job. Clean them up yourself with `docker container prune`.
+ Containers that are **still running** past the job timeout (default `60m`) are
+ force-removed by the runner's reaper goroutine, which checks every 60 seconds.
The job container never contacts the server directly; all Gitea API interaction
happens server-side.
@@ -228,17 +238,30 @@ happens server-side.
```
1. Dev pushes → Gitea sends webhook → server enqueues job (status=pending)
-2. Runner polls → server creates scoped token, responds with job payload
-3. Runner starts job container (clone → git-jci run → git-jci push)
+2. Runner polls → server responds with job payload (clone URL embeds GITEA_TOKEN)
+3. Runner starts job container (full clone → git checkout → git jci run → git jci push)
4. Runner polls again; server reads status.txt from refs/jci-runs/* on Gitea
-5. Server sees ok/err → sets Gitea commit status → deletes token → removes job
+5. Server sees ok/err → sets Gitea commit status → removes job
```
---
+## Design constraints
+
+- **No external dependencies** — pure Go stdlib + git CLI + SQLite (driver only for server/runner). No separate storage service.
+- **Results live in the repo** — CI artefacts are normal git objects under `refs/jci-runs/*`; they travel with the repo on push/pull.
+- **Runner is pull-only** — server never initiates contact with a runner; no runner address is stored.
+- **All Gitea API interaction is server-only** — runner uses only plain `git` commands (clone/push); the coordination layer is opaque to it.
+- **Stateless job containers** — no volumes; crash of a container loses only that run.
+- **Artefacts are arbitrary files** — anything written to `$JCI_OUTPUT_DIR` is stored.
+- **Single binary** — `git-jci` placed on `$PATH` becomes available as `git jci`.
+- **Server runs in foreground** — managed by Docker or systemd; no self-daemonization.
+
+---
+
## How it works
-CI results are stored as git tree objects under the `refs/jci/` namespace (or `refs/jci-runs/` when a commit has multiple runs). This keeps them completely separate from your regular branches and tags while still being part of the repository.
+CI results are stored as git tree objects under the `refs/jci-runs/` namespace. Every run — whether triggered manually, by cron, or by a distributed runner — gets a unique run ID, so multiple runs on the same commit are stored independently. This keeps them completely separate from your regular branches and tags while still being part of the repository.
- Results are **never** checked out to the working directory
- They are pushed and pulled exactly like any other git refs but only when you explicitly run `git jci push` / `git jci pull`. A plain `git push` does **not** send them.
@@ -257,10 +280,10 @@ You can pass a custom port: `git jci web 9000`.
```bash
# You: after running CI
-git jci push # pushes refs/jci/* to origin
+git jci push # pushes refs/jci-runs/* to origin
# Teammate: to see your results
-git jci pull # fetches refs/jci/* from origin
+git jci pull # fetches refs/jci-runs/* from origin
git jci web # now shows your CI results too
```
@@ -1555,3 +1578,18 @@ log " Pages : $(find "$JCI_OUTPUT_DIR/build" -name '*.html' | wc -l)"
log " Log : \$JCI_OUTPUT_DIR/build.log"
log "========================================"
```
+
+### With Server And Runner
+
+```
+$ tree .
+.
+├── .gitignore
+├── Dockerfile
+├── docker-compose.yml
+├── env.example
+├── git-jci
+├── hit_webhook.sh
+├── run_runner.sh
+└── run_server.sh
+```
diff --git a/VERSION b/VERSION
@@ -1 +1 @@
-1.0.3
+1.1.4
diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go
@@ -77,7 +77,7 @@ CI results are stored in refs/jci-runs/<commit>/<runid>.
Distributed CI env vars (server):
GITEA_HOST, GITEA_USER, GITEA_TOKEN, GITEA_WEBHOOK_SECRET,
- RUNNER_SECRET, JCI_MAX_JOBS, JCI_JOB_TIMEOUT, JCI_LISTEN, JCI_DB
+ JCI_RUNNER_SECRET, JCI_MAX_JOBS, JCI_JOB_TIMEOUT, JCI_LISTEN, JCI_DB
Distributed CI env vars (runner):
JCI_SERVER, JCI_RUNNER_SECRET, JCI_RUNNER_DB, JCI_BINARY`, version)
diff --git a/examples/15-with-server-and-runner/.gitignore b/examples/15-with-server-and-runner/.gitignore
@@ -0,0 +1 @@
+git-jci
diff --git a/examples/15-with-server-and-runner/Dockerfile b/examples/15-with-server-and-runner/Dockerfile
@@ -0,0 +1,8 @@
+FROM docker:latest AS docker-bin
+FROM python:3.11
+# COPY --from=docker-bin /usr/local/bin/docker /usr/local/bin/docker
+# ---
+ADD git-jci /bin/
+RUN chmod u+x /bin/git-jci
+
+WORKDIR /app
diff --git a/examples/15-with-server-and-runner/docker-compose.yml b/examples/15-with-server-and-runner/docker-compose.yml
@@ -0,0 +1,18 @@
+networks:
+ jci_net:
+services:
+ jci_server:
+ build: .
+ command: bash /app/run_server.sh
+ volumes:
+ - ./:/app
+ networks:
+ - jci_net
+ jci_runner:
+ build: .
+ command: bash /app/run_runner.sh
+ volumes:
+ - ./:/app
+ - /var/run/docker.sock:/var/run/docker.sock
+ networks:
+ - jci_net
diff --git a/examples/15-with-server-and-runner/env.example b/examples/15-with-server-and-runner/env.example
@@ -0,0 +1,7 @@
+export GITEA_HOST=gitea.midpathsoftware.com
+export GITEA_USER=Midpath-Bot
+export GITEA_TOKEN=a4cb3a9ceca9b68749ee76a569b30da2339a4b4d
+export GITEA_WEBHOOK_SECRET=oy9muaTe
+export JCI_SERVER=jci_server:8080
+export JCI_RUNNER_SECRET=OoRoo7hi
+export JCI_JOB_ALLOW_DOCKER_FOR_REPOS=midpath/midpathsoftware_com
diff --git a/examples/15-with-server-and-runner/hit_webhook.sh b/examples/15-with-server-and-runner/hit_webhook.sh
@@ -0,0 +1,222 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Load env vars if not already set
+if [ -f "$(dirname "$0")/env.example" ]; then
+ source "$(dirname "$0")/env.example"
+fi
+
+BODY='{
+ "ref": "refs/heads/develop",
+ "before": "bd3dbf35ac3aef03ff16701906dc5c164bfad951",
+ "after": "bd3dbf35ac3aef03ff16701906dc5c164bfad951",
+ "compare_url": "https://gitea.midpathsoftware.com/midpath/midpathsoftware_com/compare/bd3dbf35ac3aef03ff16701906dc5c164bfad951...bd3dbf35ac3aef03ff16701906dc5c164bfad951",
+ "commits": [
+ {
+ "id": "bd3dbf35ac3aef03ff16701906dc5c164bfad951",
+ "message": "remove bolmitra (!135)\n\nReviewed-on: https://gitea.midpathsoftware.com/midpath/midpathsoftware_com/pulls/135\nCo-authored-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>\nCo-committed-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>\n",
+ "url": "https://gitea.midpathsoftware.com/midpath/midpathsoftware_com/commit/bd3dbf35ac3aef03ff16701906dc5c164bfad951",
+ "author": {
+ "name": "Arjoonn Sharma",
+ "email": "arjoonn@midpathsoftware.com",
+ "username": ""
+ },
+ "committer": {
+ "name": "arjoonn",
+ "email": "arjoonn@noreply.localhost",
+ "username": ""
+ },
+ "verification": null,
+ "timestamp": "0001-01-01T00:00:00Z",
+ "added": null,
+ "removed": null,
+ "modified": null
+ }
+ ],
+ "total_commits": 1,
+ "head_commit": {
+ "id": "bd3dbf35ac3aef03ff16701906dc5c164bfad951",
+ "message": "remove bolmitra (!135)\n\nReviewed-on: https://gitea.midpathsoftware.com/midpath/midpathsoftware_com/pulls/135\nCo-authored-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>\nCo-committed-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>\n",
+ "url": "https://gitea.midpathsoftware.com/midpath/midpathsoftware_com/commit/bd3dbf35ac3aef03ff16701906dc5c164bfad951",
+ "author": {
+ "name": "Arjoonn Sharma",
+ "email": "arjoonn@midpathsoftware.com",
+ "username": ""
+ },
+ "committer": {
+ "name": "arjoonn",
+ "email": "arjoonn@noreply.localhost",
+ "username": ""
+ },
+ "verification": null,
+ "timestamp": "0001-01-01T00:00:00Z",
+ "added": null,
+ "removed": null,
+ "modified": null
+ },
+ "repository": {
+ "id": 117,
+ "owner": {
+ "id": 3,
+ "login": "midpath",
+ "login_name": "",
+ "source_id": 0,
+ "full_name": "",
+ "email": "midpath@noreply.localhost",
+ "avatar_url": "https://gitea.midpathsoftware.com/avatars/e0cd95ab87f10b5d47ac695e8c2249c277923d365269152221e775216340bbc9",
+ "html_url": "https://gitea.midpathsoftware.com/midpath",
+ "language": "",
+ "is_admin": false,
+ "last_login": "0001-01-01T00:00:00Z",
+ "created": "2022-09-16T18:24:04Z",
+ "restricted": false,
+ "active": false,
+ "prohibit_login": false,
+ "location": "",
+ "website": "",
+ "description": "",
+ "visibility": "private",
+ "followers_count": 0,
+ "following_count": 0,
+ "starred_repos_count": 0,
+ "username": "midpath"
+ },
+ "name": "midpathsoftware_com",
+ "full_name": "midpath/midpathsoftware_com",
+ "description": "",
+ "empty": false,
+ "private": true,
+ "fork": false,
+ "template": false,
+ "parent": null,
+ "mirror": false,
+ "size": 543150,
+ "language": "",
+ "languages_url": "https://gitea.midpathsoftware.com/api/v1/repos/midpath/midpathsoftware_com/languages",
+ "html_url": "https://gitea.midpathsoftware.com/midpath/midpathsoftware_com",
+ "url": "https://gitea.midpathsoftware.com/api/v1/repos/midpath/midpathsoftware_com",
+ "link": "",
+ "ssh_url": "git@gitea.midpathsoftware.com:midpath/midpathsoftware_com.git",
+ "clone_url": "https://gitea.midpathsoftware.com/midpath/midpathsoftware_com.git",
+ "original_url": "",
+ "website": "",
+ "stars_count": 0,
+ "forks_count": 0,
+ "watchers_count": 2,
+ "open_issues_count": 0,
+ "open_pr_counter": 0,
+ "release_counter": 0,
+ "default_branch": "develop",
+ "archived": false,
+ "created_at": "2023-08-29T13:35:20Z",
+ "updated_at": "2026-04-24T13:26:33Z",
+ "archived_at": "1970-01-01T00:00:00Z",
+ "permissions": {
+ "admin": false,
+ "push": false,
+ "pull": false
+ },
+ "has_issues": true,
+ "external_tracker": {
+ "external_tracker_url": "https://gitea.midpathsoftware.com/midpath/tasks/issues?labels=301",
+ "external_tracker_format": "https://gitea.midpathsoftware.com/midpath/tasks/issues/{index}",
+ "external_tracker_style": "numeric",
+ "external_tracker_regexp_pattern": ""
+ },
+ "has_wiki": false,
+ "has_pull_requests": true,
+ "has_projects": false,
+ "projects_mode": "all",
+ "has_releases": false,
+ "has_packages": false,
+ "has_actions": false,
+ "ignore_whitespace_conflicts": false,
+ "allow_merge_commits": true,
+ "allow_rebase": true,
+ "allow_rebase_explicit": true,
+ "allow_squash_merge": true,
+ "allow_fast_forward_only_merge": false,
+ "allow_rebase_update": true,
+ "default_delete_branch_after_merge": true,
+ "default_merge_style": "squash",
+ "default_allow_maintainer_edit": false,
+ "avatar_url": "",
+ "internal": false,
+ "mirror_interval": "",
+ "object_format_name": "sha1",
+ "mirror_updated": "0001-01-01T00:00:00Z",
+ "repo_transfer": null,
+ "topics": [],
+ "licenses": null
+ },
+ "pusher": {
+ "id": 1,
+ "login": "arjoonn",
+ "login_name": "",
+ "source_id": 0,
+ "full_name": "",
+ "email": "arjoonn@noreply.localhost",
+ "avatar_url": "https://gitea.midpathsoftware.com/avatars/eecdccaa8dd90641c37410078405e28a1eeb848c7ba2cbd2305180422cd00b40",
+ "html_url": "https://gitea.midpathsoftware.com/arjoonn",
+ "language": "",
+ "is_admin": false,
+ "last_login": "0001-01-01T00:00:00Z",
+ "created": "2022-09-16T18:07:08Z",
+ "restricted": false,
+ "active": false,
+ "prohibit_login": false,
+ "location": "Jaipur",
+ "website": "",
+ "description": "",
+ "visibility": "private",
+ "followers_count": 0,
+ "following_count": 0,
+ "starred_repos_count": 5,
+ "username": "arjoonn"
+ },
+ "sender": {
+ "id": 1,
+ "login": "arjoonn",
+ "login_name": "",
+ "source_id": 0,
+ "full_name": "",
+ "email": "arjoonn@noreply.localhost",
+ "avatar_url": "https://gitea.midpathsoftware.com/avatars/eecdccaa8dd90641c37410078405e28a1eeb848c7ba2cbd2305180422cd00b40",
+ "html_url": "https://gitea.midpathsoftware.com/arjoonn",
+ "language": "",
+ "is_admin": false,
+ "last_login": "0001-01-01T00:00:00Z",
+ "created": "2022-09-16T18:07:08Z",
+ "restricted": false,
+ "active": false,
+ "prohibit_login": false,
+ "location": "Jaipur",
+ "website": "",
+ "description": "",
+ "visibility": "private",
+ "followers_count": 0,
+ "following_count": 0,
+ "starred_repos_count": 5,
+ "username": "arjoonn"
+ }
+}'
+
+SIG256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$GITEA_WEBHOOK_SECRET" | awk '{print $2}')
+SIG1=$(printf '%s' "$BODY" | openssl dgst -sha1 -hmac "$GITEA_WEBHOOK_SECRET" | awk '{print $2}')
+
+curl -X POST http://localhost:8080/webhook \
+ -H "Content-Type: application/json" \
+ -H "X-GitHub-Delivery: 6a542f57-3d50-431a-a30d-f87f29933729" \
+ -H "X-GitHub-Event: push" \
+ -H "X-GitHub-Event-Type: push" \
+ -H "X-Gitea-Delivery: 6a542f57-3d50-431a-a30d-f87f29933729" \
+ -H "X-Gitea-Event: push" \
+ -H "X-Gitea-Event-Type: push" \
+ -H "X-Gitea-Signature: $SIG256" \
+ -H "X-Gogs-Delivery: 6a542f57-3d50-431a-a30d-f87f29933729" \
+ -H "X-Gogs-Event: push" \
+ -H "X-Gogs-Event-Type: push" \
+ -H "X-Gogs-Signature: $SIG256" \
+ -H "X-Hub-Signature: sha1=$SIG1" \
+ -H "X-Hub-Signature-256: sha256=$SIG256" \
+ -d "$BODY"
diff --git a/examples/15-with-server-and-runner/run_runner.sh b/examples/15-with-server-and-runner/run_runner.sh
@@ -0,0 +1,13 @@
+#! /bin/bash
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+main(){
+ cd /app
+ source env.example
+ git jci runner
+}
+main
+
diff --git a/examples/15-with-server-and-runner/run_server.sh b/examples/15-with-server-and-runner/run_server.sh
@@ -0,0 +1,12 @@
+#! /bin/bash
+
+set -o errexit
+set -o nounset
+set -o pipefail
+
+main(){
+ cd /app
+ source env.example
+ git jci server
+}
+main
diff --git a/internal/jci/default_runner_script.sh b/internal/jci/default_runner_script.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+set -e
+git clone "$JCI_CLONE_URL" /repo
+git config --global --add safe.directory /repo
+cd /repo
+git checkout "$JCI_COMMIT"
+echo "----------------------.jci/run.sh---------------------- START"
+cat .jci/run.sh || echo '.jci does not exist'
+echo "----------------------.jci/run.sh---------------------- END"
+git jci --version
+echo "----------------------starting run---------------------"
+git jci run || echo "run failed"
+echo "----------------------starting push--------------------"
+git jci push
+echo "----------------------END------------------------------"
diff --git a/internal/jci/docker_runtime.go b/internal/jci/docker_runtime.go
@@ -0,0 +1,210 @@
+package jci
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const dockerSocketPath = "/var/run/docker.sock"
+
+// dockerRuntime implements ContainerRuntime by talking directly to the Docker
+// Engine API over its Unix socket. No external dependencies are required.
+type dockerRuntime struct {
+ client *http.Client
+}
+
+func newDockerRuntime() *dockerRuntime {
+ return &dockerRuntime{
+ client: &http.Client{
+ Transport: &http.Transport{
+ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("unix", dockerSocketPath)
+ },
+ },
+ },
+ }
+}
+
+// doRequest performs an HTTP request against the Docker Engine API.
+// The base URL is always http://localhost (the host part is ignored for Unix
+// socket connections but must be present for Go's http.Client).
+func (d *dockerRuntime) doRequest(method, path string, body interface{}) (*http.Response, error) {
+ var bodyReader io.Reader
+ if body != nil {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("marshal request: %w", err)
+ }
+ bodyReader = bytes.NewReader(b)
+ }
+ req, err := http.NewRequest(method, "http://localhost"+path, bodyReader)
+ if err != nil {
+ return nil, err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ return d.client.Do(req)
+}
+
+// StartContainer implements ContainerRuntime.
+// It calls POST /containers/create then POST /containers/{id}/start.
+func (d *dockerRuntime) StartContainer(spec ContainerSpec) (string, error) {
+ log.Printf("docker: creating container image=%s binds=%v autoRemove=%v", spec.Image, spec.Binds, spec.AutoRemove)
+
+ payload := map[string]interface{}{
+ "Image": spec.Image,
+ "Cmd": spec.Command,
+ "Env": spec.Env,
+ "Labels": spec.Labels,
+ "HostConfig": map[string]interface{}{
+ "Binds": spec.Binds,
+ // AutoRemove is intentionally omitted here so that containers
+ // remain visible via `docker ps -a` for post-mortem inspection
+ // even when they exit or fail to start. The reaper handles cleanup.
+ },
+ }
+
+ resp, err := d.doRequest("POST", "/v1.41/containers/create", payload)
+ if err != nil {
+ return "", fmt.Errorf("containers/create: %w", err)
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+ log.Printf("docker: containers/create → HTTP %d body: %s", resp.StatusCode, raw)
+ if resp.StatusCode != http.StatusCreated {
+ return "", fmt.Errorf("containers/create: status %d: %s", resp.StatusCode, raw)
+ }
+
+ var created struct {
+ ID string `json:"Id"`
+ Warnings []string `json:"Warnings"`
+ }
+ if err := json.Unmarshal(raw, &created); err != nil {
+ return "", fmt.Errorf("containers/create decode: %w", err)
+ }
+ if len(created.Warnings) > 0 {
+ log.Printf("docker: containers/create warnings for %s: %v", created.ID[:12], created.Warnings)
+ }
+ log.Printf("docker: container created id=%s", created.ID[:12])
+
+ log.Printf("docker: starting container id=%s", created.ID[:12])
+ resp2, err := d.doRequest("POST", "/v1.41/containers/"+created.ID+"/start", nil)
+ if err != nil {
+ return "", fmt.Errorf("containers/start: %w", err)
+ }
+ raw2, _ := io.ReadAll(resp2.Body)
+ resp2.Body.Close()
+ log.Printf("docker: containers/start → HTTP %d body: %s", resp2.StatusCode, raw2)
+ if resp2.StatusCode != http.StatusNoContent && resp2.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("containers/start: status %d: %s", resp2.StatusCode, raw2)
+ }
+
+ log.Printf("docker: container started id=%s", created.ID[:12])
+ return created.ID, nil
+}
+
+// ListContainers implements ContainerRuntime.
+// Calls GET /containers/json?all=true&filters={"label":["<labelFilter>"]}
+// all=true is required so that stopped/exited containers are also returned and
+// can be reaped if they exceeded their timeout.
+func (d *dockerRuntime) ListContainers(labelFilter string) ([]ContainerInfo, error) {
+ filters, _ := json.Marshal(map[string][]string{"label": {labelFilter}})
+ path := "/v1.41/containers/json?all=true&filters=" + string(filters)
+
+ resp, err := d.doRequest("GET", path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("containers/json: %w", err)
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("containers/json: status %d: %s", resp.StatusCode, raw)
+ }
+
+ var list []struct {
+ ID string `json:"Id"`
+ Labels map[string]string `json:"Labels"`
+ }
+ if err := json.Unmarshal(raw, &list); err != nil {
+ return nil, fmt.Errorf("containers/json decode: %w", err)
+ }
+
+ out := make([]ContainerInfo, len(list))
+ for i, c := range list {
+ out[i] = ContainerInfo{ID: c.ID, Labels: c.Labels}
+ }
+ return out, nil
+}
+
+// InspectStartedAt implements ContainerRuntime.
+// Calls GET /containers/{id}/json and parses State.StartedAt.
+func (d *dockerRuntime) InspectStartedAt(id string) (time.Time, error) {
+ resp, err := d.doRequest("GET", "/v1.41/containers/"+id+"/json", nil)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("containers/inspect: %w", err)
+ }
+ defer resp.Body.Close()
+ raw, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return time.Time{}, fmt.Errorf("containers/inspect: status %d: %s", resp.StatusCode, raw)
+ }
+
+ var info struct {
+ State struct {
+ StartedAt string `json:"StartedAt"`
+ } `json:"State"`
+ }
+ if err := json.Unmarshal(raw, &info); err != nil {
+ return time.Time{}, fmt.Errorf("containers/inspect decode: %w", err)
+ }
+ return time.Parse(time.RFC3339Nano, info.State.StartedAt)
+}
+
+// RemoveContainer implements ContainerRuntime.
+// Calls DELETE /containers/{id}?force=true
+func (d *dockerRuntime) RemoveContainer(id string) error {
+ log.Printf("docker: removing container %s", id[:12])
+ resp, err := d.doRequest("DELETE", "/v1.41/containers/"+id+"?force=true", nil)
+ if err != nil {
+ log.Printf("docker: containers/remove %s → error: %v", id[:12], err)
+ return fmt.Errorf("containers/remove: %w", err)
+ }
+ raw, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ log.Printf("docker: containers/remove %s → HTTP %d: %s", id[:12], resp.StatusCode, raw)
+ if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("containers/remove: status %d: %s", resp.StatusCode, raw)
+ }
+ return nil
+}
+
+// logContainerCmd returns a human-readable "docker run …" style string for
+// logging purposes. Callers are responsible for masking sensitive values
+// (e.g. credentials in Command) before passing the spec.
+func logContainerCmd(spec ContainerSpec) string {
+ var b strings.Builder
+ b.WriteString("docker run -d")
+ for _, v := range spec.Binds {
+ b.WriteString(" -v " + v)
+ }
+ for _, e := range spec.Env {
+ b.WriteString(" -e " + e)
+ }
+ for k, v := range spec.Labels {
+ b.WriteString(" --label " + k + "=" + v)
+ }
+ b.WriteString(" " + spec.Image)
+ for _, c := range spec.Command {
+ b.WriteString(" " + c)
+ }
+ return b.String()
+}
diff --git a/internal/jci/git.go b/internal/jci/git.go
@@ -2,6 +2,7 @@ package jci
import (
"bytes"
+ "errors"
"fmt"
"os"
"os/exec"
@@ -9,18 +10,51 @@ import (
"strings"
)
-// git runs a git command and returns stdout
-func git(args ...string) (string, error) {
- cmd := exec.Command("git", args...)
+// gitError is returned whenever a git sub-process exits with a non-zero status.
+// It carries the original exit code together with the raw stdout and stderr so
+// callers (and users) can see exactly what git printed.
+type gitError struct {
+ Args []string
+ ExitCode int
+ Stdout string
+ Stderr string
+}
+
+func (e *gitError) Error() string {
+ return fmt.Sprintf("git %s: exit %d\nstdout: %s\nstderr: %s",
+ strings.Join(e.Args, " "), e.ExitCode, e.Stdout, e.Stderr)
+}
+
+// gitCmd runs a git command (optionally with a pre-configured *exec.Cmd so the
+// caller can set Dir/Env/Stdin) and returns trimmed stdout. On failure it
+// always returns a *gitError with exit code + both streams.
+func gitCmd(cmd *exec.Cmd) (string, error) {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
- if err := cmd.Run(); err != nil {
- return "", fmt.Errorf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String())
+ err := cmd.Run()
+ args := cmd.Args[1:] // strip the leading "git"
+ if err != nil {
+ exitCode := -1
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ exitCode = exitErr.ExitCode()
+ }
+ return "", &gitError{
+ Args: args,
+ ExitCode: exitCode,
+ Stdout: stdout.String(),
+ Stderr: stderr.String(),
+ }
}
return strings.TrimSpace(stdout.String()), nil
}
+// git runs a plain git command and returns stdout.
+func git(args ...string) (string, error) {
+ return gitCmd(exec.Command("git", args...))
+}
+
// GetCurrentCommit returns the current HEAD commit hash
func GetCurrentCommit() (string, error) {
return git("rev-parse", "HEAD")
@@ -51,19 +85,22 @@ func StoreTree(dir string, commit string, message string, runID string) error {
return fmt.Errorf("failed to hash directory: %w", err)
}
- // Create commit from tree
+ // Create commit from tree.
+ // git commit-tree requires author/committer identity. In CI environments
+ // (especially shallow clones) git config may be absent, so we inject
+ // fallback env vars while still honouring any values already set.
commitTreeCmd := exec.Command("git", "commit-tree", treeID, "-m", message)
commitTreeCmd.Dir = repoRoot
- commitOut, err := commitTreeCmd.Output()
+ commitTreeCmd.Env = append(os.Environ(), gitIdentityEnv()...)
+ commitID, err := gitCmd(commitTreeCmd)
if err != nil {
- return fmt.Errorf("git commit-tree: %v", err)
+ return err
}
- commitID := strings.TrimSpace(string(commitOut))
// Update ref: refs/jci-runs/<commit>/<runid>
ref := "refs/jci-runs/" + commit + "/" + runID
if _, err := git("update-ref", ref, commitID); err != nil {
- return fmt.Errorf("git update-ref: %v", err)
+ return err
}
return nil
@@ -92,11 +129,10 @@ func hashDir(dir string, repoRoot string) (string, error) {
// Hash file
cmd := exec.Command("git", "hash-object", "-w", path)
cmd.Dir = repoRoot
- out, err := cmd.Output()
+ blobID, err := gitCmd(cmd)
if err != nil {
- return "", fmt.Errorf("hash-object %s: %v", path, err)
+ return "", err
}
- blobID := strings.TrimSpace(string(out))
// Get file mode
info, err := entry.Info()
@@ -120,12 +156,27 @@ func hashDir(dir string, repoRoot string) (string, error) {
cmd := exec.Command("git", "mktree")
cmd.Dir = repoRoot
cmd.Stdin = strings.NewReader(treeInput)
- out, err := cmd.Output()
- if err != nil {
- return "", fmt.Errorf("mktree: %v (input: %q)", err, treeInput)
- }
+ return gitCmd(cmd)
+}
- return strings.TrimSpace(string(out)), nil
+// gitIdentityEnv returns GIT_AUTHOR_* / GIT_COMMITTER_* env vars with safe
+// fallback values so that git commit-tree works even when git config has no
+// user identity set (common in CI shallow-clone environments).
+func gitIdentityEnv() []string {
+ getOrDefault := func(envKey, fallback string) string {
+ if v := os.Getenv(envKey); v != "" {
+ return v
+ }
+ return fallback
+ }
+ name := getOrDefault("GIT_AUTHOR_NAME", "jci")
+ email := getOrDefault("GIT_AUTHOR_EMAIL", "jci@localhost")
+ return []string{
+ "GIT_AUTHOR_NAME=" + name,
+ "GIT_AUTHOR_EMAIL=" + email,
+ "GIT_COMMITTER_NAME=" + name,
+ "GIT_COMMITTER_EMAIL=" + email,
+ }
}
// ListJCIRunRefs returns all refs under refs/jci-runs/
diff --git a/internal/jci/runner.go b/internal/jci/runner.go
@@ -10,7 +10,6 @@ import (
"math/rand"
"net/http"
"os"
- "os/exec"
"strings"
"time"
@@ -21,7 +20,8 @@ import (
//
// Configuration env vars:
//
-// JCI_SERVER base URL of the jci server, e.g. https://jci.example.com
+// JCI_SERVER base URL or host:port of the jci server, e.g. https://jci.example.com or jci_server:8080
+// If no scheme is present, http:// is prepended automatically.
// JCI_RUNNER_SECRET shared secret matching RUNNER_SECRET on the server
// JCI_RUNNER_DB path to runner's SQLite DB (default /var/lib/jci/runner.db)
// JCI_BINARY path to the git-jci binary to inject into containers (default /usr/local/bin/git-jci)
@@ -42,8 +42,9 @@ func Runner(_ []string) error {
return fmt.Errorf("runner identity: %w", err)
}
log.Printf("jci runner starting (id=%s)", runnerID)
+ log.Printf("connecting to server %s", cfg.serverURL)
- r := &runner{cfg: cfg, db: db, runnerID: runnerID}
+ r := &runner{cfg: cfg, db: db, runnerID: runnerID, rt: newDockerRuntime()}
r.pollLoop()
return nil // pollLoop runs forever; return only on fatal errors
}
@@ -64,7 +65,7 @@ func loadRunnerConfig() (runnerConfig, error) {
dbPath: "/var/lib/jci/runner.db",
binaryPath: "/usr/local/bin/git-jci",
}
- cfg.serverURL = os.Getenv("JCI_SERVER")
+ cfg.serverURL = normalizeServerURL(os.Getenv("JCI_SERVER"))
cfg.runnerSecret = os.Getenv("JCI_RUNNER_SECRET")
if cfg.serverURL == "" || cfg.runnerSecret == "" {
@@ -117,20 +118,17 @@ type runner struct {
cfg runnerConfig
db *sql.DB
runnerID string
+ rt ContainerRuntime
}
// pollLoop runs forever: poll the server every 5s + 0–2s jitter.
// Container reaping runs in a separate goroutine on a 60s interval.
func (r *runner) pollLoop() {
- // Default job timeout for reaping matches the server default (60m).
- // If JCI_JOB_TIMEOUT is set the server enforces it; we use 60m as a
- // conservative local limit.
const reapInterval = 60 * time.Second
- const containerTimeout = 65 * time.Minute // slightly longer than server timeout
go func() {
for {
time.Sleep(reapInterval)
- r.reapStaleContainers(containerTimeout)
+ r.reapStaleContainers()
}
}()
@@ -144,11 +142,12 @@ func (r *runner) pollLoop() {
continue
}
if retryAfter > 0 {
- log.Printf("runner at capacity; backing off %s", retryAfter)
+ log.Printf("at capacity; server asked to back off %s", retryAfter)
time.Sleep(retryAfter)
continue
}
if job == nil {
+ log.Printf("poll: no jobs available")
continue
}
go r.dispatch(job) // non-blocking; runner returns to poll immediately
@@ -156,16 +155,21 @@ func (r *runner) pollLoop() {
}
type serverJob struct {
- JobID string `json:"job_id"`
- CloneURL string `json:"clone_url"`
- CommitSHA string `json:"commit_sha"`
- RepoOwner string `json:"repo_owner"`
- RepoName string `json:"repo_name"`
+ JobID string `json:"job_id"`
+ CloneURL string `json:"clone_url"`
+ CommitSHA string `json:"commit_sha"`
+ RepoOwner string `json:"repo_owner"`
+ RepoName string `json:"repo_name"`
+ TimeoutSeconds int `json:"timeout_seconds"`
+ Image string `json:"image"`
+ Script string `json:"script"` // base64-encoded shell script
+ AllowDockerSocket bool `json:"allow_docker_socket"` // mount /var/run/docker.sock into job container
}
// poll contacts the server. Returns (retryAfter, job, err).
// retryAfter > 0 means 429 was received; job may be nil when there is no work.
func (r *runner) poll() (time.Duration, *serverJob, error) {
+ log.Printf("polling %s/poll", r.cfg.serverURL)
body, _ := json.Marshal(map[string]string{
"runner_id": r.runnerID,
"secret": r.cfg.runnerSecret,
@@ -176,6 +180,7 @@ func (r *runner) poll() (time.Duration, *serverJob, error) {
}
defer resp.Body.Close()
+ log.Printf("poll response: %d", resp.StatusCode)
if resp.StatusCode == http.StatusTooManyRequests {
secs := 5
if v := resp.Header.Get("Retry-After"); v != "" {
@@ -185,6 +190,7 @@ func (r *runner) poll() (time.Duration, *serverJob, error) {
}
if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
+ log.Printf("poll: unexpected status %d: %s", resp.StatusCode, data)
return 0, nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, data)
}
@@ -194,92 +200,121 @@ func (r *runner) poll() (time.Duration, *serverJob, error) {
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, nil, fmt.Errorf("decode poll response: %w", err)
}
+ if result.Job != nil {
+ log.Printf("poll: received job %s (%s/%s@%.12s)", result.Job.JobID, result.Job.RepoOwner, result.Job.RepoName, result.Job.CommitSHA)
+ }
return 0, result.Job, nil
}
// dispatch starts a detached Docker container to run the job.
func (r *runner) dispatch(job *serverJob) {
- log.Printf("dispatching job %s (%s/%s@%.12s)", job.JobID, job.RepoOwner, job.RepoName, job.CommitSHA)
+ log.Printf("received job %s: %s/%s@%.12s", job.JobID, job.RepoOwner, job.RepoName, job.CommitSHA)
+ log.Printf("job %s: launching container", job.JobID)
containerID, err := r.startJobContainer(job)
if err != nil {
log.Printf("job %s: failed to start container: %v", job.JobID, err)
return
}
- log.Printf("job %s: container %s started", job.JobID, containerID[:12])
+ log.Printf("job %s: container %s started — waiting for completion (will be reaped after timeout)", job.JobID, containerID[:12])
// Container reaping is handled by the periodic reaper; nothing more to do here.
}
// startJobContainer launches a detached container and returns its ID.
func (r *runner) startJobContainer(job *serverJob) (string, error) {
- // The job container:
- // 1. Clones the repo at the target commit (credentials embedded in clone_url)
- // 2. Runs git-jci run
- // 3. Runs git-jci push (always, so status.txt is pushed even on failure)
- script := strings.Join([]string{
- fmt.Sprintf("git clone --depth=1 %s /repo", shellQuote(job.CloneURL)),
- "cd /repo",
- fmt.Sprintf("git checkout %s", shellQuote(job.CommitSHA)),
- "git-jci run || true",
- "git-jci push",
- }, " && ")
-
- args := []string{
- "run", "--rm", "-d",
- "-v", r.cfg.binaryPath + ":/usr/local/bin/git-jci:ro",
- "-e", "JCI_COMMIT=" + job.CommitSHA,
- "--label", "jci-job=y",
- "--label", "jci-job-id=" + job.JobID,
- // Use alpine as a safe default; repos can override via .jci/image file (future)
- "alpine:3",
- "/bin/sh", "-c", script,
+ // The script is delivered as a base64-encoded env var (JCI_SCRIPT).
+ // The container decodes and pipes it into sh — no script content ever
+ // touches a shell on the runner host.
+ timeoutLabel := fmt.Sprintf("%dm", job.TimeoutSeconds/60)
+
+ binds := []string{r.cfg.binaryPath + ":/usr/local/bin/git-jci:ro"}
+ if job.AllowDockerSocket {
+ binds = append(binds, "/var/run/docker.sock:/var/run/docker.sock")
}
- cmd := exec.Command("docker", args...)
- out, err := cmd.Output()
- if err != nil {
- return "", fmt.Errorf("docker run: %w", err)
+ spec := ContainerSpec{
+ Image: job.Image,
+ Command: []string{"/bin/sh", "-c", `echo "$JCI_SCRIPT" | base64 -d | /bin/sh`},
+ Env: []string{
+ "JCI_CLONE_URL=" + job.CloneURL,
+ "JCI_COMMIT=" + job.CommitSHA,
+ "JCI_SCRIPT=" + job.Script,
+ },
+ Binds: binds,
+ Labels: map[string]string{
+ "jci-job": "y",
+ "jci-job-id": job.JobID,
+ "jci-timeout": timeoutLabel,
+ },
+ }
+
+ // Log without credentials (clone URL contains token).
+ logSpec := spec
+ logSpec.Env = []string{
+ "JCI_CLONE_URL=" + maskURL(job.CloneURL),
+ "JCI_COMMIT=" + job.CommitSHA,
+ "JCI_SCRIPT=(base64, " + fmt.Sprintf("%d bytes", len(job.Script)) + ")",
}
- return strings.TrimSpace(string(out)), nil
+ log.Printf("job %s: %s", job.JobID, logContainerCmd(logSpec))
+
+ return r.rt.StartContainer(spec)
}
-// ReapStaleContainers kills containers labelled jci-job=y that have been running
-// longer than the given timeout. Called from the poll loop or independently.
-func (r *runner) reapStaleContainers(timeout time.Duration) {
- // List all containers with label jci-job=y that are still running
- out, err := exec.Command("docker", "ps", "--filter", "label=jci-job=y", "--format", "{{.ID}}\t{{.RunningFor}}").Output()
+// reapStaleContainers kills containers labelled jci-job=y whose running time
+// exceeds the duration stored in their jci-timeout label.
+func (r *runner) reapStaleContainers() {
+ containers, err := r.rt.ListContainers("jci-job=y")
if err != nil {
- log.Printf("reaper: docker ps: %v", err)
+ log.Printf("reaper: list containers: %v", err)
return
}
- lines := strings.Split(strings.TrimSpace(string(out)), "\n")
- for _, line := range lines {
- if line == "" {
- continue
- }
- parts := strings.SplitN(line, "\t", 2)
- if len(parts) < 1 {
+ log.Printf("reaper: found %d jci container(s)", len(containers))
+ for _, c := range containers {
+ label := c.Labels["jci-timeout"]
+ if label == "" {
continue
}
- cid := parts[0]
- // Inspect to get exact start time
- inspectOut, err := exec.Command("docker", "inspect", "--format", "{{.State.StartedAt}}", cid).Output()
+ timeout, err := time.ParseDuration(label)
if err != nil {
+ log.Printf("reaper: container %s has invalid jci-timeout label %q: %v", c.ID[:12], label, err)
continue
}
- startedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(inspectOut)))
+ startedAt, err := r.rt.InspectStartedAt(c.ID)
if err != nil {
+ log.Printf("reaper: inspect %s: %v", c.ID[:12], err)
continue
}
if time.Since(startedAt) > timeout {
- log.Printf("reaper: killing stale container %s (running since %s)", cid[:12], startedAt)
- exec.Command("docker", "rm", "-f", cid).Run()
+ log.Printf("reaper: killing stale container %s (timeout=%s, running since %s)", c.ID[:12], timeout, startedAt)
+ if err := r.rt.RemoveContainer(c.ID); err != nil {
+ log.Printf("reaper: remove %s: %v", c.ID[:12], err)
+ }
}
}
}
-// shellQuote wraps s in single quotes, escaping any existing single quotes.
-func shellQuote(s string) string {
- return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
+// normalizeServerURL ensures the URL has an http:// or https:// scheme.
+// This allows JCI_SERVER to be set as just a host:port (e.g. "jci_server:8080")
+// without triggering the "first path segment cannot contain colon" parse error.
+func normalizeServerURL(s string) string {
+ if s == "" {
+ return s
+ }
+ if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
+ return "http://" + s
+ }
+ return s
}
+
+// maskURL replaces the userinfo (credentials) in a URL with ***.
+// e.g. https://token:x@github.com/org/repo → https://***@github.com/org/repo
+func maskURL(raw string) string {
+ if i := strings.Index(raw, "@"); i != -1 {
+ if j := strings.Index(raw, "://"); j != -1 && j < i {
+ return raw[:j+3] + "***" + raw[i:]
+ }
+ }
+ return raw
+}
+
diff --git a/internal/jci/runtime.go b/internal/jci/runtime.go
@@ -0,0 +1,52 @@
+package jci
+
+import "time"
+
+// ContainerRuntime is the minimal interface the runner needs from a container
+// backend. Implementations exist for Docker (via Unix socket); future backends
+// (Podman, containerd, …) only need to satisfy this interface — runner.go is
+// not touched.
+type ContainerRuntime interface {
+ // StartContainer creates and starts a container according to spec and
+ // returns its full container ID.
+ StartContainer(spec ContainerSpec) (id string, err error)
+
+ // ListContainers returns running containers that carry the given label
+ // (format "key=value" or just "key").
+ ListContainers(labelFilter string) ([]ContainerInfo, error)
+
+ // InspectStartedAt returns the time the container with the given ID was
+ // started.
+ InspectStartedAt(id string) (time.Time, error)
+
+ // RemoveContainer forcefully removes (stops + deletes) the container.
+ RemoveContainer(id string) error
+}
+
+// ContainerSpec describes a container to be started.
+type ContainerSpec struct {
+ // Image is the OCI image reference, e.g. "alpine:3".
+ Image string
+
+ // Command is the entrypoint command, e.g. ["/bin/sh", "-c", "echo hi"].
+ Command []string
+
+ // Env is a list of "KEY=VALUE" environment variables.
+ Env []string
+
+ // Binds is a list of volume bind-mounts in "host:container[:options]" form.
+ Binds []string
+
+ // Labels is a map of container labels.
+ Labels map[string]string
+
+ // AutoRemove mirrors docker run --rm: remove the container on exit.
+ AutoRemove bool
+}
+
+// ContainerInfo is the subset of container state the runner needs when
+// listing running containers.
+type ContainerInfo struct {
+ ID string
+ Labels map[string]string
+}
diff --git a/internal/jci/server.go b/internal/jci/server.go
@@ -9,6 +9,8 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
+ _ "embed"
+ "flag"
"fmt"
"io"
"log"
@@ -21,34 +23,64 @@ import (
_ "modernc.org/sqlite"
)
+//go:embed default_runner_script.sh
+var defaultRunnerScript []byte
+
// Server runs the coordination server: accepts Gitea webhooks and serves runner poll requests.
//
// Configuration env vars:
//
-// GITEA_HOST e.g. gitea.example.com
-// GITEA_USER service account username
-// GITEA_TOKEN admin token (must be able to create/delete scoped tokens)
-// GITEA_WEBHOOK_SECRET HMAC-SHA256 secret shared with Gitea
-// RUNNER_SECRET shared secret that runners authenticate with
-// JCI_MAX_JOBS max concurrent jobs per runner (default 4)
-// JCI_JOB_TIMEOUT server-wide job timeout (default 60m)
-// JCI_LISTEN listen address (default :8080)
-// JCI_DB SQLite DB path (default /var/lib/jci/server.db)
-func Server(_ []string) error {
+// GITEA_HOST e.g. gitea.example.com
+// GITEA_USER service account username
+// GITEA_TOKEN token with read/write access to the target repos
+// GITEA_WEBHOOK_SECRET HMAC-SHA256 secret shared with Gitea
+// JCI_RUNNER_SECRET shared secret that runners authenticate with
+// JCI_MAX_JOBS max concurrent jobs per runner (default 4)
+// JCI_JOB_TIMEOUT server-wide job timeout (default 60m)
+// JCI_LISTEN listen address (default :8080)
+// JCI_DB SQLite DB path (default /var/lib/jci/server.db)
+// JCI_RUNNER_SCRIPT path to a shell script to send to runners (default: embedded default_runner_script.sh)
+// JCI_RUNNER_IMAGE container image runners should use (default: python:3.11)
+// JCI_JOB_ALLOW_DOCKER_FOR_REPOS comma-separated list of owner/repo that get the Docker socket mounted (e.g. myorg/myrepo,myorg/other)
+//
+// Flags (override env vars):
+//
+// --ip bind IP (default "" = all interfaces)
+// --port bind port (default 8080)
+func Server(args []string) error {
+ fs := flag.NewFlagSet("server", flag.ContinueOnError)
+ flagIP := fs.String("ip", "", "IP address to listen on (default: all interfaces)")
+ flagPort := fs.String("port", "", "port to listen on (default: 8080)")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
cfg, err := loadServerConfig()
if err != nil {
return err
}
+ // Flags override env vars
+ if *flagIP != "" || *flagPort != "" {
+ ip := *flagIP
+ port := *flagPort
+ if port == "" {
+ port = "8080"
+ }
+ cfg.listen = ip + ":" + port
+ }
+
db, err := openServerDB(cfg.dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
- if err := verifyGiteaAdminToken(cfg); err != nil {
+ log.Printf("verifying Gitea token against %s …", cfg.giteaHost)
+ if err := verifyGiteaToken(cfg); err != nil {
return fmt.Errorf("Gitea token check failed: %w", err)
}
+ log.Printf("Gitea token OK")
srv := &coordinationServer{cfg: cfg, db: db}
@@ -65,35 +97,40 @@ func Server(_ []string) error {
// ---------------------------------------------------------------------------
type serverConfig struct {
- giteaHost string
- giteaUser string
- giteaToken string
- webhookSecret string
- runnerSecret string
- maxJobsPerRunner int
- jobTimeout time.Duration
- listen string
- dbPath string
+ giteaHost string
+ giteaUser string
+ giteaToken string
+ webhookSecret string
+ runnerSecret string
+ maxJobsPerRunner int
+ jobTimeout time.Duration
+ listen string
+ dbPath string
+ runnerScript string // base64-encoded script to send to runners
+ runnerImage string // container image runners should use
+ allowDockerForRepos map[string]bool // set of "owner/repo" keys
}
func loadServerConfig() (serverConfig, error) {
cfg := serverConfig{
- maxJobsPerRunner: 4,
- jobTimeout: 60 * time.Minute,
- listen: ":8080",
- dbPath: "/var/lib/jci/server.db",
+ maxJobsPerRunner: 4,
+ jobTimeout: 60 * time.Minute,
+ listen: ":8080",
+ dbPath: "/var/lib/jci/server.db",
+ runnerImage: "python:3.11",
+ allowDockerForRepos: map[string]bool{},
}
cfg.giteaHost = os.Getenv("GITEA_HOST")
cfg.giteaUser = os.Getenv("GITEA_USER")
cfg.giteaToken = os.Getenv("GITEA_TOKEN")
cfg.webhookSecret = os.Getenv("GITEA_WEBHOOK_SECRET")
- cfg.runnerSecret = os.Getenv("RUNNER_SECRET")
+ cfg.runnerSecret = os.Getenv("JCI_RUNNER_SECRET")
missing := []string{}
for _, pair := range [][2]string{
{"GITEA_HOST", cfg.giteaHost}, {"GITEA_USER", cfg.giteaUser},
{"GITEA_TOKEN", cfg.giteaToken}, {"GITEA_WEBHOOK_SECRET", cfg.webhookSecret},
- {"RUNNER_SECRET", cfg.runnerSecret},
+ {"JCI_RUNNER_SECRET", cfg.runnerSecret},
} {
if pair[1] == "" {
missing = append(missing, pair[0])
@@ -123,6 +160,31 @@ func loadServerConfig() (serverConfig, error) {
if v := os.Getenv("JCI_DB"); v != "" {
cfg.dbPath = v
}
+ if v := os.Getenv("JCI_RUNNER_IMAGE"); v != "" {
+ cfg.runnerImage = v
+ }
+
+ // Load runner script: use file from JCI_RUNNER_SCRIPT if set, else embedded default.
+ scriptBytes := defaultRunnerScript
+ if v := os.Getenv("JCI_RUNNER_SCRIPT"); v != "" {
+ b, err := os.ReadFile(v)
+ if err != nil {
+ return cfg, fmt.Errorf("JCI_RUNNER_SCRIPT: %w", err)
+ }
+ scriptBytes = b
+ }
+ cfg.runnerScript = base64.StdEncoding.EncodeToString(scriptBytes)
+
+ // Parse allow-docker repos: "owner/repo,owner2/repo2" → set.
+ if v := os.Getenv("JCI_JOB_ALLOW_DOCKER_FOR_REPOS"); v != "" {
+ for _, entry := range strings.Split(v, ",") {
+ entry = strings.TrimSpace(entry)
+ if entry != "" {
+ cfg.allowDockerForRepos[entry] = true
+ }
+ }
+ }
+
return cfg, nil
}
@@ -144,19 +206,28 @@ func openServerDB(path string) (*sql.DB, error) {
last_seen DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS jobs (
- job_id TEXT PRIMARY KEY,
- repo_owner TEXT NOT NULL,
- repo_name TEXT NOT NULL,
- commit_sha TEXT NOT NULL,
- runner_id TEXT,
- assigned_at DATETIME,
- expires_at DATETIME,
- gitea_token TEXT NOT NULL DEFAULT '',
- status_cache TEXT,
- cache_until DATETIME
+ job_id TEXT PRIMARY KEY,
+ repo_owner TEXT NOT NULL,
+ repo_name TEXT NOT NULL,
+ commit_sha TEXT NOT NULL,
+ runner_id TEXT,
+ assigned_at DATETIME,
+ expires_at DATETIME,
+ gitea_token TEXT NOT NULL DEFAULT '',
+ status_cache TEXT,
+ cache_until DATETIME,
+ allow_docker_socket INTEGER NOT NULL DEFAULT 0
);
`)
- return db, err
+ if err != nil {
+ return db, err
+ }
+ // Migrate existing DB: add allow_docker_socket column if absent.
+ _, err = db.Exec(`ALTER TABLE jobs ADD COLUMN allow_docker_socket INTEGER NOT NULL DEFAULT 0`)
+ if err != nil && !strings.Contains(err.Error(), "duplicate column") {
+ return db, fmt.Errorf("migrate jobs table: %w", err)
+ }
+ return db, nil
}
// ---------------------------------------------------------------------------
@@ -173,6 +244,7 @@ type coordinationServer struct {
// ---------------------------------------------------------------------------
func (s *coordinationServer) handleWebhook(w http.ResponseWriter, r *http.Request) {
+ log.Printf("webhook: incoming request from %s", r.RemoteAddr)
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -185,6 +257,7 @@ func (s *coordinationServer) handleWebhook(w http.ResponseWriter, r *http.Reques
sig := r.Header.Get("X-Gitea-Signature")
if !checkHMAC(body, sig, s.cfg.webhookSecret) {
+ log.Printf("webhook: HMAC check failed from %s (sig=%q)", r.RemoteAddr, sig)
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
@@ -197,6 +270,7 @@ func (s *coordinationServer) handleWebhook(w http.ResponseWriter, r *http.Reques
} `json:"repository"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.After == "" {
+ log.Printf("webhook: ignoring non-push event from %s (no 'after' field)", r.RemoteAddr)
w.WriteHeader(http.StatusOK) // not a push event; ignore
return
}
@@ -207,13 +281,19 @@ func (s *coordinationServer) handleWebhook(w http.ResponseWriter, r *http.Reques
var count int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM jobs WHERE commit_sha = ?`, commit).Scan(&count)
if count > 0 {
+ log.Printf("webhook: duplicate commit %s/%s@%.12s — already queued, ignoring", owner, name, commit)
w.WriteHeader(http.StatusOK)
return
}
+ allowDocker := 0
+ if s.cfg.allowDockerForRepos[owner+"/"+name] {
+ allowDocker = 1
+ }
+
_, err = s.db.Exec(
- `INSERT INTO jobs (job_id, repo_owner, repo_name, commit_sha, status_cache) VALUES (?,?,?,?,'pending')`,
- randomID(), owner, name, commit,
+ `INSERT INTO jobs (job_id, repo_owner, repo_name, commit_sha, status_cache, allow_docker_socket) VALUES (?,?,?,?,'pending',?)`,
+ randomID(), owner, name, commit, allowDocker,
)
if err != nil {
log.Printf("webhook: insert job: %v", err)
@@ -247,11 +327,18 @@ func (s *coordinationServer) handlePoll(w http.ResponseWriter, r *http.Request)
}
// Auto-register / heartbeat
+ var knownRunner int
+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM runners WHERE runner_id = ?`, req.RunnerID).Scan(&knownRunner)
_, _ = s.db.Exec(
`INSERT INTO runners (runner_id, last_seen) VALUES (?,?)
ON CONFLICT(runner_id) DO UPDATE SET last_seen=excluded.last_seen`,
req.RunnerID, time.Now().UTC().Format(time.RFC3339),
)
+ if knownRunner == 0 {
+ log.Printf("new worker registered: %s", req.RunnerID)
+ } else {
+ log.Printf("worker heartbeat: %s", req.RunnerID)
+ }
// Clean expired jobs; finalise completed jobs for this runner
s.reapExpiredJobs()
@@ -261,15 +348,32 @@ func (s *coordinationServer) handlePoll(w http.ResponseWriter, r *http.Request)
var activeCount int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM jobs WHERE runner_id = ?`, req.RunnerID).Scan(&activeCount)
if activeCount >= s.cfg.maxJobsPerRunner {
+ log.Printf("worker %s at capacity (%d/%d jobs); asking it to back off", req.RunnerID, activeCount, s.cfg.maxJobsPerRunner)
w.Header().Set("Retry-After", "5")
w.WriteHeader(http.StatusTooManyRequests)
return
}
- // Pick one unassigned job (FIFO by rowid, round-robin across repos)
- row := s.db.QueryRow(`SELECT job_id, repo_owner, repo_name, commit_sha FROM jobs WHERE runner_id IS NULL ORDER BY rowid LIMIT 1`)
+ // Pick one unassigned job using round-robin across repos:
+ // choose the repo/owner pair with the fewest currently-assigned jobs
+ // (across all runners), then take its oldest (lowest rowid) unassigned job.
+ // This prevents any single repo from monopolising the queue.
var jobID, repoOwner, repoName, commitSHA string
- if err := row.Scan(&jobID, &repoOwner, &repoName, &commitSHA); err == sql.ErrNoRows {
+ var allowDockerSocket int
+ err = s.db.QueryRow(`
+ SELECT j.job_id, j.repo_owner, j.repo_name, j.commit_sha, j.allow_docker_socket
+ FROM jobs j
+ WHERE j.runner_id IS NULL
+ ORDER BY (
+ SELECT COUNT(*) FROM jobs a
+ WHERE a.runner_id IS NOT NULL
+ AND a.repo_owner = j.repo_owner
+ AND a.repo_name = j.repo_name
+ ) ASC, j.rowid ASC
+ LIMIT 1
+ `).Scan(&jobID, &repoOwner, &repoName, &commitSHA, &allowDockerSocket)
+ if err == sql.ErrNoRows {
+ log.Printf("worker %s polled: no pending jobs", req.RunnerID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"job": nil})
return
@@ -278,33 +382,29 @@ func (s *coordinationServer) handlePoll(w http.ResponseWriter, r *http.Request)
return
}
- // Create scoped one-time Gitea token
- scopedToken, err := s.createScopedGiteaToken(repoOwner, repoName, jobID)
- if err != nil {
- log.Printf("poll: create token for job %s: %v", jobID, err)
- http.Error(w, "token creation failed", http.StatusInternalServerError)
- return
- }
-
now := time.Now().UTC()
expiresAt := now.Add(s.cfg.jobTimeout)
_, _ = s.db.Exec(
- `UPDATE jobs SET runner_id=?, assigned_at=?, expires_at=?, gitea_token=?, status_cache='running' WHERE job_id=?`,
- req.RunnerID, now.Format(time.RFC3339), expiresAt.Format(time.RFC3339), scopedToken, jobID,
+ `UPDATE jobs SET runner_id=?, assigned_at=?, expires_at=?, status_cache='running' WHERE job_id=?`,
+ req.RunnerID, now.Format(time.RFC3339), expiresAt.Format(time.RFC3339), jobID,
)
- s.setGiteaCommitStatus(repoOwner, repoName, commitSHA, "pending", "jci: job assigned")
- log.Printf("poll: assigned job %s to runner %s (%s/%s@%.12s)", jobID, req.RunnerID, repoOwner, repoName, commitSHA)
+ s.setGiteaCommitStatus(repoOwner, repoName, commitSHA, "running", "jci: job assigned")
+ log.Printf("job %s assigned to worker %s (%s/%s@%.12s) allowDocker=%v", jobID, req.RunnerID, repoOwner, repoName, commitSHA, allowDockerSocket == 1)
- cloneURL := fmt.Sprintf("https://%s:%s@%s/%s/%s", s.cfg.giteaUser, scopedToken, s.cfg.giteaHost, repoOwner, repoName)
+ cloneURL := fmt.Sprintf("https://%s:%s@%s/%s/%s", s.cfg.giteaUser, s.cfg.giteaToken, s.cfg.giteaHost, repoOwner, repoName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
- "job": map[string]string{
- "job_id": jobID,
- "clone_url": cloneURL,
- "commit_sha": commitSHA,
- "repo_owner": repoOwner,
- "repo_name": repoName,
+ "job": map[string]any{
+ "job_id": jobID,
+ "clone_url": cloneURL,
+ "commit_sha": commitSHA,
+ "repo_owner": repoOwner,
+ "repo_name": repoName,
+ "timeout_seconds": int(s.cfg.jobTimeout.Seconds()),
+ "image": s.cfg.runnerImage,
+ "script": s.cfg.runnerScript,
+ "allow_docker_socket": allowDockerSocket == 1,
},
})
}
@@ -313,51 +413,20 @@ func (s *coordinationServer) handlePoll(w http.ResponseWriter, r *http.Request)
// Gitea API helpers
// ---------------------------------------------------------------------------
-// verifyGiteaAdminToken probes the Gitea token-list endpoint to confirm permissions.
-func verifyGiteaAdminToken(cfg serverConfig) error {
- url := fmt.Sprintf("https://%s/api/v1/users/%s/tokens", cfg.giteaHost, cfg.giteaUser)
+// verifyGiteaToken confirms the token is valid by listing repos accessible to it.
+func verifyGiteaToken(cfg serverConfig) error {
+ url := fmt.Sprintf("https://%s/api/v1/repos/search?limit=1", cfg.giteaHost)
+ log.Printf("gitea: verifying token for host %s", cfg.giteaHost)
resp, err := giteaCall("GET", url, cfg.giteaToken, nil)
if err != nil {
return err
}
if resp.status != 200 {
- return fmt.Errorf("GET %s returned %d — check GITEA_TOKEN permissions", url, resp.status)
+ return fmt.Errorf("GET %s returned %d — check GITEA_TOKEN", url, resp.status)
}
return nil
}
-func (s *coordinationServer) createScopedGiteaToken(owner, repo, jobID string) (string, error) {
- url := fmt.Sprintf("https://%s/api/v1/users/%s/tokens", s.cfg.giteaHost, s.cfg.giteaUser)
- expiry := time.Now().Add(s.cfg.jobTimeout).Format(time.RFC3339)
- bodyData, _ := json.Marshal(map[string]any{
- "name": "jci-job-" + jobID,
- "scopes": []string{"read:repository", "write:repository"},
- "expires_at": expiry,
- })
- resp, err := giteaCall("POST", url, s.cfg.giteaToken, bodyData)
- if err != nil {
- return "", err
- }
- if resp.status != 201 {
- return "", fmt.Errorf("create token: status %d: %s", resp.status, resp.body)
- }
- var result struct {
- SHA1 string `json:"sha1"`
- }
- if err := json.Unmarshal(resp.body, &result); err != nil || result.SHA1 == "" {
- return "", fmt.Errorf("create token: unexpected response: %s", resp.body)
- }
- return result.SHA1, nil
-}
-
-func (s *coordinationServer) deleteScopedGiteaToken(jobID string) {
- name := "jci-job-" + jobID
- url := fmt.Sprintf("https://%s/api/v1/users/%s/tokens/%s", s.cfg.giteaHost, s.cfg.giteaUser, name)
- if _, err := giteaCall("DELETE", url, s.cfg.giteaToken, nil); err != nil {
- log.Printf("delete token %s: %v", name, err)
- }
-}
-
func (s *coordinationServer) setGiteaCommitStatus(owner, repo, commitSHA, state, description string) {
url := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/statuses/%s", s.cfg.giteaHost, owner, repo, commitSHA)
body, _ := json.Marshal(map[string]string{
@@ -365,9 +434,12 @@ func (s *coordinationServer) setGiteaCommitStatus(owner, repo, commitSHA, state,
"description": description,
"context": "jci",
})
- if _, err := giteaCall("POST", url, s.cfg.giteaToken, body); err != nil {
- log.Printf("set commit status %s/%s@%.12s → %s: %v", owner, repo, commitSHA, state, err)
+ resp, err := giteaCall("POST", url, s.cfg.giteaToken, body)
+ if err != nil {
+ log.Printf("set commit status %s/%s@%.12s → %s: error: %v", owner, repo, commitSHA, state, err)
+ return
}
+ log.Printf("set commit status %s/%s@%.12s → %s: HTTP %d: %s", owner, repo, commitSHA, state, resp.status, resp.body)
}
// checkJobStatusOnGitea reads status.txt from the latest refs/jci-runs/<commit>/* on Gitea.
@@ -376,13 +448,23 @@ func (s *coordinationServer) checkJobStatusOnGitea(owner, repo, commitSHA string
// List matching refs
url := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/git/refs/jci-runs/%s", s.cfg.giteaHost, owner, repo, commitSHA)
resp, err := giteaCall("GET", url, s.cfg.giteaToken, nil)
- if err != nil || resp.status != 200 {
+ if err != nil {
+ log.Printf("checkJobStatus %s/%s@%.12s: list refs error: %v", owner, repo, commitSHA, err)
+ return ""
+ }
+ if resp.status != 200 {
+ log.Printf("checkJobStatus %s/%s@%.12s: list refs HTTP %d", owner, repo, commitSHA, resp.status)
return ""
}
var refs []struct {
Object struct{ SHA string } `json:"object"`
}
- if err := json.Unmarshal(resp.body, &refs); err != nil || len(refs) == 0 {
+ if err := json.Unmarshal(resp.body, &refs); err != nil {
+ log.Printf("checkJobStatus %s/%s@%.12s: decode refs: %v", owner, repo, commitSHA, err)
+ return ""
+ }
+ if len(refs) == 0 {
+ log.Printf("checkJobStatus %s/%s@%.12s: no jci-runs refs found yet", owner, repo, commitSHA)
return ""
}
runCommitSHA := refs[len(refs)-1].Object.SHA
@@ -390,7 +472,12 @@ func (s *coordinationServer) checkJobStatusOnGitea(owner, repo, commitSHA string
// Get tree
treeURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/git/trees/%s", s.cfg.giteaHost, owner, repo, runCommitSHA)
treeresp, err := giteaCall("GET", treeURL, s.cfg.giteaToken, nil)
- if err != nil || treeresp.status != 200 {
+ if err != nil {
+ log.Printf("checkJobStatus %s/%s@%.12s: get tree error: %v", owner, repo, commitSHA, err)
+ return ""
+ }
+ if treeresp.status != 200 {
+ log.Printf("checkJobStatus %s/%s@%.12s: get tree HTTP %d", owner, repo, commitSHA, treeresp.status)
return ""
}
var tree struct {
@@ -411,12 +498,18 @@ func (s *coordinationServer) checkJobStatusOnGitea(owner, repo, commitSHA string
}
}
if blobSHA == "" {
+ log.Printf("checkJobStatus %s/%s@%.12s: status.txt not found in tree %s", owner, repo, commitSHA, runCommitSHA)
return ""
}
blobURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/git/blobs/%s", s.cfg.giteaHost, owner, repo, blobSHA)
blobResp, err := giteaCall("GET", blobURL, s.cfg.giteaToken, nil)
- if err != nil || blobResp.status != 200 {
+ if err != nil {
+ log.Printf("checkJobStatus %s/%s@%.12s: get blob error: %v", owner, repo, commitSHA, err)
+ return ""
+ }
+ if blobResp.status != 200 {
+ log.Printf("checkJobStatus %s/%s@%.12s: get blob HTTP %d", owner, repo, commitSHA, blobResp.status)
return ""
}
var blob struct {
@@ -436,8 +529,10 @@ func (s *coordinationServer) checkJobStatusOnGitea(owner, repo, commitSHA string
}
content = strings.TrimSpace(content)
if content == "ok" || content == "err" || content == "running" {
+ log.Printf("checkJobStatus %s/%s@%.12s: status.txt = %q", owner, repo, commitSHA, content)
return content
}
+ log.Printf("checkJobStatus %s/%s@%.12s: unrecognised status.txt content %q", owner, repo, commitSHA, content)
return ""
}
@@ -466,7 +561,6 @@ func (s *coordinationServer) reapExpiredJobs() {
for _, j := range expired {
log.Printf("job %s timed out", j.jobID)
s.setGiteaCommitStatus(j.owner, j.repo, j.commit, "failure", "jci: job timed out")
- s.deleteScopedGiteaToken(j.jobID)
_, _ = s.db.Exec(`DELETE FROM jobs WHERE job_id = ?`, j.jobID)
}
}
@@ -495,11 +589,17 @@ func (s *coordinationServer) syncCompletedJobsForRunner(runnerID string) {
status := j.statusCache
cacheUntil, _ := time.Parse(time.RFC3339, j.cacheUntilStr)
if time.Now().UTC().After(cacheUntil) {
+ log.Printf("sync job %s (%s/%s@%.12s): cache expired, checking Gitea", j.jobID, j.owner, j.repo, j.commit)
if fresh := s.checkJobStatusOnGitea(j.owner, j.repo, j.commit); fresh != "" {
+ log.Printf("sync job %s: status %q → %q", j.jobID, status, fresh)
status = fresh
+ } else {
+ log.Printf("sync job %s: no fresh status from Gitea, keeping %q", j.jobID, status)
}
newCacheUntil := time.Now().UTC().Add(15 * time.Second).Format(time.RFC3339)
_, _ = s.db.Exec(`UPDATE jobs SET status_cache=?, cache_until=? WHERE job_id=?`, status, newCacheUntil, j.jobID)
+ } else {
+ log.Printf("sync job %s (%s/%s@%.12s): status cache still valid (%q until %s)", j.jobID, j.owner, j.repo, j.commit, status, j.cacheUntilStr)
}
if status == "ok" || status == "err" {
@@ -508,7 +608,6 @@ func (s *coordinationServer) syncCompletedJobsForRunner(runnerID string) {
giteaState = "failure"
}
s.setGiteaCommitStatus(j.owner, j.repo, j.commit, giteaState, "jci: job completed")
- s.deleteScopedGiteaToken(j.jobID)
_, _ = s.db.Exec(`DELETE FROM jobs WHERE job_id = ?`, j.jobID)
log.Printf("job %s completed: %s", j.jobID, status)
}
@@ -525,6 +624,7 @@ type giteaResponse struct {
}
func giteaCall(method, url, token string, body []byte) (giteaResponse, error) {
+ log.Printf("gitea: %s %s", method, url)
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
@@ -540,10 +640,12 @@ func giteaCall(method, url, token string, body []byte) (giteaResponse, error) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
+ log.Printf("gitea: %s %s → error: %v", method, url, err)
return giteaResponse{}, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
+ log.Printf("gitea: %s %s → HTTP %d: %s", method, url, resp.StatusCode, data)
return giteaResponse{status: resp.StatusCode, body: data}, nil
}
diff --git a/www_jci/public/index.html b/www_jci/public/index.html
@@ -15,7 +15,7 @@
<div>
<a href="/binaries.html">Binaries</a>
<a href="/releases/file/README.md.html">Docs</a>
- <span id="jci-version">v1.0.3</span>
+ <span id="jci-version">v1.1.4</span>
</div>
</nav>
<h1><img src="assets/logo.png" alt="JCI logo" style="height:1em;width:auto;vertical-align:middle;margin-right:0.4em;">Minimal. Offline. Local.</h1>