Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 4: a runners page, built from the queue's own presence

ac43353160989ae56b6a92ae8f1df1017c2c20c4 · Keenan Nemetz · 16d ago · parent 760b523a

Phase 4: a runners page, built from the queue's own presence

"Nobody is connected and eleven jobs are waiting" is the sentence somebody needs when
a build does not start, and until now nothing could say it: a runner existed only as
an SSH channel, invisible to both the web and the queue. `runs.db` could already
answer "who holds this lease" (lease_owner, lease_until) and "who ran this" (the
attestation), but not "who is connected and idle".

Presence is now a row per channel in the queue — `runner_sessions` — written by events
a runner already sends: connect, lease (which records the run it holds), heartbeat (a
long build must not look gone), and finish (which clears it). A clean disconnect drops
the row, and `watchLeases` forgets rows silent for ten minutes, which is the only trace
a killed process leaves. Nothing here is authoritative: presence is a derived fact
about a live channel, which is why the page applies its own two-minute window rather
than trusting a stored "online" flag.

GET /runners shows the connected runners (account, the job it is building and its
repository, or "waiting for work") and the queue depth per repository. It follows the
clone rule like every other page: a session building a private repository is not shown
at all, and a private repository's queue is not counted, because naming either would
confirm the repository exists. The header now links to it from every page.

Measured: TestARunnerSessionIsVisibleWhileItIsConnected (a connected runner is one row,
idle, then "building <run>" once it leases), TestRunnersPageShowsTheRunnerHoldingAJob
AndTheQueueDepth (the page names the account, links the run it is building and counts
one waiting and one running), TestRunnersPageKeepsAPrivateRepositoryOut (no repository
name, account, job or run id from a private repository appears).

internal/sshserver/runner.go +24−1

@@ -5,6 +5,7 @@ import (
5 5 "fmt"
6 6 "time"
7 7
8 "github.com/rodent-software/vole/internal/id"
8 9 "github.com/rodent-software/vole/internal/issues"
9 10 "github.com/rodent-software/vole/internal/runs"
10 11 "golang.org/x/crypto/ssh"
@@ -122,6 +123,18 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
122 123 dec := json.NewDecoder(ch)
123 124 enc := json.NewEncoder(ch)
124 125 owner := accountID
126 // Presence is one row per channel: a pool of three runners on one account is
127 // three rows, and the row goes away when the channel does. Nothing else records
128 // that a runner exists, so this is what the runners page shows.
129 sessionID, err := id.New(time.Now().UTC())
130 if err != nil {
131 _ = enc.Encode(runnerReply{Op: "error", Error: err.Error()})
132 return
133 }
134 if err := cfg.Runs.TouchSession(sessionID, accountID, time.Now().UTC()); err != nil {
135 cfg.Log.Warn("runner: cannot record the session", "user", user, "err", err)
136 }
137 defer cfg.Runs.DropSession(sessionID)
125 138 // repos is this session's eligibility list, reused for one poll window. Reading
126 139 // every repository's rules on every lease request is O(repos) per idle runner, and
127 140 // the answer changes only when an admin edits an allowlist — so it is refreshed on
@@ -176,6 +189,9 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
176 189 }
177 190 }
178 191 if run == nil {
192 // An idle runner is still connected: refresh its presence so the page can
193 // say "waiting for work" instead of showing a runner that vanished.
194 _ = cfg.Runs.TouchSession(sessionID, accountID, time.Now().UTC())
179 195 _ = enc.Encode(runnerReply{Op: "lease", OK: true})
180 196 continue
181 197 }
@@ -197,10 +213,13 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
197 213 }
198 214 cfg.Log.Info("runner leased a job", "user", user, "repo", run.Repo,
199 215 "job", run.Job, "sha", run.SHA[:8], "run", run.ID, "attempt", run.Attempts)
216 _ = cfg.Runs.SessionWorking(sessionID, run.Repo, run.ID, time.Now().UTC())
200 217 _ = enc.Encode(runnerReply{Op: "lease", OK: true, Run: job, Lease: int(cfg.Lease.Seconds())})
201 218
202 219 case "heartbeat":
203 // Liveness only: the lease is what a heartbeat buys.
220 // Liveness only: the lease is what a heartbeat buys. Presence is refreshed
221 // here too, so a long build does not make its runner look gone.
222 _ = cfg.Runs.TouchSession(sessionID, accountID, time.Now().UTC())
204 223 ok, err := cfg.Runs.Heartbeat(req.Run, owner, cfg.Lease, time.Now().UTC())
205 224 if err != nil {
206 225 _ = enc.Encode(runnerReply{Op: "heartbeat", Error: err.Error()})
@@ -212,6 +231,10 @@ func (s *Server) serveRunner(ch ssh.Channel, user, accountID string) {
212 231 // Nothing is created here. The runner's record is already on the branch,
213 232 // checked by `pre-receive`; this closes the lease and mirrors the record
214 233 // into the queue so a page or a gate needs no tree walk.
234 //
235 // The session goes idle either way: whether the finish is accepted or the
236 // lease was already taken away, this runner is no longer building anything.
237 _ = cfg.Runs.SessionIdle(sessionID, time.Now().UTC())
215 238 run, err := cfg.Runs.Get(req.Run)
216 239 if err != nil {
217 240 _ = enc.Encode(runnerReply{Op: "finish", Error: err.Error()})