Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README | LICENSE

commit 1d4ab12fa21063f35d29cd930ee5db37521d9742
parent 1aa9821cd9b6a63a1061aae84af5eda682d0b250
Author: Arjoonn Sharma <arjoonn@midpathsoftware.com>
Date:   Wed, 20 May 2026 02:46:17 +0000

First draft for webserver (!15)

Reviewed-on: https://gitea.midpathsoftware.com/midpath/jayporeci/pulls/15
Co-authored-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>
Co-committed-by: Arjoonn Sharma <arjoonn@midpathsoftware.com>

Diffstat:
AARCH.md | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MVERSION | 2+-
Mcmd/git-jci/main.go | 16++++++++++++++--
Mgo.mod | 14++++++++++++++
Ago.sum | 43+++++++++++++++++++++++++++++++++++++++++++
Dinternal/jci/cron_parser.go | 77-----------------------------------------------------------------------------
Dinternal/jci/cron_types.go | 87-------------------------------------------------------------------------------
Minternal/jci/git.go | 40+++-------------------------------------
Minternal/jci/prune.go | 94+++++++++++++++++++++++++++----------------------------------------------------
Minternal/jci/pull.go | 9+--------
Minternal/jci/push.go | 20+++-----------------
Minternal/jci/run.go | 17+++++++++++------
Ainternal/jci/runner.go | 285+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/jci/server.go | 576+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/jci/web.go | 112++++++++++++++++---------------------------------------------------------------
Mwww_jci/public/index.html | 4+++-
17 files changed, 1462 insertions(+), 388 deletions(-)

diff --git a/ARCH.md b/ARCH.md @@ -0,0 +1,367 @@ +# 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 @@ -9,6 +9,10 @@ - [Scheduling with crontab](#scheduling-with-crontab) - [Environment Vars](#environment-vars) - [Example workflow](#example-workflow) +- [Distributed CI (server + runner)](#distributed-ci-server-runner) + - [`git jci server`](#git-jci-server) + - [`git jci runner`](#git-jci-runner) + - [End-to-end flow](#end-to-end-flow) - [How it works](#how-it-works) - [Viewing results](#viewing-results) - [Sharing results with teammates](#sharing-results-with-teammates) @@ -149,6 +153,89 @@ git jci cron sync # install/update entries in the system crontab You can also trigger `git jci run` automatically via [git hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) so CI runs on every commit without any manual step. +## Distributed CI (server + runner) + +`git jci server` and `git jci runner` add webhook-driven, distributed CI on top of +the local workflow. The server is a thin coordination layer; all actual CI work runs +inside runner-managed Docker containers. + +``` +Gitea ──webhook──▶ jci server ◀──poll── jci runner (Docker / systemd) + │ │ + │ Gitea API │ docker socket + ▼ ▼ + Gitea job container + (set status) (git-jci run → git-jci push) +``` + +### `git jci server` + +Runs in the foreground (managed by Docker or systemd). Receives Gitea webhooks, +queues jobs in a local SQLite database, and hands them to runners on demand. + +**Environment variables** + +| Variable | Description | +|---|---| +| `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_WEBHOOK_SECRET` | HMAC secret shared with the Gitea webhook | +| `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`) | + +**Endpoints** + +| Method + path | Description | +|---|---| +| `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. + +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). + +### `git jci runner` + +Runs in the foreground alongside a Docker daemon. Polls the server every 5 s +(+ 0–2 s random jitter) and launches one Docker container per job. + +**Environment variables** + +| Variable | Description | +|---|---| +| `JCI_SERVER` | Server URL (e.g. `https://jci.example.com`) | +| `JCI_RUNNER_SECRET` | Shared secret matching `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`). +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. + +The job container never contacts the server directly; all Gitea API interaction +happens server-side. + +### End-to-end flow + +``` +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) +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 +``` + +--- + ## 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. diff --git a/VERSION b/VERSION @@ -1 +1 @@ -1.0.2 +1.0.3 diff --git a/cmd/git-jci/main.go b/cmd/git-jci/main.go @@ -34,6 +34,10 @@ func main() { err = jci.Prune(args) case "cron": err = jci.Cron(args) + case "server": + err = jci.Server(args) + case "runner": + err = jci.Runner(args) case "version", "--version", "-v": fmt.Println("git-jci version " + version) return @@ -65,8 +69,16 @@ Commands: prune Remove old CI results cron ls List cron jobs for this repository cron sync Sync .jci/crontab with system cron + server Run the coordination server (webhook + runner poll) + runner Run the job runner (polls server, dispatches Docker containers) version Print the version and exit -CI results are stored in refs/jci/<commit>. -With --multi, results are stored in refs/jci-runs/<commit>/<runid>.`, version) +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 + +Distributed CI env vars (runner): + JCI_SERVER, JCI_RUNNER_SECRET, JCI_RUNNER_DB, JCI_BINARY`, version) } diff --git a/go.mod b/go.mod @@ -1,3 +1,17 @@ module github.com/theSage21/jaypore_ci go 1.22 + +require modernc.org/sqlite v1.34.5 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,43 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/jci/cron_parser.go b/internal/jci/cron_parser.go @@ -1,77 +0,0 @@ -package jci - -import ( - "bufio" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" -) - -// LoadCronEntries opens .jci/crontab (if it exists) and parses all entries. -func LoadCronEntries(repoRoot string) ([]CronEntry, error) { - cronPath := filepath.Join(repoRoot, ".jci", "crontab") - f, err := os.Open(cronPath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer f.Close() - - return parseCronEntries(f) -} - -func parseCronEntries(r io.Reader) ([]CronEntry, error) { - scanner := bufio.NewScanner(r) - var entries []CronEntry - lineNum := 0 - - for scanner.Scan() { - lineNum++ - raw := strings.TrimSpace(scanner.Text()) - if raw == "" || strings.HasPrefix(raw, "#") { - continue - } - - schedule, command, err := splitCronLine(raw) - if err != nil { - return nil, fmt.Errorf("line %d: %w", lineNum, err) - } - - entries = append(entries, CronEntry{ - Line: lineNum, - Schedule: schedule, - Command: command, - Raw: raw, - }) - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - return entries, nil -} - -func splitCronLine(raw string) (string, string, error) { - if strings.HasPrefix(raw, "@") { - parts := strings.Fields(raw) - if len(parts) < 2 { - return "", "", errors.New("missing command for cron entry") - } - return parts[0], strings.Join(parts[1:], " "), nil - } - - parts := strings.Fields(raw) - if len(parts) < 6 { - return "", "", errors.New("cron entry must have 5 time fields and a command") - } - - schedule := strings.Join(parts[:5], " ") - command := strings.Join(parts[5:], " ") - return schedule, command, nil -} diff --git a/internal/jci/cron_types.go b/internal/jci/cron_types.go @@ -1,87 +0,0 @@ -package jci - -import ( - "crypto/sha1" - "encoding/hex" - "fmt" - "path/filepath" - "strings" -) - -type CronJobType string - -const ( - CronJobGitJCI CronJobType = "git-jci" - CronJobBinary CronJobType = "binary" - CronJobShell CronJobType = "shell" -) - -type CronJob struct { - ID string - Schedule string - Command string - Type CronJobType - BinaryPath string - BinaryArgs string - Line int - CronLog string -} - -func classifyCronCommand(command string, repoRoot string) (CronJobType, string, string) { - trimmed := strings.TrimSpace(command) - if trimmed == "" { - return CronJobShell, "", "" - } - - if strings.HasPrefix(trimmed, "git jci") { - return CronJobGitJCI, "", "" - } - - head, tail := splitCommandHeadTail(trimmed) - cleaned := strings.TrimPrefix(head, "./") - if strings.HasPrefix(cleaned, ".jci/") { - abs := filepath.Join(repoRoot, cleaned) - return CronJobBinary, abs, tail - } - - if !strings.Contains(head, "/") { - candidate := filepath.Join(repoRoot, ".jci", head) - return CronJobBinary, candidate, tail - } - - return CronJobShell, trimmed, tail -} - -func cronJobID(schedule, command string) string { - sum := sha1.Sum([]byte(schedule + "\x00" + command)) - return hex.EncodeToString(sum[:]) -} - -func (job CronJob) shellCommand(repoRoot string) string { - var command string - switch job.Type { - case CronJobBinary: - command = shellEscape(job.BinaryPath) - if job.BinaryArgs != "" { - command += " " + job.BinaryArgs - } - default: - command = job.Command - } - - full := fmt.Sprintf("cd %s && %s", shellEscape(repoRoot), command) - if job.CronLog != "" { - full = fmt.Sprintf("%s >> %s 2>&1", full, shellEscape(job.CronLog)) - } - return full -} - -func splitCommandHeadTail(cmd string) (string, string) { - parts := strings.Fields(cmd) - if len(parts) == 0 { - return "", "" - } - head := parts[0] - tail := strings.Join(parts[1:], " ") - return head, tail -} diff --git a/internal/jci/git.go b/internal/jci/git.go @@ -44,12 +44,9 @@ func StoreTree(dir string, commit string, message string, runID string) error { return err } - tmpIndex := repoRoot + "/.git/jci-index" - defer exec.Command("rm", "-f", tmpIndex).Run() - // We need to use git hash-object and mktree to build a tree // from files outside the repo - treeID, err := hashDir(dir, repoRoot, tmpIndex) + treeID, err := hashDir(dir, repoRoot) if err != nil { return fmt.Errorf("failed to hash directory: %w", err) } @@ -73,7 +70,7 @@ func StoreTree(dir string, commit string, message string, runID string) error { } // hashDir recursively hashes a directory and returns its tree ID -func hashDir(dir string, repoRoot string, tmpIndex string) (string, error) { +func hashDir(dir string, repoRoot string) (string, error) { entries, err := os.ReadDir(dir) if err != nil { return "", err @@ -86,7 +83,7 @@ func hashDir(dir string, repoRoot string, tmpIndex string) (string, error) { if entry.IsDir() { // Recursively hash subdirectory - subTreeID, err := hashDir(path, repoRoot, tmpIndex) + subTreeID, err := hashDir(path, repoRoot) if err != nil { return "", err } @@ -131,18 +128,6 @@ func hashDir(dir string, repoRoot string, tmpIndex string) (string, error) { return strings.TrimSpace(string(out)), nil } -// ListJCIRefs returns all refs under refs/jci/ -func ListJCIRefs() ([]string, error) { - out, err := git("for-each-ref", "--format=%(refname:short)", "refs/jci/") - if err != nil { - return nil, err - } - if out == "" { - return nil, nil - } - return strings.Split(out, "\n"), nil -} - // ListJCIRunRefs returns all refs under refs/jci-runs/ func ListJCIRunRefs() ([]string, error) { out, err := git("for-each-ref", "--format=%(refname)", "refs/jci-runs/") @@ -154,22 +139,3 @@ func ListJCIRunRefs() ([]string, error) { } return strings.Split(out, "\n"), nil } - -// ListAllJCIRefs returns all JCI refs (both single and multi-run) -func ListAllJCIRefs() ([]string, error) { - var allRefs []string - - // Get refs/jci/* - out, err := git("for-each-ref", "--format=%(refname)", "refs/jci/") - if err == nil && out != "" { - allRefs = append(allRefs, strings.Split(out, "\n")...) - } - - // Get refs/jci-runs/* - out, err = git("for-each-ref", "--format=%(refname)", "refs/jci-runs/") - if err == nil && out != "" { - allRefs = append(allRefs, strings.Split(out, "\n")...) - } - - return allRefs, nil -} diff --git a/internal/jci/prune.go b/internal/jci/prune.go @@ -20,29 +20,28 @@ type PruneOptions struct { func ParsePruneArgs(args []string) (*PruneOptions, error) { opts := &PruneOptions{} - for _, arg := range args { - if arg == "--commit" { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--commit": opts.Commit = true - } else if strings.HasPrefix(arg, "--on-remote=") { + case strings.HasPrefix(arg, "--on-remote="): opts.OnRemote = strings.TrimPrefix(arg, "--on-remote=") - } else if strings.HasPrefix(arg, "--on-remote") { - // Handle --on-remote origin (space separated) - continue - } else if strings.HasPrefix(arg, "--older-than=") { + case arg == "--on-remote": + if i+1 >= len(args) { + return nil, fmt.Errorf("--on-remote requires a value") + } + i++ + opts.OnRemote = args[i] + case strings.HasPrefix(arg, "--older-than="): durStr := strings.TrimPrefix(arg, "--older-than=") dur, err := parseDuration(durStr) if err != nil { return nil, fmt.Errorf("invalid duration %q: %v", durStr, err) } opts.OlderThan = dur - } else if !strings.HasPrefix(arg, "-") && opts.OnRemote == "" { - // Check if previous arg was --on-remote - for i, a := range args { - if a == "--on-remote" && i+1 < len(args) && args[i+1] == arg { - opts.OnRemote = arg - break - } - } + default: + return nil, fmt.Errorf("unknown argument: %s", arg) } } @@ -97,7 +96,7 @@ func Prune(args []string) error { } func pruneLocal(opts *PruneOptions) error { - refs, err := ListJCIRefs() + refs, err := ListJCIRunRefs() if err != nil { return err } @@ -107,35 +106,26 @@ func pruneLocal(opts *PruneOptions) error { return nil } - // Get info for all refs var refInfos []RefInfo var totalSize int64 fmt.Println("Scanning CI results...") for i, ref := range refs { - commit := strings.TrimPrefix(ref, "jci/") printProgress(i+1, len(refs), "Scanning") - info := RefInfo{ Ref: ref, - Commit: commit, + Commit: extractCommitFromRef(ref), } - - // Get timestamp from the JCI commit - timeStr, err := git("log", "-1", "--format=%ci", "refs/jci/"+commit) + timeStr, err := git("log", "-1", "--format=%ci", ref) if err == nil { info.Timestamp, _ = time.Parse("2006-01-02 15:04:05 -0700", timeStr) } - - // Get size of the tree - info.Size = getRefSize("refs/jci/" + commit) + info.Size = getRefSize(ref) totalSize += info.Size - refInfos = append(refInfos, info) } - fmt.Println() // newline after progress + fmt.Println() - // Filter refs to prune var toPrune []RefInfo var prunedSize int64 now := time.Now() @@ -143,18 +133,13 @@ func pruneLocal(opts *PruneOptions) error { for _, info := range refInfos { shouldPrune := false - // Check if commit still exists (original behavior) - _, err := git("cat-file", "-t", info.Commit) - if err != nil { + // Prune if the source commit no longer exists + if _, err := git("cat-file", "-t", info.Commit); err != nil { shouldPrune = true } - // Check age if --older-than specified - if opts.OlderThan > 0 && !info.Timestamp.IsZero() { - age := now.Sub(info.Timestamp) - if age > opts.OlderThan { - shouldPrune = true - } + if opts.OlderThan > 0 && !info.Timestamp.IsZero() && now.Sub(info.Timestamp) > opts.OlderThan { + shouldPrune = true } if shouldPrune { @@ -169,7 +154,6 @@ func pruneLocal(opts *PruneOptions) error { return nil } - // Show what will be pruned fmt.Printf("\nFound %d ref(s) to prune:\n", len(toPrune)) for _, info := range toPrune { age := "" @@ -178,7 +162,6 @@ func pruneLocal(opts *PruneOptions) error { } fmt.Printf(" %s %s%s\n", info.Commit[:12], formatSize(info.Size), age) } - fmt.Printf("\nTotal to free: %s (of %s total)\n", formatSize(prunedSize), formatSize(totalSize)) if !opts.Commit { @@ -186,20 +169,18 @@ func pruneLocal(opts *PruneOptions) error { return nil } - // Actually delete fmt.Println("\nDeleting...") deleted := 0 for i, info := range toPrune { printProgress(i+1, len(toPrune), "Deleting") - if _, err := git("update-ref", "-d", "refs/jci/"+info.Commit); err != nil { - fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Commit[:12], err) + if _, err := git("update-ref", "-d", info.Ref); err != nil { + fmt.Printf("\n Warning: failed to delete %s: %v\n", info.Ref, err) continue } deleted++ } - fmt.Println() // newline after progress + fmt.Println() - // Run gc to actually free space fmt.Println("Running git gc...") exec.Command("git", "gc", "--prune=now", "--quiet").Run() @@ -212,10 +193,8 @@ func pruneRemote(opts *PruneOptions) error { fmt.Printf("Fetching CI refs from %s...\n", remote) - // Get remote refs (both jci and jci-runs) - out1, _ := git("ls-remote", remote, "refs/jci/*") - out2, _ := git("ls-remote", remote, "refs/jci-runs/*") - out := strings.TrimSpace(out1 + "\n" + out2) + out, _ := git("ls-remote", remote, "refs/jci-runs/*") + out = strings.TrimSpace(out) if out == "" { fmt.Println("No CI results on remote") @@ -402,20 +381,11 @@ func formatAge(d time.Duration) string { return "<1h" } -// extractCommitFromRef extracts the commit hash from a JCI ref -// refs/jci/<commit> -> <commit> -// refs/jci-runs/<commit>/<runid> -> <commit> +// extractCommitFromRef extracts the commit hash from a refs/jci-runs/<commit>/<runid> ref func extractCommitFromRef(ref string) string { - if strings.HasPrefix(ref, "refs/jci-runs/") { - // refs/jci-runs/<commit>/<runid> - parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/") - if len(parts) >= 1 { - return parts[0] - } - } else if strings.HasPrefix(ref, "refs/jci/") { - return strings.TrimPrefix(ref, "refs/jci/") - } else if strings.HasPrefix(ref, "jci/") { - return strings.TrimPrefix(ref, "jci/") + parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/") + if len(parts) >= 1 { + return parts[0] } return ref } diff --git a/internal/jci/pull.go b/internal/jci/pull.go @@ -13,14 +13,7 @@ func Pull(args []string) error { fmt.Printf("Fetching CI results from %s...\n", remote) - // Fetch all refs/jci/* from remote - _, err := git("fetch", remote, "refs/jci/*:refs/jci/*") - if err != nil { - fmt.Printf("Warning: %v\n", err) - } - - // Fetch all refs/jci-runs/* from remote - _, err = git("fetch", remote, "refs/jci-runs/*:refs/jci-runs/*") + _, err := git("fetch", remote, "refs/jci-runs/*:refs/jci-runs/*") if err != nil { fmt.Printf("Warning: %v\n", err) } diff --git a/internal/jci/push.go b/internal/jci/push.go @@ -13,7 +13,7 @@ func Push(args []string) error { } // Get all local JCI refs - localRefs, err := ListAllJCIRefs() + localRefs, err := ListJCIRunRefs() if err != nil { return err } @@ -54,12 +54,10 @@ func Push(args []string) error { return nil } -// getRemoteJCIRefs returns a set of refs that exist on the remote +// getRemoteJCIRefs returns a set of refs/jci-runs/* refs that exist on the remote func getRemoteJCIRefs(remote string) map[string]bool { remoteCI := make(map[string]bool) - - // Get refs/jci/* - out, err := git("ls-remote", "--refs", remote, "refs/jci/*") + out, err := git("ls-remote", "--refs", remote, "refs/jci-runs/*") if err == nil && out != "" { for _, line := range strings.Split(out, "\n") { parts := strings.Fields(line) @@ -68,17 +66,5 @@ func getRemoteJCIRefs(remote string) map[string]bool { } } } - - // Get refs/jci-runs/* - out, err = git("ls-remote", "--refs", remote, "refs/jci-runs/*") - if err == nil && out != "" { - for _, line := range strings.Split(out, "\n") { - parts := strings.Fields(line) - if len(parts) >= 2 { - remoteCI[parts[1]] = true - } - } - } - return remoteCI } diff --git a/internal/jci/run.go b/internal/jci/run.go @@ -57,9 +57,12 @@ func Run(args []string) error { os.WriteFile(statusFile, []byte("ok"), 0644) } - // Generate index.html with results - if err := generateIndexHTML(outputDir, commit, err); err != nil { - fmt.Printf("Warning: failed to generate index.html: %v\n", err) + // Generate index.html only if the job didn't already produce one + indexPath := filepath.Join(outputDir, "index.html") + if _, statErr := os.Stat(indexPath); os.IsNotExist(statErr) { + if htmlErr := generateIndexHTML(outputDir, commit, err); htmlErr != nil { + fmt.Printf("Warning: failed to generate index.html: %v\n", htmlErr) + } } // Store results in git @@ -203,7 +206,9 @@ func escapeHTML(s string) string { func generateRunID() string { timestamp := time.Now().Unix() b := make([]byte, 2) - rand.Read(b) - randomSuffix := hex.EncodeToString(b) - return fmt.Sprintf("%d-%s", timestamp, randomSuffix) + if _, err := rand.Read(b); err != nil { + // Extremely unlikely; fall back to timestamp-only to avoid blocking + return fmt.Sprintf("%d-0000", timestamp) + } + return fmt.Sprintf("%d-%s", timestamp, hex.EncodeToString(b)) } diff --git a/internal/jci/runner.go b/internal/jci/runner.go @@ -0,0 +1,285 @@ +package jci + +import ( + "bytes" + "database/sql" + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/http" + "os" + "os/exec" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// Runner polls the jci server for jobs and dispatches them into Docker containers. +// +// Configuration env vars: +// +// JCI_SERVER base URL of the jci server, e.g. https://jci.example.com +// 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) +func Runner(_ []string) error { + cfg, err := loadRunnerConfig() + if err != nil { + return err + } + + db, err := openRunnerDB(cfg.dbPath) + if err != nil { + return fmt.Errorf("open runner db: %w", err) + } + defer db.Close() + + runnerID, err := getOrCreateRunnerID(db) + if err != nil { + return fmt.Errorf("runner identity: %w", err) + } + log.Printf("jci runner starting (id=%s)", runnerID) + + r := &runner{cfg: cfg, db: db, runnerID: runnerID} + r.pollLoop() + return nil // pollLoop runs forever; return only on fatal errors +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +type runnerConfig struct { + serverURL string + runnerSecret string + dbPath string + binaryPath string +} + +func loadRunnerConfig() (runnerConfig, error) { + cfg := runnerConfig{ + dbPath: "/var/lib/jci/runner.db", + binaryPath: "/usr/local/bin/git-jci", + } + cfg.serverURL = os.Getenv("JCI_SERVER") + cfg.runnerSecret = os.Getenv("JCI_RUNNER_SECRET") + + if cfg.serverURL == "" || cfg.runnerSecret == "" { + return cfg, fmt.Errorf("missing required env vars: JCI_SERVER, JCI_RUNNER_SECRET") + } + if v := os.Getenv("JCI_RUNNER_DB"); v != "" { + cfg.dbPath = v + } + if v := os.Getenv("JCI_BINARY"); v != "" { + cfg.binaryPath = v + } + return cfg, nil +} + +// --------------------------------------------------------------------------- +// Runner SQLite identity store +// --------------------------------------------------------------------------- + +func openRunnerDB(path string) (*sql.DB, error) { + if err := os.MkdirAll(parentDir(path), 0755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + _, err = db.Exec(`CREATE TABLE IF NOT EXISTS identity (runner_id TEXT PRIMARY KEY)`) + return db, err +} + +func getOrCreateRunnerID(db *sql.DB) (string, error) { + var id string + err := db.QueryRow(`SELECT runner_id FROM identity LIMIT 1`).Scan(&id) + if err == nil { + return id, nil + } + if err != sql.ErrNoRows { + return "", err + } + id = randomID() + _, err = db.Exec(`INSERT INTO identity (runner_id) VALUES (?)`, id) + return id, err +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +type runner struct { + cfg runnerConfig + db *sql.DB + runnerID string +} + +// 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) + } + }() + + for { + jitter := time.Duration(rand.Intn(2000)) * time.Millisecond + time.Sleep(5*time.Second + jitter) + + retryAfter, job, err := r.poll() + if err != nil { + log.Printf("poll error: %v", err) + continue + } + if retryAfter > 0 { + log.Printf("runner at capacity; backing off %s", retryAfter) + time.Sleep(retryAfter) + continue + } + if job == nil { + continue + } + go r.dispatch(job) // non-blocking; runner returns to poll immediately + } +} + +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"` +} + +// 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) { + body, _ := json.Marshal(map[string]string{ + "runner_id": r.runnerID, + "secret": r.cfg.runnerSecret, + }) + resp, err := http.Post(r.cfg.serverURL+"/poll", "application/json", bytes.NewReader(body)) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusTooManyRequests { + secs := 5 + if v := resp.Header.Get("Retry-After"); v != "" { + fmt.Sscanf(v, "%d", &secs) + } + return time.Duration(secs) * time.Second, nil, nil + } + if resp.StatusCode != http.StatusOK { + data, _ := io.ReadAll(resp.Body) + return 0, nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, data) + } + + var result struct { + Job *serverJob `json:"job"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, nil, fmt.Errorf("decode poll response: %w", err) + } + 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) + + 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]) + + // 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, + } + + cmd := exec.Command("docker", args...) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("docker run: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// 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() + if err != nil { + log.Printf("reaper: docker ps: %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 { + continue + } + cid := parts[0] + // Inspect to get exact start time + inspectOut, err := exec.Command("docker", "inspect", "--format", "{{.State.StartedAt}}", cid).Output() + if err != nil { + continue + } + startedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(inspectOut))) + if err != nil { + 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() + } + } +} + +// shellQuote wraps s in single quotes, escaping any existing single quotes. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/internal/jci/server.go b/internal/jci/server.go @@ -0,0 +1,576 @@ +package jci + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// 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 { + cfg, err := loadServerConfig() + if err != nil { + return err + } + + db, err := openServerDB(cfg.dbPath) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + if err := verifyGiteaAdminToken(cfg); err != nil { + return fmt.Errorf("Gitea token check failed: %w", err) + } + + srv := &coordinationServer{cfg: cfg, db: db} + + mux := http.NewServeMux() + mux.HandleFunc("/webhook", srv.handleWebhook) + mux.HandleFunc("/poll", srv.handlePoll) + + log.Printf("jci server listening on %s", cfg.listen) + return http.ListenAndServe(cfg.listen, mux) +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +type serverConfig struct { + giteaHost string + giteaUser string + giteaToken string + webhookSecret string + runnerSecret string + maxJobsPerRunner int + jobTimeout time.Duration + listen string + dbPath string +} + +func loadServerConfig() (serverConfig, error) { + cfg := serverConfig{ + maxJobsPerRunner: 4, + jobTimeout: 60 * time.Minute, + listen: ":8080", + dbPath: "/var/lib/jci/server.db", + } + 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") + + 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}, + } { + if pair[1] == "" { + missing = append(missing, pair[0]) + } + } + if len(missing) > 0 { + return cfg, fmt.Errorf("missing required env vars: %s", strings.Join(missing, ", ")) + } + + if v := os.Getenv("JCI_MAX_JOBS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil { + return cfg, fmt.Errorf("JCI_MAX_JOBS must be an integer: %w", err) + } + cfg.maxJobsPerRunner = n + } + if v := os.Getenv("JCI_JOB_TIMEOUT"); v != "" { + d, err := time.ParseDuration(v) + if err != nil { + return cfg, fmt.Errorf("JCI_JOB_TIMEOUT: %w", err) + } + cfg.jobTimeout = d + } + if v := os.Getenv("JCI_LISTEN"); v != "" { + cfg.listen = v + } + if v := os.Getenv("JCI_DB"); v != "" { + cfg.dbPath = v + } + return cfg, nil +} + +// --------------------------------------------------------------------------- +// SQLite schema +// --------------------------------------------------------------------------- + +func openServerDB(path string) (*sql.DB, error) { + if err := os.MkdirAll(parentDir(path), 0755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS runners ( + runner_id TEXT PRIMARY KEY, + 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 + ); + `) + return db, err +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +type coordinationServer struct { + cfg serverConfig + db *sql.DB +} + +// --------------------------------------------------------------------------- +// Webhook handler +// --------------------------------------------------------------------------- + +func (s *coordinationServer) handleWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + + sig := r.Header.Get("X-Gitea-Signature") + if !checkHMAC(body, sig, s.cfg.webhookSecret) { + http.Error(w, "invalid signature", http.StatusBadRequest) + return + } + + var payload struct { + After string `json:"after"` + Repo struct { + Owner struct{ Login string } `json:"owner"` + Name string `json:"name"` + } `json:"repository"` + } + if err := json.Unmarshal(body, &payload); err != nil || payload.After == "" { + w.WriteHeader(http.StatusOK) // not a push event; ignore + return + } + + owner, name, commit := payload.Repo.Owner.Login, payload.Repo.Name, payload.After + + // Deduplicate on commit SHA + var count int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM jobs WHERE commit_sha = ?`, commit).Scan(&count) + if count > 0 { + w.WriteHeader(http.StatusOK) + return + } + + _, err = s.db.Exec( + `INSERT INTO jobs (job_id, repo_owner, repo_name, commit_sha, status_cache) VALUES (?,?,?,?,'pending')`, + randomID(), owner, name, commit, + ) + if err != nil { + log.Printf("webhook: insert job: %v", err) + http.Error(w, "db error", http.StatusInternalServerError) + return + } + log.Printf("webhook: queued %s/%s@%.12s", owner, name, commit) + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// Poll handler +// --------------------------------------------------------------------------- + +func (s *coordinationServer) handlePoll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + RunnerID string `json:"runner_id"` + Secret string `json:"secret"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.Secret != s.cfg.runnerSecret { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + // Auto-register / heartbeat + _, _ = 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), + ) + + // Clean expired jobs; finalise completed jobs for this runner + s.reapExpiredJobs() + s.syncCompletedJobsForRunner(req.RunnerID) + + // Capacity check + var activeCount int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM jobs WHERE runner_id = ?`, req.RunnerID).Scan(&activeCount) + if 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`) + var jobID, repoOwner, repoName, commitSHA string + if err := row.Scan(&jobID, &repoOwner, &repoName, &commitSHA); err == sql.ErrNoRows { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"job": nil}) + return + } else if err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + 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, + ) + + 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) + + cloneURL := fmt.Sprintf("https://%s:%s@%s/%s/%s", s.cfg.giteaUser, scopedToken, 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, + }, + }) +} + +// --------------------------------------------------------------------------- +// 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) + 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 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{ + "state": 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) + } +} + +// checkJobStatusOnGitea reads status.txt from the latest refs/jci-runs/<commit>/* on Gitea. +// Returns "ok", "err", "running", or "" (unknown/not found). +func (s *coordinationServer) checkJobStatusOnGitea(owner, repo, commitSHA string) 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 { + return "" + } + var refs []struct { + Object struct{ SHA string } `json:"object"` + } + if err := json.Unmarshal(resp.body, &refs); err != nil || len(refs) == 0 { + return "" + } + runCommitSHA := refs[len(refs)-1].Object.SHA + + // 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 { + return "" + } + var tree struct { + Tree []struct { + Path string `json:"path"` + SHA string `json:"sha"` + Type string `json:"type"` + } `json:"tree"` + } + if err := json.Unmarshal(treeresp.body, &tree); err != nil { + return "" + } + var blobSHA string + for _, e := range tree.Tree { + if e.Path == "status.txt" && e.Type == "blob" { + blobSHA = e.SHA + break + } + } + if blobSHA == "" { + 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 { + return "" + } + var blob struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := json.Unmarshal(blobResp.body, &blob); err != nil { + return "" + } + content := blob.Content + if blob.Encoding == "base64" { + decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(content, "\n", "")) + if err != nil { + return "" + } + content = string(decoded) + } + content = strings.TrimSpace(content) + if content == "ok" || content == "err" || content == "running" { + return content + } + return "" +} + +// --------------------------------------------------------------------------- +// Background maintenance +// --------------------------------------------------------------------------- + +func (s *coordinationServer) reapExpiredJobs() { + rows, err := s.db.Query( + `SELECT job_id, repo_owner, repo_name, commit_sha FROM jobs WHERE expires_at IS NOT NULL AND expires_at < ?`, + time.Now().UTC().Format(time.RFC3339), + ) + if err != nil { + return + } + type expiredJob struct{ jobID, owner, repo, commit string } + var expired []expiredJob + for rows.Next() { + var j expiredJob + if rows.Scan(&j.jobID, &j.owner, &j.repo, &j.commit) == nil { + expired = append(expired, j) + } + } + rows.Close() + + 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) + } +} + +func (s *coordinationServer) syncCompletedJobsForRunner(runnerID string) { + rows, err := s.db.Query( + `SELECT job_id, repo_owner, repo_name, commit_sha, COALESCE(status_cache,''), COALESCE(cache_until,'') + FROM jobs WHERE runner_id = ?`, runnerID, + ) + if err != nil { + return + } + type jobRow struct { + jobID, owner, repo, commit, statusCache, cacheUntilStr string + } + var jobs []jobRow + for rows.Next() { + var j jobRow + if rows.Scan(&j.jobID, &j.owner, &j.repo, &j.commit, &j.statusCache, &j.cacheUntilStr) == nil { + jobs = append(jobs, j) + } + } + rows.Close() + + for _, j := range jobs { + status := j.statusCache + cacheUntil, _ := time.Parse(time.RFC3339, j.cacheUntilStr) + if time.Now().UTC().After(cacheUntil) { + if fresh := s.checkJobStatusOnGitea(j.owner, j.repo, j.commit); fresh != "" { + status = fresh + } + 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) + } + + if status == "ok" || status == "err" { + giteaState := "success" + if status == "err" { + 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) + } + } +} + +// --------------------------------------------------------------------------- +// Generic Gitea HTTP helper +// --------------------------------------------------------------------------- + +type giteaResponse struct { + status int + body []byte +} + +func giteaCall(method, url, token string, body []byte) (giteaResponse, error) { + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url, bodyReader) + if err != nil { + return giteaResponse{}, err + } + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return giteaResponse{}, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return giteaResponse{status: resp.StatusCode, body: data}, nil +} + +// --------------------------------------------------------------------------- +// Tiny utilities +// --------------------------------------------------------------------------- + +func checkHMAC(body []byte, sig, secret string) bool { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(sig), []byte(expected)) +} + +func randomID() string { + b := make([]byte, 16) + rand.Read(b) + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + +// parentDir returns the directory component of a file path without importing filepath +// (avoids a collision with the filepath import in other files in the package). +func parentDir(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' || path[i] == '\\' { + return path[:i] + } + } + return "." +} diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "os/exec" "path/filepath" "strings" @@ -23,8 +22,7 @@ type CommitInfo struct { Message string `json:"message"` HasCI bool `json:"hasCI"` CIStatus string `json:"ciStatus"` // "success", "failed", or "" - CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote - Runs []RunInfo `json:"runs"` // multiple runs (for cron) + Runs []RunInfo `json:"runs"` } // RunInfo holds info about a single CI run @@ -117,44 +115,20 @@ func getLocalBranches() ([]string, error) { return strings.Split(out, "\n"), nil } -// getRemoteJCIRefs returns a set of commits that have CI refs pushed to remote - - - -// getCIStatus returns "success", "failed", or "running" based on status.txt -func getCIStatus(commit string) string { - return getCIStatusFromRef("refs/jci/" + commit) -} - -// getCIStatusFromRef returns status from any ref +// getCIStatusFromRef returns status from a refs/jci-runs/ ref func getCIStatusFromRef(ref string) string { - // Try to read status.txt (new format) cmd := exec.Command("git", "show", ref+":status.txt") out, err := cmd.Output() - if err == nil { - status := strings.TrimSpace(string(out)) - switch status { - case "ok": - return "success" - case "err": - return "failed" - case "running": - return "running" - } - } - - // Fallback: parse index.html for old results - cmd = exec.Command("git", "show", ref+":index.html") - out, err = cmd.Output() if err != nil { return "" } - content := string(out) - if strings.Contains(content, "PASSED") || strings.Contains(content, "SUCCESS") { + switch strings.TrimSpace(string(out)) { + case "ok": return "success" - } - if strings.Contains(content, "FAILED") { + case "err": return "failed" + case "running": + return "running" } return "" } @@ -186,18 +160,14 @@ func serveCommitAPI(w http.ResponseWriter, commit string) { ref = "refs/jci-runs/" + actualCommit + "/" + currentRunID } else { actualCommit = commit - ref = "refs/jci/" + commit - // Check if single-run ref exists, otherwise try to find latest run - if !RefExists(ref) { - // Look for runs - get the latest one - runRefs, _ := ListJCIRunRefs() - for _, r := range runRefs { - if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { - ref = r // Use the last one (they're sorted) - parts := strings.Split(r, "/") - if len(parts) >= 4 { - currentRunID = parts[3] - } + // Find the latest run for this commit + runRefs, _ := ListJCIRunRefs() + for _, r := range runRefs { + if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { + ref = r // last one wins (sorted by timestamp) + parts := strings.Split(r, "/") + if len(parts) >= 4 { + currentRunID = parts[3] } } } @@ -345,15 +315,7 @@ func getBranchCommitsPaginated(branch string, offset, limit int) ([]CommitInfo, return nil, nil } - // Get local JCI refs (single-run) - jciRefs, _ := ListJCIRefs() - jciSet := make(map[string]bool) - for _, ref := range jciRefs { - commit := strings.TrimPrefix(ref, "jci/") - jciSet[commit] = true - } - - // Get local JCI run refs (multi-run): commit -> list of run refs + // Get local JCI run refs: commit -> list of run refs jciRuns := make(map[string][]string) runRefs, _ := ListJCIRunRefs() for _, ref := range runRefs { @@ -364,9 +326,6 @@ func getBranchCommitsPaginated(branch string, offset, limit int) ([]CommitInfo, } } - // Get remote JCI refs for CI push status - remoteCI := getRemoteJCIRefs("origin") - var commits []CommitInfo for _, line := range strings.Split(out, "\n") { parts := strings.SplitN(line, "|", 2) @@ -376,17 +335,12 @@ func getBranchCommitsPaginated(branch string, offset, limit int) ([]CommitInfo, hash := parts[0] msg := parts[1] - hasCI := jciSet[hash] || len(jciRuns[hash]) > 0 + hasCI := len(jciRuns[hash]) > 0 commit := CommitInfo{ Hash: hash, ShortHash: hash[:7], Message: msg, HasCI: hasCI, - CIPushed: remoteCI["refs/jci/"+hash], - } - - if jciSet[hash] { - commit.CIStatus = getCIStatus(hash) } for _, runRef := range jciRuns[hash] { @@ -1027,18 +981,14 @@ func showMainPage(w http.ResponseWriter, r *http.Request) { func serveFromRef(w http.ResponseWriter, commit string, runID string, filePath string) { var ref string - // Build ref based on whether we have a runID if runID != "" { ref = "refs/jci-runs/" + commit + "/" + runID } else { - ref = "refs/jci/" + commit - // If single-run ref doesn't exist, try to find latest run - if !RefExists(ref) { - runRefs, _ := ListJCIRunRefs() - for _, r := range runRefs { - if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { - ref = r // Use last matching (sorted by timestamp) - } + // No runID: find the latest run for this commit + runRefs, _ := ListJCIRunRefs() + for _, r := range runRefs { + if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { + ref = r // last one wins (sorted by timestamp) } } } @@ -1077,21 +1027,3 @@ func serveFromRef(w http.ResponseWriter, commit string, runID string, filePath s w.Write(out) } - -// extractRef extracts files from a ref to a temp directory (not used currently but useful) -func extractRef(ref string) (string, error) { - tmpDir, err := os.MkdirTemp("", "jci-view-*") - if err != nil { - return "", err - } - - cmd := exec.Command("git", "archive", ref) - tar := exec.Command("tar", "-xf", "-", "-C", tmpDir) - - tar.Stdin, _ = cmd.StdoutPipe() - tar.Start() - cmd.Run() - tar.Wait() - - return tmpDir, 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.2</span> + <span id="jci-version">v1.0.3</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> @@ -78,6 +78,8 @@ Your support allows for continued maintenance and new features. <li><code>git jci web</code> runs a web server, browse the CI results locally.</li> <li><code>git jci push/pull</code> syncs refs with your remotes.</li> <li><code>git jci cron sync</code> syncs your cron spec with system cron so that you can run midnight builds for example.</li> + <li><code>git jci server</code> runs the coordination server that receives Gitea webhooks and hands jobs to runners.</li> + <li><code>git jci runner</code> polls the server and executes jobs inside Docker containers for distributed CI.</li> </ul> </section>