Commit 4e0958a163
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
docs/plans/2026-09-27-ci-trust-and-build-reporting.md added +3250
| @@ -0,0 +1,3250 @@ | ||
| 1 | # CI trust and build reporting implementation plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Untrusted builds get a disposable home and never feed a | |
| 6 | trusted build (#255, #258); `ci/*` statuses belong to the build | |
| 7 | subsystem and merges can wait on named contexts (#258); a build can no | |
| 8 | longer share the runner's source address (#260); a failed build names | |
| 9 | its step, exit and duration on the CLI and the web (#266). | |
| 10 | ||
| 11 | **Architecture:** The claim payload gains an explicit `trusted` flag and | |
| 12 | the instance's public ssh destination. The runner picks the build home | |
| 13 | by trust (persistent per repository for trusted builds, fresh and | |
| 14 | removed for untrusted ones), keeps a loopback runner's builds off the | |
| 15 | host's loopback, and reports the failed step on `runner done`. The | |
| 16 | server refuses `ci/` contexts in `status set`, restricts tree and commit | |
| 17 | reuse to trusted builds on the same image, adds a `required_contexts` | |
| 18 | repository setting that `MergeGates` treats as pending until reported, | |
| 19 | stores the failed step and reason on the build, and cuts the log at its | |
| 20 | `$ <step>` lines for `build log --step` and the build page. | |
| 21 | ||
| 22 | **Tech Stack:** Go, SQLite (hand-written SQL), `html/template`, rootless | |
| 23 | podman with pasta, systemd. | |
| 24 | ||
| 25 | **Spec:** the issues themselves: krz/gitbay#255, #258, #260, #266 (texts | |
| 26 | in the session's `issues.txt`), plus the decisions recorded under | |
| 27 | "Decisions" below. | |
| 28 | ||
| 29 | ## Global Constraints | |
| 30 | ||
| 31 | - Each MR on its own branch off `main`. Commits are signed (the repo | |
| 32 | refuses unsigned), messages reference issues (`Ref #N`, and | |
| 33 | `Closes #N` on the commit that finishes one). No attribution to any | |
| 34 | assistant, model or AI anywhere: commits, MR bodies, comments. No | |
| 35 | `Co-Authored-By` trailer. | |
| 36 | - MR: `gitbay mr create --source <branch> --target main --title "..."`; | |
| 37 | merge with `gitbay mr merge <n> --strategy ff` once CI is green, then | |
| 38 | delete the branch locally and on the remote. Behind main → rebase, | |
| 39 | force-push, merge again. | |
| 40 | - Locally: `go build ./...`, `go vet ./...`, unit tests of touched | |
| 41 | packages, and at most the one e2e test being written | |
| 42 | (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite. | |
| 43 | - Registries that fail CI when a new thing lacks its row: top-level route | |
| 44 | word in `internal/policy/names.go`; new page template in the width map | |
| 45 | of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly` | |
| 46 | command in `readArgs` in `e2e/readonly_test.go`; new control command | |
| 47 | needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test); | |
| 48 | a command reading stdin needs `ReadsStdin: true`. A new or changed | |
| 49 | command `Summary` needs `go test ./cmd/gitbay -run TestSummariesAreCurrent -update`. | |
| 50 | - New migrations: the highest today is 0059. Six plans are written in | |
| 51 | parallel, so numbers are pre-assigned: plan 1 uses 0060–0064, plan 2 | |
| 52 | (this one) 0065–0068, plan 3 0069–0071, plan 4 0072–0074, plan 5 | |
| 53 | 0075–0077, plan 6 0078–0079. Whoever lands second renumbers to the | |
| 54 | next free number at execution time. Migrations come in | |
| 55 | `.up.sql`/`.down.sql` pairs. Hand-written SQL, no ORM. This plan uses | |
| 56 | one: 0065. | |
| 57 | - Secrets travel on stdin, never argv; never logged or echoed. | |
| 58 | - Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model, | |
| 59 | CI, Users, Performance, and the `Architecture/` folder with its | |
| 60 | Known-Gaps table and controls matrix). Update the page in the same MR | |
| 61 | that changes the behaviour it describes, and close the matching | |
| 62 | Known-Gaps row. | |
| 63 | - Writing style: plain, direct, no hype; code comments match the | |
| 64 | surrounding density. Comments and docs state facts, never | |
| 65 | before/after narration. | |
| 66 | - **Deploy order for Parts 1, 3 and 4: `make deploy` (gitbayd) before | |
| 67 | `make deploy-runner`.** A new runner reads `trusted` and `ssh` from the | |
| 68 | claim and sends `--step`/`--reason` to `runner done`; an older server | |
| 69 | omits the first two (the runner then treats every build as untrusted: | |
| 70 | no secrets, disposable home) and refuses the flags with exit 2 (the | |
| 71 | build stays running until the reaper fails it). An older runner | |
| 72 | against a newer server works unchanged. The laptop runner is a brew | |
| 73 | bottle built from a release tag, so it always trails the server. | |
| 74 | - **Runner changes are validated on a scratch repository before the | |
| 75 | bay1 runner touches real repositories** (CLAUDE.md: every deploy that | |
| 76 | skipped this took CI down). The procedure is in the runbook at the end | |
| 77 | of this plan; Parts 1, 3 and 4 each have a validation section there. | |
| 78 | - `cmd/gitbay-runner` tests run on macOS and Linux; nothing here may | |
| 79 | need podman to pass (`e2e/isolation_podman_test.go` is the only podman | |
| 80 | test and skips without it). | |
| 81 | ||
| 82 | ## Decisions | |
| 83 | ||
| 84 | - **#255.** The claim carries `"trusted": true|false` with no | |
| 85 | `omitempty`; a runner that finds the field absent treats the build as | |
| 86 | untrusted. Trusted homes move from `<workdir>/home/<owner>/<name>` to | |
| 87 | `<workdir>/trusted-home/<owner>/<name>`, so a home written before this | |
| 88 | change — which untrusted builds could write — is never read again, | |
| 89 | even before the operator deletes it. An untrusted build's home is | |
| 90 | `<workdir>/build-<id>-home`, created with `os.Mkdir` (so it is new and | |
| 91 | empty) and removed after the build with a helper that first makes | |
| 92 | every directory writable (the Go module cache leaves them 0555). No | |
| 93 | persistent cache for untrusted builds: a fork's build downloads its | |
| 94 | modules each time. The runner also drops secrets for an untrusted | |
| 95 | build, though the server never sends any. | |
| 96 | - **#258.** `status set` refuses any context starting with `ci/`, | |
| 97 | case-folded, with exit 4, before resolving the repository. Tree reuse | |
| 98 | (`SuccessBuildForTree`) and the cancelled-build fallback | |
| 99 | (`SuccessBuildFor`) consider trusted builds only, and tree reuse also | |
| 100 | requires the same `image:` as the job declares. The same-commit dedupe | |
| 101 | in `queueJobs` lets an untrusted build stand only for another | |
| 102 | untrusted queue: a fork head that lands on a branch by fast-forward is | |
| 103 | built again as trusted. `required_contexts` is a list in the | |
| 104 | repository's settings JSON (no migration), set by | |
| 105 | `repo settings require-contexts <owner/name> [<context>...]` (no | |
| 106 | contexts clears it); it applies only while `require_checks` is on, and | |
| 107 | the command says so on stderr when it is off. A missing required | |
| 108 | context makes the combined check `pending` and appears as | |
| 109 | `<context>=missing` in the unmet sentence and in `checks_missing`. | |
| 110 | - **#260.** Reading the code: the bay1 runner polls `git@127.0.0.1` | |
| 111 | (`deploy/gitbay-runner.override.conf:78`); `buildSSH` | |
| 112 | (`cmd/gitbay-runner/main.go:471-486`) sends podman builds to | |
| 113 | `169.254.1.2`, and pasta's default gateway mapping lets a build reach | |
| 114 | the host's loopback, where its connections arrive from `127.0.0.1`. | |
| 115 | The limiter keys on the remote IP (`internal/sshd/ratelimit.go:86`, | |
| 116 | used at `internal/sshd/sshd.go:122`). "Runner on the public address" | |
| 117 | does not separate anything: a build can connect to the public address | |
| 118 | too, and would then share the runner's source there instead. So the | |
| 119 | runner stays on loopback and its builds lose loopback: under podman, | |
| 120 | when the runner's remote is loopback, containers run with | |
| 121 | `--network pasta:--no-map-gw`, and `GITBAY_SSH` is the instance's | |
| 122 | public destination (`git@<site host>`), which the server sends in the | |
| 123 | claim as `ssh`. A build then reaches the host only as an internet | |
| 124 | client does. **Egress policy:** builds, trusted or not, keep outbound | |
| 125 | internet access (a fork's merge request to a Go repository must fetch | |
| 126 | its modules); they get no host loopback; they reach the forge's | |
| 127 | public ports (22, 80, 443, and the admin sshd on 2222) exactly as | |
| 128 | anyone on the internet can. No nftables rules. `-isolation none` | |
| 129 | builds run on the host and share its loopback; that mode is for | |
| 130 | instances where every repository is trusted and says so already. | |
| 131 | - **Finding for #260, recorded for the runbook.** On gitbay.org the | |
| 132 | limiter's failure count is unreachable by an unknown key: | |
| 133 | `authenticate` admits an unknown key as an anonymous `register` | |
| 134 | session whenever `registration.mode` is not `closed` | |
| 135 | (`internal/sshd/sshd.go:136-143`), and `fail` is only called on the | |
| 136 | closed path (`sshd.go:145`). gitbay.org runs `registration = "open"`, | |
| 137 | so the throttling test on bay1 is expected to show no throttling at | |
| 138 | all; the separation matters for closed-registration instances. The | |
| 139 | runbook still measures source addresses on bay1, which is the part of | |
| 140 | #260 that holds on every instance. | |
| 141 | - **#266.** Duration is not stored: `Build.Elapsed()` | |
| 142 | (`internal/store/builds.go:420`) already derives it from `started_at` | |
| 143 | and `finished_at`. Migration 0065 adds `failed_step` (1-based, 0 for | |
| 144 | "no step": success, or a failure before the first step) and | |
| 145 | `failed_reason` (one line, at most 200 bytes). The runner writes | |
| 146 | `step 3/3 failed: exit 1` and reports `runner done <id> failure --step | |
| 147 | 3 --reason 'exit 1'`; the reason is `exit <code>` for a command that | |
| 148 | exited and the error text otherwise (`build timed out after 45m0s`, | |
| 149 | `cancelled`, `git clone: exit 128`). The log format is otherwise | |
| 150 | unchanged: sections are cut at the `$ <step>` line the runner already | |
| 151 | writes before each step (`isolate.go:88`, `:186`), matched against the | |
| 152 | build's own `steps` in order and only at a line start, so logs of | |
| 153 | builds that ran before this change fold too. `build log --step` | |
| 154 | takes `0` (setup, before the first step), a step number, or `failed`. | |
| 155 | An invalid `--step` on `runner done` is recorded as 0 rather than | |
| 156 | refused, so a runner/server mismatch never loses an outcome. | |
| 157 | ||
| 158 | ## Order and dependencies | |
| 159 | ||
| 160 | | # | Branch | Closes | Migration | Needs | | |
| 161 | |---|---|---|---|---| | |
| 162 | | 1 | `ci-untrusted-home` | #255 | — | — | | |
| 163 | | 2 | `ci-status-trust` | #258 | — | — | | |
| 164 | | 3 | `runner-source-address` | Ref #260 (closed by the runbook result commit) | — | Part 1 merged (both edit `runRunnerNext`'s payload and `stepEnv`) | | |
| 165 | | 4 | `build-failure-report` | #266 | 0065 | Part 1 merged (both change `run()`); Part 3 merged (both change `runStepsPodman`) | | |
| 166 | ||
| 167 | #255 goes first. Parts 1–4 land and deploy in order; each runner deploy | |
| 168 | follows the scratch validation in the runbook. | |
| 169 | ||
| 170 | Other plans (all `docs/plans/2026-09-27-*.md`): | |
| 171 | ||
| 172 | - Plan 4 (data-at-rest-and-backup, #273) encrypts `build_secrets`; if it | |
| 173 | changes `Store.BuildSecrets`, Part 1's edit to `runRunnerNext` | |
| 174 | (`internal/control/build.go:543-564`) conflicts textually. Whoever | |
| 175 | lands second rebases; no behavioural dependency. | |
| 176 | - Plan 3 (server-hardening, #275) audits refused mutating commands; the | |
| 177 | `status set` refusal from Part 2 is one of them and needs nothing | |
| 178 | from this plan. | |
| 179 | - Plan 5 (web-ux, #261) covers documentation drift. Two items seen here | |
| 180 | and left alone: `deploy/gitbay-runner.override.conf:27-30` names | |
| 181 | `cmc/ci-smoke`, which no longer exists; `cmd/gitbay-runner/main.go:513` | |
| 182 | repeats its comment line. | |
| 183 | - Plan 1 (credentials-and-sessions, #256) closes a removed key's | |
| 184 | connections; the runner's `runner log` session is one such | |
| 185 | connection, and no code here depends on it. | |
| 186 | ||
| 187 | ## File map | |
| 188 | ||
| 189 | | File | Part | Responsibility | | |
| 190 | |---|---|---| | |
| 191 | | `internal/control/build.go` | 1, 3, 4 | claim payload `trusted`, `ssh`; `runner done` flags; `build show` fields; `build log --step/--tail`; `queueJobs` trust rule (2) | | |
| 192 | | `internal/control/buildlog.go` (create) | 4 | `LogSection`, `SplitBuildLog`, `FailedSection`, `tailLines` | | |
| 193 | | `internal/control/status.go` | 2 | `ci/` refusal | | |
| 194 | | `internal/control/mr.go`, `output.go` | 2 | `require-contexts`, `MergeGates`, `GatesOut.ChecksMissing` | | |
| 195 | | `internal/control/repo.go` | 2 | `repo settings show` prints required contexts | | |
| 196 | | `internal/store/builds.go` | 2, 4 | trust and image on reuse; failed step columns | | |
| 197 | | `internal/store/repos.go` | 2 | `RepoSettings.RequiredContexts` | | |
| 198 | | `internal/store/migrations/0065_build_failure.{up,down}.sql` | 4 | columns | | |
| 199 | | `cmd/gitbay-runner/main.go` | 1, 3, 4 | `job` fields, `buildHome`, `removeTree`, `stepEnv`, `loopbackRemote`, `buildSSH`, `buildNetwork`, `failure`, `exitReason` | | |
| 200 | | `cmd/gitbay-runner/isolate.go` | 3, 4 | network flag; `runSteps` returns `*failure` | | |
| 201 | | `cmd/gitbay-runner/report.go` | 4 | `doneArgs` | | |
| 202 | | `cmd/gitbay/main.go`, `summaries_gen.go` | 2 | `require-contexts` pass-through | | |
| 203 | | `internal/httpd/builds.go`, `settings.go` | 2, 4 | step view; settings form mapping | | |
| 204 | | `internal/web/templates/build.html`, `mr.html`, `settings.html` | 2, 4 | steps, gates row, form | | |
| 205 | | `internal/web/static/style.css` | 4 | `pre.buildlog` wraps; step folds | | |
| 206 | | `e2e/readonly_test.go`, `e2e/mrweb_test.go`, `e2e/status_test.go`, `e2e/settingsweb_test.go`, `e2e/ci_test.go` | 2, 4 | contexts off `ci/`; refusal; settings; failed step | | |
| 207 | | `.gitbay/wiki/…` | all | as listed per task | | |
| 208 | ||
| 209 | --- | |
| 210 | ||
| 211 | # Part 1: disposable home for untrusted builds (branch `ci-untrusted-home`, #255) | |
| 212 | ||
| 213 | ### Task 1.1: the claim says whether a build is trusted | |
| 214 | ||
| 215 | **Files:** | |
| 216 | - Modify: `internal/control/build.go:554-564` (the claim payload in `runRunnerNext`) | |
| 217 | - Test: `internal/control/runnernext_test.go` (append) | |
| 218 | ||
| 219 | **Interfaces:** | |
| 220 | - Produces: the `runner next --json` payload gains `"trusted": <bool>`, always present. | |
| 221 | ||
| 222 | - [ ] **Step 1: Write the failing test** | |
| 223 | ||
| 224 | Append to `internal/control/runnernext_test.go`: | |
| 225 | ||
| 226 | ```go | |
| 227 | // The claim says whether a build is trusted in so many words. A runner | |
| 228 | // must not infer it from secrets being absent: a trusted repository with | |
| 229 | // no secrets looks the same (#255). | |
| 230 | func TestRunnerNextSaysWhetherTrusted(t *testing.T) { | |
| 231 | st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) | |
| 232 | for _, trusted := range []bool{true, false} { | |
| 233 | if _, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", trusted); err != nil { | |
| 234 | t.Fatal(err) | |
| 235 | } | |
| 236 | c, out := runnerCtx(st, uid, root) | |
| 237 | c.JSON = true | |
| 238 | if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK { | |
| 239 | t.Fatalf("runner next: exit %d, output:\n%s", code, out.String()) | |
| 240 | } | |
| 241 | want := fmt.Sprintf(`"trusted":%v`, trusted) | |
| 242 | if !strings.Contains(out.String(), want) { | |
| 243 | t.Fatalf("claim of a trusted=%v build lacks %s:\n%s", trusted, want, out.String()) | |
| 244 | } | |
| 245 | } | |
| 246 | } | |
| 247 | ``` | |
| 248 | ||
| 249 | The first loop creates and claims the trusted build; the second creates | |
| 250 | the untrusted one, which is then the only pending build. | |
| 251 | ||
| 252 | - [ ] **Step 2: Run it and see it fail** | |
| 253 | ||
| 254 | Run: `go test ./internal/control -run TestRunnerNextSaysWhetherTrusted -count=1` | |
| 255 | Expected: FAIL, `claim of a trusted=true build lacks "trusted":true`. | |
| 256 | ||
| 257 | - [ ] **Step 3: Implement** | |
| 258 | ||
| 259 | Replace the payload at `internal/control/build.go:554-564` with: | |
| 260 | ||
| 261 | ```go | |
| 262 | d := struct { | |
| 263 | ID int64 `json:"id"` | |
| 264 | Repo string `json:"repo"` | |
| 265 | Number int64 `json:"number"` | |
| 266 | Job string `json:"job"` | |
| 267 | SHA string `json:"sha"` | |
| 268 | Ref string `json:"ref"` | |
| 269 | Steps []string `json:"steps"` | |
| 270 | Image string `json:"image,omitempty"` | |
| 271 | // Trusted is always sent: a runner decides a build's home and | |
| 272 | // secrets from it, and reads a missing field as untrusted (#255). | |
| 273 | Trusted bool `json:"trusted"` | |
| 274 | Secrets map[string]string `json:"secrets,omitempty"` | |
| 275 | }{ID: b.ID, Repo: repo.Path(), Number: b.Number, Job: b.Job, SHA: b.SHA, Ref: b.Ref, | |
| 276 | Steps: steps, Image: b.Image, Trusted: b.Trusted, Secrets: secrets} | |
| 277 | ``` | |
| 278 | ||
| 279 | - [ ] **Step 4: Run it and see it pass** | |
| 280 | ||
| 281 | Run: `go test ./internal/control -run 'TestRunnerNext' -count=1` | |
| 282 | Expected: PASS. | |
| 283 | ||
| 284 | - [ ] **Step 5: Commit** | |
| 285 | ||
| 286 | ```bash | |
| 287 | git add internal/control/build.go internal/control/runnernext_test.go | |
| 288 | git commit -S -m "runner next: say whether the build is trusted | |
| 289 | ||
| 290 | Ref #255" | |
| 291 | ``` | |
| 292 | ||
| 293 | ### Task 1.2: the runner's build home follows trust | |
| 294 | ||
| 295 | **Files:** | |
| 296 | - Modify: `cmd/gitbay-runner/main.go:12-30` (imports), `:32-42` (`job`), `:315-330` (`run`), `:437-463` (comment and `buildHomeFor`), `:488-511` (`stepEnv`) | |
| 297 | - Modify: `cmd/gitbay-runner/home_test.go` (rewrite), `cmd/gitbay-runner/env_test.go:47-60` | |
| 298 | ||
| 299 | **Interfaces:** | |
| 300 | - Consumes: the `trusted` claim field from Task 1.1. | |
| 301 | - Produces: | |
| 302 | - `job.Trusted bool` (`json:"trusted"`) | |
| 303 | - `func buildHome(workdir string, j job) (string, func(), error)` — the home and a cleanup to defer; replaces `buildHomeFor`. | |
| 304 | - `func removeTree(dir string) error` | |
| 305 | ||
| 306 | - [ ] **Step 1: Write the failing tests** | |
| 307 | ||
| 308 | Replace `cmd/gitbay-runner/home_test.go` with: | |
| 309 | ||
| 310 | ```go | |
| 311 | package main | |
| 312 | ||
| 313 | import ( | |
| 314 | "os" | |
| 315 | "path/filepath" | |
| 316 | "strings" | |
| 317 | "testing" | |
| 318 | ) | |
| 319 | ||
| 320 | // A trusted build's home is its repository's, kept between builds so | |
| 321 | // tool caches survive: the same repository gets the same directory back, | |
| 322 | // another repository a different one (#184). | |
| 323 | func TestTrustedHomeIsPerRepositoryAndKept(t *testing.T) { | |
| 324 | work := t.TempDir() | |
| 325 | a, done, err := buildHome(work, job{ID: 1, Repo: "alice/app", Trusted: true}) | |
| 326 | if err != nil { | |
| 327 | t.Fatal(err) | |
| 328 | } | |
| 329 | done() | |
| 330 | if _, err := os.Stat(a); err != nil { | |
| 331 | t.Fatalf("trusted home removed after its build: %v", err) | |
| 332 | } | |
| 333 | b, done, err := buildHome(work, job{ID: 2, Repo: "bob/app", Trusted: true}) | |
| 334 | if err != nil { | |
| 335 | t.Fatal(err) | |
| 336 | } | |
| 337 | done() | |
| 338 | if a == b { | |
| 339 | t.Fatalf("two repositories share a build home: %s", a) | |
| 340 | } | |
| 341 | again, done, _ := buildHome(work, job{ID: 3, Repo: "alice/app", Trusted: true}) | |
| 342 | done() | |
| 343 | if again != a { | |
| 344 | t.Fatalf("build home moved between builds: %s then %s", a, again) | |
| 345 | } | |
| 346 | for _, dir := range []string{a, b} { | |
| 347 | rel, err := filepath.Rel(filepath.Join(work, "trusted-home"), dir) | |
| 348 | if err != nil || rel == "." || strings.HasPrefix(rel, "..") { | |
| 349 | t.Fatalf("build home %s is not under %s/trusted-home", dir, work) | |
| 350 | } | |
| 351 | st, err := os.Stat(dir) | |
| 352 | if err != nil { | |
| 353 | t.Fatal(err) | |
| 354 | } | |
| 355 | if st.Mode().Perm() != 0o700 { | |
| 356 | t.Fatalf("build home mode %o, want 0700", st.Mode().Perm()) | |
| 357 | } | |
| 358 | } | |
| 359 | } | |
| 360 | ||
| 361 | // An untrusted build gets a home of its own, outside the trusted root, | |
| 362 | // removed when the build ends: nothing a fork's build writes reaches a | |
| 363 | // later build of the repository (#255). | |
| 364 | func TestUntrustedHomeIsDisposable(t *testing.T) { | |
| 365 | work := t.TempDir() | |
| 366 | trusted, done, err := buildHome(work, job{ID: 1, Repo: "alice/app", Trusted: true}) | |
| 367 | if err != nil { | |
| 368 | t.Fatal(err) | |
| 369 | } | |
| 370 | done() | |
| 371 | home, done, err := buildHome(work, job{ID: 2, Repo: "alice/app"}) | |
| 372 | if err != nil { | |
| 373 | t.Fatal(err) | |
| 374 | } | |
| 375 | if home == trusted || strings.HasPrefix(home, filepath.Join(work, "trusted-home")) { | |
| 376 | t.Fatalf("untrusted build got a trusted home: %s", home) | |
| 377 | } | |
| 378 | // What the Go module cache leaves behind: read-only directories. | |
| 379 | cache := filepath.Join(home, "go", "pkg", "mod", "example.com", "m@v1") | |
| 380 | if err := os.MkdirAll(cache, 0o755); err != nil { | |
| 381 | t.Fatal(err) | |
| 382 | } | |
| 383 | if err := os.WriteFile(filepath.Join(cache, "go.mod"), []byte("module m\n"), 0o444); err != nil { | |
| 384 | t.Fatal(err) | |
| 385 | } | |
| 386 | os.Chmod(cache, 0o555) | |
| 387 | os.Chmod(filepath.Dir(cache), 0o555) | |
| 388 | done() | |
| 389 | if _, err := os.Stat(home); !os.IsNotExist(err) { | |
| 390 | t.Fatalf("untrusted home left behind: %v", err) | |
| 391 | } | |
| 392 | } | |
| 393 | ||
| 394 | // A repository path is server-validated, but a trusted home must still | |
| 395 | // never resolve outside the runner's home root. | |
| 396 | func TestBuildHomeRefusesTraversal(t *testing.T) { | |
| 397 | if _, _, err := buildHome(t.TempDir(), job{Repo: "../../etc", Trusted: true}); err == nil { | |
| 398 | t.Fatal("a traversing repository path produced a build home") | |
| 399 | } | |
| 400 | } | |
| 401 | ``` | |
| 402 | ||
| 403 | In `cmd/gitbay-runner/env_test.go`, replace `TestStepEnvCarriesSecrets` | |
| 404 | (lines 47-60) with: | |
| 405 | ||
| 406 | ```go | |
| 407 | // Secrets reach a trusted build's steps and never an untrusted one's, | |
| 408 | // whatever the claim carried: the trust flag decides, not whether any | |
| 409 | // secrets arrived (#255). | |
| 410 | func TestStepEnvCarriesSecrets(t *testing.T) { | |
| 411 | secrets := map[string]string{"TOKEN": "s3cret"} | |
| 412 | env := stepEnv(job{Trusted: true, Secrets: secrets}, "/tmp/buildhome", "git@x.test") | |
| 413 | if !containsEnv(env, "TOKEN=s3cret") { | |
| 414 | t.Error("a trusted build's secret did not reach the step") | |
| 415 | } | |
| 416 | for _, j := range []job{{}, {Secrets: secrets}} { | |
| 417 | for _, e := range stepEnv(j, "/tmp/buildhome", "git@x.test") { | |
| 418 | if strings.HasPrefix(e, "TOKEN=") { | |
| 419 | t.Errorf("a secret reached an untrusted build: %q", e) | |
| 420 | } | |
| 421 | } | |
| 422 | } | |
| 423 | } | |
| 424 | ``` | |
| 425 | ||
| 426 | - [ ] **Step 2: Run them and see them fail** | |
| 427 | ||
| 428 | Run: `go test ./cmd/gitbay-runner -count=1` | |
| 429 | Expected: build failure, `undefined: buildHome` and `unknown field Trusted in struct literal of type job`. | |
| 430 | ||
| 431 | - [ ] **Step 3: Implement** | |
| 432 | ||
| 433 | In `cmd/gitbay-runner/main.go`: | |
| 434 | ||
| 435 | Add `"io/fs"` to the imports (between `"io"` and `"log"`). | |
| 436 | ||
| 437 | Add the field to `job` (after `Image`): | |
| 438 | ||
| 439 | ```go | |
| 440 | Image string `json:"image"` | |
| 441 | // Trusted is false for a merge request head from a fork, and when the | |
| 442 | // server did not say: such a build gets no secrets and a home of its | |
| 443 | // own (#255). | |
| 444 | Trusted bool `json:"trusted"` | |
| 445 | Secrets map[string]string `json:"secrets"` | |
| 446 | ``` | |
| 447 | ||
| 448 | Replace lines 315-330 of `run` (the build-home comment and the | |
| 449 | `buildHomeFor` call) with: | |
| 450 | ||
| 451 | ```go | |
| 452 | home, doneHome, err := buildHome(r.workdir, j) | |
| 453 | if err != nil { | |
| 454 | log.Printf("build %d: build home: %v", j.ID, err) | |
| 455 | return false | |
| 456 | } | |
| 457 | defer doneHome() | |
| 458 | ``` | |
| 459 | ||
| 460 | and change line 433 to `env := stepEnv(j, home, r.buildSSH())`. | |
| 461 | ||
| 462 | Replace lines 437-463 (the `stepEnv` comment that sits above | |
| 463 | `buildHomeFor`, and `buildHomeFor`) with `buildHome` and `removeTree`; | |
| 464 | the `stepEnv` comment moves to `stepEnv` in the next block: | |
| 465 | ||
| 466 | ```go | |
| 467 | // buildHome is a build's HOME and what to do with it when the build ends. | |
| 468 | // | |
| 469 | // Not the workspace, which is removed after every build: the Go module | |
| 470 | // cache and every other tool cache live under HOME. Not the runner's own | |
| 471 | // home either, where its SSH key and credential dotfiles are. | |
| 472 | // | |
| 473 | // A trusted build gets its repository's home, | |
| 474 | // <workdir>/trusted-home/<owner>/<name>, kept between builds so the | |
| 475 | // caches survive. One per repository: shared across repositories, a step | |
| 476 | // could poison a cache or plant a .gitconfig that another repository's | |
| 477 | // build would honour (#184). The root is not <workdir>/home, where homes | |
| 478 | // that untrusted builds could write were kept before #255, so none of | |
| 479 | // those is read again. | |
| 480 | // | |
| 481 | // An untrusted build gets <workdir>/build-<id>-home, new and empty, | |
| 482 | // removed when the build ends. The container mounts HOME read-write, so | |
| 483 | // a home a fork's build could write is a cache a stranger controls | |
| 484 | // (#255). | |
| 485 | func buildHome(workdir string, j job) (string, func(), error) { | |
| 486 | if !j.Trusted { | |
| 487 | dir := filepath.Join(workdir, fmt.Sprintf("build-%d-home", j.ID)) | |
| 488 | if err := os.Mkdir(dir, 0o700); err != nil { | |
| 489 | return "", nil, err | |
| 490 | } | |
| 491 | return dir, func() { | |
| 492 | if err := removeTree(dir); err != nil { | |
| 493 | log.Printf("build %d: removing its home: %v", j.ID, err) | |
| 494 | } | |
| 495 | }, nil | |
| 496 | } | |
| 497 | root := filepath.Join(workdir, "trusted-home") | |
| 498 | dir := filepath.Join(root, filepath.FromSlash(j.Repo)) | |
| 499 | if rel, err := filepath.Rel(root, dir); err != nil || rel == "." || strings.HasPrefix(rel, "..") { | |
| 500 | return "", nil, fmt.Errorf("repository path %q escapes the build home root", j.Repo) | |
| 501 | } | |
| 502 | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 503 | return "", nil, err | |
| 504 | } | |
| 505 | return dir, func() {}, nil | |
| 506 | } | |
| 507 | ||
| 508 | // removeTree deletes dir and everything under it. os.RemoveAll alone | |
| 509 | // fails on a directory without write permission, and the Go module cache | |
| 510 | // makes every directory it fills read-only. | |
| 511 | func removeTree(dir string) error { | |
| 512 | filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { | |
| 513 | if err == nil && d.IsDir() { | |
| 514 | os.Chmod(p, 0o700) | |
| 515 | } | |
| 516 | return nil | |
| 517 | }) | |
| 518 | return os.RemoveAll(dir) | |
| 519 | } | |
| 520 | ``` | |
| 521 | ||
| 522 | Replace `stepEnv` (lines 488-511) with the function and the comment | |
| 523 | that belongs to it: | |
| 524 | ||
| 525 | ```go | |
| 526 | // stepEnv builds the environment a build step runs with. It is | |
| 527 | // constructed, not inherited: os.Environ() would hand repository content | |
| 528 | // the runner's entire environment, including anything an operator set on | |
| 529 | // the service (#144). | |
| 530 | // | |
| 531 | // HOME is the build's home (buildHome), not the runner's own: tools read | |
| 532 | // credentials out of dotfiles — .netrc, .npmrc, .gitconfig — and a build | |
| 533 | // has no business finding the runner's. | |
| 534 | // | |
| 535 | // PATH is the one thing carried over: without it a step cannot find the | |
| 536 | // tools the host was provisioned with. | |
| 537 | func stepEnv(j job, home, sshDest string) []string { | |
| 538 | path := os.Getenv("PATH") | |
| 539 | if path == "" { | |
| 540 | path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" | |
| 541 | } | |
| 542 | env := []string{ | |
| 543 | "PATH=" + path, | |
| 544 | "HOME=" + home, | |
| 545 | "LANG=C.UTF-8", | |
| 546 | "CI=true", | |
| 547 | "GITBAY_REPO=" + j.Repo, | |
| 548 | "GITBAY_SHA=" + j.SHA, | |
| 549 | "GITBAY_REF=" + j.Ref, | |
| 550 | "GITBAY_JOB=" + j.Job, | |
| 551 | "GITBAY_SSH=" + sshDest, | |
| 552 | } | |
| 553 | // The server sends secrets only for a trusted build. The claim's | |
| 554 | // trust flag decides here as well, not whether any arrived (#255). | |
| 555 | if j.Trusted { | |
| 556 | for name, value := range j.Secrets { | |
| 557 | env = append(env, name+"="+value) | |
| 558 | } | |
| 559 | } | |
| 560 | return env | |
| 561 | } | |
| 562 | ``` | |
| 563 | ||
| 564 | - [ ] **Step 4: Run the tests and see them pass** | |
| 565 | ||
| 566 | Run: `go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner -count=1` | |
| 567 | Expected: PASS. (`TestStepEnvHomeIsNotTheWorkspace` and the other | |
| 568 | `stepEnv` tests pass unchanged.) | |
| 569 | ||
| 570 | - [ ] **Step 5: Commit** | |
| 571 | ||
| 572 | ```bash | |
| 573 | git add cmd/gitbay-runner/main.go cmd/gitbay-runner/home_test.go cmd/gitbay-runner/env_test.go | |
| 574 | git commit -S -m "runner: disposable home for untrusted builds | |
| 575 | ||
| 576 | A trusted build keeps its repository's home, now under | |
| 577 | <workdir>/trusted-home; an untrusted build gets a new home removed with | |
| 578 | the build, and no secrets whatever the claim carries. | |
| 579 | ||
| 580 | Ref #255" | |
| 581 | ``` | |
| 582 | ||
| 583 | ### Task 1.3: wiki, and the MR | |
| 584 | ||
| 585 | **Files:** | |
| 586 | - Modify: `.gitbay/wiki/Threat-Model.org:135-147`, `:172-180` | |
| 587 | - Modify: `.gitbay/wiki/Admin.org:640-642` | |
| 588 | - Modify: `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org:30-33`, `:67` | |
| 589 | - Modify: `.gitbay/wiki/Architecture/09-Controls.org:87` | |
| 590 | - Modify: `.gitbay/wiki/Architecture/04-Trust-Boundaries.org` (TB7 row) | |
| 591 | - Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org:13` (remove the #255 row) | |
| 592 | ||
| 593 | - [ ] **Step 1: Threat-Model** | |
| 594 | ||
| 595 | In "The CI runner", replace the sentences of the "What a build sees" | |
| 596 | bullet from "=HOME= is a build home" to the end of the bullet with: | |
| 597 | ||
| 598 | ```org | |
| 599 | secrets, and nothing the operator set on the service. =HOME= is a build | |
| 600 | home under the runner's =-workdir=, not the runner's own home, so a | |
| 601 | build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where tools | |
| 602 | keep credentials. A trusted build's home belongs to its repository | |
| 603 | and persists, so caches survive; an untrusted build's home is new, | |
| 604 | empty and removed when the build ends, so nothing a fork's build | |
| 605 | writes is read by a later build (krz/gitbay#255). The claim names a | |
| 606 | build's trust explicitly, and a runner that finds no trust flag treats | |
| 607 | the build as untrusted. | |
| 608 | ``` | |
| 609 | ||
| 610 | Replace the paragraph after the bullets ("Under =-isolation none=, | |
| 611 | anything a step can do…") with: | |
| 612 | ||
| 613 | ```org | |
| 614 | Under =-isolation none=, anything a step can do as the runner's user a | |
| 615 | pushed =ci.yml= can do. Under podman a step is confined to its | |
| 616 | container, the bind-mounted workspace and its build home: a trusted | |
| 617 | build's cache is read only by later trusted builds of the same | |
| 618 | repository, and an untrusted build's home is discarded with it. Treat | |
| 619 | the runner host as executing untrusted code all the same: keep it off | |
| 620 | the daemon's host where the database lives, or scope it to repositories | |
| 621 | whose writers you trust. gitbay.org does the latter — its runner builds | |
| 622 | only the repositories the operator names. | |
| 623 | ``` | |
| 624 | ||
| 625 | - [ ] **Step 2: Admin** | |
| 626 | ||
| 627 | Replace `Admin.org:640-642` ("Each repository gets its own build home…") | |
| 628 | with: | |
| 629 | ||
| 630 | ```org | |
| 631 | A trusted build's home is its repository's, under | |
| 632 | =<workdir>/trusted-home/<owner>/<name>=, mounted into its containers as | |
| 633 | =HOME=: caches persist between trusted builds of one repository and are | |
| 634 | never read by another's. An untrusted build — a merge request head from | |
| 635 | a fork — gets =<workdir>/build-<id>-home=, new and empty, removed when | |
| 636 | the build ends. Homes under =<workdir>/home= are from runners before | |
| 637 | krz/gitbay#255, which shared them with untrusted builds; nothing reads | |
| 638 | them any more, and they can be deleted. | |
| 639 | ``` | |
| 640 | ||
| 641 | - [ ] **Step 3: Architecture pages** | |
| 642 | ||
| 643 | `07-CI-and-Supply-Chain.org`, lifecycle step 2, replace "The claim | |
| 644 | returns id, repository, job, commit, ref, steps, image and — for | |
| 645 | trusted builds only — the repository's secrets (=build.go=)." with | |
| 646 | "The claim returns id, repository, job, commit, ref, steps, image, the | |
| 647 | build's trust, and — for trusted builds only — the repository's secrets | |
| 648 | (=build.go=)." | |
| 649 | ||
| 650 | Replace the Build home row (line 67) with: | |
| 651 | ||
| 652 | ```org | |
| 653 | | Build home | trusted: =<workdir>/trusted-home/<owner>/<name>=, one per repository, persistent; untrusted: =<workdir>/build-<id>-home=, removed with the build (=main.go=) | | |
| 654 | ``` | |
| 655 | ||
| 656 | `09-Controls.org` line 87: | |
| 657 | ||
| 658 | ```org | |
| 659 | | Untrusted code runs isolated | in place | rootless podman, cgroup limits; untrusted builds get a disposable home (=cmd/gitbay-runner/main.go=) | | |
| 660 | ``` | |
| 661 | ||
| 662 | `04-Trust-Boundaries.org` TB7 row, last column: | |
| 663 | ||
| 664 | ```org | |
| 665 | | TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; a trusted build's home is its repository's, an untrusted build's is discarded with it; the network is open (#260) | | |
| 666 | ``` | |
| 667 | ||
| 668 | `10-Known-Gaps.org`: delete the `#255` row. | |
| 669 | ||
| 670 | - [ ] **Step 4: Verify and commit** | |
| 671 | ||
| 672 | Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./internal/control -count=1` | |
| 673 | Expected: PASS. | |
| 674 | ||
| 675 | ```bash | |
| 676 | git add .gitbay/wiki | |
| 677 | git commit -S -m "wiki: trusted and untrusted build homes | |
| 678 | ||
| 679 | Closes #255" | |
| 680 | ``` | |
| 681 | ||
| 682 | - [ ] **Step 5: MR** | |
| 683 | ||
| 684 | ```bash | |
| 685 | git push -u origin ci-untrusted-home | |
| 686 | gitbay mr create --source ci-untrusted-home --target main --title "runner: disposable home for untrusted builds" | |
| 687 | ``` | |
| 688 | ||
| 689 | Body (via `--file -` from a file written with the Write tool): what | |
| 690 | changed, the deploy order (gitbayd before the runner), and a pointer to | |
| 691 | runbook sections R1 and R2. After CI is green and the runbook's R2 | |
| 692 | validation passed on the scratch repository: | |
| 693 | `gitbay mr merge <n> --strategy ff`, delete the branch both places. | |
| 694 | ||
| 695 | --- | |
| 696 | ||
| 697 | # Part 2: `ci/` statuses, trusted reuse, required contexts (branch `ci-status-trust`, #258) | |
| 698 | ||
| 699 | ### Task 2.1: `status set` refuses `ci/` | |
| 700 | ||
| 701 | **Files:** | |
| 702 | - Modify: `internal/control/status.go:15-27` (registration), `:68-70` (after the usage check) | |
| 703 | - Create: `internal/control/status_test.go` | |
| 704 | - Modify: `e2e/readonly_test.go:75`, `e2e/mrweb_test.go:275`, `e2e/status_test.go` (after line 49) | |
| 705 | - Modify: `cmd/gitbay/summaries_gen.go` only if the summary changes (it does not here) | |
| 706 | ||
| 707 | **Interfaces:** | |
| 708 | - Produces: `status set … --context ci/…` exits 4 (`protocol.ExitDenied`) with a message containing `reserved`. | |
| 709 | ||
| 710 | - [ ] **Step 1: Write the failing test** | |
| 711 | ||
| 712 | Create `internal/control/status_test.go`: | |
| 713 | ||
| 714 | ```go | |
| 715 | package control | |
| 716 | ||
| 717 | import ( | |
| 718 | "strings" | |
| 719 | "testing" | |
| 720 | ||
| 721 | "gitbay.org/gitbay/internal/protocol" | |
| 722 | "gitbay.org/gitbay/internal/store" | |
| 723 | ) | |
| 724 | ||
| 725 | // ci/<job> statuses are the build subsystem's. A writer who could post | |
| 726 | // one could mark ci/test green on their own head before, or instead of, | |
| 727 | // the build (#258). | |
| 728 | func TestStatusSetRefusesReservedContext(t *testing.T) { | |
| 729 | st, repo, uid := newQueueTestRepo(t) | |
| 730 | for _, ctx := range []string{"ci/test", "CI/test", "ci/"} { | |
| 731 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | |
| 732 | code := Dispatch(c, []string{"status", "set", repo.Path(), "abc1234", "--context", ctx, "--state", "success"}) | |
| 733 | if code != protocol.ExitDenied || !strings.Contains(errOut.String(), "reserved") { | |
| 734 | t.Errorf("--context %s: exit %d, %s", ctx, code, errOut.String()) | |
| 735 | } | |
| 736 | } | |
| 737 | if has, err := st.RepoHasStatuses(repo.ID); err != nil || has { | |
| 738 | t.Fatalf("a refused status was stored: %v %v", has, err) | |
| 739 | } | |
| 740 | } | |
| 741 | ``` | |
| 742 | ||
| 743 | - [ ] **Step 2: Run it and see it fail** | |
| 744 | ||
| 745 | Run: `go test ./internal/control -run TestStatusSetRefusesReservedContext -count=1` | |
| 746 | Expected: FAIL, exit 3 (`no commit abc1234`), since today the context is | |
| 747 | accepted and the missing commit is what stops it. | |
| 748 | ||
| 749 | - [ ] **Step 3: Implement** | |
| 750 | ||
| 751 | In the registration, change the `--context` flag and the example: | |
| 752 | ||
| 753 | ```go | |
| 754 | {"--context", "<c>", "the check this status reports for; ci/ is reserved for the instance's builds", ""}, | |
| 755 | ``` | |
| 756 | ||
| 757 | ```go | |
| 758 | Examples: []string{ | |
| 759 | "status set krz/gitbay a1b2c3d --context ext/lint --state success", | |
| 760 | }, | |
| 761 | ``` | |
| 762 | ||
| 763 | After the usage check at `status.go:68-70`, before the `--url` check: | |
| 764 | ||
| 765 | ```go | |
| 766 | // ci/<job> statuses are the build subsystem's: queued, reused, | |
| 767 | // skipped and finished by the server itself. A writer who could post | |
| 768 | // one could mark ci/test green on their own head before, or instead | |
| 769 | // of, the build (#258). Case-folded, so CI/test is no way around it. | |
| 770 | if strings.HasPrefix(strings.ToLower(context), "ci/") { | |
| 771 | return c.fail(protocol.ExitDenied, "the ci/ prefix is reserved for the instance's builds; report under another name, such as ext/%s", | |
| 772 | strings.TrimPrefix(strings.ToLower(context), "ci/")) | |
| 773 | } | |
| 774 | ``` | |
| 775 | ||
| 776 | - [ ] **Step 4: Run it and see it pass** | |
| 777 | ||
| 778 | Run: `go test ./internal/control -run 'TestStatusSet|TestHelp' -count=1` | |
| 779 | Expected: PASS. | |
| 780 | ||
| 781 | - [ ] **Step 5: e2e callers off `ci/`, and the refusal over SSH** | |
| 782 | ||
| 783 | `e2e/readonly_test.go:75`: `"--context", "ci/x"` → `"--context", "ext/x"`. | |
| 784 | `e2e/mrweb_test.go:275`: `"--context", "ci/test"` → `"--context", "ext/test"`. | |
| 785 | ||
| 786 | In `e2e/status_test.go`, after the reader-denied check (line 49), add: | |
| 787 | ||
| 788 | ```go | |
| 789 | // ci/ is the instance's own: a writer is refused it (#258). | |
| 790 | if _, errOut, code := inst.ssh(t, bobKey, "", "status", "set", "alice/svc", head, "--context", "ci/build", "--state", "success"); code != 4 || !strings.Contains(errOut, "reserved") { | |
| 791 | t.Fatalf("writer posted a ci/ status: exit %d, %s", code, errOut) | |
| 792 | } | |
| 793 | ``` | |
| 794 | ||
| 795 | Run: `go test ./e2e -run TestCommitStatuses -count=1` | |
| 796 | Expected: PASS. | |
| 797 | ||
| 798 | - [ ] **Step 6: Commit** | |
| 799 | ||
| 800 | ```bash | |
| 801 | git add internal/control/status.go internal/control/status_test.go e2e/readonly_test.go e2e/mrweb_test.go e2e/status_test.go | |
| 802 | git commit -S -m "status set: ci/ is reserved for the instance's builds | |
| 803 | ||
| 804 | Ref #258" | |
| 805 | ``` | |
| 806 | ||
| 807 | ### Task 2.2: reuse only trusted results on the same image | |
| 808 | ||
| 809 | **Files:** | |
| 810 | - Modify: `internal/store/builds.go:453-477` (`SuccessBuildForTree`, `SuccessBuildFor`) | |
| 811 | - Modify: `internal/control/build.go:856-864` (`queueJobs`) | |
| 812 | - Modify: `internal/store/builds_test.go:241-249` (`TestSuccessBuildForTree` call sites) and append a test | |
| 813 | - Test: `internal/control/build_test.go` (append) | |
| 814 | ||
| 815 | **Interfaces:** | |
| 816 | - Produces: `func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error)` — trusted builds only, same image. `SuccessBuildFor` keeps its signature and considers trusted builds only. | |
| 817 | ||
| 818 | - [ ] **Step 1: Write the failing tests** | |
| 819 | ||
| 820 | In `internal/store/builds_test.go`, add `""` as the fourth argument to | |
| 821 | the three `SuccessBuildForTree` calls in `TestSuccessBuildForTree`, then | |
| 822 | append: | |
| 823 | ||
| 824 | ```go | |
| 825 | // A result stands for another commit only when it came from a trusted | |
| 826 | // build on the same image: a fork's green build, or one on an image the | |
| 827 | // job has since left, proves nothing about the repository's own (#258). | |
| 828 | func TestSuccessReuseNeedsTrustAndImage(t *testing.T) { | |
| 829 | s := open(t) | |
| 830 | if err := s.MigrateUp(); err != nil { | |
| 831 | t.Fatal(err) | |
| 832 | } | |
| 833 | uid, _ := s.CreateUser("cmc", true) | |
| 834 | repoID, _ := s.CreateRepo("user", uid, "app", "public") | |
| 835 | for _, b := range []struct { | |
| 836 | sha, image string | |
| 837 | trusted bool | |
| 838 | }{ | |
| 839 | {"aaa", "", false}, | |
| 840 | {"bbb", "localhost/old:1", true}, | |
| 841 | } { | |
| 842 | if _, err := s.CreateBuild(repoID, "unit", b.sha, "main", `["true"]`, b.image, "tree1", b.trusted); err != nil { | |
| 843 | t.Fatal(err) | |
| 844 | } | |
| 845 | claimed, ok, err := s.ClaimBuild([]int64{repoID}, true) | |
| 846 | if err != nil || !ok { | |
| 847 | t.Fatalf("claim: ok=%v err=%v", ok, err) | |
| 848 | } | |
| 849 | if err := s.FinishBuild(claimed.ID, "success"); err != nil { | |
| 850 | t.Fatal(err) | |
| 851 | } | |
| 852 | } | |
| 853 | if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", ""); ok { | |
| 854 | t.Fatalf("reused build %d: untrusted, or on another image", prev.Number) | |
| 855 | } | |
| 856 | if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", "localhost/old:1"); !ok || prev.SHA != "bbb" { | |
| 857 | t.Fatalf("trusted build on the same image not found: ok=%v prev=%+v", ok, prev) | |
| 858 | } | |
| 859 | if _, ok, _ := s.SuccessBuildFor(repoID, "aaa", "unit"); ok { | |
| 860 | t.Error("an untrusted success stood for its commit") | |
| 861 | } | |
| 862 | } | |
| 863 | ``` | |
| 864 | ||
| 865 | Append to `internal/control/build_test.go`: | |
| 866 | ||
| 867 | ```go | |
| 868 | // A fork's green build of a commit does not stand for the repository's | |
| 869 | // own: the same commit landing on a branch, or a commit with the same | |
| 870 | // tree, is built again as trusted (#258). | |
| 871 | func TestQueueBranchBuildsRebuildsWhatOnlyAForkBuilt(t *testing.T) { | |
| 872 | st, repo, uid := newQueueTestRepo(t) | |
| 873 | git := gitRunner(t) | |
| 874 | root := t.TempDir() | |
| 875 | ||
| 876 | src := filepath.Join(root, "src") | |
| 877 | os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755) | |
| 878 | os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte( | |
| 879 | "jobs:\n unit:\n steps:\n - echo hi\n"), 0o644) | |
| 880 | git(root, "init", "-q", "-b", "main", "src") | |
| 881 | git(src, "add", ".") | |
| 882 | git(src, "commit", "-q", "-m", "base") | |
| 883 | first := strings.TrimSpace(git(src, "rev-parse", "HEAD")) | |
| 884 | git(src, "commit", "-q", "--allow-empty", "-m", "same tree") | |
| 885 | second := strings.TrimSpace(git(src, "rev-parse", "HEAD")) | |
| 886 | ||
| 887 | dir := RepoDir(root, repo.OwnerName, repo.Name) | |
| 888 | os.MkdirAll(filepath.Dir(dir), 0o755) | |
| 889 | git(root, "clone", "-q", "--bare", src, dir) | |
| 890 | ||
| 891 | // A fork's merge request head, built untrusted and green. | |
| 892 | QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, first) | |
| 893 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, true) | |
| 894 | if err != nil || !ok || b.Trusted { | |
| 895 | t.Fatalf("claim: ok=%v trusted=%v err=%v", ok, b.Trusted, err) | |
| 896 | } | |
| 897 | if err := st.FinishBuild(b.ID, "success"); err != nil { | |
| 898 | t.Fatal(err) | |
| 899 | } | |
| 900 | ||
| 901 | // The same commit lands on main, then a commit with the same tree. | |
| 902 | QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now()) | |
| 903 | QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now()) | |
| 904 | pending, _ := st.ListBuilds(repo.ID, store.BuildFilter{Status: "pending"}, 10) | |
| 905 | if len(pending) != 2 { | |
| 906 | t.Fatalf("queued %d builds, want 2 (one per commit): %+v", len(pending), pending) | |
| 907 | } | |
| 908 | for _, p := range pending { | |
| 909 | if !p.Trusted { | |
| 910 | t.Errorf("build %d queued untrusted on a branch push", p.Number) | |
| 911 | } | |
| 912 | } | |
| 913 | } | |
| 914 | ``` | |
| 915 | ||
| 916 | - [ ] **Step 2: Run them and see them fail** | |
| 917 | ||
| 918 | Run: `go test ./internal/store -run 'TestSuccessBuildForTree|TestSuccessReuse' -count=1` | |
| 919 | Expected: build failure (`too many arguments in call to s.SuccessBuildForTree`). | |
| 920 | ||
| 921 | Run: `go test ./internal/control -run TestQueueBranchBuildsRebuildsWhatOnlyAForkBuilt -count=1` | |
| 922 | Expected: FAIL, `queued 0 builds, want 2`. | |
| 923 | ||
| 924 | - [ ] **Step 3: Store** | |
| 925 | ||
| 926 | Replace `internal/store/builds.go:453-477` with: | |
| 927 | ||
| 928 | ```go | |
| 929 | // SuccessBuildForTree finds a passed build of the job for a tree rather | |
| 930 | // than a commit: a rebase that changes nothing in the tree has already | |
| 931 | // been built (#177). Only a trusted build on the image the job names | |
| 932 | // counts: a fork's result, or one from an image the job has left, does | |
| 933 | // not stand for the repository's own (#258). An empty tree never | |
| 934 | // matches. | |
| 935 | func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error) { | |
| 936 | if tree == "" { | |
| 937 | return Build{}, false, nil | |
| 938 | } | |
| 939 | b, err := scanBuild(s.DB.QueryRow(buildSelect+ | |
| 940 | " WHERE repo_id = ? AND tree = ? AND job = ? AND image = ? AND trusted = 1 AND status = 'success'"+ | |
| 941 | " ORDER BY number DESC LIMIT 1", repoID, tree, job, image)) | |
| 942 | if errors.Is(err, sql.ErrNoRows) { | |
| 943 | return Build{}, false, nil | |
| 944 | } | |
| 945 | return b, err == nil, err | |
| 946 | } | |
| 947 | ||
| 948 | // SuccessBuildFor finds a passed trusted build of the commit for the job, | |
| 949 | // on any ref: what a cancelled duplicate can point back at. | |
| 950 | func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) { | |
| 951 | b, err := scanBuild(s.DB.QueryRow(buildSelect+ | |
| 952 | " WHERE repo_id = ? AND sha = ? AND job = ? AND trusted = 1 AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job)) | |
| 953 | if errors.Is(err, sql.ErrNoRows) { | |
| 954 | return Build{}, false, nil | |
| 955 | } | |
| 956 | return b, err == nil, err | |
| 957 | } | |
| 958 | ``` | |
| 959 | ||
| 960 | - [ ] **Step 4: `queueJobs`** | |
| 961 | ||
| 962 | Replace `internal/control/build.go:856-859` with: | |
| 963 | ||
| 964 | ```go | |
| 965 | // A build of this commit that passed, or is queued or running, | |
| 966 | // stands for it — unless this queue is trusted and that build was | |
| 967 | // not: a fork's head that lands on a branch is built again as the | |
| 968 | // repository's own (#258). | |
| 969 | if b, ok := built[j.Name]; ok && (b.Trusted || !trusted) && | |
| 970 | (b.Status == "success" || b.Status == "pending" || b.Status == "running") { | |
| 971 | continue | |
| 972 | } | |
| 973 | if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name, j.Image); ok && prev.SHA != sha { | |
| 974 | ``` | |
| 975 | ||
| 976 | (the body of the `if prev, ok` block is unchanged.) | |
| 977 | ||
| 978 | - [ ] **Step 5: Run the tests and see them pass** | |
| 979 | ||
| 980 | Run: `go vet ./... && go test ./internal/store ./internal/control ./internal/hookd ./internal/ci -count=1` | |
| 981 | Expected: PASS. `TestPushShapes` (hookd) has no row where a fork's | |
| 982 | build lands on a branch, so its table is unchanged. | |
| 983 | ||
| 984 | - [ ] **Step 6: Commit** | |
| 985 | ||
| 986 | ```bash | |
| 987 | git add internal/store/builds.go internal/store/builds_test.go internal/control/build.go internal/control/build_test.go | |
| 988 | git commit -S -m "ci: reuse only trusted results on the job's image | |
| 989 | ||
| 990 | Ref #258" | |
| 991 | ``` | |
| 992 | ||
| 993 | ### Task 2.3: required contexts | |
| 994 | ||
| 995 | **Files:** | |
| 996 | - Modify: `internal/store/repos.go:25-37` (`RepoSettings`) | |
| 997 | - Modify: `internal/control/output.go:59-60` (`GatesOut`) | |
| 998 | - Modify: `internal/control/mr.go:45-49` (registration), after `runRequireChecks` (`:326-341`), `:1579-1603` (`MergeGates` checks block) | |
| 999 | - Modify: `internal/control/repo.go:710-717` (`repo settings show`) | |
| 1000 | - Modify: `internal/control/checksgate_test.go` (helper refactor, two tests) | |
| 1001 | - Test: `internal/control/mr_test.go` (append) | |
| 1002 | - Modify: `cmd/gitbay/main.go:580` (add a `pass`), `cmd/gitbay/summaries_gen.go` (regenerated) | |
| 1003 | - Modify: `internal/httpd/settings.go:106-107`, `:228-229`; `internal/web/templates/settings.html:73-78`; `internal/web/templates/mr.html:124` | |
| 1004 | - Modify: `internal/httpd/mrpage_test.go` (`TestMRGatesRender`), `e2e/settingsweb_test.go` | |
| 1005 | ||
| 1006 | **Interfaces:** | |
| 1007 | - Produces: | |
| 1008 | - `RepoSettings.RequiredContexts []string` (`json:"required_contexts,omitempty"`) | |
| 1009 | - `GatesOut.ChecksMissing []string` (`json:"checks_missing,omitempty"`) | |
| 1010 | - command `repo settings require-contexts <owner/name> [<context>...]` | |
| 1011 | ||
| 1012 | - [ ] **Step 1: Write the failing tests** | |
| 1013 | ||
| 1014 | In `internal/control/checksgate_test.go`, replace `gatesForHeadSeeded` | |
| 1015 | (lines 20-73) with a general helper and a thin wrapper: | |
| 1016 | ||
| 1017 | ```go | |
| 1018 | func gatesForHeadSeeded(t *testing.T, ciYML string, seed bool) GatesOut { | |
| 1019 | return gatesFor(t, ciYML, nil, func(st *store.Store, repoID, uid int64, targetSHA, _ string) { | |
| 1020 | if !seed { | |
| 1021 | return | |
| 1022 | } | |
| 1023 | if err := st.SetCommitStatus(repoID, targetSHA, "lint", "success", "", "", uid); err != nil { | |
| 1024 | t.Fatal(err) | |
| 1025 | } | |
| 1026 | }) | |
| 1027 | } | |
| 1028 | ||
| 1029 | // gatesFor builds a repository with require_checks on and set applied to | |
| 1030 | // its settings, a bare dir holding the given .gitbay/ci.yml (empty | |
| 1031 | // string for none), and one MR; seed records statuses before the gates | |
| 1032 | // are computed. | |
| 1033 | func gatesFor(t *testing.T, ciYML string, set func(*store.RepoSettings), | |
| 1034 | seed func(st *store.Store, repoID, uid int64, targetSHA, headSHA string)) GatesOut { | |
| 1035 | t.Helper() | |
| 1036 | st, repo, uid := newQueueTestRepo(t) | |
| 1037 | if _, err := st.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { | |
| 1038 | s.RequireChecks = true | |
| 1039 | if set != nil { | |
| 1040 | set(s) | |
| 1041 | } | |
| 1042 | }); err != nil { | |
| 1043 | t.Fatal(err) | |
| 1044 | } | |
| 1045 | repo, err := st.RepoByID(repo.ID) | |
| 1046 | if err != nil { | |
| 1047 | t.Fatal(err) | |
| 1048 | } | |
| 1049 | ||
| 1050 | git := gitRunner(t) | |
| 1051 | root := t.TempDir() | |
| 1052 | src := filepath.Join(root, "src") | |
| 1053 | os.MkdirAll(src, 0o755) | |
| 1054 | git(root, "init", "-q", "-b", "main", "src") | |
| 1055 | os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644) | |
| 1056 | git(src, "add", ".") | |
| 1057 | git(src, "commit", "-q", "-m", "base") | |
| 1058 | targetSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD")) | |
| 1059 | git(src, "checkout", "-q", "-b", "feature") | |
| 1060 | if ciYML != "" { | |
| 1061 | os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755) | |
| 1062 | os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(ciYML), 0o644) | |
| 1063 | } | |
| 1064 | os.WriteFile(filepath.Join(src, "README"), []byte("y\n"), 0o644) | |
| 1065 | git(src, "add", ".") | |
| 1066 | git(src, "commit", "-q", "-m", "change") | |
| 1067 | headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD")) | |
| 1068 | ||
| 1069 | dir := RepoDir(root, repo.OwnerName, repo.Name) | |
| 1070 | os.MkdirAll(filepath.Dir(dir), 0o755) | |
| 1071 | git(root, "clone", "-q", "--bare", src, dir) | |
| 1072 | ||
| 1073 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", headSHA, "md", false); err != nil { | |
| 1074 | t.Fatal(err) | |
| 1075 | } | |
| 1076 | mr, err := st.MRByNumber(repo.ID, 1) | |
| 1077 | if err != nil { | |
| 1078 | t.Fatal(err) | |
| 1079 | } | |
| 1080 | if seed != nil { | |
| 1081 | seed(st, repo.ID, uid, targetSHA, headSHA) | |
| 1082 | } | |
| 1083 | g, err := MergeGates(st, repo, mr, dir, targetSHA, headSHA) | |
| 1084 | if err != nil { | |
| 1085 | t.Fatal(err) | |
| 1086 | } | |
| 1087 | return g | |
| 1088 | } | |
| 1089 | ``` | |
| 1090 | ||
| 1091 | Append to the same file (add `"slices"` to its imports): | |
| 1092 | ||
| 1093 | ```go | |
| 1094 | // A required context that has not reported holds the merge as pending, | |
| 1095 | // even when every status that did report is green (#258). | |
| 1096 | func TestRequiredContextMissingIsPending(t *testing.T) { | |
| 1097 | g := gatesFor(t, "", func(s *store.RepoSettings) { s.RequiredContexts = []string{"ext/deploy", "lint"} }, | |
| 1098 | func(st *store.Store, repoID, uid int64, _, headSHA string) { | |
| 1099 | if err := st.SetCommitStatus(repoID, headSHA, "lint", "success", "", "", uid); err != nil { | |
| 1100 | t.Fatal(err) | |
| 1101 | } | |
| 1102 | }) | |
| 1103 | if g.Checks != "pending" || !slices.Equal(g.ChecksMissing, []string{"ext/deploy"}) { | |
| 1104 | t.Fatalf("checks %q, missing %v", g.Checks, g.ChecksMissing) | |
| 1105 | } | |
| 1106 | if !checksUnmet(g) || !strings.Contains(strings.Join(g.Unmet, "\n"), "ext/deploy=missing") { | |
| 1107 | t.Fatalf("unmet: %v", g.Unmet) | |
| 1108 | } | |
| 1109 | } | |
| 1110 | ||
| 1111 | // Every required context reported green: nothing is held. | |
| 1112 | func TestRequiredContextsReportedPass(t *testing.T) { | |
| 1113 | g := gatesFor(t, "", func(s *store.RepoSettings) { s.RequiredContexts = []string{"lint"} }, | |
| 1114 | func(st *store.Store, repoID, uid int64, _, headSHA string) { | |
| 1115 | if err := st.SetCommitStatus(repoID, headSHA, "lint", "success", "", "", uid); err != nil { | |
| 1116 | t.Fatal(err) | |
| 1117 | } | |
| 1118 | }) | |
| 1119 | if checksUnmet(g) || len(g.ChecksMissing) != 0 || g.Checks != "success" { | |
| 1120 | t.Fatalf("checks %q, missing %v, unmet %v", g.Checks, g.ChecksMissing, g.Unmet) | |
| 1121 | } | |
| 1122 | } | |
| 1123 | ``` | |
| 1124 | ||
| 1125 | Append to `internal/control/mr_test.go` (add imports `slices`, | |
| 1126 | `protocol`, `store` if absent): | |
| 1127 | ||
| 1128 | ```go | |
| 1129 | // require-contexts stores a deduplicated list, refuses a context with | |
| 1130 | // whitespace, and clears with no contexts (#258). | |
| 1131 | func TestRequireContextsSetsAndClears(t *testing.T) { | |
| 1132 | st, repo, uid := newQueueTestRepo(t) | |
| 1133 | alice := store.User{ID: uid, Username: "alice"} | |
| 1134 | run := func(args ...string) int { | |
| 1135 | t.Helper() | |
| 1136 | c, _ := pruneCtx(st, t.TempDir(), alice) | |
| 1137 | return Dispatch(c, append([]string{"repo", "settings", "require-contexts", repo.Path()}, args...)) | |
| 1138 | } | |
| 1139 | if code := run("lint", "ext/deploy", "lint"); code != protocol.ExitOK { | |
| 1140 | t.Fatalf("set: exit %d", code) | |
| 1141 | } | |
| 1142 | got, _ := st.RepoByID(repo.ID) | |
| 1143 | if !slices.Equal(got.Settings.RequiredContexts, []string{"lint", "ext/deploy"}) { | |
| 1144 | t.Fatalf("stored %v", got.Settings.RequiredContexts) | |
| 1145 | } | |
| 1146 | if code := run("bad context"); code != protocol.ExitUsage { | |
| 1147 | t.Fatalf("a context with a space: exit %d", code) | |
| 1148 | } | |
| 1149 | if code := run(); code != protocol.ExitOK { | |
| 1150 | t.Fatalf("clear: exit %d", code) | |
| 1151 | } | |
| 1152 | if got, _ := st.RepoByID(repo.ID); len(got.Settings.RequiredContexts) != 0 { | |
| 1153 | t.Fatalf("not cleared: %v", got.Settings.RequiredContexts) | |
| 1154 | } | |
| 1155 | } | |
| 1156 | ``` | |
| 1157 | ||
| 1158 | - [ ] **Step 2: Run them and see them fail** | |
| 1159 | ||
| 1160 | Run: `go test ./internal/control -run 'TestRequire|TestRequiredContext' -count=1` | |
| 1161 | Expected: build failure (`s.RequiredContexts undefined`). | |
| 1162 | ||
| 1163 | - [ ] **Step 3: Store and output types** | |
| 1164 | ||
| 1165 | `internal/store/repos.go`, in `RepoSettings` after `RequireChecks`: | |
| 1166 | ||
| 1167 | ```go | |
| 1168 | RequireChecks bool `json:"require_checks,omitempty"` | |
| 1169 | // RequiredContexts are statuses require_checks waits for whether or | |
| 1170 | // not they have reported; one that has not is pending (#258). | |
| 1171 | RequiredContexts []string `json:"required_contexts,omitempty"` | |
| 1172 | ``` | |
| 1173 | ||
| 1174 | `internal/control/output.go`, in `GatesOut` after `Checks`: | |
| 1175 | ||
| 1176 | ```go | |
| 1177 | Checks string `json:"checks,omitempty"` // combined status; "" when none reported | |
| 1178 | ChecksMissing []string `json:"checks_missing,omitempty"` // required contexts not reported | |
| 1179 | ``` | |
| 1180 | ||
| 1181 | - [ ] **Step 4: The command** | |
| 1182 | ||
| 1183 | Registration, after `require-checks` at `mr.go:49`: | |
| 1184 | ||
| 1185 | ```go | |
| 1186 | register(Command{Path: []string{"repo", "settings", "require-contexts"}, | |
| 1187 | Summary: "name the statuses require-checks waits for, reported or not", | |
| 1188 | Usage: "repo settings require-contexts <owner/name> [<context>...] (none clears)", | |
| 1189 | Examples: []string{"repo settings require-contexts krz/gitbay ci/build ci/test"}, | |
| 1190 | Run: runRequireContexts}) | |
| 1191 | ``` | |
| 1192 | ||
| 1193 | After `runRequireChecks`: | |
| 1194 | ||
| 1195 | ```go | |
| 1196 | // maxRequiredContexts bounds the list: a gate naming more checks than | |
| 1197 | // this is a configuration mistake. | |
| 1198 | const maxRequiredContexts = 20 | |
| 1199 | ||
| 1200 | func runRequireContexts(c *Ctx, args []string) int { | |
| 1201 | if len(args) < 1 { | |
| 1202 | return c.usage() | |
| 1203 | } | |
| 1204 | var contexts []string | |
| 1205 | for _, ctx := range args[1:] { | |
| 1206 | if ctx == "" || len(ctx) > 100 || strings.ContainsAny(ctx, " \t\r\n") { | |
| 1207 | return c.fail(protocol.ExitUsage, "a context is 1 to 100 characters with no whitespace: %q", ctx) | |
| 1208 | } | |
| 1209 | if !slices.Contains(contexts, ctx) { | |
| 1210 | contexts = append(contexts, ctx) | |
| 1211 | } | |
| 1212 | } | |
| 1213 | if len(contexts) > maxRequiredContexts { | |
| 1214 | return c.fail(protocol.ExitUsage, "at most %d required contexts", maxRequiredContexts) | |
| 1215 | } | |
| 1216 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 1217 | if code >= 0 { | |
| 1218 | return code | |
| 1219 | } | |
| 1220 | s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequiredContexts = contexts }) | |
| 1221 | if err != nil { | |
| 1222 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1223 | } | |
| 1224 | return c.emit(s, func(w io.Writer) { | |
| 1225 | if len(contexts) == 0 { | |
| 1226 | fmt.Fprintf(w, "required contexts cleared on %s\n", repo.Path()) | |
| 1227 | return | |
| 1228 | } | |
| 1229 | fmt.Fprintf(w, "required contexts on %s: %s\n", repo.Path(), strings.Join(contexts, ", ")) | |
| 1230 | if !s.RequireChecks { | |
| 1231 | fmt.Fprintf(c.Stderr, "they apply once require-checks is on: gitbay repo settings require-checks %s on\n", repo.Path()) | |
| 1232 | } | |
| 1233 | }) | |
| 1234 | } | |
| 1235 | ``` | |
| 1236 | ||
| 1237 | - [ ] **Step 5: `MergeGates`** | |
| 1238 | ||
| 1239 | Replace `mr.go:1579-1603` (the checks block, from the comment through | |
| 1240 | the end of `if set.RequireChecks { … }`) with: | |
| 1241 | ||
| 1242 | ```go | |
| 1243 | // Checks: with require_checks, every status the head carries must be | |
| 1244 | // green, a head something was going to report on must carry some, and | |
| 1245 | // every required context must have reported: one that has not is | |
| 1246 | // pending whatever the others say (#258). | |
| 1247 | statuses, err := st.ListCommitStatuses(repo.ID, headSHA) | |
| 1248 | if err != nil { | |
| 1249 | return g, err | |
| 1250 | } | |
| 1251 | g.Checks = store.CombinedStatus(statuses) | |
| 1252 | if set.RequireChecks { | |
| 1253 | reported := map[string]bool{} | |
| 1254 | for _, s := range statuses { | |
| 1255 | reported[s.Context] = true | |
| 1256 | } | |
| 1257 | for _, want := range set.RequiredContexts { | |
| 1258 | if !reported[want] { | |
| 1259 | g.ChecksMissing = append(g.ChecksMissing, want) | |
| 1260 | } | |
| 1261 | } | |
| 1262 | if len(g.ChecksMissing) > 0 && (g.Checks == "" || g.Checks == "success") { | |
| 1263 | g.Checks = "pending" | |
| 1264 | } | |
| 1265 | switch g.Checks { | |
| 1266 | case "success": | |
| 1267 | case "": | |
| 1268 | if checksExpected(st, repo.ID, dir, headSHA) { | |
| 1269 | g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA)) | |
| 1270 | } | |
| 1271 | default: | |
| 1272 | var bad []string | |
| 1273 | for _, st := range statuses { | |
| 1274 | if st.State != "success" { | |
| 1275 | bad = append(bad, st.Context+"="+st.State) | |
| 1276 | } | |
| 1277 | } | |
| 1278 | for _, m := range g.ChecksMissing { | |
| 1279 | bad = append(bad, m+"=missing") | |
| 1280 | } | |
| 1281 | g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", "))) | |
| 1282 | } | |
| 1283 | } | |
| 1284 | ``` | |
| 1285 | ||
| 1286 | `repo.go:710-717`, add a field after `"protected tags"`: | |
| 1287 | ||
| 1288 | ```go | |
| 1289 | "required contexts", strings.Join(repo.Settings.RequiredContexts, ", "), | |
| 1290 | ``` | |
| 1291 | ||
| 1292 | - [ ] **Step 6: Run the control tests** | |
| 1293 | ||
| 1294 | Run: `go vet ./... && go test ./internal/control ./internal/store -count=1` | |
| 1295 | Expected: PASS. | |
| 1296 | ||
| 1297 | - [ ] **Step 7: CLI, web, summaries** | |
| 1298 | ||
| 1299 | `cmd/gitbay/main.go`, after the `require-checks` line (580): | |
| 1300 | ||
| 1301 | ```go | |
| 1302 | pass("require-contexts", passOpts{server: []string{"repo", "settings", "require-contexts"}, needsRepo: true}), | |
| 1303 | ``` | |
| 1304 | ||
| 1305 | Run: `go test ./cmd/gitbay -run TestSummariesAreCurrent -update -count=1 && go test ./cmd/gitbay -count=1` | |
| 1306 | Expected: PASS; `summaries_gen.go` gains the `repo settings require-contexts` line. | |
| 1307 | ||
| 1308 | `internal/httpd/settings.go`, in `settingsSubmit` after the | |
| 1309 | `require-checks` case: | |
| 1310 | ||
| 1311 | ```go | |
| 1312 | case "require-contexts": | |
| 1313 | argv = append([]string{"repo", "settings", "require-contexts", repo}, strings.Fields(v("contexts"))...) | |
| 1314 | ``` | |
| 1315 | ||
| 1316 | and in `fieldLabel` after `require-checks`: | |
| 1317 | ||
| 1318 | ```go | |
| 1319 | case "require-contexts": | |
| 1320 | return "required contexts" | |
| 1321 | ``` | |
| 1322 | ||
| 1323 | `internal/web/templates/settings.html`, after the `require-checks` | |
| 1324 | form (line 78): | |
| 1325 | ||
| 1326 | ```html | |
| 1327 | <form method="post" action="{{$base}}" class="setform"> | |
| 1328 | <input type="hidden" name="field" value="require-contexts"> | |
| 1329 | <div><label for="contexts">Required contexts</label><p class="hint">Statuses the checks gate waits for until they report, separated by spaces. Applies with required checks on.</p></div> | |
| 1330 | <div><input type="text" id="contexts" name="contexts" value="{{range $i, $c := .Repo.Settings.RequiredContexts}}{{if $i}} {{end}}{{$c}}{{end}}" autocomplete="off"></div> | |
| 1331 | <div><button type="submit" class="btn">Save</button></div> | |
| 1332 | </form> | |
| 1333 | ``` | |
| 1334 | ||
| 1335 | `internal/web/templates/mr.html`, after the `OwnersOutstanding` line | |
| 1336 | (124): | |
| 1337 | ||
| 1338 | ```html | |
| 1339 | {{range .ChecksMissing}}<p class="row none">waiting on <code>{{.}}</code>, not yet reported</p>{{end}} | |
| 1340 | ``` | |
| 1341 | ||
| 1342 | In `internal/httpd/mrpage_test.go` `TestMRGatesRender`, add after the | |
| 1343 | first `for` loop: | |
| 1344 | ||
| 1345 | ```go | |
| 1346 | if out := render(&control.GatesOut{Checks: "pending", ChecksMissing: []string{"ext/deploy"}}); !strings.Contains(out, "waiting on <code>ext/deploy</code>") { | |
| 1347 | t.Errorf("missing required context not rendered:\n%s", out) | |
| 1348 | } | |
| 1349 | ``` | |
| 1350 | ||
| 1351 | In `e2e/settingsweb_test.go`, after | |
| 1352 | `post(url.Values{"field": {"require-checks"}, …})`: | |
| 1353 | ||
| 1354 | ```go | |
| 1355 | post(url.Values{"field": {"require-contexts"}, "contexts": {"ext/deploy lint"}}) | |
| 1356 | ``` | |
| 1357 | ||
| 1358 | and add `` `"required_contexts":["ext/deploy","lint"]` `` to the | |
| 1359 | `settings show --json` want list. | |
| 1360 | ||
| 1361 | Run: `go test ./internal/httpd ./internal/web -count=1 && go test ./e2e -run TestRepoSettingsWeb -count=1` | |
| 1362 | Expected: PASS. | |
| 1363 | ||
| 1364 | - [ ] **Step 8: Commit** | |
| 1365 | ||
| 1366 | ```bash | |
| 1367 | git add internal/store/repos.go internal/control cmd/gitbay internal/httpd internal/web e2e/settingsweb_test.go | |
| 1368 | git commit -S -m "repo settings: required contexts, pending until reported | |
| 1369 | ||
| 1370 | Ref #258" | |
| 1371 | ``` | |
| 1372 | ||
| 1373 | ### Task 2.4: wiki, and the MR | |
| 1374 | ||
| 1375 | **Files:** | |
| 1376 | - Modify: `.gitbay/wiki/API.org:100-122`, `.gitbay/wiki/CI.org:5-9`, `.gitbay/wiki/Users.org:542-545`, `.gitbay/wiki/Parity.org` (settings table near line 194) | |
| 1377 | - Modify: `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org:57`, `09-Controls.org:39`, `:92`, `10-Known-Gaps.org` (remove the #258 row) | |
| 1378 | ||
| 1379 | - [ ] **Step 1: API.org** | |
| 1380 | ||
| 1381 | Change the two example lines to `--context ext/build`, and after the | |
| 1382 | paragraph ending "…Each report also emits a =status= event to | |
| 1383 | webhooks." add: | |
| 1384 | ||
| 1385 | ```org | |
| 1386 | Contexts starting with =ci/= are the instance's own: its builds queue, | |
| 1387 | reuse, skip and finish them, and =status set= refuses them with exit 4, | |
| 1388 | so a writer cannot mark =ci/test= green on a head the build has not | |
| 1389 | passed. Report under another prefix, such as =ext/=. | |
| 1390 | ||
| 1391 | =repo settings require-contexts <repo> ext/deploy ci/test= names | |
| 1392 | statuses the checks gate waits for whether or not they have reported: | |
| 1393 | one that has not is =pending=, and =mr show= lists it as | |
| 1394 | =ext/deploy=missing=. It applies while =require-checks= is on; with no | |
| 1395 | contexts it clears the list. | |
| 1396 | ``` | |
| 1397 | ||
| 1398 | - [ ] **Step 2: CI.org** | |
| 1399 | ||
| 1400 | In the *Dedupe* bullet, after "…naming the build it came from (#177).", | |
| 1401 | add: "Only a trusted build counts, and for tree reuse only one on the | |
| 1402 | image the job names: a fork's green build does not stand for the | |
| 1403 | repository's own, so its commit is built again when it lands on a | |
| 1404 | branch (#258)." | |
| 1405 | ||
| 1406 | - [ ] **Step 3: Users.org** | |
| 1407 | ||
| 1408 | After "…a =ci/<job>= commit status, which =repo settings | |
| 1409 | require-checks= can gate merges on." add: "=repo settings | |
| 1410 | require-contexts= names statuses the gate waits for until they report." | |
| 1411 | ||
| 1412 | - [ ] **Step 4: Parity.org** | |
| 1413 | ||
| 1414 | After the `require codeowners` row: | |
| 1415 | ||
| 1416 | ```org | |
| 1417 | | require contexts | yes | yes | no | | |
| 1418 | ``` | |
| 1419 | ||
| 1420 | - [ ] **Step 5: Architecture** | |
| 1421 | ||
| 1422 | `07-CI-and-Supply-Chain.org:57`: | |
| 1423 | ||
| 1424 | ```org | |
| 1425 | | =status set= | write on the repository; =ci/*= contexts refused (=status.go=) | | |
| 1426 | ``` | |
| 1427 | ||
| 1428 | and in lifecycle step 1 replace "or =success= copied from an earlier | |
| 1429 | build of the same tree (#177)." with "or =success= copied from an | |
| 1430 | earlier trusted build of the same tree on the same image (#177, #258)." | |
| 1431 | ||
| 1432 | `09-Controls.org:39`: | |
| 1433 | ||
| 1434 | ```org | |
| 1435 | | Merge gates | in place | =MergeGates=; =ci/*= statuses written only by the build subsystem; required contexts | | |
| 1436 | ``` | |
| 1437 | ||
| 1438 | `09-Controls.org:92`: | |
| 1439 | ||
| 1440 | ```org | |
| 1441 | | Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) | | |
| 1442 | ``` | |
| 1443 | ||
| 1444 | `10-Known-Gaps.org`: delete the `#258` row. | |
| 1445 | ||
| 1446 | - [ ] **Step 6: Commit and MR** | |
| 1447 | ||
| 1448 | ```bash | |
| 1449 | git add .gitbay/wiki | |
| 1450 | git commit -S -m "wiki: reserved ci/ statuses, trusted reuse, required contexts | |
| 1451 | ||
| 1452 | Closes #258" | |
| 1453 | git push -u origin ci-status-trust | |
| 1454 | gitbay mr create --source ci-status-trust --target main --title "ci: reserve ci/ statuses; reuse only trusted results; required contexts" | |
| 1455 | ``` | |
| 1456 | ||
| 1457 | No runner change: this part deploys with `make deploy` alone. Merge | |
| 1458 | with `--strategy ff` once CI is green; delete the branch both places. | |
| 1459 | ||
| 1460 | --- | |
| 1461 | ||
| 1462 | # Part 3: separate the runner's source address from its builds (branch `runner-source-address`, #260) | |
| 1463 | ||
| 1464 | ### Task 3.1: the claim carries the instance's public ssh destination | |
| 1465 | ||
| 1466 | **Files:** | |
| 1467 | - Modify: `internal/control/build.go` (the payload from Task 1.1; a helper beside `runRunnerNext`) | |
| 1468 | - Test: `internal/control/runnernext_test.go` (append) | |
| 1469 | ||
| 1470 | **Interfaces:** | |
| 1471 | - Produces: `runner next --json` payload `"ssh": "git@<site host>"`, omitted when `site_url` is empty. | |
| 1472 | ||
| 1473 | - [ ] **Step 1: Write the failing test** | |
| 1474 | ||
| 1475 | ```go | |
| 1476 | // A build on the daemon's own host is given the public destination, not | |
| 1477 | // the loopback address its runner polls (#260). | |
| 1478 | func TestRunnerNextCarriesPublicSSH(t *testing.T) { | |
| 1479 | st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) | |
| 1480 | if _, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true); err != nil { | |
| 1481 | t.Fatal(err) | |
| 1482 | } | |
| 1483 | c, out := runnerCtx(st, uid, root) // site_url https://x.test | |
| 1484 | c.JSON = true | |
| 1485 | if code := runRunnerNext(c, nil); code != protocol.ExitOK { | |
| 1486 | t.Fatalf("runner next: exit %d, output:\n%s", code, out.String()) | |
| 1487 | } | |
| 1488 | if !strings.Contains(out.String(), `"ssh":"git@x.test"`) { | |
| 1489 | t.Fatalf("claim lacks the public destination:\n%s", out.String()) | |
| 1490 | } | |
| 1491 | } | |
| 1492 | ``` | |
| 1493 | ||
| 1494 | - [ ] **Step 2: Run it and see it fail** | |
| 1495 | ||
| 1496 | Run: `go test ./internal/control -run TestRunnerNextCarriesPublicSSH -count=1` | |
| 1497 | Expected: FAIL, `claim lacks the public destination`. | |
| 1498 | ||
| 1499 | - [ ] **Step 3: Implement** | |
| 1500 | ||
| 1501 | Add after `maxOrphanSkip`: | |
| 1502 | ||
| 1503 | ```go | |
| 1504 | // publicSSH is the instance's ssh destination as anyone outside reaches | |
| 1505 | // it. A runner on the daemon's own host polls over loopback and hands | |
| 1506 | // its builds this instead, so no build connects from the runner's source | |
| 1507 | // address (#260). Empty when site_url is not set. | |
| 1508 | func publicSSH(c *Ctx) string { | |
| 1509 | if host := c.Cfg.SiteHost(); host != "" { | |
| 1510 | return "git@" + host | |
| 1511 | } | |
| 1512 | return "" | |
| 1513 | } | |
| 1514 | ``` | |
| 1515 | ||
| 1516 | In the payload struct add, after `Trusted`: | |
| 1517 | ||
| 1518 | ```go | |
| 1519 | // SSH is the instance's public destination for the build's | |
| 1520 | // GITBAY_SSH when its runner polls over loopback (#260). | |
| 1521 | SSH string `json:"ssh,omitempty"` | |
| 1522 | ``` | |
| 1523 | ||
| 1524 | and `SSH: publicSSH(c),` in the literal. | |
| 1525 | ||
| 1526 | - [ ] **Step 4: Run it and see it pass** | |
| 1527 | ||
| 1528 | Run: `go test ./internal/control -run TestRunnerNext -count=1` | |
| 1529 | Expected: PASS. | |
| 1530 | ||
| 1531 | - [ ] **Step 5: Commit** | |
| 1532 | ||
| 1533 | ```bash | |
| 1534 | git add internal/control/build.go internal/control/runnernext_test.go | |
| 1535 | git commit -S -m "runner next: send the instance's public ssh destination | |
| 1536 | ||
| 1537 | Ref #260" | |
| 1538 | ``` | |
| 1539 | ||
| 1540 | ### Task 3.2: a loopback runner's builds get no host loopback | |
| 1541 | ||
| 1542 | **Files:** | |
| 1543 | - Modify: `cmd/gitbay-runner/main.go:32-42` (`job`), `:433` (`stepEnv` call), `:465-486` (`buildSSH`) | |
| 1544 | - Modify: `cmd/gitbay-runner/isolate.go:150-154` (podman run args) | |
| 1545 | - Modify: `cmd/gitbay-runner/env_test.go:190-212` | |
| 1546 | ||
| 1547 | **Interfaces:** | |
| 1548 | - Consumes: the `ssh` claim field from Task 3.1. | |
| 1549 | - Produces: | |
| 1550 | - `job.SSH string` (`json:"ssh"`) | |
| 1551 | - `func (r *runner) loopbackRemote() bool` | |
| 1552 | - `func (r *runner) buildSSH(public string) string` (was `buildSSH()`) | |
| 1553 | - `func (r *runner) buildNetwork() []string` | |
| 1554 | ||
| 1555 | - [ ] **Step 1: Write the failing tests** | |
| 1556 | ||
| 1557 | Replace `TestStepEnvCarriesInstanceAddress` (`env_test.go:190-212`) with: | |
| 1558 | ||
| 1559 | ```go | |
| 1560 | // A build that talks back to the instance needs an address that works | |
| 1561 | // from where it runs. A runner polling over loopback keeps its podman | |
| 1562 | // builds off the host's loopback, so they get the instance's public | |
| 1563 | // destination from the claim; any other remote is used as it is (#260). | |
| 1564 | func TestStepEnvCarriesInstanceAddress(t *testing.T) { | |
| 1565 | env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org") | |
| 1566 | if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") { | |
| 1567 | t.Errorf("GITBAY_SSH missing: %q", env) | |
| 1568 | } | |
| 1569 | for _, tc := range []struct{ remote, isolation, public, want string }{ | |
| 1570 | {"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@127.0.0.1"}, | |
| 1571 | {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@gitbay.org"}, | |
| 1572 | {"git@localhost", isolationPodman, "git@gitbay.org", "git@gitbay.org"}, | |
| 1573 | {"git@127.0.0.1", isolationPodman, "", "git@127.0.0.1"}, | |
| 1574 | {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"}, | |
| 1575 | {"gitbay.org", isolationPodman, "git@gitbay.org", "gitbay.org"}, | |
| 1576 | } { | |
| 1577 | r := &runner{remote: tc.remote, isolation: tc.isolation} | |
| 1578 | if got := r.buildSSH(tc.public); got != tc.want { | |
| 1579 | t.Errorf("remote %s under %s, public %q: got %s want %s", tc.remote, tc.isolation, tc.public, got, tc.want) | |
| 1580 | } | |
| 1581 | } | |
| 1582 | } | |
| 1583 | ||
| 1584 | // Only a runner that polls over loopback shares an address a build could | |
| 1585 | // connect from, so only its builds lose the host-loopback mapping (#260). | |
| 1586 | func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) { | |
| 1587 | for _, tc := range []struct { | |
| 1588 | remote string | |
| 1589 | want []string | |
| 1590 | }{ | |
| 1591 | {"git@127.0.0.1", []string{"--network", "pasta:--no-map-gw"}}, | |
| 1592 | {"localhost", []string{"--network", "pasta:--no-map-gw"}}, | |
| 1593 | {"git@::1", []string{"--network", "pasta:--no-map-gw"}}, | |
| 1594 | {"git@gitbay.org", nil}, | |
| 1595 | } { | |
| 1596 | r := &runner{remote: tc.remote, isolation: isolationPodman} | |
| 1597 | if got := r.buildNetwork(); strings.Join(got, " ") != strings.Join(tc.want, " ") { | |
| 1598 | t.Errorf("remote %s: %q, want %q", tc.remote, got, tc.want) | |
| 1599 | } | |
| 1600 | } | |
| 1601 | } | |
| 1602 | ``` | |
| 1603 | ||
| 1604 | - [ ] **Step 2: Run them and see them fail** | |
| 1605 | ||
| 1606 | Run: `go test ./cmd/gitbay-runner -count=1` | |
| 1607 | Expected: build failure (`too many arguments in call to r.buildSSH`, `r.buildNetwork undefined`). | |
| 1608 | ||
| 1609 | - [ ] **Step 3: Implement** | |
| 1610 | ||
| 1611 | `job`, after `Trusted`: | |
| 1612 | ||
| 1613 | ```go | |
| 1614 | // SSH is the instance's public ssh destination, for a build whose | |
| 1615 | // runner polls over loopback (#260). | |
| 1616 | SSH string `json:"ssh"` | |
| 1617 | ``` | |
| 1618 | ||
| 1619 | Replace `buildSSH` (`main.go:465-486`) with: | |
| 1620 | ||
| 1621 | ```go | |
| 1622 | // loopbackRemote reports whether the runner polls the daemon on its own | |
| 1623 | // host over loopback. | |
| 1624 | func (r *runner) loopbackRemote() bool { | |
| 1625 | _, host, ok := strings.Cut(r.remote, "@") | |
| 1626 | if !ok { | |
| 1627 | host = r.remote | |
| 1628 | } | |
| 1629 | return host == "127.0.0.1" || host == "localhost" || host == "::1" | |
| 1630 | } | |
| 1631 | ||
| 1632 | // buildSSH is the instance's ssh destination as a build reaches it. A | |
| 1633 | // runner polling over loopback keeps its podman builds off the host's | |
| 1634 | // loopback (buildNetwork), so they get the instance's public destination | |
| 1635 | // from the claim. Any other remote is a real host elsewhere and works as | |
| 1636 | // it is, and under -isolation none a build runs on the host itself. | |
| 1637 | func (r *runner) buildSSH(public string) string { | |
| 1638 | if r.isolation == isolationPodman && r.loopbackRemote() && public != "" { | |
| 1639 | return public | |
| 1640 | } | |
| 1641 | return r.remote | |
| 1642 | } | |
| 1643 | ||
| 1644 | // buildNetwork is the podman network option for a build. pasta maps the | |
| 1645 | // container's gateway address to the host's loopback, and a build's | |
| 1646 | // connection through it arrives from 127.0.0.1 — the address a runner on | |
| 1647 | // the daemon's host polls from. The SSH auth limiter counts failures per | |
| 1648 | // source address, so a build sharing the runner's could throttle its | |
| 1649 | // polling (#260). --no-map-gw removes the mapping: the build reaches the | |
| 1650 | // host only at its public address, as any client on the internet does, | |
| 1651 | // and keeps its outbound access. | |
| 1652 | func (r *runner) buildNetwork() []string { | |
| 1653 | if !r.loopbackRemote() { | |
| 1654 | return nil | |
| 1655 | } | |
| 1656 | return []string{"--network", "pasta:--no-map-gw"} | |
| 1657 | } | |
| 1658 | ``` | |
| 1659 | ||
| 1660 | In `run`, the `stepEnv` call becomes `env := stepEnv(j, home, r.buildSSH(j.SSH))`. | |
| 1661 | ||
| 1662 | In `isolate.go`, after the `--env-file` append (line 153): | |
| 1663 | ||
| 1664 | ```go | |
| 1665 | args = append(args, r.buildNetwork()...) | |
| 1666 | ``` | |
| 1667 | ||
| 1668 | - [ ] **Step 4: Run the tests and see them pass** | |
| 1669 | ||
| 1670 | Run: `go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner -count=1` | |
| 1671 | Expected: PASS. | |
| 1672 | ||
| 1673 | - [ ] **Step 5: Commit** | |
| 1674 | ||
| 1675 | ```bash | |
| 1676 | git add cmd/gitbay-runner | |
| 1677 | git commit -S -m "runner: builds off the host's loopback when the runner polls over it | |
| 1678 | ||
| 1679 | Ref #260" | |
| 1680 | ``` | |
| 1681 | ||
| 1682 | ### Task 3.3: egress policy in the wiki, and the MR | |
| 1683 | ||
| 1684 | **Files:** | |
| 1685 | - Modify: `.gitbay/wiki/Threat-Model.org` ("The CI runner", after the *Images* bullet) | |
| 1686 | - Modify: `.gitbay/wiki/CI.org` (new section before "* The table") | |
| 1687 | - Modify: `.gitbay/wiki/Users.org:550-555` (the `GITBAY_SSH` sentence) | |
| 1688 | - Modify: `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org` (Network row), `04-Trust-Boundaries.org` (TB7), `09-Controls.org:91` | |
| 1689 | ||
| 1690 | - [ ] **Step 1: Threat-Model** | |
| 1691 | ||
| 1692 | Add a bullet after *Images are provisioned…*: | |
| 1693 | ||
| 1694 | ```org | |
| 1695 | - *What a build can reach.* Outbound internet, trusted or not: a fork's | |
| 1696 | merge request to a Go repository has to fetch its modules. Not the | |
| 1697 | host's loopback: a runner that polls the daemon over loopback starts | |
| 1698 | its containers with pasta's gateway mapping off, so a build reaches | |
| 1699 | the host only at its public address, as anyone on the internet does, | |
| 1700 | and =GITBAY_SSH= names that address. That keeps the runner's source | |
| 1701 | address, =127.0.0.1=, one no build can connect from; the SSH auth | |
| 1702 | limiter counts failures per address, and a build sharing the runner's | |
| 1703 | could throttle its polling (krz/gitbay#260). The forge's public ports | |
| 1704 | — 22, 80, 443 and the operator's sshd — are reachable from a build | |
| 1705 | exactly as from the internet. Under =-isolation none= a build runs on | |
| 1706 | the host and shares its loopback; that mode is for instances where | |
| 1707 | every repository is trusted. | |
| 1708 | ``` | |
| 1709 | ||
| 1710 | - [ ] **Step 2: CI.org** | |
| 1711 | ||
| 1712 | Add before `* The table`: | |
| 1713 | ||
| 1714 | ```org | |
| 1715 | * What a build can reach | |
| 1716 | ||
| 1717 | Builds have outbound internet access, trusted and untrusted alike, and | |
| 1718 | no access to the runner host's loopback when the runner polls the | |
| 1719 | daemon over it: the forge is reached at its public address, the one in | |
| 1720 | =GITBAY_SSH=. See the Threat-Model page, "What a build can reach", for | |
| 1721 | why (krz/gitbay#260). | |
| 1722 | ``` | |
| 1723 | ||
| 1724 | - [ ] **Step 3: Users.org** | |
| 1725 | ||
| 1726 | Replace "(=git@gitbay.org= from a runner elsewhere; inside a container | |
| 1727 | on the server's own runner the host is at a private address the runner | |
| 1728 | fills in)" with "(=git@gitbay.org=, the instance's public address, from | |
| 1729 | a runner elsewhere and from a container on the server's own runner | |
| 1730 | alike)". | |
| 1731 | ||
| 1732 | - [ ] **Step 4: Architecture** | |
| 1733 | ||
| 1734 | `07-CI-and-Supply-Chain.org` Network row: | |
| 1735 | ||
| 1736 | ```org | |
| 1737 | | Network | pasta; outbound open; a loopback runner's builds run with =--no-map-gw= and reach the host only at its public address (=main.go=, #260) | | |
| 1738 | ``` | |
| 1739 | ||
| 1740 | `04-Trust-Boundaries.org` TB7: replace "the network is open (#260)" | |
| 1741 | with "outbound is open and the host's loopback is not reachable | |
| 1742 | (#260)". `09-Controls.org:91`: | |
| 1743 | ||
| 1744 | ```org | |
| 1745 | | Build network egress restricted | partial | host loopback closed to builds; outbound open by decision (#260) | | |
| 1746 | ``` | |
| 1747 | ||
| 1748 | The Known-Gaps row for #260 and its "What can a build reach…" question | |
| 1749 | stay until the runbook's R3 results are recorded. | |
| 1750 | ||
| 1751 | - [ ] **Step 5: Verify, commit, MR** | |
| 1752 | ||
| 1753 | Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./internal/control -count=1` | |
| 1754 | Expected: PASS. | |
| 1755 | ||
| 1756 | ```bash | |
| 1757 | git add .gitbay/wiki | |
| 1758 | git commit -S -m "wiki: what a build can reach | |
| 1759 | ||
| 1760 | Ref #260" | |
| 1761 | git push -u origin runner-source-address | |
| 1762 | gitbay mr create --source runner-source-address --target main --title "runner: keep builds off the runner's source address" | |
| 1763 | ``` | |
| 1764 | ||
| 1765 | Before merging, run the runbook's R3 on the scratch repository. Merge | |
| 1766 | with `--strategy ff`; delete the branch both places. | |
| 1767 | ||
| 1768 | --- | |
| 1769 | ||
| 1770 | # Part 4: failed step and duration (branch `build-failure-report`, #266) | |
| 1771 | ||
| 1772 | ### Task 4.1: store the failed step | |
| 1773 | ||
| 1774 | **Files:** | |
| 1775 | - Create: `internal/store/migrations/0065_build_failure.up.sql`, `0065_build_failure.down.sql` | |
| 1776 | - Modify: `internal/store/builds.go:12-33` (`Build`), `:67-78` (`buildSelect`, `scanBuild`), after `FinishBuild` (`:251-264`) | |
| 1777 | - Test: `internal/store/builds_test.go` (append; add `"errors"` to imports) | |
| 1778 | ||
| 1779 | **Interfaces:** | |
| 1780 | - Produces: | |
| 1781 | - `Build.FailedStep int`, `Build.FailedReason string` | |
| 1782 | - `func (s *Store) SetBuildFailure(id int64, step int, reason string) error` — only on a running build; `ErrNotFound` otherwise. | |
| 1783 | ||
| 1784 | - [ ] **Step 1: Write the failing test** | |
| 1785 | ||
| 1786 | ```go | |
| 1787 | // Where a failed build stopped is recorded while it runs, before the | |
| 1788 | // outcome, and a finished build is not rewritten (#266). | |
| 1789 | func TestSetBuildFailure(t *testing.T) { | |
| 1790 | s := open(t) | |
| 1791 | if err := s.MigrateUp(); err != nil { | |
| 1792 | t.Fatal(err) | |
| 1793 | } | |
| 1794 | uid, _ := s.CreateUser("cmc", true) | |
| 1795 | repoID, _ := s.CreateRepo("user", uid, "app", "public") | |
| 1796 | if _, err := s.CreateBuild(repoID, "unit", "abc", "main", `["true","false"]`, "", "", true); err != nil { | |
| 1797 | t.Fatal(err) | |
| 1798 | } | |
| 1799 | b, ok, err := s.ClaimBuild(nil, false) | |
| 1800 | if err != nil || !ok { | |
| 1801 | t.Fatalf("claim: ok=%v err=%v", ok, err) | |
| 1802 | } | |
| 1803 | if err := s.SetBuildFailure(b.ID, 2, "exit 1"); err != nil { | |
| 1804 | t.Fatal(err) | |
| 1805 | } | |
| 1806 | if err := s.FinishBuild(b.ID, "failure"); err != nil { | |
| 1807 | t.Fatal(err) | |
| 1808 | } | |
| 1809 | got, err := s.BuildByID(b.ID) | |
| 1810 | if err != nil { | |
| 1811 | t.Fatal(err) | |
| 1812 | } | |
| 1813 | if got.FailedStep != 2 || got.FailedReason != "exit 1" { | |
| 1814 | t.Fatalf("failed step %d reason %q", got.FailedStep, got.FailedReason) | |
| 1815 | } | |
| 1816 | if err := s.SetBuildFailure(b.ID, 1, "late"); !errors.Is(err, ErrNotFound) { | |
| 1817 | t.Fatalf("rewrote a finished build: %v", err) | |
| 1818 | } | |
| 1819 | } | |
| 1820 | ``` | |
| 1821 | ||
| 1822 | - [ ] **Step 2: Run it and see it fail** | |
| 1823 | ||
| 1824 | Run: `go test ./internal/store -run TestSetBuildFailure -count=1` | |
| 1825 | Expected: build failure (`s.SetBuildFailure undefined`). | |
| 1826 | ||
| 1827 | - [ ] **Step 3: Migration** | |
| 1828 | ||
| 1829 | `0065_build_failure.up.sql`: | |
| 1830 | ||
| 1831 | ```sql | |
| 1832 | -- Where a failed build stopped: the 1-based step, 0 when it stopped | |
| 1833 | -- before any step or did not fail, and the runner's one-line reason. | |
| 1834 | ALTER TABLE builds ADD COLUMN failed_step INTEGER NOT NULL DEFAULT 0; | |
| 1835 | ALTER TABLE builds ADD COLUMN failed_reason TEXT NOT NULL DEFAULT ''; | |
| 1836 | ``` | |
| 1837 | ||
| 1838 | `0065_build_failure.down.sql`: | |
| 1839 | ||
| 1840 | ```sql | |
| 1841 | ALTER TABLE builds DROP COLUMN failed_reason; | |
| 1842 | ALTER TABLE builds DROP COLUMN failed_step; | |
| 1843 | ``` | |
| 1844 | ||
| 1845 | - [ ] **Step 4: Store** | |
| 1846 | ||
| 1847 | In `Build`, after `Trusted`: | |
| 1848 | ||
| 1849 | ```go | |
| 1850 | // FailedStep is the 1-based step a failed build stopped at, 0 when it | |
| 1851 | // stopped before its first step or did not fail. FailedReason is the | |
| 1852 | // runner's one line: "exit 1", "build timed out after 45m0s". | |
| 1853 | FailedStep int | |
| 1854 | FailedReason string | |
| 1855 | ``` | |
| 1856 | ||
| 1857 | `buildSelect` and `scanBuild`: | |
| 1858 | ||
| 1859 | ```go | |
| 1860 | const buildSelect = ` | |
| 1861 | SELECT id, repo_id, number, job, sha, ref, steps, image, tree, status, created_at, started_at, finished_at, log_closed_at, trusted, | |
| 1862 | failed_step, failed_reason | |
| 1863 | FROM builds` | |
| 1864 | ||
| 1865 | func scanBuild(row interface{ Scan(...any) error }) (Build, error) { | |
| 1866 | var b Build | |
| 1867 | var trusted int | |
| 1868 | err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, &b.Tree, | |
| 1869 | &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &b.LogClosedAt, &trusted, | |
| 1870 | &b.FailedStep, &b.FailedReason) | |
| 1871 | b.Trusted = trusted != 0 | |
| 1872 | return b, err | |
| 1873 | } | |
| 1874 | ``` | |
| 1875 | ||
| 1876 | After `FinishBuild`: | |
| 1877 | ||
| 1878 | ```go | |
| 1879 | // SetBuildFailure records where a running build failed. The runner | |
| 1880 | // reports it with the outcome; it is written first, so a reader woken | |
| 1881 | // by the finish sees both. | |
| 1882 | func (s *Store) SetBuildFailure(id int64, step int, reason string) error { | |
| 1883 | res, err := s.DB.Exec(`UPDATE builds SET failed_step = ?, failed_reason = ? | |
| 1884 | WHERE id = ? AND status = 'running'`, step, reason, id) | |
| 1885 | if err != nil { | |
| 1886 | return err | |
| 1887 | } | |
| 1888 | if n, _ := res.RowsAffected(); n == 0 { | |
| 1889 | return ErrNotFound | |
| 1890 | } | |
| 1891 | return nil | |
| 1892 | } | |
| 1893 | ``` | |
| 1894 | ||
| 1895 | - [ ] **Step 5: Run the tests and see them pass** | |
| 1896 | ||
| 1897 | Run: `go test ./internal/store -count=1` | |
| 1898 | Expected: PASS, `TestMigrateUpDown` included. | |
| 1899 | ||
| 1900 | - [ ] **Step 6: Commit** | |
| 1901 | ||
| 1902 | ```bash | |
| 1903 | git add internal/store | |
| 1904 | git commit -S -m "store: failed step and reason on a build | |
| 1905 | ||
| 1906 | Ref #266" | |
| 1907 | ``` | |
| 1908 | ||
| 1909 | ### Task 4.2: `runner done --step --reason` | |
| 1910 | ||
| 1911 | **Files:** | |
| 1912 | - Modify: `internal/control/build.go:102-106` (registration), `:648-713` (`runRunnerDone`) | |
| 1913 | - Test: `internal/control/runnernext_test.go` (append) | |
| 1914 | ||
| 1915 | **Interfaces:** | |
| 1916 | - Consumes: `Store.SetBuildFailure`. | |
| 1917 | - Produces: `runner done <build-id> success|failure [--step <n>] [--reason <text>]`; `func failureReason(s string) string`. | |
| 1918 | ||
| 1919 | - [ ] **Step 1: Write the failing tests** | |
| 1920 | ||
| 1921 | ```go | |
| 1922 | // runner done records the failed step and a one-line reason (#266). | |
| 1923 | func TestRunnerDoneRecordsFailedStep(t *testing.T) { | |
| 1924 | st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) | |
| 1925 | n, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["go build ./...","go test ./..."]`, "", "", true) | |
| 1926 | if err != nil { | |
| 1927 | t.Fatal(err) | |
| 1928 | } | |
| 1929 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, false) | |
| 1930 | if err != nil || !ok { | |
| 1931 | t.Fatalf("claim: ok=%v err=%v", ok, err) | |
| 1932 | } | |
| 1933 | c, out := runnerCtx(st, uid, root) | |
| 1934 | if code := runRunnerDone(c, []string{fmt.Sprint(b.ID), "failure", "--step", "2", "--reason", "exit 1\n"}); code != protocol.ExitOK { | |
| 1935 | t.Fatalf("runner done: exit %d\n%s", code, out.String()) | |
| 1936 | } | |
| 1937 | got, _ := st.BuildByNumber(repo.ID, n) | |
| 1938 | if got.Status != "failure" || got.FailedStep != 2 || got.FailedReason != "exit 1" { | |
| 1939 | t.Fatalf("status %s step %d reason %q", got.Status, got.FailedStep, got.FailedReason) | |
| 1940 | } | |
| 1941 | } | |
| 1942 | ||
| 1943 | // A report with no flags — an older runner — or with a step past the | |
| 1944 | // job's still finishes the build; the step is then recorded as 0. | |
| 1945 | func TestRunnerDoneToleratesMissingOrBadStep(t *testing.T) { | |
| 1946 | st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t) | |
| 1947 | for _, extra := range [][]string{nil, {"--step", "9"}} { | |
| 1948 | n, _ := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["true"]`, "", "", true) | |
| 1949 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, false) | |
| 1950 | if err != nil || !ok { | |
| 1951 | t.Fatalf("claim: ok=%v err=%v", ok, err) | |
| 1952 | } | |
| 1953 | c, out := runnerCtx(st, uid, root) | |
| 1954 | if code := runRunnerDone(c, append([]string{fmt.Sprint(b.ID), "failure"}, extra...)); code != protocol.ExitOK { | |
| 1955 | t.Fatalf("runner done %v: exit %d\n%s", extra, code, out.String()) | |
| 1956 | } | |
| 1957 | if got, _ := st.BuildByNumber(repo.ID, n); got.Status != "failure" || got.FailedStep != 0 { | |
| 1958 | t.Fatalf("%v: status %s step %d", extra, got.Status, got.FailedStep) | |
| 1959 | } | |
| 1960 | } | |
| 1961 | } | |
| 1962 | ``` | |
| 1963 | ||
| 1964 | - [ ] **Step 2: Run them and see them fail** | |
| 1965 | ||
| 1966 | Run: `go test ./internal/control -run TestRunnerDone -count=1` | |
| 1967 | Expected: FAIL, the first with exit 2 (usage: four arguments where two | |
| 1968 | are accepted). | |
| 1969 | ||
| 1970 | - [ ] **Step 3: Implement** | |
| 1971 | ||
| 1972 | Registration: | |
| 1973 | ||
| 1974 | ```go | |
| 1975 | register(Command{Path: []string{"runner", "done"}, | |
| 1976 | Summary: "finish a build", | |
| 1977 | Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]", | |
| 1978 | Flags: []Flag{ | |
| 1979 | {"--step", "<n>", "the 1-based step a failed build stopped at", ""}, | |
| 1980 | {"--reason", "<text>", "how it failed, one line", ""}, | |
| 1981 | }, | |
| 1982 | Examples: []string{"runner done 431 success", "runner done 431 failure --step 3 --reason 'exit 1'"}, | |
| 1983 | Run: runRunnerDone}) | |
| 1984 | ``` | |
| 1985 | ||
| 1986 | Replace `runRunnerDone` with: | |
| 1987 | ||
| 1988 | ```go | |
| 1989 | func runRunnerDone(c *Ctx, args []string) int { | |
| 1990 | key, code := runnerSession(c) | |
| 1991 | if code >= 0 { | |
| 1992 | return code | |
| 1993 | } | |
| 1994 | f, err := parseFlags(args, flagSpec{Values: []string{"--step", "--reason"}, MaxPos: 2, | |
| 1995 | Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]"}) | |
| 1996 | if err != nil { | |
| 1997 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 1998 | } | |
| 1999 | if len(f.Pos) != 2 || (f.Pos[1] != "success" && f.Pos[1] != "failure") { | |
| 2000 | return c.usage() | |
| 2001 | } | |
| 2002 | outcome := f.Pos[1] | |
| 2003 | id, err := strconv.ParseInt(f.Pos[0], 10, 64) | |
| 2004 | if err != nil { | |
| 2005 | return c.fail(protocol.ExitUsage, "bad build id %q", f.Pos[0]) | |
| 2006 | } | |
| 2007 | b, err := c.Store.BuildByID(id) | |
| 2008 | if err != nil { | |
| 2009 | return c.fail(protocol.ExitNotFound, "no build %d", id) | |
| 2010 | } | |
| 2011 | if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil { | |
| 2012 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 2013 | } else if !ok { | |
| 2014 | return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository; a repository admin attaches it with repo runner add") | |
| 2015 | } | |
| 2016 | // Cancelled underneath the runner: its report is late, not wrong. | |
| 2017 | // The row, the status and the log were settled by the cancel. | |
| 2018 | if b.Status == "cancelled" { | |
| 2019 | c.Store.RunnerDone(key.ID) | |
| 2020 | return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) { | |
| 2021 | fmt.Fprintf(w, "build %d was cancelled\n", b.Number) | |
| 2022 | }) | |
| 2023 | } | |
| 2024 | if outcome == "failure" { | |
| 2025 | // A step the job does not have is recorded as none rather than | |
| 2026 | // refused: refusing would lose the outcome over a detail (#266). | |
| 2027 | var steps []string | |
| 2028 | json.Unmarshal([]byte(b.Steps), &steps) | |
| 2029 | step, _ := strconv.Atoi(f.Value("--step")) | |
| 2030 | if step < 0 || step > len(steps) { | |
| 2031 | step = 0 | |
| 2032 | } | |
| 2033 | if err := c.Store.SetBuildFailure(id, step, failureReason(f.Value("--reason"))); err != nil && !errors.Is(err, store.ErrNotFound) { | |
| 2034 | return c.fail(protocol.ExitFailure, "recording build %d's failure: %v", id, err) | |
| 2035 | } | |
| 2036 | } | |
| 2037 | if err := c.Store.FinishBuild(id, outcome); err != nil { | |
| 2038 | return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err) | |
| 2039 | } | |
| 2040 | c.Store.RunnerDone(key.ID) | |
| 2041 | repo, err := c.Store.RepoByID(b.RepoID) | |
| 2042 | if err != nil { | |
| 2043 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 2044 | } | |
| 2045 | url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number) | |
| 2046 | desc := "build " + outcome | |
| 2047 | if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, outcome, desc, url, c.User.ID); err != nil { | |
| 2048 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 2049 | } | |
| 2050 | c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome, | |
| 2051 | fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) | |
| 2052 | // A red build mails the repo's notify targets with the log tail — a | |
| 2053 | // failed scheduled job must not wait to be noticed. | |
| 2054 | if outcome == "failure" { | |
| 2055 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { | |
| 2056 | tail := "" | |
| 2057 | if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 { | |
| 2058 | if len(log) > 2000 { | |
| 2059 | log = log[len(log)-2000:] | |
| 2060 | } | |
| 2061 | tail = string(log) | |
| 2062 | } | |
| 2063 | notify(c, targets, notice{repo: repo, kind: "build", | |
| 2064 | subject: fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref), | |
| 2065 | action: fmt.Sprintf("build %d failed: %s on %s", b.Number, b.Job, b.Ref), | |
| 2066 | body: fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url), | |
| 2067 | path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)}) | |
| 2068 | } | |
| 2069 | } | |
| 2070 | return c.emit(map[string]any{"build": b.Number, "status": outcome}, func(w io.Writer) { | |
| 2071 | fmt.Fprintf(w, "build %d %s\n", b.Number, outcome) | |
| 2072 | }) | |
| 2073 | } | |
| 2074 | ||
| 2075 | // failureReason keeps a runner's reason to one line of at most 200 | |
| 2076 | // bytes: it is shown on the build page and by build show. | |
| 2077 | func failureReason(s string) string { | |
| 2078 | s = strings.Join(strings.Fields(s), " ") | |
| 2079 | if len(s) > 200 { | |
| 2080 | s = s[:200] | |
| 2081 | } | |
| 2082 | return strings.ToValidUTF8(s, "") | |
| 2083 | } | |
| 2084 | ``` | |
| 2085 | ||
| 2086 | - [ ] **Step 4: Run the tests and see them pass** | |
| 2087 | ||
| 2088 | Run: `go vet ./... && go test ./internal/control -count=1` | |
| 2089 | Expected: PASS. | |
| 2090 | ||
| 2091 | - [ ] **Step 5: Commit** | |
| 2092 | ||
| 2093 | ```bash | |
| 2094 | git add internal/control/build.go internal/control/runnernext_test.go | |
| 2095 | git commit -S -m "runner done: record the failed step and reason | |
| 2096 | ||
| 2097 | Ref #266" | |
| 2098 | ``` | |
| 2099 | ||
| 2100 | ### Task 4.3: the runner names the failed step | |
| 2101 | ||
| 2102 | **Files:** | |
| 2103 | - Modify: `cmd/gitbay-runner/main.go:245-277` (`step`), `:309-435` (`run`), `:363-398` (`runStep`) | |
| 2104 | - Modify: `cmd/gitbay-runner/isolate.go:79-197` (`runSteps`, `runStepsPodman`) | |
| 2105 | - Modify: `cmd/gitbay-runner/report.go:19-23` (`reportDone`), new `doneArgs` | |
| 2106 | - Test: `cmd/gitbay-runner/steps_test.go` (create), `cmd/gitbay-runner/report_test.go` (append) | |
| 2107 | ||
| 2108 | **Interfaces:** | |
| 2109 | - Consumes: `runner done … --step <n> --reason <text>` from Task 4.2. | |
| 2110 | - Produces: | |
| 2111 | - `type failure struct { Step int; Reason string }` | |
| 2112 | - `func exitReason(err error) string` | |
| 2113 | - `run(j job) *failure`, `runSteps(…) *failure`, `runStepsPodman(…) *failure` (nil is success) | |
| 2114 | - `func (r *runner) reportDone(id int64, status string, f *failure) error` | |
| 2115 | - `func doneArgs(id int64, status string, f *failure) []string` | |
| 2116 | ||
| 2117 | - [ ] **Step 1: Write the failing tests** | |
| 2118 | ||
| 2119 | Create `cmd/gitbay-runner/steps_test.go`: | |
| 2120 | ||
| 2121 | ```go | |
| 2122 | package main | |
| 2123 | ||
| 2124 | import ( | |
| 2125 | "os" | |
| 2126 | "os/exec" | |
| 2127 | "strings" | |
| 2128 | "testing" | |
| 2129 | "time" | |
| 2130 | ) | |
| 2131 | ||
| 2132 | // The failing step is named by number in the log and in the outcome | |
| 2133 | // reported to the server (#266). | |
| 2134 | func TestRunStepsNamesTheFailedStep(t *testing.T) { | |
| 2135 | r := &runner{isolation: isolationNone} | |
| 2136 | run := func(cmd *exec.Cmd, _ time.Time) (bool, string) { | |
| 2137 | if err := cmd.Run(); err != nil { | |
| 2138 | return false, exitReason(err) | |
| 2139 | } | |
| 2140 | return true, "" | |
| 2141 | } | |
| 2142 | env := []string{"PATH=" + os.Getenv("PATH")} | |
| 2143 | var log strings.Builder | |
| 2144 | f := r.runSteps(job{Steps: []string{"true", "exit 3", "true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run) | |
| 2145 | if f == nil || f.Step != 2 || f.Reason != "exit 3" { | |
| 2146 | t.Fatalf("failure %+v, want step 2, exit 3", f) | |
| 2147 | } | |
| 2148 | if !strings.Contains(log.String(), "step 2/3 failed: exit 3\n") { | |
| 2149 | t.Fatalf("log does not name the step:\n%s", log.String()) | |
| 2150 | } | |
| 2151 | if f := r.runSteps(job{Steps: []string{"true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run); f != nil { | |
| 2152 | t.Fatalf("a passing job failed: %+v", f) | |
| 2153 | } | |
| 2154 | } | |
| 2155 | ``` | |
| 2156 | ||
| 2157 | Append to `cmd/gitbay-runner/report_test.go` (add imports | |
| 2158 | `"strings"` and `"gitbay.org/gitbay/internal/protocol"`): | |
| 2159 | ||
| 2160 | ```go | |
| 2161 | // The reason survives the trip: ssh joins arguments with spaces and the | |
| 2162 | // server splits the line again with POSIX rules (#266). | |
| 2163 | func TestDoneArgsNameTheFailedStep(t *testing.T) { | |
| 2164 | got := doneArgs(7, "failure", &failure{Step: 3, Reason: "can't: exit 1"}) | |
| 2165 | argv, err := protocol.Tokenize(strings.Join(got, " ")) | |
| 2166 | if err != nil { | |
| 2167 | t.Fatal(err) | |
| 2168 | } | |
| 2169 | want := []string{"runner", "done", "7", "failure", "--step", "3", "--reason", "can't: exit 1"} | |
| 2170 | if strings.Join(argv, "|") != strings.Join(want, "|") { | |
| 2171 | t.Fatalf("server reads %q, want %q", argv, want) | |
| 2172 | } | |
| 2173 | if got := doneArgs(7, "success", nil); strings.Join(got, " ") != "runner done 7 success" { | |
| 2174 | t.Fatalf("success: %q", got) | |
| 2175 | } | |
| 2176 | if got := doneArgs(7, "failure", &failure{Reason: "git clone: exit 128"}); strings.Contains(strings.Join(got, " "), "--step") { | |
| 2177 | t.Fatalf("a failure before any step sent a step: %q", got) | |
| 2178 | } | |
| 2179 | } | |
| 2180 | ``` | |
| 2181 | ||
| 2182 | - [ ] **Step 2: Run them and see them fail** | |
| 2183 | ||
| 2184 | Run: `go test ./cmd/gitbay-runner -count=1` | |
| 2185 | Expected: build failure (`undefined: exitReason`, `undefined: doneArgs`, `undefined: failure`). | |
| 2186 | ||
| 2187 | - [ ] **Step 3: `failure` and `exitReason`** | |
| 2188 | ||
| 2189 | In `main.go`, before `run`: | |
| 2190 | ||
| 2191 | ```go | |
| 2192 | // failure says where a build stopped: Step is the 1-based step that | |
| 2193 | // failed, 0 when the build stopped before its first step (the clone, the | |
| 2194 | // container), and Reason is one short line (#266). | |
| 2195 | type failure struct { | |
| 2196 | Step int | |
| 2197 | Reason string | |
| 2198 | } | |
| 2199 | ||
| 2200 | // exitReason is how a finished command's failure reads in a build's log | |
| 2201 | // and on the build: "exit 1" for a command that exited, the error | |
| 2202 | // otherwise (a signal, a start failure). | |
| 2203 | func exitReason(err error) string { | |
| 2204 | var ee *exec.ExitError | |
| 2205 | if errors.As(err, &ee) && ee.ExitCode() >= 0 { | |
| 2206 | return fmt.Sprintf("exit %d", ee.ExitCode()) | |
| 2207 | } | |
| 2208 | return err.Error() | |
| 2209 | } | |
| 2210 | ``` | |
| 2211 | ||
| 2212 | Add `"errors"` to `main.go`'s imports. | |
| 2213 | ||
| 2214 | - [ ] **Step 4: `run` and `step`** | |
| 2215 | ||
| 2216 | In `run`, the signature becomes `func (r *runner) run(j job) *failure` | |
| 2217 | with its comment "…Returns nil when every step succeeded, else where the | |
| 2218 | build stopped." Each early `return false` returns a failure instead: | |
| 2219 | ||
| 2220 | ```go | |
| 2221 | home, doneHome, err := buildHome(r.workdir, j) | |
| 2222 | if err != nil { | |
| 2223 | log.Printf("build %d: build home: %v", j.ID, err) | |
| 2224 | return &failure{Reason: "preparing the build home failed"} | |
| 2225 | } | |
| 2226 | defer doneHome() | |
| 2227 | ``` | |
| 2228 | ||
| 2229 | ```go | |
| 2230 | pipe, err := logCmd.StdinPipe() | |
| 2231 | if err != nil { | |
| 2232 | log.Printf("build %d: log pipe: %v", j.ID, err) | |
| 2233 | return &failure{Reason: "opening the log stream failed"} | |
| 2234 | } | |
| 2235 | ``` | |
| 2236 | ||
| 2237 | ```go | |
| 2238 | if err := logCmd.Start(); err != nil { | |
| 2239 | log.Printf("build %d: log stream: %v", j.ID, err) | |
| 2240 | return &failure{Reason: "opening the log stream failed"} | |
| 2241 | } | |
| 2242 | ``` | |
| 2243 | ||
| 2244 | In `runStep`, `return false, fmt.Sprintf("step failed: %v", err)` | |
| 2245 | becomes `return false, exitReason(err)`. | |
| 2246 | ||
| 2247 | The clone loop's failure: | |
| 2248 | ||
| 2249 | ```go | |
| 2250 | if ok, why := runStep(cmd, deadline); !ok { | |
| 2251 | fmt.Fprintf(sink, "git %s: %s\n", args[0], why) | |
| 2252 | return &failure{Reason: "git " + args[0] + ": " + why} | |
| 2253 | } | |
| 2254 | ``` | |
| 2255 | ||
| 2256 | `step()`, from `status := "failure"` to the report: | |
| 2257 | ||
| 2258 | ```go | |
| 2259 | f := r.run(j) | |
| 2260 | status := "success" | |
| 2261 | if f != nil { | |
| 2262 | status = "failure" | |
| 2263 | } | |
| 2264 | if err := r.reportDone(j.ID, status, f); err != nil { | |
| 2265 | return true, err | |
| 2266 | } | |
| 2267 | ``` | |
| 2268 | ||
| 2269 | - [ ] **Step 5: `runSteps`, `runStepsPodman`** | |
| 2270 | ||
| 2271 | In `isolate.go`, the `runSteps` comment ends "…Returns nil when every | |
| 2272 | step succeeded." and the function becomes: | |
| 2273 | ||
| 2274 | ```go | |
| 2275 | func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) *failure { | |
| 2276 | if r.isolation == isolationNone { | |
| 2277 | for i, step := range j.Steps { | |
| 2278 | fmt.Fprintf(sink, "$ %s\n", step) | |
| 2279 | cmd := exec.Command(toolpath.Look("sh"), "-c", step) | |
| 2280 | cmd.Dir, cmd.Env = dir, env | |
| 2281 | cmd.Stdout, cmd.Stderr = sink, sink | |
| 2282 | if ok, why := runStep(cmd, deadline); !ok { | |
| 2283 | fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why) | |
| 2284 | return &failure{Step: i + 1, Reason: why} | |
| 2285 | } | |
| 2286 | } | |
| 2287 | return nil | |
| 2288 | } | |
| 2289 | return r.runStepsPodman(j, dir, env, sink, deadline, runStep) | |
| 2290 | } | |
| 2291 | ``` | |
| 2292 | ||
| 2293 | `runStepsPodman` returns `*failure`. Its setup failures (env file, | |
| 2294 | cgroup, container start) keep their log lines and return | |
| 2295 | `&failure{Reason: "preparing the build environment failed"}`, | |
| 2296 | `&failure{Reason: "preparing the build cgroup failed"}` and | |
| 2297 | `&failure{Reason: "starting the build container failed"}` | |
| 2298 | respectively. The step loop: | |
| 2299 | ||
| 2300 | ```go | |
| 2301 | for i, step := range j.Steps { | |
| 2302 | fmt.Fprintf(sink, "$ %s\n", step) | |
| 2303 | cmd := exec.Command(podman, append(r.podmanGlobal(), "exec", "--workdir", "/workspace", name, "sh", "-c", step)...) | |
| 2304 | cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()} | |
| 2305 | intoCgroup(cmd, cgroupFD) | |
| 2306 | cmd.Stdout, cmd.Stderr = sink, sink | |
| 2307 | if ok, why := runStep(cmd, deadline); !ok { | |
| 2308 | fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why) | |
| 2309 | return &failure{Step: i + 1, Reason: why} | |
| 2310 | } | |
| 2311 | } | |
| 2312 | return nil | |
| 2313 | ``` | |
| 2314 | ||
| 2315 | `podman exec` exits with the step's own status, so `exit 1` is the | |
| 2316 | step's. | |
| 2317 | ||
| 2318 | - [ ] **Step 6: `reportDone`, `doneArgs`** | |
| 2319 | ||
| 2320 | In `report.go` (add `"strconv"` and `"strings"` to its imports): | |
| 2321 | ||
| 2322 | ```go | |
| 2323 | func (r *runner) reportDone(id int64, status string, f *failure) error { | |
| 2324 | args := doneArgs(id, status, f) | |
| 2325 | return reportWithRetry(func() (string, error) { | |
| 2326 | return r.ssh(nil, args...) | |
| 2327 | }, id, retryDelays) | |
| 2328 | } | |
| 2329 | ||
| 2330 | // doneArgs is the runner done command for a build's outcome. The reason | |
| 2331 | // is single-quoted: ssh joins arguments with spaces, and the server | |
| 2332 | // splits the line again with POSIX rules. | |
| 2333 | func doneArgs(id int64, status string, f *failure) []string { | |
| 2334 | args := []string{"runner", "done", fmt.Sprint(id), status} | |
| 2335 | if f == nil { | |
| 2336 | return args | |
| 2337 | } | |
| 2338 | if f.Step > 0 { | |
| 2339 | args = append(args, "--step", strconv.Itoa(f.Step)) | |
| 2340 | } | |
| 2341 | if f.Reason != "" { | |
| 2342 | args = append(args, "--reason", "'"+strings.ReplaceAll(f.Reason, "'", `'\''`)+"'") | |
| 2343 | } | |
| 2344 | return args | |
| 2345 | } | |
| 2346 | ``` | |
| 2347 | ||
| 2348 | (The comment above `reportDone` is unchanged.) | |
| 2349 | ||
| 2350 | - [ ] **Step 7: Run the tests and see them pass** | |
| 2351 | ||
| 2352 | Run: `go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner -count=1` | |
| 2353 | Expected: PASS. | |
| 2354 | ||
| 2355 | - [ ] **Step 8: Commit** | |
| 2356 | ||
| 2357 | ```bash | |
| 2358 | git add cmd/gitbay-runner | |
| 2359 | git commit -S -m "runner: name the failed step and report it | |
| 2360 | ||
| 2361 | Ref #266" | |
| 2362 | ``` | |
| 2363 | ||
| 2364 | ### Task 4.4: `build show`, `build log --step/--tail` | |
| 2365 | ||
| 2366 | **Files:** | |
| 2367 | - Create: `internal/control/buildlog.go`, `internal/control/buildlog_test.go` | |
| 2368 | - Modify: `internal/control/build.go:40-47` (registration), `:109-126` (`BuildOut`, `buildToOut`), `:221-238` (`runBuildShow`), `:240-258` (`runBuildLog`) | |
| 2369 | ||
| 2370 | **Interfaces:** | |
| 2371 | - Consumes: `Build.FailedStep`, `Build.FailedReason`, `Build.Elapsed()`. | |
| 2372 | - Produces (used by Task 4.5): | |
| 2373 | - `type LogSection struct { N int; Step string; Text string }` | |
| 2374 | - `func SplitBuildLog(log string, steps []string) []LogSection` | |
| 2375 | - `func FailedSection(sections []LogSection, status string, failedStep int) int` | |
| 2376 | - `BuildOut.FailedStep int` (`failed_step`), `BuildOut.FailedReason string` (`failed_reason`), `BuildOut.DurationS int64` (`duration_s`), `BuildOut.Steps []string` (`steps`, on `build show` only) | |
| 2377 | ||
| 2378 | - [ ] **Step 1: Write the failing tests** | |
| 2379 | ||
| 2380 | Create `internal/control/buildlog_test.go`: | |
| 2381 | ||
| 2382 | ```go | |
| 2383 | package control | |
| 2384 | ||
| 2385 | import ( | |
| 2386 | "bytes" | |
| 2387 | "fmt" | |
| 2388 | "reflect" | |
| 2389 | "regexp" | |
| 2390 | "strings" | |
| 2391 | "testing" | |
| 2392 | ||
| 2393 | "gitbay.org/gitbay/internal/protocol" | |
| 2394 | "gitbay.org/gitbay/internal/store" | |
| 2395 | ) | |
| 2396 | ||
| 2397 | func TestSplitBuildLog(t *testing.T) { | |
| 2398 | log := "$ git clone ssh://x/a.git (abc)\n" + | |
| 2399 | "$ go build ./...\n" + | |
| 2400 | "built\n" + | |
| 2401 | "$ go test ./...\n" + | |
| 2402 | "--- FAIL: TestX\n" + | |
| 2403 | "step 2/2 failed: exit 1\n" | |
| 2404 | got := SplitBuildLog(log, []string{"go build ./...", "go test ./..."}) | |
| 2405 | want := []LogSection{ | |
| 2406 | {N: 0, Text: "$ git clone ssh://x/a.git (abc)\n"}, | |
| 2407 | {N: 1, Step: "go build ./...", Text: "built\n"}, | |
| 2408 | {N: 2, Step: "go test ./...", Text: "--- FAIL: TestX\nstep 2/2 failed: exit 1\n"}, | |
| 2409 | } | |
| 2410 | if !reflect.DeepEqual(got, want) { | |
| 2411 | t.Fatalf("got %+v\nwant %+v", got, want) | |
| 2412 | } | |
| 2413 | } | |
| 2414 | ||
| 2415 | // A step's line inside other output, not at a line start, does not cut; | |
| 2416 | // a build that stopped before a step has no section for it; an empty | |
| 2417 | // setup is left out. | |
| 2418 | func TestSplitBuildLogStopsAtMissingStep(t *testing.T) { | |
| 2419 | got := SplitBuildLog("$ make\nrunning: $ make test\nerror\n", []string{"make", "make test"}) | |
| 2420 | want := []LogSection{{N: 1, Step: "make", Text: "running: $ make test\nerror\n"}} | |
| 2421 | if !reflect.DeepEqual(got, want) { | |
| 2422 | t.Fatalf("got %+v\nwant %+v", got, want) | |
| 2423 | } | |
| 2424 | } | |
| 2425 | ||
| 2426 | func TestTailLines(t *testing.T) { | |
| 2427 | for _, tc := range []struct { | |
| 2428 | in string | |
| 2429 | n int | |
| 2430 | want string | |
| 2431 | }{ | |
| 2432 | {"a\nb\nc\n", 2, "b\nc\n"}, | |
| 2433 | {"a\nb\nc\n", 5, "a\nb\nc\n"}, | |
| 2434 | {"a\nb", 1, "b"}, | |
| 2435 | } { | |
| 2436 | if got := string(tailLines([]byte(tc.in), tc.n)); got != tc.want { | |
| 2437 | t.Errorf("tailLines(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want) | |
| 2438 | } | |
| 2439 | } | |
| 2440 | } | |
| 2441 | ||
| 2442 | // failedBuild is a finished failure whose second of two steps failed, | |
| 2443 | // having run 10m56s. | |
| 2444 | func failedBuild(t *testing.T) (*store.Store, store.Repo, int64, int64) { | |
| 2445 | t.Helper() | |
| 2446 | st, repo, uid := newQueueTestRepo(t) | |
| 2447 | n, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["go build ./...","go test ./..."]`, "", "", true) | |
| 2448 | if err != nil { | |
| 2449 | t.Fatal(err) | |
| 2450 | } | |
| 2451 | b, ok, err := st.ClaimBuild([]int64{repo.ID}, false) | |
| 2452 | if err != nil || !ok { | |
| 2453 | t.Fatalf("claim: ok=%v err=%v", ok, err) | |
| 2454 | } | |
| 2455 | st.AppendBuildLog(b.ID, []byte("$ git clone x (abc)\n$ go build ./...\nok\n$ go test ./...\none\n--- FAIL: TestX\nstep 2/2 failed: exit 1\n")) | |
| 2456 | if err := st.SetBuildFailure(b.ID, 2, "exit 1"); err != nil { | |
| 2457 | t.Fatal(err) | |
| 2458 | } | |
| 2459 | if err := st.FinishBuild(b.ID, "failure"); err != nil { | |
| 2460 | t.Fatal(err) | |
| 2461 | } | |
| 2462 | if _, err := st.DB.Exec(`UPDATE builds SET started_at = '2026-09-27T10:00:00Z', finished_at = '2026-09-27T10:10:56Z' WHERE id = ?`, b.ID); err != nil { | |
| 2463 | t.Fatal(err) | |
| 2464 | } | |
| 2465 | return st, repo, uid, n | |
| 2466 | } | |
| 2467 | ||
| 2468 | func TestBuildLogStepAndTail(t *testing.T) { | |
| 2469 | st, repo, uid, n := failedBuild(t) | |
| 2470 | run := func(args ...string) (string, int) { | |
| 2471 | t.Helper() | |
| 2472 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | |
| 2473 | code := Dispatch(c, append([]string{"build", "log", repo.Path(), fmt.Sprint(n)}, args...)) | |
| 2474 | return c.Stdout.(*bytes.Buffer).String() + errOut.String(), code | |
| 2475 | } | |
| 2476 | if out, _ := run("--step", "1"); out != "ok\n" { | |
| 2477 | t.Errorf("--step 1: %q", out) | |
| 2478 | } | |
| 2479 | if out, _ := run("--step", "failed", "--tail", "2"); out != "--- FAIL: TestX\nstep 2/2 failed: exit 1\n" { | |
| 2480 | t.Errorf("--step failed --tail 2: %q", out) | |
| 2481 | } | |
| 2482 | if out, _ := run("--tail", "1"); out != "step 2/2 failed: exit 1\n" { | |
| 2483 | t.Errorf("--tail 1: %q", out) | |
| 2484 | } | |
| 2485 | if _, code := run("--step", "3"); code != protocol.ExitUsage { | |
| 2486 | t.Errorf("--step past the job: exit %d", code) | |
| 2487 | } | |
| 2488 | if _, code := run("--follow", "--tail", "1"); code != protocol.ExitUsage { | |
| 2489 | t.Errorf("--follow with --tail: exit %d", code) | |
| 2490 | } | |
| 2491 | } | |
| 2492 | ||
| 2493 | func TestBuildShowNamesFailedStepAndDuration(t *testing.T) { | |
| 2494 | st, repo, uid, n := failedBuild(t) | |
| 2495 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | |
| 2496 | if code := Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)}); code != protocol.ExitOK { | |
| 2497 | t.Fatalf("exit %d: %s", code, errOut) | |
| 2498 | } | |
| 2499 | out := c.Stdout.(*bytes.Buffer).String() | |
| 2500 | for _, re := range []string{`failed step\s+2/2 go test \./\.\.\. \(exit 1\)`, `duration\s+10m56s`} { | |
| 2501 | if !regexp.MustCompile(re).MatchString(out) { | |
| 2502 | t.Errorf("build show missing %s:\n%s", re, out) | |
| 2503 | } | |
| 2504 | } | |
| 2505 | c, _ = pruneCtx(st, t.TempDir(), store.User{ID: uid}) | |
| 2506 | c.JSON = true | |
| 2507 | Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)}) | |
| 2508 | for _, want := range []string{`"failed_step":2`, `"failed_reason":"exit 1"`, `"duration_s":656`, `"steps":["go build ./...","go test ./..."]`} { | |
| 2509 | if !strings.Contains(c.Stdout.(*bytes.Buffer).String(), want) { | |
| 2510 | t.Errorf("build show --json missing %s", want) | |
| 2511 | } | |
| 2512 | } | |
| 2513 | } | |
| 2514 | ``` | |
| 2515 | ||
| 2516 | - [ ] **Step 2: Run them and see them fail** | |
| 2517 | ||
| 2518 | Run: `go test ./internal/control -run 'TestSplitBuildLog|TestTailLines|TestBuildLogStep|TestBuildShowNames' -count=1` | |
| 2519 | Expected: build failure (`undefined: SplitBuildLog`). | |
| 2520 | ||
| 2521 | - [ ] **Step 3: `buildlog.go`** | |
| 2522 | ||
| 2523 | ```go | |
| 2524 | package control | |
| 2525 | ||
| 2526 | import "strings" | |
| 2527 | ||
| 2528 | // LogSection is one part of a build log: the setup before the first | |
| 2529 | // step (N 0), or one step and its output. | |
| 2530 | type LogSection struct { | |
| 2531 | N int // 0 for the setup, else the 1-based step | |
| 2532 | Step string // the step's command; "" for the setup | |
| 2533 | Text string | |
| 2534 | } | |
| 2535 | ||
| 2536 | // SplitBuildLog cuts a log at the "$ <step>" line the runner writes | |
| 2537 | // before each step, matching the build's steps in order and only at a | |
| 2538 | // line start. Output before the first step is the setup section, left | |
| 2539 | // out when empty. A step with no line in the log — the build stopped | |
| 2540 | // before it — has no section, and neither has any step after it. | |
| 2541 | func SplitBuildLog(log string, steps []string) []LogSection { | |
| 2542 | var out []LogSection | |
| 2543 | cur := LogSection{} | |
| 2544 | start := 0 | |
| 2545 | for i, step := range steps { | |
| 2546 | marker := "$ " + step + "\n" | |
| 2547 | at := findLine(log, marker, start) | |
| 2548 | if at < 0 { | |
| 2549 | break | |
| 2550 | } | |
| 2551 | cur.Text = log[start:at] | |
| 2552 | if cur.N > 0 || cur.Text != "" { | |
| 2553 | out = append(out, cur) | |
| 2554 | } | |
| 2555 | cur = LogSection{N: i + 1, Step: step} | |
| 2556 | start = at + len(marker) | |
| 2557 | } | |
| 2558 | cur.Text = log[start:] | |
| 2559 | if cur.N > 0 || cur.Text != "" { | |
| 2560 | out = append(out, cur) | |
| 2561 | } | |
| 2562 | return out | |
| 2563 | } | |
| 2564 | ||
| 2565 | // findLine is the index of line in log at or after from where it starts | |
| 2566 | // a line, or -1. | |
| 2567 | func findLine(log, line string, from int) int { | |
| 2568 | for i := from; i <= len(log)-len(line); { | |
| 2569 | j := strings.Index(log[i:], line) | |
| 2570 | if j < 0 { | |
| 2571 | return -1 | |
| 2572 | } | |
| 2573 | at := i + j | |
| 2574 | if at == 0 || log[at-1] == '\n' { | |
| 2575 | return at | |
| 2576 | } | |
| 2577 | i = at + 1 | |
| 2578 | } | |
| 2579 | return -1 | |
| 2580 | } | |
| 2581 | ||
| 2582 | // FailedSection is the index of the section a failed build stopped in: | |
| 2583 | // the step the runner named, or the last section when it named none (an | |
| 2584 | // older runner, or a failure the runner could not tie to a step). -1 | |
| 2585 | // when the build did not fail or its log is empty. | |
| 2586 | func FailedSection(sections []LogSection, status string, failedStep int) int { | |
| 2587 | if status != "failure" || len(sections) == 0 { | |
| 2588 | return -1 | |
| 2589 | } | |
| 2590 | for i, s := range sections { | |
| 2591 | if failedStep > 0 && s.N == failedStep { | |
| 2592 | return i | |
| 2593 | } | |
| 2594 | } | |
| 2595 | return len(sections) - 1 | |
| 2596 | } | |
| 2597 | ||
| 2598 | // tailLines is the last n lines of b; a final newline ends the last line | |
| 2599 | // rather than starting another. | |
| 2600 | func tailLines(b []byte, n int) []byte { | |
| 2601 | end := len(b) | |
| 2602 | if end > 0 && b[end-1] == '\n' { | |
| 2603 | end-- | |
| 2604 | } | |
| 2605 | for i := end - 1; i >= 0; i-- { | |
| 2606 | if b[i] == '\n' { | |
| 2607 | n-- | |
| 2608 | if n == 0 { | |
| 2609 | return b[i+1:] | |
| 2610 | } | |
| 2611 | } | |
| 2612 | } | |
| 2613 | return b | |
| 2614 | } | |
| 2615 | ``` | |
| 2616 | ||
| 2617 | - [ ] **Step 4: `BuildOut`, `build show`** | |
| 2618 | ||
| 2619 | `BuildOut`, after `Subject`: | |
| 2620 | ||
| 2621 | ```go | |
| 2622 | // FailedStep is the 1-based step a failed build stopped at, 0 when | |
| 2623 | // none; FailedReason says how ("exit 1") (#266). | |
| 2624 | FailedStep int `json:"failed_step,omitempty"` | |
| 2625 | FailedReason string `json:"failed_reason,omitempty"` | |
| 2626 | // DurationS is how long the build ran, once it has a start and a | |
| 2627 | // finish. | |
| 2628 | DurationS int64 `json:"duration_s,omitempty"` | |
| 2629 | // Steps are the job's commands; build show only. | |
| 2630 | Steps []string `json:"steps,omitempty"` | |
| 2631 | ``` | |
| 2632 | ||
| 2633 | `buildToOut`: | |
| 2634 | ||
| 2635 | ```go | |
| 2636 | func buildToOut(b store.Build) BuildOut { | |
| 2637 | return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA, | |
| 2638 | Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt, | |
| 2639 | FailedStep: b.FailedStep, FailedReason: b.FailedReason, | |
| 2640 | DurationS: int64(b.Elapsed() / time.Second)} | |
| 2641 | } | |
| 2642 | ``` | |
| 2643 | ||
| 2644 | `runBuildShow`: | |
| 2645 | ||
| 2646 | ```go | |
| 2647 | func runBuildShow(c *Ctx, args []string) int { | |
| 2648 | repo, b, code := buildRef(c, args) | |
| 2649 | if code >= 0 { | |
| 2650 | return code | |
| 2651 | } | |
| 2652 | d := buildToOut(b) | |
| 2653 | json.Unmarshal([]byte(b.Steps), &d.Steps) | |
| 2654 | return c.emit(d, func(w io.Writer) { | |
| 2655 | failedStep, failed := "", "" | |
| 2656 | if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) { | |
| 2657 | step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n") | |
| 2658 | failedStep = fmt.Sprintf("%d/%d %s", d.FailedStep, len(d.Steps), step) | |
| 2659 | if d.FailedReason != "" { | |
| 2660 | failedStep += " (" + d.FailedReason + ")" | |
| 2661 | } | |
| 2662 | } else { | |
| 2663 | failed = d.FailedReason | |
| 2664 | } | |
| 2665 | duration := "" | |
| 2666 | if d.DurationS > 0 { | |
| 2667 | duration = (time.Duration(d.DurationS) * time.Second).String() | |
| 2668 | } | |
| 2669 | v := c.view(w) | |
| 2670 | v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) | |
| 2671 | v.fields( | |
| 2672 | "sha", fmt.Sprintf("%.10s", d.SHA), | |
| 2673 | "ref", d.Ref, | |
| 2674 | "queued", c.when(d.CreatedAt), | |
| 2675 | "finished", c.when(d.FinishedAt), | |
| 2676 | "duration", duration, | |
| 2677 | "failed step", failedStep, | |
| 2678 | "failed", failed, | |
| 2679 | "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), | |
| 2680 | ) | |
| 2681 | }) | |
| 2682 | } | |
| 2683 | ``` | |
| 2684 | ||
| 2685 | - [ ] **Step 5: `build log`** | |
| 2686 | ||
| 2687 | Registration: | |
| 2688 | ||
| 2689 | ```go | |
| 2690 | register(Command{Path: []string{"build", "log"}, | |
| 2691 | Summary: "print a build's log, or follow it until the build ends", | |
| 2692 | Usage: "build log <owner/name> <n> [--follow] [--step <step>|failed] [--tail <lines>]", | |
| 2693 | Flags: []Flag{ | |
| 2694 | {"--follow", "", "stream the log until the build ends", ""}, | |
| 2695 | {"--step", "<step>|failed", "only one step's output: 0 for the setup, a step number, or the one that failed", ""}, | |
| 2696 | {"--tail", "<lines>", "only the last lines", ""}, | |
| 2697 | }, | |
| 2698 | Examples: []string{"build log krz/gitbay 431 --follow", "build log krz/gitbay 431 --step failed --tail 40"}, | |
| 2699 | ReadOnly: true, Run: runBuildLog}) | |
| 2700 | ``` | |
| 2701 | ||
| 2702 | `runBuildLog`: | |
| 2703 | ||
| 2704 | ```go | |
| 2705 | func runBuildLog(c *Ctx, args []string) int { | |
| 2706 | f, err := parseFlags(args, flagSpec{Bools: []string{"--follow"}, Values: []string{"--step", "--tail"}, MaxPos: 2, Usage: c.Cmd.Usage}) | |
| 2707 | if err != nil { | |
| 2708 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 2709 | } | |
| 2710 | repo, b, code := buildRef(c, f.Pos) | |
| 2711 | if code >= 0 { | |
| 2712 | return code | |
| 2713 | } | |
| 2714 | if f.Has("--follow") { | |
| 2715 | if f.Has("--step") || f.Has("--tail") { | |
| 2716 | return c.fail(protocol.ExitUsage, "--step and --tail read the stored log; drop --follow") | |
| 2717 | } | |
| 2718 | return followBuildLog(c, repo, b) | |
| 2719 | } | |
| 2720 | tail := 0 | |
| 2721 | if f.Has("--tail") { | |
| 2722 | if tail, err = strconv.Atoi(f.Value("--tail")); err != nil || tail < 1 { | |
| 2723 | return c.fail(protocol.ExitUsage, "--tail takes a number of lines, 1 or more") | |
| 2724 | } | |
| 2725 | } | |
| 2726 | log, err := c.Store.BuildLog(b.ID) | |
| 2727 | if err != nil { | |
| 2728 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 2729 | } | |
| 2730 | if f.Has("--step") { | |
| 2731 | var steps []string | |
| 2732 | json.Unmarshal([]byte(b.Steps), &steps) | |
| 2733 | sections := SplitBuildLog(string(log), steps) | |
| 2734 | at := -1 | |
| 2735 | if want := f.Value("--step"); want == "failed" { | |
| 2736 | if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 { | |
| 2737 | return c.fail(protocol.ExitNotFound, "build %d did not fail", b.Number) | |
| 2738 | } | |
| 2739 | } else { | |
| 2740 | n, err := strconv.Atoi(want) | |
| 2741 | if err != nil || n < 0 || n > len(steps) { | |
| 2742 | return c.fail(protocol.ExitUsage, "--step takes 0 (the setup) to %d, or failed", len(steps)) | |
| 2743 | } | |
| 2744 | for i, s := range sections { | |
| 2745 | if s.N == n { | |
| 2746 | at = i | |
| 2747 | } | |
| 2748 | } | |
| 2749 | if at < 0 { | |
| 2750 | return c.fail(protocol.ExitNotFound, "build %d has no output for step %d", b.Number, n) | |
| 2751 | } | |
| 2752 | } | |
| 2753 | log = []byte(sections[at].Text) | |
| 2754 | } | |
| 2755 | if tail > 0 { | |
| 2756 | log = tailLines(log, tail) | |
| 2757 | } | |
| 2758 | c.Stdout.Write(log) | |
| 2759 | return protocol.ExitOK | |
| 2760 | } | |
| 2761 | ``` | |
| 2762 | ||
| 2763 | - [ ] **Step 6: Run the tests and see them pass** | |
| 2764 | ||
| 2765 | Run: `go vet ./... && go test ./internal/control -count=1 && go test ./cmd/gitbay -run TestSummariesAreCurrent -count=1` | |
| 2766 | Expected: PASS (the `build log` summary is unchanged; no regeneration | |
| 2767 | needed). | |
| 2768 | ||
| 2769 | - [ ] **Step 7: Commit** | |
| 2770 | ||
| 2771 | ```bash | |
| 2772 | git add internal/control | |
| 2773 | git commit -S -m "build show: failed step and duration; build log --step, --tail | |
| 2774 | ||
| 2775 | Ref #266" | |
| 2776 | ``` | |
| 2777 | ||
| 2778 | ### Task 4.5: the build page folds by step | |
| 2779 | ||
| 2780 | **Files:** | |
| 2781 | - Modify: `internal/httpd/builds.go:1-18` (imports: add `"time"`), `:287-322` (`build`, `buildView`) | |
| 2782 | - Modify: `internal/web/templates/build.html:14-17` | |
| 2783 | - Modify: `internal/web/static/style.css:1116` | |
| 2784 | - Test: `internal/httpd/buildpages_test.go` (append) | |
| 2785 | ||
| 2786 | **Interfaces:** | |
| 2787 | - Consumes: `control.SplitBuildLog`, `control.FailedSection`, `control.LogSection`, `BuildOut` fields from Task 4.4. | |
| 2788 | - Produces: `buildView.Steps []logStep`, `buildView.Failed bool`, `buildView.Duration string`; `func logSteps(log string, b control.BuildOut) ([]logStep, bool)`. | |
| 2789 | ||
| 2790 | - [ ] **Step 1: Write the failing test** | |
| 2791 | ||
| 2792 | Append to `internal/httpd/buildpages_test.go`: | |
| 2793 | ||
| 2794 | ```go | |
| 2795 | // A failed build's page folds its log by step, opens the step that | |
| 2796 | // failed and links to it; no JavaScript (#266). | |
| 2797 | func TestBuildPageFoldsStepsAndOpensFailure(t *testing.T) { | |
| 2798 | b := control.BuildOut{Number: 61, Job: "test", Status: "failure", | |
| 2799 | SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main", | |
| 2800 | CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:53:50Z", DurationS: 656, | |
| 2801 | Steps: []string{"go build ./...", "go test ./..."}, FailedStep: 2, FailedReason: "exit 1"} | |
| 2802 | log := "$ git clone x (ff6271a9d4)\n$ go build ./...\n$ go test ./...\n--- FAIL: TestCLI\nstep 2/2 failed: exit 1\n" | |
| 2803 | v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "10m56s"} | |
| 2804 | v.Steps, v.Failed = logSteps(log, b) | |
| 2805 | var sb strings.Builder | |
| 2806 | if err := web.Render(&sb, "build.html", v); err != nil { | |
| 2807 | t.Fatalf("render: %v", err) | |
| 2808 | } | |
| 2809 | out := sb.String() | |
| 2810 | for _, want := range []string{ | |
| 2811 | `<details class="difffold buildstep" id="failed" open>`, | |
| 2812 | "step 2/2", "<code>go test ./...</code>", `href="#failed"`, "Jump to failure", | |
| 2813 | "ran 10m56s", "--- FAIL: TestCLI", | |
| 2814 | } { | |
| 2815 | if !strings.Contains(out, want) { | |
| 2816 | t.Errorf("build.html missing %q", want) | |
| 2817 | } | |
| 2818 | } | |
| 2819 | if n := strings.Count(out, `class="difffold buildstep"`); n != 3 { | |
| 2820 | t.Errorf("%d step folds, want 3 (setup and two steps)", n) | |
| 2821 | } | |
| 2822 | if n := strings.Count(out, `id="failed"`); n != 1 { | |
| 2823 | t.Errorf("%d failed anchors, want 1", n) | |
| 2824 | } | |
| 2825 | } | |
| 2826 | ``` | |
| 2827 | ||
| 2828 | - [ ] **Step 2: Run it and see it fail** | |
| 2829 | ||
| 2830 | Run: `go test ./internal/httpd -run TestBuildPageFoldsStepsAndOpensFailure -count=1` | |
| 2831 | Expected: build failure (`undefined: logSteps`). | |
| 2832 | ||
| 2833 | - [ ] **Step 3: Handler** | |
| 2834 | ||
| 2835 | Replace `buildView` and add `logStep` and `logSteps`: | |
| 2836 | ||
| 2837 | ```go | |
| 2838 | type buildView struct { | |
| 2839 | repoPage | |
| 2840 | Build control.BuildOut | |
| 2841 | Log string | |
| 2842 | // Steps is the finished log cut at its steps, nil when there is no | |
| 2843 | // step to cut at; Failed says whether one of them is marked failed. | |
| 2844 | Steps []logStep | |
| 2845 | Failed bool | |
| 2846 | Duration string | |
| 2847 | Live bool | |
| 2848 | CanWrite bool | |
| 2849 | Notice string | |
| 2850 | } | |
| 2851 | ||
| 2852 | type logStep struct { | |
| 2853 | control.LogSection | |
| 2854 | Failed bool | |
| 2855 | } | |
| 2856 | ||
| 2857 | // logSteps cuts a finished build's log at its steps and marks the one it | |
| 2858 | // failed at. Nil when no step's line is in the log — a build that | |
| 2859 | // stopped in the clone — which renders as one block. | |
| 2860 | func logSteps(log string, b control.BuildOut) ([]logStep, bool) { | |
| 2861 | sections := control.SplitBuildLog(log, b.Steps) | |
| 2862 | stepped := false | |
| 2863 | for _, s := range sections { | |
| 2864 | if s.N > 0 { | |
| 2865 | stepped = true | |
| 2866 | } | |
| 2867 | } | |
| 2868 | if !stepped { | |
| 2869 | return nil, false | |
| 2870 | } | |
| 2871 | failed := control.FailedSection(sections, b.Status, b.FailedStep) | |
| 2872 | out := make([]logStep, len(sections)) | |
| 2873 | for i, s := range sections { | |
| 2874 | out[i] = logStep{LogSection: s, Failed: i == failed} | |
| 2875 | } | |
| 2876 | return out, failed >= 0 | |
| 2877 | } | |
| 2878 | ``` | |
| 2879 | ||
| 2880 | In `build`, replace the last two lines (`v.Log, _, _ = …` and | |
| 2881 | `s.render(…)`) with: | |
| 2882 | ||
| 2883 | ```go | |
| 2884 | v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n}) | |
| 2885 | v.Steps, v.Failed = logSteps(v.Log, b) | |
| 2886 | if b.DurationS > 0 { | |
| 2887 | v.Duration = (time.Duration(b.DurationS) * time.Second).String() | |
| 2888 | } | |
| 2889 | s.render(w, "build.html", v) | |
| 2890 | ``` | |
| 2891 | ||
| 2892 | Add `"time"` to the imports. | |
| 2893 | ||
| 2894 | - [ ] **Step 4: Template** | |
| 2895 | ||
| 2896 | Replace `build.html:14-17` with: | |
| 2897 | ||
| 2898 | ```html | |
| 2899 | <p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{with .Duration}} · ran {{.}}{{end}}{{end}}{{if .Failed}} · <a href="#failed">Jump to failure</a>{{end}}</p> | |
| 2900 | {{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p> | |
| 2901 | <pre class="code buildlog" tabindex="0">{{.Log}}</pre> | |
| 2902 | {{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}} | |
| 2903 | <details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}> | |
| 2904 | <summary>{{if .N}}<span>step {{.N}}/{{$total}}</span> <code>{{.Step}}</code>{{else}}<span>setup</span>{{end}}{{if .Failed}} <span class="chip check-failure">failed</span>{{end}}</summary> | |
| 2905 | <pre class="code buildlog" tabindex="0">{{.Text}}</pre> | |
| 2906 | </details>{{end}} | |
| 2907 | {{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} | |
| 2908 | ``` | |
| 2909 | ||
| 2910 | The live branch keeps its single `<pre>` directly after the marker, which | |
| 2911 | `streamBuild` requires (`builds.go:340`). | |
| 2912 | ||
| 2913 | - [ ] **Step 5: CSS** | |
| 2914 | ||
| 2915 | Replace `style.css:1116` with: | |
| 2916 | ||
| 2917 | ```css | |
| 2918 | pre.buildlog { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 2919 | details.buildstep pre.buildlog { margin: 0; border: 0; border-radius: 0; } | |
| 2920 | details.buildstep summary code { overflow-wrap: anywhere; } | |
| 2921 | ``` | |
| 2922 | ||
| 2923 | - [ ] **Step 6: Run the tests** | |
| 2924 | ||
| 2925 | Run: `go test ./internal/httpd ./internal/web -count=1` | |
| 2926 | Expected: PASS (`TestPreBlocksAreFocusable` sees the new `<pre>` with | |
| 2927 | `tabindex="0"`; no new template, so `TestMainWidthClass` is unchanged). | |
| 2928 | ||
| 2929 | - [ ] **Step 7: Commit** | |
| 2930 | ||
| 2931 | ```bash | |
| 2932 | git add internal/httpd internal/web | |
| 2933 | git commit -S -m "web: build log folded by step, failed step open | |
| 2934 | ||
| 2935 | Ref #266" | |
| 2936 | ``` | |
| 2937 | ||
| 2938 | ### Task 4.6: e2e, wiki, and the MR | |
| 2939 | ||
| 2940 | **Files:** | |
| 2941 | - Modify: `e2e/ci_test.go:145-149` (`TestCI`) | |
| 2942 | - Modify: `.gitbay/wiki/CI.org` (after the `build log --follow` paragraph), `.gitbay/wiki/Users.org:542-547`, `.gitbay/wiki/Parity.org:211-213`, `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org` (lifecycle step 5) | |
| 2943 | ||
| 2944 | - [ ] **Step 1: e2e** | |
| 2945 | ||
| 2946 | Replace `e2e/ci_test.go:145-149` with: | |
| 2947 | ||
| 2948 | ```go | |
| 2949 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN) | |
| 2950 | if !strings.Contains(out, "step 1/1 failed: exit 1") { | |
| 2951 | t.Fatalf("broken log:\n%s", out) | |
| 2952 | } | |
| 2953 | out, _, _ = inst.ssh(t, aliceKey, "", "build", "show", "alice/app", brokenN) | |
| 2954 | if !strings.Contains(out, "1/1 false (exit 1)") { | |
| 2955 | t.Fatalf("build show does not name the failed step:\n%s", out) | |
| 2956 | } | |
| 2957 | if out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN, "--step", "failed"); strings.Contains(out, "git clone") || !strings.Contains(out, "exit 1") { | |
| 2958 | t.Fatalf("build log --step failed:\n%s", out) | |
| 2959 | } | |
| 2960 | if _, body := inst.get(t, "/alice/app/builds/"+brokenN); !strings.Contains(body, `id="failed" open`) { | |
| 2961 | t.Fatalf("build page does not open the failed step:\n%s", body) | |
| 2962 | } | |
| 2963 | ``` | |
| 2964 | ||
| 2965 | Before editing, check the `broken` job's step in `TestCI`'s `ci.yml` | |
| 2966 | (line ~100) is `"false"`; the expected `1/1 false` follows from it. | |
| 2967 | ||
| 2968 | Run: `go test ./e2e -run 'TestCI$' -count=1` | |
| 2969 | Expected: PASS. | |
| 2970 | ||
| 2971 | - [ ] **Step 2: Wiki** | |
| 2972 | ||
| 2973 | CI.org, after the `build log --follow` paragraph: | |
| 2974 | ||
| 2975 | ```org | |
| 2976 | A failed build names the step it stopped at: the log's last line reads | |
| 2977 | =step 3/3 failed: exit 1=, and =build show= prints =failed step= (=3/3 | |
| 2978 | go test ./... (exit 1)=) and =duration=. =build log <owner/name> <n> | |
| 2979 | --step failed= prints only that step's output, =--step 2= another one | |
| 2980 | (=0= is the clone before the first step), and =--tail 40= the last forty | |
| 2981 | lines of whichever was chosen; neither combines with =--follow=. The | |
| 2982 | build page folds the finished log into one section per step, opens the | |
| 2983 | failed one and links to it from the top as "Jump to failure". Builds | |
| 2984 | from before this reported no step; their last section is taken as the | |
| 2985 | failed one. | |
| 2986 | ``` | |
| 2987 | ||
| 2988 | Users.org, after "…=build list= takes =--ref=, =--status= and =--job= | |
| 2989 | to narrow the listing, combinable;" sentence group, add: "=build show= | |
| 2990 | names a failed build's step and how long it ran, and =build log= takes | |
| 2991 | =--step <n>|failed= and =--tail <lines>=." | |
| 2992 | ||
| 2993 | Parity.org, after `build log follow (until it ends)`: | |
| 2994 | ||
| 2995 | ```org | |
| 2996 | | build failed step, duration | yes | yes | no | | |
| 2997 | | build log one step | yes | yes | no | | |
| 2998 | | build log tail | yes | no | no | | |
| 2999 | ``` | |
| 3000 | ||
| 3001 | `07-CI-and-Supply-Chain.org`, lifecycle step 5, replace | |
| 3002 | "=runner done <id> success|failure= sets the status," with "=runner | |
| 3003 | done <id> success|failure [--step <n>] [--reason <text>]= records where | |
| 3004 | a failed build stopped, sets the status,". | |
| 3005 | ||
| 3006 | - [ ] **Step 3: Verify, commit, MR** | |
| 3007 | ||
| 3008 | Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./cmd/gitbay ./internal/control ./internal/store ./internal/httpd ./internal/web -count=1` | |
| 3009 | Expected: PASS. | |
| 3010 | ||
| 3011 | ```bash | |
| 3012 | git add e2e/ci_test.go .gitbay/wiki | |
| 3013 | git commit -S -m "wiki: failed step, build log --step and --tail | |
| 3014 | ||
| 3015 | Closes #266" | |
| 3016 | git push -u origin build-failure-report | |
| 3017 | gitbay mr create --source build-failure-report --target main --title "builds: name the failed step and duration; jump to failure" | |
| 3018 | ``` | |
| 3019 | ||
| 3020 | Deploy `gitbayd` (schema 64→65 on the first start, or whatever the | |
| 3021 | number is after renumbering), then validate per runbook R4, then merge | |
| 3022 | with `--strategy ff` and delete the branch both places. | |
| 3023 | ||
| 3024 | --- | |
| 3025 | ||
| 3026 | # Open questions | |
| 3027 | ||
| 3028 | 1. **pasta on bay1.** The plan relies on `--network pasta:--no-map-gw` | |
| 3029 | removing the host-loopback path and on a pasta container reaching the | |
| 3030 | host's public address. The code comment at `main.go:465-470` says | |
| 3031 | pasta exposes the host at `169.254.1.2` as its `--map-host-loopback` | |
| 3032 | default; newer podman instead passes `--map-guest-addr 169.254.1.2`, | |
| 3033 | which maps to the host's public address. Which one bay1's podman | |
| 3034 | does is not in the repository. Runbook R0 measures it before Part 3 | |
| 3035 | deploys; if `--no-map-gw` is refused or leaves `127.0.0.1` reachable, | |
| 3036 | stop and revisit Part 3 before merging. | |
| 3037 | 2. **Default image and tree reuse.** Tree reuse now keys on the job's | |
| 3038 | declared `image:`. A job that names none runs on the runner's | |
| 3039 | `-image`, which the server does not know; bumping `-image` | |
| 3040 | (`gitbay-ci:2` → `:3`) does not invalidate reuse for such jobs. | |
| 3041 | Covering it would need the runner to report the image it resolved | |
| 3042 | (and its digest) on `runner done`, and reuse to compare that. Not | |
| 3043 | planned; confirm that the declared image is enough. | |
| 3044 | 3. **Non-22 ssh ports.** `GITBAY_SSH` is `user@host` — hutch and orgo | |
| 3045 | build URLs as `ssh://$GITBAY_SSH/...` — so the claim's `ssh` carries | |
| 3046 | no port. An instance with `[ssh] port` other than 22 and a loopback | |
| 3047 | runner gives its builds a destination without the port. gitbay.org | |
| 3048 | is on 22. Should the field carry the port (and the builds' scripts | |
| 3049 | change), or is this left to such an instance's own ssh config? | |
| 3050 | 4. **Required contexts without require-checks.** Decided here as "the | |
| 3051 | list applies only while require-checks is on, and the command says | |
| 3052 | so". The alternative is that setting contexts turns the gate on. | |
| 3053 | Confirm. | |
| 3054 | 5. **Throttling test on gitbay.org.** Under open registration the | |
| 3055 | limiter never counts an unknown key's attempt (see Decisions), so | |
| 3056 | the issue's throttling test on bay1 is expected to show nothing. R3 | |
| 3057 | runs it as the issue asks and records that; a closed-registration | |
| 3058 | scratch daemon on bay1 would be needed to show the limiter itself | |
| 3059 | separating the two addresses. Is the source-address measurement | |
| 3060 | enough to close #260? | |
| 3061 | ||
| 3062 | --- | |
| 3063 | ||
| 3064 | # Operator runbook (cmc) | |
| 3065 | ||
| 3066 | Everything here runs from the laptop against bay1. One forge write per | |
| 3067 | Bash call; after `make deploy` the CLI's control master is gone, so do | |
| 3068 | not poll with several ssh calls a tick. Operator ssh is | |
| 3069 | `ssh -p 2222 root@gitbay.org`. | |
| 3070 | ||
| 3071 | ### R1. Scratch repository and scoped runner (once, before Part 1's runner deploy) | |
| 3072 | ||
| 3073 | 1. Create the scratch repository and its fork, and attach the bay1 | |
| 3074 | runner key to the scratch repository only: | |
| 3075 | ||
| 3076 | ```sh | |
| 3077 | gitbay repo create cmc/ci-scratch --private | |
| 3078 | gitbay repo fork cmc/ci-scratch --name ci-scratch-fork | |
| 3079 | ssh -p 2222 root@gitbay.org cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub > /tmp/ci-runner.pub | |
| 3080 | gitbay repo runner add cmc/ci-scratch < /tmp/ci-runner.pub | |
| 3081 | ``` | |
| 3082 | ||
| 3083 | 2. Scope the service to it with a second drop-in that sorts after | |
| 3084 | `override.conf`. Copy the current `ExecStart` from | |
| 3085 | `deploy/gitbay-runner.override.conf` and add | |
| 3086 | `-repos cmc/ci-scratch`: | |
| 3087 | ||
| 3088 | ```sh | |
| 3089 | ssh -p 2222 root@gitbay.org 'cat > /etc/systemd/system/gitbay-runner.service.d/zz-scratch.conf' <<'EOF' | |
| 3090 | [Service] | |
| 3091 | ExecStart= | |
| 3092 | ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -isolation podman -image localhost/gitbay-ci:2 -cpus 3 -memory 6g -untrusted -repos cmc/ci-scratch | |
| 3093 | EOF | |
| 3094 | ``` | |
| 3095 | ||
| 3096 | `make deploy-runner` reloads and restarts the unit, so the scoped | |
| 3097 | `ExecStart` is live for each validation below. While it is in place | |
| 3098 | builds of real repositories queue and wait. | |
| 3099 | ||
| 3100 | 3. After each validation: remove the drop-in and restart. | |
| 3101 | ||
| 3102 | ```sh | |
| 3103 | ssh -p 2222 root@gitbay.org 'rm /etc/systemd/system/gitbay-runner.service.d/zz-scratch.conf && systemctl daemon-reload && systemctl restart gitbay-runner' | |
| 3104 | ``` | |
| 3105 | ||
| 3106 | ### R2. Part 1 (#255): validate, then discard the old homes | |
| 3107 | ||
| 3108 | 1. `make deploy` from the Part 1 branch; `curl -s https://gitbay.org/healthz` | |
| 3109 | names its commit. Install the R1 drop-in, then `make deploy-runner`. | |
| 3110 | 2. On `cmc/ci-scratch` `main`, push a `.gitbay/ci.yml`: | |
| 3111 | ||
| 3112 | ```yaml | |
| 3113 | jobs: | |
| 3114 | home: | |
| 3115 | steps: | |
| 3116 | - echo "HOME=$HOME" | |
| 3117 | - test ! -e "$HOME/poison" || { echo "POISONED"; exit 1; } | |
| 3118 | - touch "$HOME/trusted-marker" | |
| 3119 | ``` | |
| 3120 | ||
| 3121 | The build passes; its log shows `HOME=/var/lib/gitbay-runner/work/trusted-home/cmc/ci-scratch`. | |
| 3122 | 3. In `cmc/ci-scratch-fork`, change the step list to | |
| 3123 | `echo "HOME=$HOME"`, `ls -a "$HOME"`, `touch "$HOME/poison"`, push a | |
| 3124 | branch and open an MR into `cmc/ci-scratch`. The untrusted build's | |
| 3125 | log shows `HOME=/var/lib/gitbay-runner/work/build-<id>-home` and an | |
| 3126 | empty listing (no `trusted-marker`). | |
| 3127 | 4. On bay1: `ls /var/lib/gitbay-runner/work` shows no `build-<id>-home` | |
| 3128 | left behind. | |
| 3129 | 5. `gitbay build trigger cmc/ci-scratch home`: passes (no `POISONED`). | |
| 3130 | 6. Remove the R1 drop-in (R1 step 3). Merge Part 1. | |
| 3131 | 7. Discard the homes that trusted and untrusted builds shared: | |
| 3132 | ||
| 3133 | ```sh | |
| 3134 | ssh -p 2222 root@gitbay.org 'chmod -R u+w /var/lib/gitbay-runner/work/home && rm -rf /var/lib/gitbay-runner/work/home' | |
| 3135 | ``` | |
| 3136 | ||
| 3137 | The laptop runner (`~/Library/Caches/gitbay-runner/home` or its | |
| 3138 | configured workdir) gets the same once its brew bottle carries Part 1. | |
| 3139 | 8. Record: the release's CHANGELOG upgrade note says to deploy gitbayd | |
| 3140 | before runners, and that `<workdir>/home` can be deleted after the | |
| 3141 | runner upgrade. Nothing further in the wiki; Part 1's MR updated it. | |
| 3142 | ||
| 3143 | ### R3. Part 3 (#260): pasta check, source addresses, throttling | |
| 3144 | ||
| 3145 | 0. Before merging Part 3, on bay1 as the runner user: | |
| 3146 | ||
| 3147 | ```sh | |
| 3148 | ssh -p 2222 root@gitbay.org "podman --version; pasta --version | head -1" | |
| 3149 | ssh -p 2222 root@gitbay.org "su - ci-runner -s /bin/sh -c 'podman --cgroup-manager=cgroupfs run --rm --pull=never --network pasta:--no-map-gw --entrypoint sh localhost/gitbay-ci:2 -c \"getent hosts proxy.golang.org; timeout 5 bash -c \\\"exec 3<>/dev/tcp/gitbay.org/22\\\" && echo public-ok; cat /proc/net/route\"'" | |
| 3150 | ``` | |
| 3151 | ||
| 3152 | Expected: `proxy.golang.org` resolves, `public-ok` prints. If podman | |
| 3153 | rejects the option, stop (open question 1). | |
| 3154 | 1. `make deploy` from the Part 3 branch, the R1 drop-in, then | |
| 3155 | `make deploy-runner`. | |
| 3156 | 2. On `cmc/ci-scratch` `main`, a probe job (keep the step under 4096 | |
| 3157 | bytes): | |
| 3158 | ||
| 3159 | ```yaml | |
| 3160 | jobs: | |
| 3161 | probe: | |
| 3162 | steps: | |
| 3163 | - echo "GITBAY_SSH=$GITBAY_SSH" | |
| 3164 | - | | |
| 3165 | gw=$(awk '$2=="00000000"{print $3}' /proc/net/route | head -1) | |
| 3166 | echo "gateway (hex, little-endian): $gw" | |
| 3167 | for a in 127.0.0.1 169.254.1.2; do timeout 5 bash -c "exec 3<>/dev/tcp/$a/22" && echo "reach $a:22" || echo "no $a:22"; done | |
| 3168 | - | | |
| 3169 | ssh-keygen -q -t ed25519 -N '' -f /tmp/k | |
| 3170 | for i in $(seq 1 30); do ssh -F /dev/null -i /tmp/k -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 "$GITBAY_SSH" whoami; done | |
| 3171 | - bash -c 'exec 3<>/dev/tcp/${GITBAY_SSH#*@}/22; sleep 90' | |
| 3172 | ``` | |
| 3173 | ||
| 3174 | 3. While the last step holds its connection open, on bay1: | |
| 3175 | ||
| 3176 | ```sh | |
| 3177 | ssh -p 2222 root@gitbay.org "ss -tn state established '( sport = :22 )'" | |
| 3178 | ``` | |
| 3179 | ||
| 3180 | Record the peer address of the build's connection and of the | |
| 3181 | runner's `runner log` session (`127.0.0.1`). Expected: the build's | |
| 3182 | peer is the host's public address, never `127.0.0.1`. | |
| 3183 | 4. `gitbay audit --json` (admin): look for `auth.throttled` rows since | |
| 3184 | the build started. Expected: none, since registration is open (see | |
| 3185 | Decisions); the runner kept claiming (`gitbay admin runners` shows a | |
| 3186 | recent poll). | |
| 3187 | 5. Expected build log: `GITBAY_SSH=git@gitbay.org`, `no 127.0.0.1:22`, | |
| 3188 | the `169.254.1.2` result as measured, the `whoami` loop answering | |
| 3189 | from an anonymous session. | |
| 3190 | 6. Remove the R1 drop-in. Trigger one real trusted job that talks back | |
| 3191 | (`gitbay build trigger krz/orgo <its release or pages job>` only if | |
| 3192 | one is due; otherwise wait for the next hutch/orgo scheduled job) and | |
| 3193 | check it reached `git@gitbay.org`. | |
| 3194 | 7. Record in the wiki, one commit on a branch `wiki-260-results` | |
| 3195 | (`Closes #260`): the measured source addresses and the pasta/podman | |
| 3196 | versions under Threat-Model "What a build can reach"; the Known-Gaps | |
| 3197 | question "What can a build reach on the host's network?" answered | |
| 3198 | with the date and result, and the `#260` row removed; `09-Controls` | |
| 3199 | row status left `partial` (outbound is open by decision). | |
| 3200 | ||
| 3201 | ### R4. Part 4 (#266): validate the failure report | |
| 3202 | ||
| 3203 | 1. `make deploy` from the Part 4 branch (migration 0065 runs on start), | |
| 3204 | the R1 drop-in, `make deploy-runner`. | |
| 3205 | 2. On `cmc/ci-scratch` `main`: | |
| 3206 | ||
| 3207 | ```yaml | |
| 3208 | jobs: | |
| 3209 | fail: | |
| 3210 | steps: | |
| 3211 | - echo one | |
| 3212 | - echo two | |
| 3213 | - echo about to fail; exit 7 | |
| 3214 | ``` | |
| 3215 | ||
| 3216 | 3. Expected: the log ends `step 3/3 failed: exit 7`; `gitbay build show | |
| 3217 | cmc/ci-scratch <n>` prints `failed step 3/3 echo about to fail; exit 7 | |
| 3218 | (exit 7)` and a `duration`; `gitbay build log cmc/ci-scratch <n> --step | |
| 3219 | failed` prints `about to fail` and the failure line; the build page | |
| 3220 | opens step 3 and "Jump to failure" scrolls to it; at phone width the | |
| 3221 | log wraps with no sideways scroll. | |
| 3222 | 4. Remove the R1 drop-in; merge Part 4. Once all four parts are in, | |
| 3223 | delete `cmc/ci-scratch` and its fork, or keep them as the standing | |
| 3224 | scratch pair for the next runner change. | |
| 3225 | ||
| 3226 | --- | |
| 3227 | ||
| 3228 | # Self-review | |
| 3229 | ||
| 3230 | - **Coverage.** #255: explicit trust (1.1), disposable untrusted home | |
| 3231 | and trusted-only caches (1.2), the discard (R2.7), wiki (1.3). #258: | |
| 3232 | `ci/` refused (2.1), reuse by trust and image (2.2), required contexts | |
| 3233 | with missing as pending in `MergeGates` (2.3), wiki (2.4). #260: code | |
| 3234 | (3.1, 3.2), egress policy in Threat-Model and CI (3.3), throttling | |
| 3235 | test and source-address measurement in the runbook (R3), with the | |
| 3236 | scratch-repository rule (R1). #266: runner names the step (4.3), stored | |
| 3237 | step and duration (4.1; duration derived), `build show` (4.4), | |
| 3238 | `build log --step`/`--tail` (4.4), web `<details>` per step with the | |
| 3239 | failed one open, `id="failed"`, "Jump to failure", duration beside | |
| 3240 | finished (4.5), `pre.buildlog` wraps (4.5). | |
| 3241 | - **Placeholders.** Every code step carries the code. The one reference | |
| 3242 | to "the number after renumbering" is the migration rule, not a gap. | |
| 3243 | - **Names across tasks.** `buildHome` (1.2) is used by `run` in 1.2 and | |
| 3244 | 4.3. `job.Trusted` (1.2), `job.SSH` (3.2). `buildSSH(public string)` | |
| 3245 | replaces `buildSSH()` in 3.2 and the call site changes there. | |
| 3246 | `failure`/`exitReason`/`doneArgs` (4.3). `SetBuildFailure` (4.1) is | |
| 3247 | used in 4.2 and 4.4's test fixture. `SplitBuildLog`, `FailedSection`, | |
| 3248 | `LogSection` (4.4) are used by `logSteps` (4.5). `SuccessBuildForTree` | |
| 3249 | gains `image` in 2.2 and its one caller changes there. | |
| 3250 | `GatesOut.ChecksMissing` (2.3) is rendered in `mr.html` (2.3). | |
docs/plans/2026-09-27-cli-ux.md added +1849
| @@ -0,0 +1,1849 @@ | ||
| 1 | # CLI UX small fixes implementation plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Close #265, #267 and #268: the dashboard's activity feed reads | |
| 6 | as sentences instead of raw event payloads and stops repeating assigned | |
| 7 | issues; CLI help and usage print the form the caller actually typed | |
| 8 | (`gitbay ...` or `ssh git@host ...`), with the `auth` grouping, the | |
| 9 | `--help` flag and command summaries fixed to match; and five smaller UX | |
| 10 | findings (the unregistered-key message, `issue create` flags, `mr show` | |
| 11 | plurals, a `repo readme` command, and a truncated mirror timestamp). | |
| 12 | ||
| 13 | **Architecture:** No schema changes and no new migrations. The dashboard | |
| 14 | and web feed currently keep two copies of "turn a stored event into a | |
| 15 | sentence" (`internal/httpd/feed.go`) and "the worst of a set of build | |
| 16 | statuses" (`internal/httpd/builds.go`); both move into | |
| 17 | `internal/control` so the CLI can call them directly (same package) and | |
| 18 | `internal/httpd` calls the exported forms. Help and usage already carry | |
| 19 | a `Ctx.Term` set from the CLI's `--term=<cols>[,color]`; `c.program()` | |
| 20 | already picks `"gitbay"` or `"ssh git@<host>"` from it for the `--help` | |
| 21 | path, but `c.usage()`/`c.usageWith()` (the wrong-argument path) do not | |
| 22 | yet call it, and the CLI's `auth` grouping is not a registry path at | |
| 23 | all, so its `--help` falls back to cobra's own listing. | |
| 24 | ||
| 25 | **Tech stack:** Go, `golang.org/x/crypto/ssh`, cobra. | |
| 26 | ||
| 27 | **Spec:** none — these are small, independently-scoped fixes; this plan | |
| 28 | is its own spec. | |
| 29 | ||
| 30 | ## Global constraints | |
| 31 | ||
| 32 | - Three MRs, each on its own branch off `main`: `cli-ux-activity` | |
| 33 | (#265), `cli-ux-help` (#267), `cli-ux-fixes` (#268). No MR depends on | |
| 34 | either of the others; land in any order. | |
| 35 | - Commits are signed (the repository refuses unsigned ones); messages | |
| 36 | reference the issue they touch (`Ref #N`), and the last commit that | |
| 37 | finishes an issue says `Closes #N`. No attribution to any assistant, | |
| 38 | model or AI anywhere: commits, MR bodies, comments. | |
| 39 | - MR: `gitbay mr create --source <branch> --target main --title "..."`; | |
| 40 | merge with `gitbay mr merge <n> --strategy ff` once CI is green | |
| 41 | (this repository requires signed commits, so `squash`/`merge` are | |
| 42 | refused), then delete the branch locally and on the remote. If the | |
| 43 | merge reports the branch is behind, rebase onto `main`, force-push, | |
| 44 | merge again. | |
| 45 | - Locally: `go build ./...`, `go vet ./...`, and the unit tests of every | |
| 46 | touched package. Run at most the one e2e test being written per task | |
| 47 | (`go test ./e2e -run TestName -count=1`); CI on bay1 runs the full | |
| 48 | suite. | |
| 49 | - No new migrations; none of #265/#267/#268 touch the schema. The plan | |
| 50 | numbers 0078–0079 pre-assigned to "plan 6" go unused. | |
| 51 | - Registries that fail CI when a new thing lacks its row: a `ReadOnly` | |
| 52 | command needs an entry in `readArgs` in `e2e/readonly_test.go`; a new | |
| 53 | control command needs a `pass()` entry in `cmd/gitbay/main.go` | |
| 54 | (`cmd/gitbay/summaries_test.go`'s coverage and `summaries_gen.go` | |
| 55 | currency checks); a command reading stdin needs `ReadsStdin: true`. | |
| 56 | - `--json` output: field shapes are unchanged throughout this plan. | |
| 57 | Where a task changes plain-text wording it says so; JSON error | |
| 58 | strings for usage refusals do change in Part 2 (Task 2.1), which is | |
| 59 | called out there specifically since no other task touches JSON text. | |
| 60 | - The `.gitbay/wiki/Parity.org` page is updated in the same commit that | |
| 61 | changes the row it describes (Task 3.4). | |
| 62 | - Writing style: plain, direct, no hype; code comments match the | |
| 63 | surrounding density; no before/after narration in comments or docs. | |
| 64 | ||
| 65 | ## Order and dependencies | |
| 66 | ||
| 67 | 1. **`cli-ux-activity`** — closes #265. Independent. | |
| 68 | 2. **`cli-ux-help`** — closes #267. Independent. | |
| 69 | 3. **`cli-ux-fixes`** — closes #268. Independent. | |
| 70 | ||
| 71 | None of these three depend on any of the other five plans running in | |
| 72 | parallel (credentials-and-sessions, ci-trust-and-build-reporting, | |
| 73 | server-hardening, data-at-rest-and-backup, web-ux); nothing here touches | |
| 74 | authentication, secrets, CI, backups or the pages those plans change. | |
| 75 | ||
| 76 | --- | |
| 77 | ||
| 78 | # Part 1: dashboard activity, no duplicates, one empty-state wording (branch `cli-ux-activity`, closes #265) | |
| 79 | ||
| 80 | ### Task 1.1: move the feed-line sentence renderer into `internal/control` | |
| 81 | ||
| 82 | The web renders "recent activity" as a sentence (`cmc opened issue #12`) | |
| 83 | via `internal/httpd/feed.go`'s unexported `feedLine`/`feedLines`, which | |
| 84 | the CLI cannot reach — `internal/httpd` imports `internal/control`, not | |
| 85 | the other way around. Move the renderer into `internal/control` so both | |
| 86 | sides call the same code; `internal/httpd` becomes a thin caller of the | |
| 87 | exported form. | |
| 88 | ||
| 89 | **Files:** | |
| 90 | - Create: `internal/control/feedline.go` (from `internal/httpd/feed.go`) | |
| 91 | - Create: `internal/control/feedline_test.go` (from `internal/httpd/feed_test.go`) | |
| 92 | - Modify: `internal/httpd/builds.go:150-183` (`worstStatus`, `runStatusPriority` move out; `combinedStatus` calls the moved form) | |
| 93 | - Modify: `internal/httpd/web.go:219`, `:470`, `:493`, `:511` (`feedLine`/`feedLines` → `control.FeedLine`/`control.FeedLines`) | |
| 94 | - Modify: `internal/httpd/ownerpage_test.go:58` (`feedLine{...}` → `control.FeedLine{...}`) | |
| 95 | - Delete: `internal/httpd/feed.go`, `internal/httpd/feed_test.go` | |
| 96 | ||
| 97 | **Interfaces:** | |
| 98 | - Produces: `type FeedLine struct{ Actor, Verb, Ref, Repo, URL string; When string; WhenT time.Time; State string; Jobs []string; sha string }` (exported type, one unexported field kept for the fold logic — same package as its only user); `func FeedLines(events []store.FeedEvent) []FeedLine`; `func WorstStatus(statuses []string) string`. | |
| 99 | - Consumes (Task 1.2, 1.3): the same `FeedLines`/`FeedLine`. | |
| 100 | ||
| 101 | - [ ] **Step 1: Run the existing web feed tests to see the baseline pass** | |
| 102 | ||
| 103 | Run: `go test ./internal/httpd -run TestFeedLines -count=1` | |
| 104 | Expected: PASS (nothing changed yet). | |
| 105 | ||
| 106 | - [ ] **Step 2: Move the renderer** | |
| 107 | ||
| 108 | `git mv internal/httpd/feed.go internal/control/feedline.go` and | |
| 109 | `git mv internal/httpd/feed_test.go internal/control/feedline_test.go`. | |
| 110 | In `internal/control/feedline.go`, change `package httpd` to | |
| 111 | `package control`, capitalize the moved identifiers, and drop the now- | |
| 112 | unused `"gitbay.org/gitbay/internal/store"` import path prefix | |
| 113 | adjustments are unnecessary (the import path is the same from either | |
| 114 | package). Concretely: | |
| 115 | ||
| 116 | ```go | |
| 117 | package control | |
| 118 | ||
| 119 | import ( | |
| 120 | "encoding/json" | |
| 121 | "fmt" | |
| 122 | "slices" | |
| 123 | "strings" | |
| 124 | "time" | |
| 125 | ||
| 126 | "gitbay.org/gitbay/internal/store" | |
| 127 | ) | |
| 128 | ||
| 129 | // FeedLine is one activity entry, already phrased and linked. | |
| 130 | type FeedLine struct { | |
| 131 | Actor string | |
| 132 | Verb string // "opened issue", "merged", "ran 2 jobs on" | |
| 133 | Ref string // "#12", "!35", "v0.4.0", a short sha | |
| 134 | Repo string | |
| 135 | URL string | |
| 136 | When string // the stored timestamp, for anything still reading it raw | |
| 137 | WhenT time.Time // parsed from When, for ago/whenT rendering | |
| 138 | State string // a build run's combined status; empty for anything else | |
| 139 | Jobs []string // job names folded into a build run | |
| 140 | sha string // the commit a build event fired on, for fold-matching | |
| 141 | } | |
| 142 | ``` | |
| 143 | ||
| 144 | Keep the rest of the function bodies (`FeedLines`, `issueVerb`, `mrVerb`, | |
| 145 | `parseEventTime`) unchanged apart from `feedLines` → `FeedLines` and | |
| 146 | `feedLine{` → `FeedLine{`; `issueVerb`/`mrVerb`/`parseEventTime` stay | |
| 147 | unexported (nothing outside the package calls them directly). In | |
| 148 | `internal/control/feedline_test.go`, change `package httpd` to | |
| 149 | `package control` and `feedLines(` → `FeedLines(` throughout (ten call | |
| 150 | sites, all named `feedLines(events)`). | |
| 151 | ||
| 152 | - [ ] **Step 3: Move `worstStatus`** | |
| 153 | ||
| 154 | In `internal/httpd/builds.go`, cut `runStatusPriority` and `worstStatus` | |
| 155 | (the two declarations at lines 150–183) and paste them into | |
| 156 | `internal/control/feedline.go`, renaming `worstStatus` to `WorstStatus` | |
| 157 | and updating its one internal call site in `FeedLines` | |
| 158 | (`out[i].State = worstStatus(statuses[i])` → `WorstStatus(...)`). In | |
| 159 | `internal/httpd/builds.go`, `combinedStatus` becomes: | |
| 160 | ||
| 161 | ```go | |
| 162 | func combinedStatus(builds []control.BuildOut) string { | |
| 163 | statuses := make([]string, len(builds)) | |
| 164 | for i, b := range builds { | |
| 165 | statuses[i] = b.Status | |
| 166 | } | |
| 167 | return control.WorstStatus(statuses) | |
| 168 | } | |
| 169 | ``` | |
| 170 | ||
| 171 | - [ ] **Step 4: Update `internal/httpd/web.go`'s call sites** | |
| 172 | ||
| 173 | Line 219 (`dashboard`'s anonymous struct): `Feed []control.FeedLine`. | |
| 174 | Line 470: `func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine`, | |
| 175 | its final `return feedLines(events)` becomes `return control.FeedLines(events)`. | |
| 176 | Line 493 inside `dashboard`: `feedLines(events)` → `control.FeedLines(events)`. | |
| 177 | Line 511 (`ownerPage.Log`): `Log []control.FeedLine`. | |
| 178 | ||
| 179 | - [ ] **Step 5: Update `internal/httpd/ownerpage_test.go:58`** | |
| 180 | ||
| 181 | ```go | |
| 182 | d.Log = []control.FeedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}} | |
| 183 | ``` | |
| 184 | ||
| 185 | - [ ] **Step 6: Build and test both packages** | |
| 186 | ||
| 187 | Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd -count=1` | |
| 188 | Expected: PASS. A compile error naming `feedLine`/`feedLines`/`worstStatus` | |
| 189 | means a call site in `internal/httpd` was missed — `grep -rn | |
| 190 | "feedLine\|worstStatus" internal/httpd/*.go` should come back empty | |
| 191 | except inside comments. | |
| 192 | ||
| 193 | - [ ] **Step 7: Commit** | |
| 194 | ||
| 195 | ```bash | |
| 196 | git add internal/control/feedline.go internal/control/feedline_test.go internal/httpd/feed.go internal/httpd/feed_test.go internal/httpd/builds.go internal/httpd/web.go internal/httpd/ownerpage_test.go | |
| 197 | git commit -m "control: move the feed-line sentence renderer from httpd, so the CLI can share it" -m "Ref #265" | |
| 198 | ``` | |
| 199 | ||
| 200 | ### Task 1.2: a labelled event's sentence names the labels | |
| 201 | ||
| 202 | `issue.labeled`/`mr.labeled` events currently fall through `issueVerb`/ | |
| 203 | `mrVerb`'s default case (`"issue " + s"`, i.e. "issue labeled"), naming | |
| 204 | neither what changed nor which labels — on the web today, not only in | |
| 205 | the CLI this plan is fixing. Give both label events their own verb and | |
| 206 | carry the label list alongside the ref. | |
| 207 | ||
| 208 | **Files:** | |
| 209 | - Modify: `internal/control/feedline.go` (`FeedLine`, `FeedLines`, `issueVerb`, `mrVerb`) | |
| 210 | - Modify: `internal/control/feedline_test.go` | |
| 211 | - Modify: `internal/web/templates/dashboard.html:49`, `internal/web/templates/owner.html:39` | |
| 212 | ||
| 213 | **Interfaces:** | |
| 214 | - Produces: `FeedLine.Extra string` — trailing detail rendered after the ref; empty for every event kind but a labelled one. | |
| 215 | ||
| 216 | - [ ] **Step 1: Write the failing test** | |
| 217 | ||
| 218 | ```go | |
| 219 | func TestFeedLinesNamesTheLabelsOnALabelledIssue(t *testing.T) { | |
| 220 | events := []store.FeedEvent{ | |
| 221 | {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "issue.labeled", | |
| 222 | Data: `{"number":262,"labels":["ops","security"]}`}, | |
| 223 | } | |
| 224 | lines := FeedLines(events) | |
| 225 | if len(lines) != 1 { | |
| 226 | t.Fatalf("FeedLines returned %d lines, want 1", len(lines)) | |
| 227 | } | |
| 228 | l := lines[0] | |
| 229 | if l.Verb != "labelled" || l.Ref != "#262" || l.Extra != "ops, security" { | |
| 230 | t.Errorf("got %+v", l) | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | func TestFeedLinesNamesTheLabelsOnALabelledMR(t *testing.T) { | |
| 235 | events := []store.FeedEvent{ | |
| 236 | {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "mr.labeled", | |
| 237 | Data: `{"number":471,"labels":["review"]}`}, | |
| 238 | } | |
| 239 | lines := FeedLines(events) | |
| 240 | if len(lines) != 1 || lines[0].Verb != "labelled" || lines[0].Ref != "!471" || lines[0].Extra != "review" { | |
| 241 | t.Errorf("got %+v", lines) | |
| 242 | } | |
| 243 | } | |
| 244 | ``` | |
| 245 | ||
| 246 | - [ ] **Step 2: Run and see them fail** | |
| 247 | ||
| 248 | Run: `go test ./internal/control -run TestFeedLinesNamesTheLabels -count=1` | |
| 249 | Expected: FAIL (`Verb = "issue labeled"`/`"merge request labeled"`, `Extra` unset). | |
| 250 | ||
| 251 | - [ ] **Step 3: Implement** | |
| 252 | ||
| 253 | Add a field to the local decode struct and the `FeedLine` type, then set | |
| 254 | `Extra` for the two label kinds. In `FeedLines`, the local `d` struct | |
| 255 | gains `Labels []string`: | |
| 256 | ||
| 257 | ```go | |
| 258 | var d struct { | |
| 259 | Number int64 `json:"number"` | |
| 260 | Job string `json:"job"` | |
| 261 | Tag string `json:"tag"` | |
| 262 | SHA string `json:"sha"` | |
| 263 | Labels []string `json:"labels"` | |
| 264 | } | |
| 265 | ``` | |
| 266 | ||
| 267 | `FeedLine` gains, after `Jobs`: | |
| 268 | ||
| 269 | ```go | |
| 270 | // Extra is trailing detail shown after the ref: the label list on a | |
| 271 | // labelled event, empty for everything else. | |
| 272 | Extra string | |
| 273 | ``` | |
| 274 | ||
| 275 | In the `case "issue":`/`case "mr":` arms, after setting `l.Verb, l.Ref`: | |
| 276 | ||
| 277 | ```go | |
| 278 | case "issue": | |
| 279 | l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) | |
| 280 | l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number) | |
| 281 | if rest == "labeled" { | |
| 282 | l.Extra = strings.Join(d.Labels, ", ") | |
| 283 | } | |
| 284 | case "mr": | |
| 285 | l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number) | |
| 286 | l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number) | |
| 287 | if rest == "labeled" { | |
| 288 | l.Extra = strings.Join(d.Labels, ", ") | |
| 289 | } | |
| 290 | ``` | |
| 291 | ||
| 292 | `issueVerb` and `mrVerb` each gain a case: | |
| 293 | ||
| 294 | ```go | |
| 295 | case "labeled": | |
| 296 | return "labelled" | |
| 297 | ``` | |
| 298 | ||
| 299 | - [ ] **Step 4: Run** | |
| 300 | ||
| 301 | Run: `go test ./internal/control -count=1` | |
| 302 | Expected: PASS. | |
| 303 | ||
| 304 | - [ ] **Step 5: Templates carry `Extra`** | |
| 305 | ||
| 306 | `internal/web/templates/dashboard.html:49` and | |
| 307 | `internal/web/templates/owner.html:39` both gain `{{if .Extra}} {{.Extra}}{{end}}` | |
| 308 | right after the closing `</a>` of the ref link, before the `<br>`: | |
| 309 | ||
| 310 | ``` | |
| 311 | {{range .Feed}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a>{{if .Extra}} {{.Extra}}{{end}}<br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p> | |
| 312 | ``` | |
| 313 | ||
| 314 | - [ ] **Step 6: Build** | |
| 315 | ||
| 316 | Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1` | |
| 317 | Expected: PASS. | |
| 318 | ||
| 319 | - [ ] **Step 7: Commit** | |
| 320 | ||
| 321 | ```bash | |
| 322 | git add internal/control/feedline.go internal/control/feedline_test.go internal/web/templates/dashboard.html internal/web/templates/owner.html | |
| 323 | git commit -m "feed: a labelled event names the labels" -m "Ref #265" | |
| 324 | ``` | |
| 325 | ||
| 326 | ### Task 1.3: dashboard and feed render activity as sentences, not raw payloads | |
| 327 | ||
| 328 | `gitbay dashboard`'s "recent activity" table and `gitbay feed` both | |
| 329 | print the event's kind and its raw JSON payload | |
| 330 | (`2026-09-28T02:36:51Z cmc issue.labeled krz/gitbay | |
| 331 | {"number":262,"labels":["ops","security"]}`). Render the same sentence | |
| 332 | the web shows instead; the payload stays available under `--json` | |
| 333 | (`DashboardOut.Activity`/`FeedOut.Data` are untouched). | |
| 334 | ||
| 335 | **Files:** | |
| 336 | - Modify: `internal/control/dashboard.go` (`runDashboard`'s `activityRows`, `runFeed`'s plain formatter) | |
| 337 | - Test: `internal/control/dashboard_test.go` | |
| 338 | ||
| 339 | **Interfaces:** | |
| 340 | - Consumes: `FeedLines`, `FeedLine` (Task 1.1/1.2). | |
| 341 | ||
| 342 | - [ ] **Step 1: Write the failing test** | |
| 343 | ||
| 344 | ```go | |
| 345 | func TestDashboardActivityIsASentence(t *testing.T) { | |
| 346 | c := notifTestCtx(t, "cmc") | |
| 347 | repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public") | |
| 348 | if err != nil { | |
| 349 | t.Fatal(err) | |
| 350 | } | |
| 351 | repo, err := c.Store.RepoByID(repoID) | |
| 352 | if err != nil { | |
| 353 | t.Fatal(err) | |
| 354 | } | |
| 355 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled", `{"number":262,"labels":["ops","security"]}`) | |
| 356 | ||
| 357 | var out bytes.Buffer | |
| 358 | c.Stdout, c.Stderr = &out, &out | |
| 359 | if code := runDashboard(c, nil); code != 0 { | |
| 360 | t.Fatalf("exit %d: %s", code, out.String()) | |
| 361 | } | |
| 362 | if strings.Contains(out.String(), `{"number"`) { | |
| 363 | t.Errorf("raw payload leaked into plain output:\n%s", out.String()) | |
| 364 | } | |
| 365 | if !strings.Contains(out.String(), "cmc labelled krz/gitbay#262 ops, security") { | |
| 366 | t.Errorf("no sentence in output:\n%s", out.String()) | |
| 367 | } | |
| 368 | } | |
| 369 | ``` | |
| 370 | ||
| 371 | - [ ] **Step 2: Run and see it fail** | |
| 372 | ||
| 373 | Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1` | |
| 374 | Expected: FAIL (output has `KIND`/`DATA` columns and the raw JSON). | |
| 375 | ||
| 376 | - [ ] **Step 3: Implement in `runDashboard`** | |
| 377 | ||
| 378 | Replace the `activityRows`/`section("recent activity:", ...)` block: | |
| 379 | ||
| 380 | ```go | |
| 381 | lines := FeedLines(events) | |
| 382 | activityRows := make([][]cell, len(lines)) | |
| 383 | for i, l := range lines { | |
| 384 | sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref) | |
| 385 | if l.Extra != "" { | |
| 386 | sentence += " " + l.Extra | |
| 387 | } | |
| 388 | activityRows[i] = []cell{cAge(l.When), cFlex(sentence)} | |
| 389 | } | |
| 390 | section("recent activity:", []string{"WHEN", "EVENT"}, activityRows) | |
| 391 | ``` | |
| 392 | ||
| 393 | `events` is already in scope (it is what `d.Activity = feedOutputs(events)` | |
| 394 | was built from, a few lines above); nothing else in `runDashboard` reads | |
| 395 | it again, so no variable needs renaming. | |
| 396 | ||
| 397 | - [ ] **Step 4: Run** | |
| 398 | ||
| 399 | Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1` | |
| 400 | Expected: PASS. | |
| 401 | ||
| 402 | - [ ] **Step 5: Same fix in `runFeed`, its own failing test first** | |
| 403 | ||
| 404 | ```go | |
| 405 | func TestFeedIsASentence(t *testing.T) { | |
| 406 | c := notifTestCtx(t, "cmc") | |
| 407 | repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public") | |
| 408 | if err != nil { | |
| 409 | t.Fatal(err) | |
| 410 | } | |
| 411 | repo, err := c.Store.RepoByID(repoID) | |
| 412 | if err != nil { | |
| 413 | t.Fatal(err) | |
| 414 | } | |
| 415 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", `{"number":1}`) | |
| 416 | ||
| 417 | var out bytes.Buffer | |
| 418 | c.Stdout, c.Stderr = &out, &out | |
| 419 | if code := runFeed(c, nil); code != 0 { | |
| 420 | t.Fatalf("exit %d: %s", code, out.String()) | |
| 421 | } | |
| 422 | if !strings.Contains(out.String(), "cmc opened issue krz/gitbay#1") { | |
| 423 | t.Errorf("no sentence in output:\n%s", out.String()) | |
| 424 | } | |
| 425 | } | |
| 426 | ``` | |
| 427 | ||
| 428 | Run: `go test ./internal/control -run TestFeedIsASentence -count=1` | |
| 429 | Expected: FAIL. | |
| 430 | ||
| 431 | Implement: `runFeed`'s plain closure changes from the five-column | |
| 432 | `WHEN`/`ACTOR`/`KIND`/`REPO`/`DATA` table to the same two-column shape, | |
| 433 | built from `FeedLines(events)` (the same `events` slice `runFeed` | |
| 434 | already queried, before `feedOutputs(events)` is called for `ds`): | |
| 435 | ||
| 436 | ```go | |
| 437 | lines := FeedLines(events) | |
| 438 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 439 | tb := c.table(w, "WHEN", "EVENT") | |
| 440 | for _, l := range lines { | |
| 441 | sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref) | |
| 442 | if l.Extra != "" { | |
| 443 | sentence += " " + l.Extra | |
| 444 | } | |
| 445 | tb.row(cAge(l.When), cFlex(sentence)) | |
| 446 | } | |
| 447 | tb.flush() | |
| 448 | }) | |
| 449 | ``` | |
| 450 | ||
| 451 | `ds` (the `FeedOut` slice) stays exactly as it was: it is what `--json` | |
| 452 | still emits, and `emitPage`'s cursor logic pages `ds`, not `lines` — the | |
| 453 | two slices are always the same length and order since both come from | |
| 454 | the same `events`. | |
| 455 | ||
| 456 | - [ ] **Step 6: Run** | |
| 457 | ||
| 458 | Run: `go test ./internal/control -count=1` | |
| 459 | Expected: PASS. A failure elsewhere in the package on a "recent | |
| 460 | activity"/"WHEN\tACTOR\tKIND" assertion means an existing test asserted | |
| 461 | the old five-column shape; update its expectation to the new sentence | |
| 462 | (the test name will say `TestDashboard...` or `TestFeed...`). | |
| 463 | ||
| 464 | - [ ] **Step 7: Commit** | |
| 465 | ||
| 466 | ```bash | |
| 467 | git add internal/control/dashboard.go internal/control/dashboard_test.go | |
| 468 | git commit -m "dashboard, feed: render activity as the web's sentence, not the raw payload" -m "Ref #265" | |
| 469 | ``` | |
| 470 | ||
| 471 | ### Task 1.4: an issue assigned to you no longer repeats in "open issues" | |
| 472 | ||
| 473 | `dashboardIssuesQuery` (open issues you are involved in) and | |
| 474 | `assignedIssuesQuery` (open issues assigned to you) overlap whenever an | |
| 475 | assigned issue also sits in a repository you can otherwise reach — the | |
| 476 | common case — so the same issue prints under both "assigned to you:" | |
| 477 | and "open issues:" on the CLI, and under both lists on the web | |
| 478 | dashboard, which calls the same two store methods | |
| 479 | (`internal/httpd/web.go:206-209`). Exclude assigned issues from the | |
| 480 | "open issues" query; the fix is in the store, so both surfaces get it | |
| 481 | at once. | |
| 482 | ||
| 483 | **Files:** | |
| 484 | - Modify: `internal/store/dashboard.go` (`dashboardIssuesQuery`) | |
| 485 | - Test: `internal/store/dashboard_test.go` (create) | |
| 486 | ||
| 487 | **Interfaces:** | |
| 488 | - Consumes: nothing new. | |
| 489 | - Produces: nothing new (`DashboardIssues` keeps its signature). | |
| 490 | ||
| 491 | - [ ] **Step 1: Write the failing test** | |
| 492 | ||
| 493 | ```go | |
| 494 | package store | |
| 495 | ||
| 496 | import "testing" | |
| 497 | ||
| 498 | // An issue assigned to the user is not repeated under DashboardIssues: | |
| 499 | // AssignedIssues already covers it, and a repository the user can | |
| 500 | // otherwise reach (here, one they own) is the common case where the two | |
| 501 | // queries used to overlap (#265). | |
| 502 | func TestDashboardIssuesExcludesAssignedIssues(t *testing.T) { | |
| 503 | s := open(t) | |
| 504 | if err := s.MigrateUp(); err != nil { | |
| 505 | t.Fatal(err) | |
| 506 | } | |
| 507 | uid, err := s.CreateUser("cmc", false) | |
| 508 | if err != nil { | |
| 509 | t.Fatal(err) | |
| 510 | } | |
| 511 | repoID, err := s.CreateRepo("user", uid, "gitbay", "public") | |
| 512 | if err != nil { | |
| 513 | t.Fatal(err) | |
| 514 | } | |
| 515 | repo, err := s.RepoByID(repoID) | |
| 516 | if err != nil { | |
| 517 | t.Fatal(err) | |
| 518 | } | |
| 519 | assignedNum, err := s.CreateIssue(repo.ID, uid, "assigned to me", "", "markdown") | |
| 520 | if err != nil { | |
| 521 | t.Fatal(err) | |
| 522 | } | |
| 523 | if _, err := s.CreateIssue(repo.ID, uid, "not assigned", "", "markdown"); err != nil { | |
| 524 | t.Fatal(err) | |
| 525 | } | |
| 526 | assigned, err := s.IssueByNumber(repo.ID, assignedNum) | |
| 527 | if err != nil { | |
| 528 | t.Fatal(err) | |
| 529 | } | |
| 530 | if err := s.SetIssueAssignee(assigned.ID, uid, true); err != nil { | |
| 531 | t.Fatal(err) | |
| 532 | } | |
| 533 | ||
| 534 | issues, err := s.DashboardIssues(uid) | |
| 535 | if err != nil { | |
| 536 | t.Fatal(err) | |
| 537 | } | |
| 538 | if len(issues) != 1 || issues[0].Title != "not assigned" { | |
| 539 | t.Fatalf("DashboardIssues = %+v, want only the unassigned issue", issues) | |
| 540 | } | |
| 541 | assignedList, err := s.AssignedIssues(uid) | |
| 542 | if err != nil { | |
| 543 | t.Fatal(err) | |
| 544 | } | |
| 545 | if len(assignedList) != 1 || assignedList[0].Title != "assigned to me" { | |
| 546 | t.Fatalf("AssignedIssues = %+v, want the assigned issue", assignedList) | |
| 547 | } | |
| 548 | } | |
| 549 | ``` | |
| 550 | ||
| 551 | - [ ] **Step 2: Run and see it fail** | |
| 552 | ||
| 553 | Run: `go test ./internal/store -run TestDashboardIssuesExcludesAssignedIssues -count=1` | |
| 554 | Expected: FAIL (`DashboardIssues` returns both issues). | |
| 555 | ||
| 556 | - [ ] **Step 3: Implement** | |
| 557 | ||
| 558 | `dashboardIssuesQuery` in `internal/store/dashboard.go` gains one | |
| 559 | `NOT EXISTS` clause: | |
| 560 | ||
| 561 | ```go | |
| 562 | const dashboardIssuesQuery = ` | |
| 563 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | |
| 564 | x.number, x.title, au.username, x.state, x.updated_at | |
| 565 | FROM issues x | |
| 566 | JOIN repos r ON r.id = x.repo_id | |
| 567 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 568 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 569 | JOIN users au ON au.id = x.author_id | |
| 570 | WHERE x.state = 'open' AND ` + involvedCond + ` | |
| 571 | AND NOT EXISTS (SELECT 1 FROM issue_assignees ia | |
| 572 | WHERE ia.issue_id = x.id AND ia.user_id = ?1) | |
| 573 | ORDER BY x.updated_at DESC LIMIT 50` | |
| 574 | ``` | |
| 575 | ||
| 576 | - [ ] **Step 4: Run the new test, then the package and the query-plan guard** | |
| 577 | ||
| 578 | Run: `go test ./internal/store -count=1` | |
| 579 | Expected: PASS, `TestDashboardQueriesUseIndexes`'s `DashboardIssues` case | |
| 580 | included — a correlated `NOT EXISTS` does not change which index drives | |
| 581 | the `ORDER BY`, so the plan should still show `issues_recent` with no | |
| 582 | `USE TEMP B-TREE FOR ORDER BY`. If it does regress, the `NOT EXISTS` | |
| 583 | subquery needs `issue_assignees`'s existing `(issue_id, user_id)` index | |
| 584 | (check `migrations/` for its name) rather than a new one — this task | |
| 585 | does not add a migration. | |
| 586 | ||
| 587 | - [ ] **Step 5: Run the CLI package too** | |
| 588 | ||
| 589 | Run: `go test ./internal/control -count=1` | |
| 590 | Expected: PASS. `TestDashboardEmptySectionsSayNone` and any other | |
| 591 | dashboard test that seeded an assigned issue and expected it under | |
| 592 | "open issues" needs its expectation updated to match the new, | |
| 593 | non-overlapping behavior. | |
| 594 | ||
| 595 | - [ ] **Step 6: Commit** | |
| 596 | ||
| 597 | ```bash | |
| 598 | git add internal/store/dashboard.go internal/store/dashboard_test.go | |
| 599 | git commit -m "dashboard: an assigned issue no longer repeats under open issues" -m "Ref #265" | |
| 600 | ``` | |
| 601 | ||
| 602 | ### Task 1.5: `notifications list`'s empty state names `--all` | |
| 603 | ||
| 604 | An inbox with only read notifications prints the generic `nothing to | |
| 605 | list` on stderr when `notifications list` is run without `--all`, | |
| 606 | without saying unread items are what it shows by default. | |
| 607 | ||
| 608 | **Files:** | |
| 609 | - Modify: `internal/control/notifications.go` (`runNotificationsList`) | |
| 610 | - Test: `internal/control/notifications_test.go` | |
| 611 | ||
| 612 | - [ ] **Step 1: Write the failing test** | |
| 613 | ||
| 614 | ```go | |
| 615 | func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) { | |
| 616 | c, repo, bob := testRepoWithWatcher(t) | |
| 617 | // Give bob one notice, then mark it read, so his inbox has rows but | |
| 618 | // no unread ones. | |
| 619 | c.User = store.User{ID: bob, Username: "bob"} | |
| 620 | notify(c, []int64{bob}, notice{repo: repo, kind: "issue", subject: "s", action: "a", path: "x"}) | |
| 621 | if code := runNotificationsRead(c, []string{"--all"}); code != protocol.ExitOK { | |
| 622 | t.Fatalf("mark read: exit %d", code) | |
| 623 | } | |
| 624 | var out, errOut bytes.Buffer | |
| 625 | c.Stdout, c.Stderr = &out, &errOut | |
| 626 | if code := runNotificationsList(c, nil); code != protocol.ExitOK { | |
| 627 | t.Fatalf("exit %d: %s", code, errOut.String()) | |
| 628 | } | |
| 629 | if got := errOut.String(); got != "no unread notifications (--all for read ones)\n" { | |
| 630 | t.Errorf("stderr = %q", got) | |
| 631 | } | |
| 632 | // --all sees it and stays the generic message when that too is empty. | |
| 633 | out.Reset() | |
| 634 | errOut.Reset() | |
| 635 | if code := runNotificationsList(c, []string{"--all"}); code != protocol.ExitOK { | |
| 636 | t.Fatalf("exit %d: %s", code, errOut.String()) | |
| 637 | } | |
| 638 | if !strings.Contains(out.String(), "s") { | |
| 639 | t.Errorf("--all did not show the read notice: %q", out.String()) | |
| 640 | } | |
| 641 | } | |
| 642 | ``` | |
| 643 | ||
| 644 | (This test needs `notify` and `notice` — the same helpers | |
| 645 | `testRepoWithWatcher`'s package already exercises in | |
| 646 | `notifications_test.go`'s other tests; if their exact names differ, | |
| 647 | `grep -n "^func notify\b\|^type notice\b" internal/control/*.go` and use | |
| 648 | what is actually there.) | |
| 649 | ||
| 650 | - [ ] **Step 2: Run and see it fail** | |
| 651 | ||
| 652 | Run: `go test ./internal/control -run TestNotificationsListEmptyUnreadSaysHowToSeeRead -count=1` | |
| 653 | Expected: FAIL (stderr is `nothing to list`). | |
| 654 | ||
| 655 | - [ ] **Step 3: Implement** | |
| 656 | ||
| 657 | In `runNotificationsList`, after `ds` is built and before the `return | |
| 658 | c.emitPage(...)`: | |
| 659 | ||
| 660 | ```go | |
| 661 | if !c.JSON && !p.active && len(ds) == 0 { | |
| 662 | msg := "nothing to list" | |
| 663 | if !all { | |
| 664 | msg = "no unread notifications (--all for read ones)" | |
| 665 | } | |
| 666 | fmt.Fprintln(c.Stderr, msg) | |
| 667 | return protocol.ExitOK | |
| 668 | } | |
| 669 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 670 | ``` | |
| 671 | ||
| 672 | This runs before pagination wraps the result (`p.active`, from | |
| 673 | `--limit`/`--cursor`) and before JSON, both of which already have their | |
| 674 | own well-defined empty shape (`{"items":[],...}` or a bare `[]`) that | |
| 675 | this task leaves alone. | |
| 676 | ||
| 677 | - [ ] **Step 4: Run** | |
| 678 | ||
| 679 | Run: `go test ./internal/control -run TestNotifications -count=1` | |
| 680 | Expected: PASS. | |
| 681 | ||
| 682 | - [ ] **Step 5: Run the package, commit, open the MR** | |
| 683 | ||
| 684 | Run: `go test ./internal/control ./internal/store ./internal/httpd -count=1` | |
| 685 | Expected: PASS. | |
| 686 | ||
| 687 | ```bash | |
| 688 | git add internal/control/notifications.go internal/control/notifications_test.go | |
| 689 | git commit -m "notifications list: name --all when the empty inbox is just read items" -m "Closes #265" | |
| 690 | git push -u origin cli-ux-activity | |
| 691 | gitbay mr create --source cli-ux-activity --target main --title "dashboard activity as sentences, no duplicate issues" | |
| 692 | ``` | |
| 693 | ||
| 694 | Wait for CI, merge with `--strategy ff`, delete the branch both places. | |
| 695 | ||
| 696 | --- | |
| 697 | ||
| 698 | # Part 2: help and usage print the form the caller typed (branch `cli-ux-help`, closes #267) | |
| 699 | ||
| 700 | ### Task 2.1: `c.usage()`/`c.usageWith()` print the program form | |
| 701 | ||
| 702 | `c.usage()` prints the bare registered usage (`usage: keys remove | |
| 703 | <fingerprint>`), with neither the `gitbay` nor the `ssh git@host` | |
| 704 | prefix `c.program()`/`helpVerb` already use for `--help`. Give both the | |
| 705 | same prefix, and mark a leading `<owner/name>` optional when the caller | |
| 706 | is the CLI at a terminal — the CLI fills it in from the clone's origin | |
| 707 | remote (`cmd/gitbay/ssh.go`'s `withRepo`); stock ssh never does. | |
| 708 | ||
| 709 | **Files:** | |
| 710 | - Modify: `internal/control/help.go` (`cliUsage`, new) | |
| 711 | - Modify: `internal/control/control.go` (`usage`, `usageWith`) | |
| 712 | - Modify: `internal/control/control_test.go` (`TestArgumentRefusalsNameTheUsage`) | |
| 713 | - Test: `internal/control/help_test.go` | |
| 714 | ||
| 715 | **Interfaces:** | |
| 716 | - Produces: `func cliUsage(usage string) string` — marks the first | |
| 717 | `<owner/name>` optional; `func (c *Ctx) cmdUsage() string` — the | |
| 718 | program-prefixed, owner/name-optional-at-a-terminal usage line. | |
| 719 | ||
| 720 | - [ ] **Step 1: Write the failing test** | |
| 721 | ||
| 722 | ```go | |
| 723 | func TestCmdUsagePrefixesTheProgram(t *testing.T) { | |
| 724 | c := &Ctx{Cmd: Command{Usage: "keys remove <fingerprint>"}, Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}} | |
| 725 | if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" { | |
| 726 | t.Errorf("ssh form: %q", got) | |
| 727 | } | |
| 728 | c.Term = Term{Cols: 100} | |
| 729 | if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" { | |
| 730 | t.Errorf("cli form: %q", got) | |
| 731 | } | |
| 732 | ||
| 733 | c2 := &Ctx{Cmd: Command{Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}} | |
| 734 | if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" { | |
| 735 | t.Errorf("optional owner/name: %q", got) | |
| 736 | } | |
| 737 | } | |
| 738 | ``` | |
| 739 | ||
| 740 | - [ ] **Step 2: Run and see it fail** | |
| 741 | ||
| 742 | Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1` | |
| 743 | Expected: FAIL to compile (`c.cmdUsage undefined`). | |
| 744 | ||
| 745 | - [ ] **Step 3: Implement `cliUsage` and `cmdUsage` in `internal/control/help.go`** | |
| 746 | ||
| 747 | ```go | |
| 748 | // cliUsage marks a leading <owner/name> optional in a CLI-rendered usage | |
| 749 | // line: the CLI infers it inside a clone (cmd/gitbay/ssh.go's withRepo), | |
| 750 | // stock ssh never does. Only the first occurrence is marked — a usage | |
| 751 | // line never repeats the placeholder. | |
| 752 | func cliUsage(usage string) string { | |
| 753 | return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1) | |
| 754 | } | |
| 755 | ||
| 756 | // cmdUsage is the registered usage as this call should see it: the | |
| 757 | // gitbay form with <owner/name> optional at a terminal, the ssh form | |
| 758 | // otherwise. Every usage message — the --help path and a wrong-argument | |
| 759 | // refusal alike — goes through this, so a caller never sees the bare | |
| 760 | // registered path with no program in front of it. | |
| 761 | func (c *Ctx) cmdUsage() string { | |
| 762 | shape := c.Cmd.Usage | |
| 763 | if c.Term.Cols > 0 { | |
| 764 | shape = cliUsage(shape) | |
| 765 | } | |
| 766 | return c.program() + " " + shape | |
| 767 | } | |
| 768 | ``` | |
| 769 | ||
| 770 | - [ ] **Step 4: Run** | |
| 771 | ||
| 772 | Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1` | |
| 773 | Expected: PASS. | |
| 774 | ||
| 775 | - [ ] **Step 5: Route `usage`/`usageWith` through it** | |
| 776 | ||
| 777 | In `internal/control/control.go`: | |
| 778 | ||
| 779 | ```go | |
| 780 | // usage reports a bad invocation with the command's registered usage, | |
| 781 | // the one source of it. | |
| 782 | func (c *Ctx) usage() int { | |
| 783 | return c.fail(protocol.ExitUsage, "usage: %s", c.cmdUsage()) | |
| 784 | } | |
| 785 | ||
| 786 | // usageWith reports a specific problem with the arguments, then the | |
| 787 | // registered usage, so a person always sees the shape that was expected. | |
| 788 | func (c *Ctx) usageWith(msg string) int { | |
| 789 | return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.cmdUsage()) | |
| 790 | } | |
| 791 | ``` | |
| 792 | ||
| 793 | Also use it in `helpVerb` (`internal/control/help.go`), which today | |
| 794 | recomputes the same "cut at ` [--`" shape independently: | |
| 795 | ||
| 796 | ```go | |
| 797 | shape := cmd.Usage | |
| 798 | if i := strings.Index(shape, " [--"); i >= 0 { | |
| 799 | shape = shape[:i] + " [flags]" | |
| 800 | } | |
| 801 | fmt.Fprintf(w, " %s %s\n", c.program(), shape) | |
| 802 | ``` | |
| 803 | ||
| 804 | stays as its own thing (it additionally collapses everything from the | |
| 805 | first optional flag into `[flags]`, which `cmdUsage` does not do), but | |
| 806 | its `c.program()` + owner/name handling should not fork from | |
| 807 | `cmdUsage`'s: replace the `c.program()` call with `cliUsage` applied the | |
| 808 | same way: | |
| 809 | ||
| 810 | ```go | |
| 811 | shape := cmd.Usage | |
| 812 | if c.Term.Cols > 0 { | |
| 813 | shape = cliUsage(shape) | |
| 814 | } | |
| 815 | if i := strings.Index(shape, " [--"); i >= 0 { | |
| 816 | shape = shape[:i] + " [flags]" | |
| 817 | } | |
| 818 | fmt.Fprintf(w, " %s %s\n", c.program(), shape) | |
| 819 | ``` | |
| 820 | ||
| 821 | - [ ] **Step 6: Update the test this changes** | |
| 822 | ||
| 823 | `TestArgumentRefusalsNameTheUsage` in `internal/control/control_test.go` | |
| 824 | asserts `errOut.String()` contains the bare `"usage: " + | |
| 825 | strings.Join(argv, " ")`; with no `Cfg.Server.SiteURL` and no `Term` set | |
| 826 | on its `Ctx`, the message now reads `usage: ssh git@ build show` (an | |
| 827 | empty host — `hostOf("")` returns `""`). Set a `SiteURL` on the test's | |
| 828 | `Ctx` and assert the ssh-prefixed form: | |
| 829 | ||
| 830 | ```go | |
| 831 | func TestArgumentRefusalsNameTheUsage(t *testing.T) { | |
| 832 | for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} { | |
| 833 | var out, errOut bytes.Buffer | |
| 834 | c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut, | |
| 835 | Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}} | |
| 836 | if code := Dispatch(c, argv); code != protocol.ExitUsage { | |
| 837 | t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String()) | |
| 838 | continue | |
| 839 | } | |
| 840 | want := "usage: ssh git@forge.test " + strings.Join(argv, " ") | |
| 841 | if !strings.Contains(errOut.String(), want) { | |
| 842 | t.Errorf("%v: no usage line: got %q, want to contain %q", argv, errOut.String(), want) | |
| 843 | } | |
| 844 | } | |
| 845 | } | |
| 846 | ``` | |
| 847 | ||
| 848 | (Add `"gitbay.org/gitbay/internal/config"` to the file's imports if it | |
| 849 | is not already there.) | |
| 850 | ||
| 851 | - [ ] **Step 7: Run the package** | |
| 852 | ||
| 853 | Run: `go test ./internal/control -count=1` | |
| 854 | Expected: PASS. Any other test asserting a bare `"usage: <path>..."` with | |
| 855 | no program prefix needs the same treatment — `grep -rn '"usage: ' | |
| 856 | internal/control/*_test.go` finds them all. | |
| 857 | ||
| 858 | - [ ] **Step 8: Commit** | |
| 859 | ||
| 860 | ```bash | |
| 861 | git add internal/control/help.go internal/control/control.go internal/control/control_test.go internal/control/help_test.go | |
| 862 | git commit -m "usage: print the program form (gitbay or ssh git@host), owner/name optional at a terminal" -m "Ref #267" | |
| 863 | ``` | |
| 864 | ||
| 865 | ### Task 2.2: `--help` check in `keys add` and `pgp add` | |
| 866 | ||
| 867 | Every other passthrough command checks for `--help`/`-h` in `pass()` | |
| 868 | before reading stdin; `keysAdd.RunE` and `pgpAdd.RunE` in | |
| 869 | `cmd/gitbay/main.go`'s `authCmd()` were given their own `RunE` (to wire | |
| 870 | stdin directly) and lost that check, so `gitbay auth keys add --help` | |
| 871 | tries to read a public key from stdin instead of showing help, and | |
| 872 | blocks or fails depending on what stdin happens to be. | |
| 873 | ||
| 874 | **Files:** | |
| 875 | - Modify: `cmd/gitbay/main.go` (`authCmd`'s `keysAdd.RunE`, `pgpAdd.RunE`) | |
| 876 | - Test: `cmd/gitbay/main_test.go` | |
| 877 | ||
| 878 | - [ ] **Step 1: Write the failing test** | |
| 879 | ||
| 880 | ```go | |
| 881 | func TestKeysAddAndPGPAddCheckHelpBeforeStdin(t *testing.T) { | |
| 882 | for _, args := range [][]string{{"auth", "keys", "add", "--help"}, {"auth", "pgp", "add", "--help"}} { | |
| 883 | root := newRoot() | |
| 884 | root.SetArgs(args) | |
| 885 | root.SetIn(strings.NewReader("")) // would block/fail if read as the key body | |
| 886 | if err := root.Execute(); err != nil { | |
| 887 | t.Errorf("%v: %v", args, err) | |
| 888 | } | |
| 889 | } | |
| 890 | } | |
| 891 | ``` | |
| 892 | ||
| 893 | (`cmd/gitbay` runs its `RunE` through `os.Exit`, so this test only | |
| 894 | proves the command does not attempt to read stdin as a key before | |
| 895 | exiting — check with `go test ./cmd/gitbay -run | |
| 896 | TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -v` that it does not | |
| 897 | hang; if the harness needs the process not to call `os.Exit` at all, | |
| 898 | grep `main_test.go` for how existing `--help` tests in this package | |
| 899 | already handle that and follow the same pattern rather than inventing a | |
| 900 | new one.) | |
| 901 | ||
| 902 | - [ ] **Step 2: Run and see it fail (or hang)** | |
| 903 | ||
| 904 | Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s` | |
| 905 | Expected: FAIL or timeout (stdin read attempted). | |
| 906 | ||
| 907 | - [ ] **Step 3: Implement** | |
| 908 | ||
| 909 | `keysAdd.RunE` and `pgpAdd.RunE` in `cmd/gitbay/main.go` each gain the | |
| 910 | same loop `pass()` already has, before resolving the target: | |
| 911 | ||
| 912 | ```go | |
| 913 | keysAdd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 914 | for _, a := range args { | |
| 915 | if a == "--help" || a == "-h" { | |
| 916 | os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}})) | |
| 917 | } | |
| 918 | } | |
| 919 | t, err := resolveTarget() | |
| 920 | ... | |
| 921 | ``` | |
| 922 | ||
| 923 | and, for `pgpAdd`: | |
| 924 | ||
| 925 | ```go | |
| 926 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 927 | for _, a := range args { | |
| 928 | if a == "--help" || a == "-h" { | |
| 929 | os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}})) | |
| 930 | } | |
| 931 | } | |
| 932 | t, err := resolveTarget() | |
| 933 | ... | |
| 934 | ``` | |
| 935 | ||
| 936 | - [ ] **Step 4: Run** | |
| 937 | ||
| 938 | Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s` | |
| 939 | Expected: PASS. | |
| 940 | ||
| 941 | - [ ] **Step 5: Build and run the package** | |
| 942 | ||
| 943 | Run: `go build ./... && go test ./cmd/gitbay -count=1` | |
| 944 | Expected: PASS. | |
| 945 | ||
| 946 | - [ ] **Step 6: Commit** | |
| 947 | ||
| 948 | ```bash | |
| 949 | git add cmd/gitbay/main.go cmd/gitbay/main_test.go | |
| 950 | git commit -m "auth keys add, pgp add: check --help before reading stdin" -m "Ref #267" | |
| 951 | ``` | |
| 952 | ||
| 953 | ### Task 2.3: `auth --help` renders with the registry layout | |
| 954 | ||
| 955 | `auth` is a CLI-only grouping — no registry command's path starts with | |
| 956 | `auth`, so `gitbay auth --help` asks the server for help on prefix | |
| 957 | `"auth"`, gets `ExitNotFound`, and `cmd/gitbay/main.go`'s `group()` | |
| 958 | falls back to cobra's own subcommand listing, which carries no flags or | |
| 959 | examples (the reason `group()` exists at all, per its own comment). | |
| 960 | Give the registry an alias table for CLI-only groupings so `auth` | |
| 961 | renders the same READ/WRITE, aligned-summary layout every real noun | |
| 962 | gets. | |
| 963 | ||
| 964 | **Files:** | |
| 965 | - Modify: `internal/control/help.go` (`runHelp`, `nounSummaries`) | |
| 966 | - Modify: `cmd/gitbay/main.go` (`authCmd`'s `group("auth", ...)` description) | |
| 967 | - Test: `internal/control/help_test.go` | |
| 968 | ||
| 969 | **Interfaces:** | |
| 970 | - Produces: `var nounAliases map[string][]string` — a CLI-only noun name to the real registry prefixes it gathers. | |
| 971 | ||
| 972 | - [ ] **Step 1: Write the failing test** | |
| 973 | ||
| 974 | ```go | |
| 975 | func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) { | |
| 976 | var out bytes.Buffer | |
| 977 | c := &Ctx{Stdout: &out, Term: Term{Cols: 100}} | |
| 978 | if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK { | |
| 979 | t.Fatalf("exit %d", code) | |
| 980 | } | |
| 981 | got := out.String() | |
| 982 | for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} { | |
| 983 | if !strings.Contains(got, want) { | |
| 984 | t.Errorf("missing %q in:\n%s", want, got) | |
| 985 | } | |
| 986 | } | |
| 987 | if strings.Contains(got, "no command matches") { | |
| 988 | t.Errorf("auth did not resolve: %s", got) | |
| 989 | } | |
| 990 | } | |
| 991 | ``` | |
| 992 | ||
| 993 | - [ ] **Step 2: Run and see it fail** | |
| 994 | ||
| 995 | Run: `go test ./internal/control -run TestHelpRendersAnAliasedNounWithTheRegistryLayout -count=1` | |
| 996 | Expected: FAIL (`no command matches "auth"`). | |
| 997 | ||
| 998 | - [ ] **Step 3: Implement the alias table and the lookup change** | |
| 999 | ||
| 1000 | In `internal/control/help.go`, near `nounSummaries`: | |
| 1001 | ||
| 1002 | ```go | |
| 1003 | // nounAliases groups a CLI-only noun (one with no registry path of its | |
| 1004 | // own, such as auth, which the cmd/gitbay CLI assembles from several | |
| 1005 | // unrelated registry prefixes) into the real prefixes it gathers, so | |
| 1006 | // `help auth` renders with the same layout a real noun gets instead of | |
| 1007 | // falling back to whatever a caller does when help fails. | |
| 1008 | var nounAliases = map[string][]string{ | |
| 1009 | "auth": {"account export", "whoami", "keys", "email", "pgp", "token"}, | |
| 1010 | } | |
| 1011 | ``` | |
| 1012 | ||
| 1013 | and add, to `nounSummaries`: | |
| 1014 | ||
| 1015 | ```go | |
| 1016 | "auth": "whoami, SSH and PGP keys, email, API tokens", | |
| 1017 | ``` | |
| 1018 | ||
| 1019 | In `runHelp`, widen the match to every aliased prefix: | |
| 1020 | ||
| 1021 | ```go | |
| 1022 | func runHelp(c *Ctx, args []string) int { | |
| 1023 | prefix := joinPath(args) | |
| 1024 | prefixes := []string{prefix} | |
| 1025 | if aliased, ok := nounAliases[prefix]; ok { | |
| 1026 | prefixes = aliased | |
| 1027 | } | |
| 1028 | var matched []Command | |
| 1029 | for _, cmd := range registry { | |
| 1030 | p := joinPath(cmd.Path) | |
| 1031 | for _, pfx := range prefixes { | |
| 1032 | if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") { | |
| 1033 | matched = append(matched, cmd) | |
| 1034 | break | |
| 1035 | } | |
| 1036 | } | |
| 1037 | } | |
| 1038 | ``` | |
| 1039 | ||
| 1040 | The rest of `runHelp` is unchanged: `matched[0].Path` never equals | |
| 1041 | `"auth"` literally (nothing in the registry is named that), so the | |
| 1042 | alias always takes the `helpNoun` branch, which already handles a | |
| 1043 | command list whose paths do not share `prefix` as an actual prefix — | |
| 1044 | `strings.TrimPrefix` is a no-op on a path it does not match, so `keys | |
| 1045 | add`, `pgp add`, `token create` and `whoami` print by their real, full | |
| 1046 | paths under the `auth` heading. | |
| 1047 | ||
| 1048 | - [ ] **Step 4: Sync the CLI's own description** | |
| 1049 | ||
| 1050 | `cmd/gitbay/main.go`'s `authCmd()`: | |
| 1051 | ||
| 1052 | ```go | |
| 1053 | return group("auth", "whoami, SSH and PGP keys, email, API tokens", | |
| 1054 | ``` | |
| 1055 | ||
| 1056 | (`TestGroupsSayWhatTheServerSays` checks this against | |
| 1057 | `nounSummaries["auth"]`, added above.) | |
| 1058 | ||
| 1059 | - [ ] **Step 5: Run** | |
| 1060 | ||
| 1061 | Run: `go test ./internal/control -run TestHelpRendersAnAliasedNounWithTheRegistryLayout -count=1` | |
| 1062 | Expected: PASS. | |
| 1063 | ||
| 1064 | - [ ] **Step 6: Run both packages** | |
| 1065 | ||
| 1066 | Run: `go test ./internal/control ./cmd/gitbay -count=1` | |
| 1067 | Expected: PASS. | |
| 1068 | ||
| 1069 | - [ ] **Step 7: Commit** | |
| 1070 | ||
| 1071 | ```bash | |
| 1072 | git add internal/control/help.go internal/control/help_test.go cmd/gitbay/main.go | |
| 1073 | git commit -m "help: auth (and any future CLI-only grouping) renders with the registry layout" -m "Ref #267" | |
| 1074 | ``` | |
| 1075 | ||
| 1076 | **Open question** (cannot be resolved from the code, flagging rather | |
| 1077 | than guessing): #267's own text quotes the desired usage as `gitbay auth | |
| 1078 | keys remove <fingerprint>` — with `auth` in the printed command — but | |
| 1079 | the server has no notion of the CLI's `auth` grouping; it only knows the | |
| 1080 | registered path `keys remove`. This task and Task 2.1 make the server | |
| 1081 | print `gitbay keys remove <fingerprint>` (correct, runnable, but missing | |
| 1082 | the `auth` cobra sits it under). Inserting `auth` would mean either | |
| 1083 | teaching the registry about a purely cobra-side grouping, or having the | |
| 1084 | CLI rewrite the server's usage string client-side by pattern-matching | |
| 1085 | its own command tree — decide which, if the exact wording matters, before | |
| 1086 | merging this MR. | |
| 1087 | ||
| 1088 | ### Task 2.4: verb-phrase summaries | |
| 1089 | ||
| 1090 | Six commands' one-line summaries are bare nouns rather than a phrase | |
| 1091 | saying what the command does: `issue comment`/`mr comment` ("comment"), | |
| 1092 | `issue label`/`mr label` ("labels"), `issue assign` ("assignees"), `mr | |
| 1093 | review` ("review"). | |
| 1094 | ||
| 1095 | **Files:** | |
| 1096 | - Modify: `internal/control/issue.go:70`, `:93`, `:102` | |
| 1097 | - Modify: `internal/control/mr.go:146`, `:156`, `:176` | |
| 1098 | - Modify: `cmd/gitbay/summaries_gen.go` (regenerated, not hand-edited) | |
| 1099 | - Test: `cmd/gitbay/summaries_test.go` (existing `TestSummariesAreCurrent` enforces this) | |
| 1100 | ||
| 1101 | - [ ] **Step 1: Change the six `Summary` strings** | |
| 1102 | ||
| 1103 | `internal/control/issue.go:70`: `Summary: "add a comment",` | |
| 1104 | `internal/control/issue.go:93`: `Summary: "add or remove labels",` | |
| 1105 | `internal/control/issue.go:102`: `Summary: "add or remove assignees",` | |
| 1106 | `internal/control/mr.go:146`: `Summary: "add a comment",` | |
| 1107 | `internal/control/mr.go:156`: `Summary: "record a review verdict",` | |
| 1108 | `internal/control/mr.go:176`: `Summary: "add or remove labels",` | |
| 1109 | ||
| 1110 | - [ ] **Step 2: Regenerate `summaries_gen.go`** | |
| 1111 | ||
| 1112 | Run: `go test ./cmd/gitbay -run TestSummariesAreCurrent -update` | |
| 1113 | This rewrites `cmd/gitbay/summaries_gen.go`'s six affected map entries | |
| 1114 | (`"issue comment"`, `"issue label"`, `"issue assign"`, `"mr comment"`, | |
| 1115 | `"mr review"`, `"mr label"`) to the new strings; nothing else in the | |
| 1116 | generated file changes. | |
| 1117 | ||
| 1118 | - [ ] **Step 3: Run** | |
| 1119 | ||
| 1120 | Run: `go test ./internal/control ./cmd/gitbay -count=1` | |
| 1121 | Expected: PASS. | |
| 1122 | ||
| 1123 | - [ ] **Step 4: Commit and open the MR** | |
| 1124 | ||
| 1125 | ```bash | |
| 1126 | git add internal/control/issue.go internal/control/mr.go cmd/gitbay/summaries_gen.go | |
| 1127 | git commit -m "summaries: verb phrases instead of bare nouns" -m "Closes #267" | |
| 1128 | git push -u origin cli-ux-help | |
| 1129 | gitbay mr create --source cli-ux-help --target main --title "CLI help and usage print the form the caller typed" | |
| 1130 | ``` | |
| 1131 | ||
| 1132 | Wait for CI, merge with `--strategy ff`, delete the branch both places. | |
| 1133 | ||
| 1134 | --- | |
| 1135 | ||
| 1136 | # Part 3: unregistered key, issue create flags, mr show plurals, repo readme, mirror time (branch `cli-ux-fixes`, closes #268) | |
| 1137 | ||
| 1138 | ### Task 3.1: the unregistered-key message names the fingerprint and the real host | |
| 1139 | ||
| 1140 | `runAnonymous` in `internal/sshd/sshd.go:332` tells a connecting | |
| 1141 | stranger to register with a literal `<host>` placeholder and no | |
| 1142 | fingerprint, whether they are truly unknown or someone on a new laptop | |
| 1143 | whose existing account has a different key. Print the fingerprint and | |
| 1144 | the real host, and offer both the web and the ssh path. | |
| 1145 | ||
| 1146 | **Files:** | |
| 1147 | - Modify: `internal/sshd/sshd.go` (`runAnonymous`) | |
| 1148 | - Test: `internal/sshd/sshd_test.go` | |
| 1149 | ||
| 1150 | - [ ] **Step 1: Write the failing test** | |
| 1151 | ||
| 1152 | ```go | |
| 1153 | func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) { | |
| 1154 | st, cleanup := newTestStore(t) // reuse whatever helper sshd_test.go's other tests use to open a migrated store | |
| 1155 | defer cleanup() | |
| 1156 | srv := &Server{st: st, cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}} | |
| 1157 | pub, _, err := ed25519.GenerateKey(rand.Reader) | |
| 1158 | if err != nil { | |
| 1159 | t.Fatal(err) | |
| 1160 | } | |
| 1161 | sshPub, err := ssh.NewPublicKey(pub) | |
| 1162 | if err != nil { | |
| 1163 | t.Fatal(err) | |
| 1164 | } | |
| 1165 | var out bytes.Buffer | |
| 1166 | ch := &fakeChannel{stderr: &out} // sshd_test.go's existing fake ssh.Channel, if it has one | |
| 1167 | code := srv.runAnonymous(ch, base64.StdEncoding.EncodeToString(sshPub.Marshal()), "whoami") | |
| 1168 | if code != protocol.ExitDenied { | |
| 1169 | t.Fatalf("exit %d", code) | |
| 1170 | } | |
| 1171 | fp := ssh.FingerprintSHA256(sshPub) | |
| 1172 | for _, want := range []string{fp, "forge.test", "https://forge.test/settings#keys", "ssh git@forge.test register"} { | |
| 1173 | if !strings.Contains(out.String(), want) { | |
| 1174 | t.Errorf("message missing %q:\n%s", want, out.String()) | |
| 1175 | } | |
| 1176 | } | |
| 1177 | } | |
| 1178 | ``` | |
| 1179 | ||
| 1180 | `newTestStore`/`fakeChannel` are placeholders for whatever | |
| 1181 | `internal/sshd/sshd_test.go` already provides for its other | |
| 1182 | `runAnonymous`-adjacent tests — read the top of that file (`grep -n | |
| 1183 | "^func " internal/sshd/sshd_test.go`) and use its actual helpers rather | |
| 1184 | than the names guessed here. | |
| 1185 | ||
| 1186 | - [ ] **Step 2: Run and see it fail** | |
| 1187 | ||
| 1188 | Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1` | |
| 1189 | Expected: FAIL (message contains the literal string `<host>`, no fingerprint). | |
| 1190 | ||
| 1191 | - [ ] **Step 3: Implement** | |
| 1192 | ||
| 1193 | ```go | |
| 1194 | if len(argv) == 0 || argv[0] != "register" { | |
| 1195 | host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/") | |
| 1196 | fp := ssh.FingerprintSHA256(pub) | |
| 1197 | flag := map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode] | |
| 1198 | fmt.Fprintf(ch.Stderr(), | |
| 1199 | "this key (%s) is not registered on %s.\n"+ | |
| 1200 | "already have an account? add it at https://%s/settings#keys\n"+ | |
| 1201 | "new here? ssh git@%s register --username <name> %s\n", | |
| 1202 | fp, host, host, host, flag) | |
| 1203 | return protocol.ExitDenied | |
| 1204 | } | |
| 1205 | ``` | |
| 1206 | ||
| 1207 | - [ ] **Step 4: Run** | |
| 1208 | ||
| 1209 | Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1` | |
| 1210 | Expected: PASS. | |
| 1211 | ||
| 1212 | - [ ] **Step 5: Run the package** | |
| 1213 | ||
| 1214 | Run: `go test ./internal/sshd -count=1` | |
| 1215 | Expected: PASS. A failing e2e-adjacent unit test asserting the old `this | |
| 1216 | key is not registered here` text needs its expectation updated the same | |
| 1217 | way. | |
| 1218 | ||
| 1219 | - [ ] **Step 6: Commit** | |
| 1220 | ||
| 1221 | ```bash | |
| 1222 | git add internal/sshd/sshd.go internal/sshd/sshd_test.go | |
| 1223 | git commit -m "sshd: unregistered-key message names the fingerprint and the real host" -m "Ref #268" | |
| 1224 | ``` | |
| 1225 | ||
| 1226 | ### Task 3.2: `issue create` takes `--label`, `--milestone`, `--assignee` | |
| 1227 | ||
| 1228 | `issue create` only sets title, body and format; labels, milestone and | |
| 1229 | assignees each need a separate call afterward, unlike the web form. Add | |
| 1230 | the three flags (label repeatable) and document that `$EDITOR` already | |
| 1231 | opens when neither `--body` nor `--file` is given (`cmd/gitbay/ssh.go`'s | |
| 1232 | `withRepo`/`maybeEditor` machinery already does this via `issueCmd()`'s | |
| 1233 | `editor: "issue"` — this task only adds the missing flags and says so | |
| 1234 | in the registered help). | |
| 1235 | ||
| 1236 | **Files:** | |
| 1237 | - Modify: `internal/control/issue.go` (`init`'s `issue create` registration, `runIssueCreate`) | |
| 1238 | - Test: `internal/control/issue_test.go` | |
| 1239 | ||
| 1240 | **Interfaces:** | |
| 1241 | - Consumes: `parseFlags`/`flagSpec.Multi` (existing), `c.Store.SetIssueLabel`, `c.Store.MilestoneByTitle`, `c.Store.SetIssueMilestone`, `c.Store.UserByUsername`, `c.Store.SetIssueAssignee` (all existing store methods). | |
| 1242 | ||
| 1243 | - [ ] **Step 1: Write the failing test** | |
| 1244 | ||
| 1245 | ```go | |
| 1246 | func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) { | |
| 1247 | c := notifTestCtx(t, "alice") | |
| 1248 | repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public") | |
| 1249 | if err != nil { | |
| 1250 | t.Fatal(err) | |
| 1251 | } | |
| 1252 | repo, err := c.Store.RepoByID(repoID) | |
| 1253 | if err != nil { | |
| 1254 | t.Fatal(err) | |
| 1255 | } | |
| 1256 | if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil { | |
| 1257 | t.Fatal(err) | |
| 1258 | } | |
| 1259 | if _, err := c.Store.CreateMilestone(repo.ID, "m1", ""); err != nil { | |
| 1260 | t.Fatal(err) | |
| 1261 | } | |
| 1262 | if _, err := c.Store.CreateUser("bob", false); err != nil { | |
| 1263 | t.Fatal(err) | |
| 1264 | } | |
| 1265 | ||
| 1266 | if code := runIssueCreate(c, []string{repo.Path(), "--title", "t", | |
| 1267 | "--label", "bug", "--milestone", "m1", "--assignee", "bob"}); code != 0 { | |
| 1268 | t.Fatalf("exit %d: %s", code, c.Stderr.(*bytes.Buffer).String()) | |
| 1269 | } | |
| 1270 | issue, err := c.Store.IssueByNumber(repo.ID, 1) | |
| 1271 | if err != nil { | |
| 1272 | t.Fatal(err) | |
| 1273 | } | |
| 1274 | if len(issue.Labels) != 1 || issue.Labels[0] != "bug" { | |
| 1275 | t.Errorf("labels = %v", issue.Labels) | |
| 1276 | } | |
| 1277 | if issue.Milestone != "m1" { | |
| 1278 | t.Errorf("milestone = %q", issue.Milestone) | |
| 1279 | } | |
| 1280 | if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" { | |
| 1281 | t.Errorf("assignees = %v", issue.Assignees) | |
| 1282 | } | |
| 1283 | } | |
| 1284 | ``` | |
| 1285 | ||
| 1286 | (`c.Store.SetLabel`/`CreateMilestone` are placeholders for the real | |
| 1287 | label/milestone creation helpers — `grep -n "func (s \*Store) | |
| 1288 | SetLabel\|func (s \*Store) CreateMilestone" internal/store/*.go` for | |
| 1289 | their actual names and signatures and use those; `issue.Milestone` | |
| 1290 | similarly needs to match whatever field `store.Issue` actually carries | |
| 1291 | for its milestone title, e.g. via `grep -n "Milestone" internal/store/issues.go`.) | |
| 1292 | ||
| 1293 | - [ ] **Step 2: Run and see it fail** | |
| 1294 | ||
| 1295 | Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1` | |
| 1296 | Expected: FAIL, exit 2 (`--label` not accepted). | |
| 1297 | ||
| 1298 | - [ ] **Step 3: Register the new flags** | |
| 1299 | ||
| 1300 | ```go | |
| 1301 | register(Command{Path: []string{"issue", "create"}, | |
| 1302 | Summary: "open an issue", | |
| 1303 | Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...", | |
| 1304 | Flags: []Flag{ | |
| 1305 | {"--title", "<t>", "the issue's title", ""}, | |
| 1306 | {"--body", "<b>", "the issue's body", ""}, | |
| 1307 | {"--file", "-", "read the body from stdin", ""}, | |
| 1308 | {"--format", "md|org", "the body's markup", "md"}, | |
| 1309 | {"--label", "<l>", "label to add, may repeat", ""}, | |
| 1310 | {"--milestone", "<title>", "milestone to set", ""}, | |
| 1311 | {"--assignee", "<user>", "user to assign, may repeat", ""}, | |
| 1312 | }, | |
| 1313 | Examples: []string{ | |
| 1314 | `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`, | |
| 1315 | "issue create krz/gitbay --title notes --file - < notes.md", | |
| 1316 | "issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc", | |
| 1317 | }, | |
| 1318 | ReadsStdin: true, Run: runIssueCreate}) | |
| 1319 | ``` | |
| 1320 | ||
| 1321 | Note in a doc comment above `runIssueCreate`, since the flags list | |
| 1322 | above has no room for prose: `$EDITOR` opens for the body when the CLI | |
| 1323 | is asked for neither `--body` nor `--file` — that behavior is entirely | |
| 1324 | client-side (`cmd/gitbay/main.go`'s `issueCmd()` already sets | |
| 1325 | `editor: "issue"`), this registration only documents it: | |
| 1326 | ||
| 1327 | ```go | |
| 1328 | // runIssueCreate opens an issue. The CLI opens $EDITOR for the body | |
| 1329 | // when neither --body nor --file is given (cmd/gitbay's issueCmd, | |
| 1330 | // editor: "issue"); over stock ssh the body must be one of the two. | |
| 1331 | func runIssueCreate(c *Ctx, args []string) int { | |
| 1332 | f, err := parseFlags(args, flagSpec{ | |
| 1333 | Values: []string{"--format", "--title", "--body", "--file", "--milestone"}, | |
| 1334 | Multi: []string{"--label", "--assignee"}, | |
| 1335 | MaxPos: 1, | |
| 1336 | Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...", | |
| 1337 | }) | |
| 1338 | if err != nil { | |
| 1339 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 1340 | } | |
| 1341 | ``` | |
| 1342 | ||
| 1343 | - [ ] **Step 4: Set labels, milestone and assignees after creation** | |
| 1344 | ||
| 1345 | After the existing `n, err := c.Store.CreateIssue(...)` block and its | |
| 1346 | `RecordEvent`/notify calls, before the final `return c.emit(...)`: | |
| 1347 | ||
| 1348 | ```go | |
| 1349 | for _, l := range f.List("--label") { | |
| 1350 | if err := c.Store.SetIssueLabel(repo, n, l, true); err != nil { | |
| 1351 | return c.failErr(err) | |
| 1352 | } | |
| 1353 | } | |
| 1354 | if m := f.Value("--milestone"); m != "" { | |
| 1355 | ms, err := c.Store.MilestoneByTitle(repo, m) | |
| 1356 | if err != nil { | |
| 1357 | return milestoneErr(c, repo, m, err) | |
| 1358 | } | |
| 1359 | if err := c.Store.SetIssueMilestone(n, ms.ID); err != nil { | |
| 1360 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1361 | } | |
| 1362 | } | |
| 1363 | for _, name := range f.List("--assignee") { | |
| 1364 | u, err := c.Store.UserByUsername(name) | |
| 1365 | if errors.Is(err, store.ErrNotFound) { | |
| 1366 | return c.fail(protocol.ExitNotFound, "no such user %q", name) | |
| 1367 | } | |
| 1368 | if err != nil { | |
| 1369 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1370 | } | |
| 1371 | if err := c.Store.SetIssueAssignee(n, u.ID, true); err != nil { | |
| 1372 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1373 | } | |
| 1374 | } | |
| 1375 | ``` | |
| 1376 | ||
| 1377 | `SetIssueLabel`'s second parameter in `runIssueLabel` is `issue.ID`, not | |
| 1378 | the issue number — `CreateIssue` returns the number `n`, so fetch the | |
| 1379 | row first if `SetIssueLabel`/`SetIssueMilestone`/`SetIssueAssignee` all | |
| 1380 | key on the database id rather than the number (check each store | |
| 1381 | method's actual first parameter — `grep -n "func (s \*Store) | |
| 1382 | SetIssueLabel\|SetIssueMilestone\|SetIssueAssignee" internal/store/*.go` | |
| 1383 | and adjust to fetch `issue, err := c.Store.IssueByNumber(repo.ID, n)` | |
| 1384 | first if any of them needs `issue.ID` rather than `n`). Add | |
| 1385 | `"errors"` to the file's imports if not already present. | |
| 1386 | ||
| 1387 | - [ ] **Step 5: Run** | |
| 1388 | ||
| 1389 | Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1` | |
| 1390 | Expected: PASS. | |
| 1391 | ||
| 1392 | - [ ] **Step 6: Run the package, regenerate the CLI summary if `Usage` changed its flag list** | |
| 1393 | ||
| 1394 | Run: `go test ./internal/control -count=1` | |
| 1395 | Expected: PASS (the `Summary` string is unchanged, so | |
| 1396 | `summaries_gen.go` does not need regenerating — only `Usage`/`Flags` | |
| 1397 | changed, which is not part of that generated file). | |
| 1398 | ||
| 1399 | - [ ] **Step 7: Commit** | |
| 1400 | ||
| 1401 | ```bash | |
| 1402 | git add internal/control/issue.go internal/control/issue_test.go | |
| 1403 | git commit -m "issue create: --label, --milestone, --assignee" -m "Ref #268" | |
| 1404 | ``` | |
| 1405 | ||
| 1406 | ### Task 3.3: `mr show` pluralizes its multi-row section headings | |
| 1407 | ||
| 1408 | `mr show`'s commit/check/review sub-tables print a singular label | |
| 1409 | (`commit:`, `check:`) even when they hold several rows. | |
| 1410 | ||
| 1411 | **Files:** | |
| 1412 | - Modify: `internal/control/mr.go` (the three `v.section(...)` calls around lines 793, 802, 811) | |
| 1413 | - Test: `internal/control/mr_test.go` | |
| 1414 | ||
| 1415 | - [ ] **Step 1: Write the failing test** | |
| 1416 | ||
| 1417 | Find `mr show`'s existing plain-output test (`grep -n "func Test.*MRShow" | |
| 1418 | internal/control/mr_test.go`) and add a case with more than one commit, | |
| 1419 | check and review, asserting the plural, counted heading: | |
| 1420 | ||
| 1421 | ```go | |
| 1422 | func TestMRShowPluralizesMultiRowSections(t *testing.T) { | |
| 1423 | // build on whatever fixture the existing MR-show tests in this file | |
| 1424 | // use to get a repo with an open MR; push two commits onto its | |
| 1425 | // source branch, set two statuses, and record two reviews before | |
| 1426 | // calling runMRShow, following that fixture's own setup exactly. | |
| 1427 | ... | |
| 1428 | out := ... // runMRShow's plain stdout | |
| 1429 | for _, want := range []string{"commits (2):", "checks (2):", "reviews (2):"} { | |
| 1430 | if !strings.Contains(out, want) { | |
| 1431 | t.Errorf("missing %q in:\n%s", want, out) | |
| 1432 | } | |
| 1433 | } | |
| 1434 | } | |
| 1435 | ``` | |
| 1436 | ||
| 1437 | - [ ] **Step 2: Run and see it fail** | |
| 1438 | ||
| 1439 | Run: `go test ./internal/control -run TestMRShowPluralizesMultiRowSections -count=1` | |
| 1440 | Expected: FAIL (headings read `commit:`, `check:`, `review:`). | |
| 1441 | ||
| 1442 | - [ ] **Step 3: Implement** | |
| 1443 | ||
| 1444 | ```go | |
| 1445 | if len(commits) > 1 { | |
| 1446 | v.section(fmt.Sprintf("commits (%d)", len(commits))) | |
| 1447 | tb := c.table(w, "SHA", "SUBJECT") | |
| 1448 | ... | |
| 1449 | } | |
| 1450 | ||
| 1451 | if len(checks) > 1 { | |
| 1452 | v.section(fmt.Sprintf("checks (%d)", len(checks))) | |
| 1453 | tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED") | |
| 1454 | ... | |
| 1455 | } | |
| 1456 | ||
| 1457 | if len(rs) > 1 { | |
| 1458 | v.section(fmt.Sprintf("reviews (%d)", len(rs))) | |
| 1459 | tb := c.table(w, "REVIEWER", "VERDICT", "WHEN") | |
| 1460 | ... | |
| 1461 | } | |
| 1462 | ``` | |
| 1463 | ||
| 1464 | (`v.section` prints `label + ":"` in plain mode already — do not add a | |
| 1465 | trailing colon inside the `fmt.Sprintf` string.) | |
| 1466 | ||
| 1467 | - [ ] **Step 4: Run** | |
| 1468 | ||
| 1469 | Run: `go test ./internal/control -run TestMRShow -count=1` | |
| 1470 | Expected: PASS. | |
| 1471 | ||
| 1472 | - [ ] **Step 5: Run the package** | |
| 1473 | ||
| 1474 | Run: `go test ./internal/control -count=1` | |
| 1475 | Expected: PASS. | |
| 1476 | ||
| 1477 | - [ ] **Step 6: Commit** | |
| 1478 | ||
| 1479 | ```bash | |
| 1480 | git add internal/control/mr.go internal/control/mr_test.go | |
| 1481 | git commit -m "mr show: pluralize commits/checks/reviews section headings" -m "Ref #268" | |
| 1482 | ``` | |
| 1483 | ||
| 1484 | ### Task 3.4: `repo readme` prints a repository's README | |
| 1485 | ||
| 1486 | No command prints a repository's README; the web page's own | |
| 1487 | README-picking logic (`pickReadme` in `internal/httpd/web.go`) is not | |
| 1488 | reachable from `internal/control`. Move it into `internal/control`, | |
| 1489 | exported, and add `repo readme <owner/name> [--ref <ref>]` following | |
| 1490 | `repo cat`'s shape. | |
| 1491 | ||
| 1492 | **Files:** | |
| 1493 | - Modify: `internal/control/read.go` (new `repo readme` registration and `runRepoReadme`, model on `runRepoCat`/`runRepoTree`) | |
| 1494 | - Modify: `internal/httpd/web.go` (move `readmeRank`/`pickReadme` out, call site at line 660 updated) | |
| 1495 | - Modify: `cmd/gitbay/main.go` (`repoCmd`, new `pass("readme", ...)`) | |
| 1496 | - Modify: `e2e/readonly_test.go` (`readArgs["repo readme"]`) | |
| 1497 | - Modify: `.gitbay/wiki/Parity.org` (Repositories table) | |
| 1498 | - Test: `internal/control/read_test.go` | |
| 1499 | ||
| 1500 | **Interfaces:** | |
| 1501 | - Produces: `func PickReadme(entries []gitutil.TreeEntry) string` (moved from `internal/httpd`, exported). | |
| 1502 | ||
| 1503 | - [ ] **Step 1: Move `readmeRank`/`pickReadme`** | |
| 1504 | ||
| 1505 | Cut both from `internal/httpd/web.go` (around lines 1185–1210) and paste | |
| 1506 | into `internal/control/read.go`, renaming `pickReadme` to `PickReadme`: | |
| 1507 | ||
| 1508 | ```go | |
| 1509 | // readmeRank orders competing README files: richer renderers win. | |
| 1510 | var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3} | |
| 1511 | ||
| 1512 | // PickReadme returns the best README-ish blob in a tree listing: any | |
| 1513 | // file named "readme" or "readme.<ext>" (case-insensitive), preferring | |
| 1514 | // formats we can render richly. | |
| 1515 | func PickReadme(entries []gitutil.TreeEntry) string { | |
| 1516 | best, bestRank := "", 1<<30 | |
| 1517 | for _, e := range entries { | |
| 1518 | if e.Type != "blob" { | |
| 1519 | continue | |
| 1520 | } | |
| 1521 | lower := strings.ToLower(e.Name) | |
| 1522 | if lower != "readme" && !strings.HasPrefix(lower, "readme.") { | |
| 1523 | continue | |
| 1524 | } | |
| 1525 | rank, ok := readmeRank[path.Ext(lower)] | |
| 1526 | if !ok { | |
| 1527 | rank = 10 // plaintext fallback | |
| 1528 | } | |
| 1529 | if rank < bestRank { | |
| 1530 | best, bestRank = e.Name, rank | |
| 1531 | } | |
| 1532 | } | |
| 1533 | return best | |
| 1534 | } | |
| 1535 | ``` | |
| 1536 | ||
| 1537 | In `internal/httpd/web.go`, the call site at line 660 becomes | |
| 1538 | `readmeName := control.PickReadme(entries)`. Remove the unused `"path"` | |
| 1539 | import from `web.go` only if nothing else in the file still uses it | |
| 1540 | (`grep -n '"path"' internal/httpd/web.go` and `grep -n "path\." | |
| 1541 | internal/httpd/web.go` — this file is large and almost certainly uses | |
| 1542 | `path` elsewhere, so this removal is likely a no-op check, not an edit). | |
| 1543 | ||
| 1544 | - [ ] **Step 2: Build to confirm the move alone is clean** | |
| 1545 | ||
| 1546 | Run: `go build ./... && go vet ./...` | |
| 1547 | Expected: no errors. | |
| 1548 | ||
| 1549 | - [ ] **Step 3: Write the failing test for the new command** | |
| 1550 | ||
| 1551 | ```go | |
| 1552 | func TestRepoReadmePicksTheRichestFormat(t *testing.T) { | |
| 1553 | st, repo, uid := newQueueTestRepo(t) | |
| 1554 | dir := RepoDir(config.Config{}.Server.Root, repo.OwnerName, repo.Name) // adjust to however read_test.go's existing repo-cat tests get a working tree with committed files — reuse that helper rather than re-deriving RepoDir's root | |
| 1555 | git := gitRunner(t) | |
| 1556 | git(dir, "init", "--bare") // only if newQueueTestRepo does not already leave a real git repo on disk; check runRepoCat's own test setup and mirror it exactly | |
| 1557 | ... | |
| 1558 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | |
| 1559 | if code := Dispatch(c, []string{"repo", "readme", repo.Path()}); code != protocol.ExitOK { | |
| 1560 | t.Fatalf("exit %d: %s", code, errOut) | |
| 1561 | } | |
| 1562 | if got := c.Stdout.(*bytes.Buffer).String(); got != "# app\n\nhello\n" { | |
| 1563 | t.Errorf("readme = %q", got) | |
| 1564 | } | |
| 1565 | } | |
| 1566 | ``` | |
| 1567 | ||
| 1568 | `runRepoCat`'s own test in `internal/control/read_test.go` already sets | |
| 1569 | up a real on-disk repository with a committed file — copy that setup | |
| 1570 | exactly (bare repo, a work tree pushed into it, matching | |
| 1571 | `gitTestEnv()`/`gitRunner(t)` from `build_test.go`) rather than | |
| 1572 | reinventing it; commit a `README.md` instead of whatever file that test | |
| 1573 | uses. | |
| 1574 | ||
| 1575 | - [ ] **Step 4: Run and see it fail** | |
| 1576 | ||
| 1577 | Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1` | |
| 1578 | Expected: FAIL (`unknown command "readme"`). | |
| 1579 | ||
| 1580 | - [ ] **Step 5: Register the command and implement it** | |
| 1581 | ||
| 1582 | In `internal/control/read.go`'s `init()`, after the `repo cat` | |
| 1583 | registration: | |
| 1584 | ||
| 1585 | ```go | |
| 1586 | register(Command{ | |
| 1587 | Path: []string{"repo", "readme"}, | |
| 1588 | Summary: "print a repository's README", | |
| 1589 | Usage: "repo readme <owner/name> [--ref <ref>]", | |
| 1590 | Flags: []Flag{ | |
| 1591 | {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"}, | |
| 1592 | }, | |
| 1593 | Examples: []string{"repo readme krz/gitbay"}, | |
| 1594 | ReadOnly: true, | |
| 1595 | Run: runRepoReadme, | |
| 1596 | }) | |
| 1597 | ``` | |
| 1598 | ||
| 1599 | ```go | |
| 1600 | func runRepoReadme(c *Ctx, args []string) int { | |
| 1601 | pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1) | |
| 1602 | if code >= 0 { | |
| 1603 | return code | |
| 1604 | } | |
| 1605 | if len(pos) != 1 { | |
| 1606 | return c.usage() | |
| 1607 | } | |
| 1608 | repo, code := resolveRepo(c, pos[0], policy.CanRead) | |
| 1609 | if code >= 0 { | |
| 1610 | return code | |
| 1611 | } | |
| 1612 | if ref == "" { | |
| 1613 | ref = repo.DefaultBranch | |
| 1614 | } | |
| 1615 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 1616 | if _, err := gitutil.ResolveRef(dir, ref); err != nil { | |
| 1617 | return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path()) | |
| 1618 | } | |
| 1619 | entries, err := gitutil.ListTree(dir, ref, "") | |
| 1620 | if err != nil { | |
| 1621 | return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref) | |
| 1622 | } | |
| 1623 | name := PickReadme(entries) | |
| 1624 | if name == "" { | |
| 1625 | return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref) | |
| 1626 | } | |
| 1627 | limit := c.Cfg.Limits.MaxBlobBytes | |
| 1628 | data, err := gitutil.ReadBlob(dir, ref, name, limit+1) | |
| 1629 | if err != nil { | |
| 1630 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 1631 | } | |
| 1632 | truncated := int64(len(data)) > limit | |
| 1633 | if truncated { | |
| 1634 | data = data[:limit] | |
| 1635 | } | |
| 1636 | binary := gitutil.IsBinary(data) | |
| 1637 | type out struct { | |
| 1638 | Path string `json:"path"` | |
| 1639 | Ref string `json:"ref"` | |
| 1640 | File string `json:"file"` | |
| 1641 | Size int `json:"size"` | |
| 1642 | Truncated bool `json:"truncated,omitempty"` | |
| 1643 | Binary bool `json:"binary,omitempty"` | |
| 1644 | Content string `json:"content,omitempty"` | |
| 1645 | Base64 string `json:"base64,omitempty"` | |
| 1646 | } | |
| 1647 | d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data), Truncated: truncated, Binary: binary} | |
| 1648 | if binary { | |
| 1649 | d.Base64 = base64.StdEncoding.EncodeToString(data) | |
| 1650 | } else { | |
| 1651 | d.Content = string(data) | |
| 1652 | } | |
| 1653 | return c.emit(d, func(w io.Writer) { | |
| 1654 | if binary { | |
| 1655 | fmt.Fprintf(w, "%s is binary (%d bytes)\n", d.File, d.Size) | |
| 1656 | return | |
| 1657 | } | |
| 1658 | io.WriteString(w, d.Content) | |
| 1659 | if truncated { | |
| 1660 | fmt.Fprintln(w, "... truncated") | |
| 1661 | } | |
| 1662 | }) | |
| 1663 | } | |
| 1664 | ``` | |
| 1665 | ||
| 1666 | (Match `runRepoCat`'s actual truncation/binary field names and JSON tags | |
| 1667 | exactly — read the rest of its `out` struct at | |
| 1668 | `internal/control/read.go:355` onward and copy its shape rather than | |
| 1669 | inventing a divergent one, so a client handles both commands the same | |
| 1670 | way.) | |
| 1671 | ||
| 1672 | - [ ] **Step 6: Run** | |
| 1673 | ||
| 1674 | Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1` | |
| 1675 | Expected: PASS. | |
| 1676 | ||
| 1677 | - [ ] **Step 7: Wire the CLI passthrough** | |
| 1678 | ||
| 1679 | `cmd/gitbay/main.go`'s `repoCmd()`, next to `pass("cat", ...)`: | |
| 1680 | ||
| 1681 | ```go | |
| 1682 | pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}), | |
| 1683 | ``` | |
| 1684 | ||
| 1685 | - [ ] **Step 8: Add it to the ReadOnly coverage list** | |
| 1686 | ||
| 1687 | `e2e/readonly_test.go`'s `readArgs` map gains, next to `"repo refs"`: | |
| 1688 | ||
| 1689 | ```go | |
| 1690 | "repo readme": {"alice/app"}, | |
| 1691 | ``` | |
| 1692 | ||
| 1693 | (the fixture's `alice/app` already has a committed `README.md`, so this | |
| 1694 | does not need a `notFoundOK` entry.) | |
| 1695 | ||
| 1696 | - [ ] **Step 9: Update Parity** | |
| 1697 | ||
| 1698 | `.gitbay/wiki/Parity.org`'s Repositories table gains a row, next to | |
| 1699 | `| read a file | yes | yes | yes |`: | |
| 1700 | ||
| 1701 | ``` | |
| 1702 | | render a README | yes | yes | yes | | |
| 1703 | ``` | |
| 1704 | ||
| 1705 | - [ ] **Step 10: Run the full local suite for touched packages** | |
| 1706 | ||
| 1707 | Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd ./cmd/gitbay -count=1` | |
| 1708 | Expected: PASS. | |
| 1709 | ||
| 1710 | - [ ] **Step 11: Commit** | |
| 1711 | ||
| 1712 | ```bash | |
| 1713 | git add internal/control/read.go internal/control/read_test.go internal/httpd/web.go cmd/gitbay/main.go e2e/readonly_test.go .gitbay/wiki/Parity.org | |
| 1714 | git commit -m "repo readme: print a repository's README, the web page's file order" -m "Ref #268" | |
| 1715 | ``` | |
| 1716 | ||
| 1717 | ### Task 3.5: `repo show`'s mirror time drops the milliseconds | |
| 1718 | ||
| 1719 | `repo show`'s mirror sub-table prints `LAST SYNC` with milliseconds | |
| 1720 | (`2026-09-24T15:31:50.839Z`) instead of the second-truncated form every | |
| 1721 | other timestamp in a `view` uses. | |
| 1722 | ||
| 1723 | **Files:** | |
| 1724 | - Modify: `internal/control/repo.go` (`runRepoShow`'s mirror table row, around line 474) | |
| 1725 | - Test: `internal/control/repo_test.go` | |
| 1726 | ||
| 1727 | - [ ] **Step 1: Write the failing test** | |
| 1728 | ||
| 1729 | Find `repo show`'s existing mirror-table test (`grep -n | |
| 1730 | "func Test.*Mirror" internal/control/repo_test.go`), or add one if none | |
| 1731 | exists: | |
| 1732 | ||
| 1733 | ```go | |
| 1734 | func TestRepoShowMirrorTimeIsTruncatedToTheSecond(t *testing.T) { | |
| 1735 | c, repo, _ := newQueueTestRepo(t) // adjust to whatever gives an admin Ctx over a repo with a mirror row in repo_test.go's existing fixtures | |
| 1736 | if err := c.Store.CreateMirror(repo.ID, "push", "ssh://example.test/x.git", ""); err != nil { | |
| 1737 | t.Fatal(err) | |
| 1738 | } | |
| 1739 | if err := c.Store.MarkMirrorSynced(repo.ID, "ssh://example.test/x.git", "2026-09-24T15:31:50.839Z"); err != nil { | |
| 1740 | t.Fatal(err) | |
| 1741 | } | |
| 1742 | var out bytes.Buffer | |
| 1743 | c.Stdout, c.User.IsAdmin = &out, true // repo show's mirror section is admin-only in this Ctx | |
| 1744 | if code := runRepoShow(c, []string{repo.Path()}); code != 0 { | |
| 1745 | t.Fatalf("exit %d", code) | |
| 1746 | } | |
| 1747 | if strings.Contains(out.String(), ".839Z") { | |
| 1748 | t.Errorf("milliseconds leaked: %s", out.String()) | |
| 1749 | } | |
| 1750 | if !strings.Contains(out.String(), "2026-09-24T15:31:50Z") { | |
| 1751 | t.Errorf("no truncated timestamp: %s", out.String()) | |
| 1752 | } | |
| 1753 | } | |
| 1754 | ``` | |
| 1755 | ||
| 1756 | (`CreateMirror`/`MarkMirrorSynced` are placeholders — `grep -n "func (s | |
| 1757 | \*Store) .*Mirror" internal/store/*.go` for the real names/signatures | |
| 1758 | that get a `ListMirrors` row with a non-empty `LastSync`, and use those; | |
| 1759 | `runRepoShow`'s mirror section additionally requires | |
| 1760 | `policy.CanAdmin(c.User, repo, grant)` to hold for the caller, so the | |
| 1761 | test's `Ctx` needs to be the repo's owner or otherwise admin over it — | |
| 1762 | `newQueueTestRepo`'s `uid` already owns the repo it returns, which | |
| 1763 | satisfies that.) | |
| 1764 | ||
| 1765 | - [ ] **Step 2: Run and see it fail** | |
| 1766 | ||
| 1767 | Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1` | |
| 1768 | Expected: FAIL (`.839Z` present). | |
| 1769 | ||
| 1770 | - [ ] **Step 3: Implement** | |
| 1771 | ||
| 1772 | ```go | |
| 1773 | tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status)) | |
| 1774 | ``` | |
| 1775 | ||
| 1776 | (`c.when` is already what every other timestamp in a `view` goes | |
| 1777 | through: RFC3339-to-the-second in plain output, `2006-01-02 15:04 UTC` | |
| 1778 | at a terminal; `orDash` keeps an empty `LastSync` — a mirror that has | |
| 1779 | never synced — printing `-` rather than an empty cell, since `c.when("")` | |
| 1780 | returns `""` unchanged.) | |
| 1781 | ||
| 1782 | - [ ] **Step 4: Run** | |
| 1783 | ||
| 1784 | Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1` | |
| 1785 | Expected: PASS. | |
| 1786 | ||
| 1787 | - [ ] **Step 5: Run the package** | |
| 1788 | ||
| 1789 | Run: `go test ./internal/control -count=1` | |
| 1790 | Expected: PASS. | |
| 1791 | ||
| 1792 | - [ ] **Step 6: Commit, open the MR** | |
| 1793 | ||
| 1794 | ```bash | |
| 1795 | git add internal/control/repo.go internal/control/repo_test.go | |
| 1796 | git commit -m "repo show: truncate the mirror's last-sync time to the second" -m "Closes #268" | |
| 1797 | git push -u origin cli-ux-fixes | |
| 1798 | gitbay mr create --source cli-ux-fixes --target main --title "CLI UX review small fixes" | |
| 1799 | ``` | |
| 1800 | ||
| 1801 | Wait for CI, merge with `--strategy ff`, delete the branch both places. | |
| 1802 | ||
| 1803 | --- | |
| 1804 | ||
| 1805 | ## Self-review | |
| 1806 | ||
| 1807 | **Spec coverage** (against #265/#267/#268's text, this plan's spec): | |
| 1808 | ||
| 1809 | - #265: sentences for activity (Task 1.1–1.3), no duplicate assigned | |
| 1810 | issues (Task 1.4), one empty-state wording for `notifications list` | |
| 1811 | (Task 1.5). The issue's other empty-state line ("Empty sections print | |
| 1812 | none; empty lists elsewhere print nothing to list on stderr") already | |
| 1813 | matches current behavior (`internal/control/dashboard.go`'s `section` | |
| 1814 | helper, `internal/control/control.go`'s `emit`) — no task needed. | |
| 1815 | - #267: CLI sends `--term`/`Ctx.Term` (already present; verified, not | |
| 1816 | re-implemented) and the server now uses it in `usage()`/`usageWith()` | |
| 1817 | too (Task 2.1); `[<owner/name>]` optional from the CLI (Task 2.1); | |
| 1818 | `--help` check in `keys add`/`pgp add` (Task 2.2); `auth` rendered | |
| 1819 | with the registry layout (Task 2.3); verb-phrase summaries (Task 2.4). | |
| 1820 | - #268: unregistered-key message (Task 3.1); `issue create` flags | |
| 1821 | (Task 3.2); `mr show` plurals (Task 3.3); `repo readme` (Task 3.4); | |
| 1822 | `repo show` mirror time (Task 3.5). | |
| 1823 | ||
| 1824 | **Placeholder scan:** Tasks 3.3 and 3.4's tests name real assertions but | |
| 1825 | lean on "copy this file's existing fixture setup" rather than spelling | |
| 1826 | out git plumbing calls verbatim, and Task 3.1's test invents | |
| 1827 | `newTestStore`/`fakeChannel` names to be replaced by whatever | |
| 1828 | `internal/sshd/sshd_test.go` actually has. That is intentional, not a | |
| 1829 | placeholder in the sense the skill warns against: the actual assertions | |
| 1830 | (what strings must appear, what exit code, what store rows) are | |
| 1831 | concrete; only the test-scaffolding names are marked as needing a look | |
| 1832 | at each file's neighbors before typing them in, because this plan was | |
| 1833 | written from reading the production code, not the test helper's exact | |
| 1834 | current shape in every file it touches. Anyone executing this plan | |
| 1835 | reads the named test file's other tests first, per each step's own | |
| 1836 | instruction, before writing the step. | |
| 1837 | ||
| 1838 | **Type consistency:** `FeedLine`/`FeedLines`/`WorstStatus` (Task 1.1) | |
| 1839 | are used with the same names in Tasks 1.2 and 1.3. `cmdUsage`/`cliUsage` | |
| 1840 | (Task 2.1) are used with the same names in Task 2.3's commentary and | |
| 1841 | nowhere else redefines them. `PickReadme` (Task 3.4) is the only name | |
| 1842 | introduced for that logic and is used consistently in its own task. | |
| 1843 | ||
| 1844 | **Open question**, restated from Task 2.3: whether `gitbay auth keys | |
| 1845 | remove <fingerprint>`'s usage line should literally say `auth` (the | |
| 1846 | CLI's own grouping, invisible to the server) or `gitbay keys remove | |
| 1847 | <fingerprint>` (what the server can actually know and this plan | |
| 1848 | implements) needs a decision from whoever merges Part 2, since #267's | |
| 1849 | own text quotes the former. | |
docs/plans/2026-09-27-credentials-and-sessions.md added +3583
| @@ -0,0 +1,3583 @@ | ||
| 1 | # Credentials and sessions implementation plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Revocation of an SSH key takes effect on open connections | |
| 6 | (#256); an expiring credential cannot mint one that outlives it, and | |
| 7 | credentials record the token that made them (#257); SSH and deploy | |
| 8 | keys take an optional expiry and show their last use (#277); browser | |
| 9 | sessions end after 12 hours idle (#276); `web login` over SSH spends | |
| 10 | the login-link budget (#278). | |
| 11 | ||
| 12 | **Architecture:** The store announces revocations it commits | |
| 13 | (`Store.OnRevoke`); the SSH listener tracks which key opened each | |
| 14 | connection and cuts the ones a revocation names, killing a git | |
| 15 | transport's process group. A 15-second sweep catches revocations made | |
| 16 | by another process and keys that expire while connected. Every exec | |
| 17 | re-reads its key. `Command.MintsCredential` marks the commands that | |
| 18 | create credentials; `Dispatch` refuses them when `Ctx.Expires` is set. | |
| 19 | `api_tokens` and `ssh_keys` gain `created_by_token`; `ssh_keys` gains | |
| 20 | `expires_at`; `web_sessions` gains a sliding expiry under an absolute | |
| 21 | cap. | |
| 22 | ||
| 23 | **Tech stack:** Go, `golang.org/x/crypto/ssh`, SQLite (modernc), OpenSSH | |
| 24 | client for e2e. | |
| 25 | ||
| 26 | **Spec:** issues #256, #257, #276, #277, #278 on krz/gitbay (the | |
| 27 | decisions on #256 and #257 are recorded there), and the brief | |
| 28 | `/private/tmp/claude-501/-Users-cmc-git-krz-gitbay/7b2f1ea4-aab6-44e2-b2ad-d4ec6852ce42/scratchpad/brief.md`. | |
| 29 | ||
| 30 | ## Global constraints | |
| 31 | ||
| 32 | - Each MR on its own branch off `main`. Commits are signed (the repo | |
| 33 | refuses unsigned), messages reference issues (`Ref #N`, and | |
| 34 | `Closes #N` on the commit that finishes one). No attribution to any | |
| 35 | assistant, model or AI anywhere: commits, MR bodies, comments. | |
| 36 | - MR: `gitbay mr create --source <branch> --target main --title "..."`; | |
| 37 | merge with `gitbay mr merge <n> --strategy ff` once CI is green, then | |
| 38 | delete the branch locally and on the remote. Behind main → rebase, | |
| 39 | force-push, merge again. | |
| 40 | - Locally: `go build ./...`, `go vet ./...`, unit tests of touched | |
| 41 | packages, and at most the one e2e test being written | |
| 42 | (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite. | |
| 43 | - Registries that fail CI when a new thing lacks its row: top-level route | |
| 44 | word in `internal/policy/names.go`; new page template in the width map | |
| 45 | of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly` | |
| 46 | command in `readArgs` in `e2e/readonly_test.go`; new control command | |
| 47 | needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test); | |
| 48 | a command reading stdin needs `ReadsStdin: true`. This plan adds no | |
| 49 | route, template, command or read command; it changes flags and a | |
| 50 | `Summary` of none, so `cmd/gitbay/summaries_gen.go` stays current. | |
| 51 | - Migrations: this plan owns 0060–0064 and uses 0060, 0061, 0062. Six | |
| 52 | plans are written in parallel with pre-assigned ranges; whoever lands | |
| 53 | second renumbers to the next free number at execution time. | |
| 54 | Migrations come in `.up.sql`/`.down.sql` pairs. Hand-written SQL, no | |
| 55 | ORM. `TestMigrateUpDown` (`internal/store/store_test.go`) exercises | |
| 56 | every down script. | |
| 57 | - Secrets travel on stdin, never argv; never logged or echoed. | |
| 58 | - Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model, | |
| 59 | CI, Users, Performance, and the `Architecture/` folder with its | |
| 60 | Known-Gaps table and controls matrix). Update the page in the same MR | |
| 61 | that changes the behaviour it describes, and remove the matching | |
| 62 | row from `Architecture/10-Known-Gaps.org`. | |
| 63 | - Release notes go in `CHANGELOG.org` under the topmost heading that | |
| 64 | has no tag yet. If the top heading is a released version, add | |
| 65 | `* Unreleased` above it; whoever tags renames it. | |
| 66 | - Writing style: plain, direct, no hype; code comments match the | |
| 67 | surrounding density. Comments and docs state facts, never | |
| 68 | before/after narration. | |
| 69 | - Work in a worktree of `krz/gitbay`; the main checkout may hold | |
| 70 | another session's edits. | |
| 71 | ||
| 72 | ## Order and dependencies | |
| 73 | ||
| 74 | | # | Branch | Closes | Migration | Depends on | | |
| 75 | |---|---|---|---|---| | |
| 76 | | 1 | `revoke-closes-connections` | #256 | none | — | | |
| 77 | | 2 | `token-delegation` | #257 | 0060 | MR 1 (`Store.announce`, `fingerprint` e2e helper, `Exec` taking a key) | | |
| 78 | | 3 | `key-expiry` | #277 | 0061 | MR 1 (sweep, per-exec check), MR 2 (`KeyOrigin`, `Ctx.Expires`) | | |
| 79 | | 4 | `session-idle` | #276 | 0062 | — | | |
| 80 | | 5 | `weblogin-limit` | #278 | none | — | | |
| 81 | ||
| 82 | \#256 goes first. #257 and #277 both add columns to `ssh_keys`; both | |
| 83 | are nullable `ADD COLUMN`s with no table rebuild, so they compose in | |
| 84 | either order, and `KeyOrigin` (MR 2) is the one insert path both use. | |
| 85 | ||
| 86 | Other plans: | |
| 87 | ||
| 88 | - Plan 5 (web-ux) #264 depends on MR 2's `--scope read` default. | |
| 89 | - Plan 3 (server-hardening) touches the same code: #262 limits | |
| 90 | concurrent git processes in `gitutil.Transport` / `sshd.runGit` | |
| 91 | (MR 1 changes both signatures), #275 audits refused commands in | |
| 92 | `control.Dispatch` (MR 2 adds a refusal there, which #275 should | |
| 93 | audit like the others), #282 changes `hookd`. Whoever lands second | |
| 94 | rebases; the conflicts are mechanical. | |
| 95 | ||
| 96 | ## File map | |
| 97 | ||
| 98 | | File | MR | Responsibility | | |
| 99 | |---|---|---| | |
| 100 | | `internal/store/revoke.go` (create) | 1 | `Revoked`, `OnRevoke`, `announce`, `LiveSSHKeys` | | |
| 101 | | `internal/store/store.go` | 1 | subscriber fields on `Store` | | |
| 102 | | `internal/store/users.go` | 1, 2, 3 | removals announce; `KeyOrigin`, `AddSSHKeyFrom`; `expires_at` | | |
| 103 | | `internal/sshd/sshd.go` | 1, 3 | connection tracking, `cut`, sweep, per-exec check, `Exec(key)`; expired keys refused | | |
| 104 | | `internal/gitutil/gitutil.go`, `proc_unix.go`, `proc_other.go` | 1 | `Transport` takes a cancel channel, kills the process group | | |
| 105 | | `cmd/gitbayd/system.go` | 1, 3 | `Exec` call; expired keys in system mode | | |
| 106 | | `internal/control/control.go` | 2 | `MintsCredential`, `Ctx.TokenID`, `Ctx.Expires`, the refusal | | |
| 107 | | `internal/store/tokens.go` | 2 | token id, creator, chained revoke | | |
| 108 | | `internal/control/token.go` | 2, 3 | default read, creator, `revoke --created`; `ttlFlag` | | |
| 109 | | `internal/control/identity.go`, `deploykey.go`, `runnerrepo.go`, `adminhost.go`, `register.go`, `web.go` | 2, 3, 4, 5 | flags, creator, `--ttl`, list columns, login limit | | |
| 110 | | `internal/httpd/api.go` | 2 | token into `Ctx` | | |
| 111 | | `internal/store/sessions.go` | 4 | sliding session expiry | | |
| 112 | | `internal/control/loginlink.go` | 5 | comment | | |
| 113 | | `e2e/revoke_test.go` (create) | 1 | multiplexed connection cut | | |
| 114 | | `e2e/tokenorigin_test.go` (create) | 2 | expiring token refused, `revoke --created` | | |
| 115 | | `.gitbay/wiki/*`, `CHANGELOG.org` | all | docs in the MR that changes behaviour | | |
| 116 | ||
| 117 | --- | |
| 118 | ||
| 119 | # MR 1: removing a key closes its connections (branch `revoke-closes-connections`, #256) | |
| 120 | ||
| 121 | Decision on the issue: revocation is immediate, running commands | |
| 122 | included. What that means here, stated in the Threat-Model page: | |
| 123 | ||
| 124 | - Every exec and every git transport session re-reads its key: gone, | |
| 125 | moved to another account, or re-scoped takes effect on the next | |
| 126 | command. | |
| 127 | - `keys remove`, `repo deploy-key remove`, `admin user disable`, | |
| 128 | `admin user delete` and (MR 2) `token revoke --created` close every | |
| 129 | connection opened by an affected key. A git transport on it is | |
| 130 | killed with its process group. A control command in flight loses its | |
| 131 | channel; one that watches `Done` (`build log --follow`) stops, one | |
| 132 | already inside its store write finishes that write and its output is | |
| 133 | lost. | |
| 134 | - A push cut before its pre-receive hook answers updates no refs. The | |
| 135 | ref transaction that follows the hook is not interrupted mid-write in | |
| 136 | practice; it is milliseconds long. | |
| 137 | - Revocations committed by another process (`gitbayd admin` on the | |
| 138 | host) are found by a 15-second sweep. | |
| 139 | - System mode (`ssh.mode = "system"`) runs one process per exec, so | |
| 140 | the per-exec check applies; a forced-command process already running | |
| 141 | is not cut (open question 4). | |
| 142 | ||
| 143 | ### Task 1.1: the store announces revocations and answers which keys are live | |
| 144 | ||
| 145 | **Files:** | |
| 146 | - Create: `internal/store/revoke.go` | |
| 147 | - Modify: `internal/store/store.go:23-30` (`Store` struct) | |
| 148 | - Modify: `internal/store/users.go` — `DeleteUser` (58-101), `SetUserDisabled` (171-194), `RemoveSSHKey` (291-309), `RemoveDeployKey` (534-554) | |
| 149 | - Test: `internal/store/revoke_test.go` (create) | |
| 150 | ||
| 151 | **Interfaces:** | |
| 152 | - Produces: | |
| 153 | - `type Revoked struct { KeyIDs []int64; UserID int64 }` | |
| 154 | - `func (s *Store) OnRevoke(f func(Revoked))` | |
| 155 | - `func (s *Store) announce(r Revoked)` (package-private; MR 2 calls it) | |
| 156 | - `func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error)` | |
| 157 | ||
| 158 | - [ ] **Step 1: Write the failing test** | |
| 159 | ||
| 160 | `internal/store/revoke_test.go`: | |
| 161 | ||
| 162 | ```go | |
| 163 | package store | |
| 164 | ||
| 165 | import ( | |
| 166 | "slices" | |
| 167 | "testing" | |
| 168 | ) | |
| 169 | ||
| 170 | func revokeFixture(t *testing.T) (*Store, int64, *[]Revoked) { | |
| 171 | t.Helper() | |
| 172 | s := open(t) | |
| 173 | if err := s.MigrateUp(); err != nil { | |
| 174 | t.Fatal(err) | |
| 175 | } | |
| 176 | uid, err := s.CreateUser("alice", false) | |
| 177 | if err != nil { | |
| 178 | t.Fatal(err) | |
| 179 | } | |
| 180 | var got []Revoked | |
| 181 | s.OnRevoke(func(r Revoked) { got = append(got, r) }) | |
| 182 | return s, uid, &got | |
| 183 | } | |
| 184 | ||
| 185 | func keyID(t *testing.T, s *Store, fp string) int64 { | |
| 186 | t.Helper() | |
| 187 | k, err := s.SSHKeyByFingerprint(fp) | |
| 188 | if err != nil { | |
| 189 | t.Fatal(err) | |
| 190 | } | |
| 191 | return k.ID | |
| 192 | } | |
| 193 | ||
| 194 | func TestRemovalsAnnounceTheirKeys(t *testing.T) { | |
| 195 | s, uid, got := revokeFixture(t) | |
| 196 | if err := s.AddSSHKey(uid, "SHA256:a", "ssh-ed25519", []byte("a"), "full", ""); err != nil { | |
| 197 | t.Fatal(err) | |
| 198 | } | |
| 199 | if err := s.AddSSHKey(uid, "SHA256:d", "ssh-ed25519", []byte("d"), "deploy:7:ro", ""); err != nil { | |
| 200 | t.Fatal(err) | |
| 201 | } | |
| 202 | a, d := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:d") | |
| 203 | ||
| 204 | if err := s.RemoveSSHKey(uid, "SHA256:a"); err != nil { | |
| 205 | t.Fatal(err) | |
| 206 | } | |
| 207 | if err := s.RemoveDeployKey(7, "SHA256:d"); err != nil { | |
| 208 | t.Fatal(err) | |
| 209 | } | |
| 210 | if err := s.SetUserDisabled(uid, true); err != nil { | |
| 211 | t.Fatal(err) | |
| 212 | } | |
| 213 | if err := s.SetUserDisabled(uid, false); err != nil { | |
| 214 | t.Fatal(err) | |
| 215 | } | |
| 216 | want := []Revoked{{KeyIDs: []int64{a}}, {KeyIDs: []int64{d}}, {UserID: uid}} | |
| 217 | if !slices.EqualFunc(*got, want, func(x, y Revoked) bool { | |
| 218 | return slices.Equal(x.KeyIDs, y.KeyIDs) && x.UserID == y.UserID | |
| 219 | }) { | |
| 220 | t.Fatalf("announced %+v, want %+v (enabling announces nothing)", *got, want) | |
| 221 | } | |
| 222 | // A removal that found nothing announces nothing. | |
| 223 | if err := s.RemoveSSHKey(uid, "SHA256:a"); err != ErrNotFound { | |
| 224 | t.Fatalf("second remove: %v", err) | |
| 225 | } | |
| 226 | if len(*got) != 3 { | |
| 227 | t.Fatalf("a miss was announced: %+v", *got) | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | func TestDeleteUserAnnounces(t *testing.T) { | |
| 232 | s, uid, got := revokeFixture(t) | |
| 233 | if err := s.DeleteUser(uid); err != nil { | |
| 234 | t.Fatal(err) | |
| 235 | } | |
| 236 | if len(*got) != 1 || (*got)[0].UserID != uid { | |
| 237 | t.Fatalf("announced %+v", *got) | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | func TestLiveSSHKeys(t *testing.T) { | |
| 242 | s, uid, _ := revokeFixture(t) | |
| 243 | bob, err := s.CreateUser("bob", false) | |
| 244 | if err != nil { | |
| 245 | t.Fatal(err) | |
| 246 | } | |
| 247 | for _, k := range []struct { | |
| 248 | uid int64 | |
| 249 | fp string | |
| 250 | }{{uid, "SHA256:a"}, {bob, "SHA256:b"}} { | |
| 251 | if err := s.AddSSHKey(k.uid, k.fp, "ssh-ed25519", []byte(k.fp), "full", ""); err != nil { | |
| 252 | t.Fatal(err) | |
| 253 | } | |
| 254 | } | |
| 255 | a, b := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:b") | |
| 256 | if _, err := s.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob); err != nil { | |
| 257 | t.Fatal(err) | |
| 258 | } | |
| 259 | live, err := s.LiveSSHKeys([]int64{a, b, 999}) | |
| 260 | if err != nil { | |
| 261 | t.Fatal(err) | |
| 262 | } | |
| 263 | if !live[a] || live[b] || live[999] { | |
| 264 | t.Fatalf("live = %v; want only %d", live, a) | |
| 265 | } | |
| 266 | if live, err := s.LiveSSHKeys(nil); err != nil || len(live) != 0 { | |
| 267 | t.Fatalf("no ids: %v %v", live, err) | |
| 268 | } | |
| 269 | } | |
| 270 | ``` | |
| 271 | ||
| 272 | - [ ] **Step 2: Run it and see it fail** | |
| 273 | ||
| 274 | Run: `go test ./internal/store -run 'TestRemovalsAnnounceTheirKeys|TestDeleteUserAnnounces|TestLiveSSHKeys' -count=1` | |
| 275 | Expected: FAIL to compile, `s.OnRevoke undefined`. | |
| 276 | ||
| 277 | - [ ] **Step 3: Add the subscriber fields** | |
| 278 | ||
| 279 | In `internal/store/store.go`, the `Store` struct becomes: | |
| 280 | ||
| 281 | ```go | |
| 282 | type Store struct { | |
| 283 | DB *sql.DB | |
| 284 | ||
| 285 | // logWait holds one channel per build someone is following, closed | |
| 286 | // by the next change to that build's row (BuildLogWait). | |
| 287 | logMu sync.Mutex | |
| 288 | logWait map[int64]chan struct{} | |
| 289 | ||
| 290 | // onRevoke runs after each key revocation this process commits. | |
| 291 | revokeMu sync.Mutex | |
| 292 | onRevoke []func(Revoked) | |
| 293 | } | |
| 294 | ``` | |
| 295 | ||
| 296 | - [ ] **Step 4: Create `internal/store/revoke.go`** | |
| 297 | ||
| 298 | ```go | |
| 299 | package store | |
| 300 | ||
| 301 | import ( | |
| 302 | "slices" | |
| 303 | "strings" | |
| 304 | ) | |
| 305 | ||
| 306 | // Revoked names SSH keys that stopped being valid: by id, or every key | |
| 307 | // of an account. The SSH listener closes the connections they opened. | |
| 308 | type Revoked struct { | |
| 309 | KeyIDs []int64 | |
| 310 | UserID int64 // every key of this account; 0 for none | |
| 311 | } | |
| 312 | ||
| 313 | // OnRevoke registers f to run after each revocation this process | |
| 314 | // commits. Revocations committed by another process (gitbayd admin on | |
| 315 | // the host) are not announced; the listener's sweep finds those. | |
| 316 | func (s *Store) OnRevoke(f func(Revoked)) { | |
| 317 | s.revokeMu.Lock() | |
| 318 | defer s.revokeMu.Unlock() | |
| 319 | s.onRevoke = append(s.onRevoke, f) | |
| 320 | } | |
| 321 | ||
| 322 | // announce runs the subscribers. Call it after the commit, outside any | |
| 323 | // transaction. | |
| 324 | func (s *Store) announce(r Revoked) { | |
| 325 | s.revokeMu.Lock() | |
| 326 | fs := slices.Clone(s.onRevoke) | |
| 327 | s.revokeMu.Unlock() | |
| 328 | for _, f := range fs { | |
| 329 | f(r) | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | // LiveSSHKeys reports which of ids still name a registered key on an | |
| 334 | // account that is not disabled. | |
| 335 | func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) { | |
| 336 | live := map[int64]bool{} | |
| 337 | if len(ids) == 0 { | |
| 338 | return live, nil | |
| 339 | } | |
| 340 | args := make([]any, len(ids)) | |
| 341 | for i, id := range ids { | |
| 342 | args[i] = id | |
| 343 | } | |
| 344 | rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id | |
| 345 | WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...) | |
| 346 | if err != nil { | |
| 347 | return nil, err | |
| 348 | } | |
| 349 | defer rows.Close() | |
| 350 | for rows.Next() { | |
| 351 | var id int64 | |
| 352 | if err := rows.Scan(&id); err != nil { | |
| 353 | return nil, err | |
| 354 | } | |
| 355 | live[id] = true | |
| 356 | } | |
| 357 | return live, rows.Err() | |
| 358 | } | |
| 359 | ``` | |
| 360 | ||
| 361 | - [ ] **Step 5: Announce from the four removals** | |
| 362 | ||
| 363 | `RemoveSSHKey` in `internal/store/users.go`: | |
| 364 | ||
| 365 | ```go | |
| 366 | // RemoveSSHKey removes a key owned by userID, bumps the key epoch, and | |
| 367 | // announces the revocation. | |
| 368 | func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error { | |
| 369 | tx, err := s.DB.Begin() | |
| 370 | if err != nil { | |
| 371 | return err | |
| 372 | } | |
| 373 | defer tx.Rollback() | |
| 374 | var id int64 | |
| 375 | err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id) | |
| 376 | if errors.Is(err, sql.ErrNoRows) { | |
| 377 | return ErrNotFound | |
| 378 | } | |
| 379 | if err != nil { | |
| 380 | return err | |
| 381 | } | |
| 382 | if err := bumpKeyEpoch(tx); err != nil { | |
| 383 | return err | |
| 384 | } | |
| 385 | if err := tx.Commit(); err != nil { | |
| 386 | return err | |
| 387 | } | |
| 388 | s.announce(Revoked{KeyIDs: []int64{id}}) | |
| 389 | return nil | |
| 390 | } | |
| 391 | ``` | |
| 392 | ||
| 393 | `RemoveDeployKey`: | |
| 394 | ||
| 395 | ```go | |
| 396 | // RemoveDeployKey removes a deploy key from a repository by fingerprint; | |
| 397 | // any repo admin may remove it regardless of who added it. | |
| 398 | func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error { | |
| 399 | tx, err := s.DB.Begin() | |
| 400 | if err != nil { | |
| 401 | return err | |
| 402 | } | |
| 403 | defer tx.Rollback() | |
| 404 | var id int64 | |
| 405 | err = tx.QueryRow( | |
| 406 | "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id", | |
| 407 | fingerprint, repoID).Scan(&id) | |
| 408 | if errors.Is(err, sql.ErrNoRows) { | |
| 409 | return ErrNotFound | |
| 410 | } | |
| 411 | if err != nil { | |
| 412 | return err | |
| 413 | } | |
| 414 | if err := bumpKeyEpoch(tx); err != nil { | |
| 415 | return err | |
| 416 | } | |
| 417 | if err := tx.Commit(); err != nil { | |
| 418 | return err | |
| 419 | } | |
| 420 | s.announce(Revoked{KeyIDs: []int64{id}}) | |
| 421 | return nil | |
| 422 | } | |
| 423 | ``` | |
| 424 | ||
| 425 | `SetUserDisabled`, the tail from `if disabled {`: | |
| 426 | ||
| 427 | ```go | |
| 428 | if disabled { | |
| 429 | // A pending login link is a session in waiting, so it goes with | |
| 430 | // the sessions and API tokens. Re-enabling means minting again. | |
| 431 | for _, table := range []string{"web_sessions", "api_tokens", "login_tokens"} { | |
| 432 | if _, err := s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil { | |
| 433 | return err | |
| 434 | } | |
| 435 | } | |
| 436 | s.announce(Revoked{UserID: userID}) | |
| 437 | } | |
| 438 | return nil | |
| 439 | } | |
| 440 | ``` | |
| 441 | ||
| 442 | Update its doc comment's last clause to: "and leaves the SSH keys registered but refused at every entry point until re-enabled; connections they opened are closed." | |
| 443 | ||
| 444 | `DeleteUser`, the tail: | |
| 445 | ||
| 446 | ```go | |
| 447 | res, err := s.DB.Exec("DELETE FROM users WHERE id = ?", id) | |
| 448 | if err != nil { | |
| 449 | return err | |
| 450 | } | |
| 451 | if n, _ := res.RowsAffected(); n == 0 { | |
| 452 | return ErrNotFound | |
| 453 | } | |
| 454 | s.announce(Revoked{UserID: id}) | |
| 455 | return nil | |
| 456 | } | |
| 457 | ``` | |
| 458 | ||
| 459 | - [ ] **Step 6: Run the tests** | |
| 460 | ||
| 461 | Run: `go test ./internal/store -count=1` | |
| 462 | Expected: PASS. | |
| 463 | ||
| 464 | - [ ] **Step 7: Commit** | |
| 465 | ||
| 466 | ```bash | |
| 467 | git add internal/store/revoke.go internal/store/revoke_test.go internal/store/store.go internal/store/users.go | |
| 468 | git commit -S -m "store: announce key revocations; LiveSSHKeys | |
| 469 | ||
| 470 | Ref #256" | |
| 471 | ``` | |
| 472 | ||
| 473 | ### Task 1.2: `gitutil.Transport` can be cancelled | |
| 474 | ||
| 475 | **Files:** | |
| 476 | - Modify: `internal/gitutil/gitutil.go:39-56` | |
| 477 | - Create: `internal/gitutil/proc_unix.go`, `internal/gitutil/proc_other.go` | |
| 478 | - Test: `internal/gitutil/transport_test.go` (create) | |
| 479 | ||
| 480 | **Interfaces:** | |
| 481 | - Produces: `func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error` — closing `cancel` kills the git process and its children; a nil `cancel` never fires. | |
| 482 | ||
| 483 | - [ ] **Step 1: Write the failing test** | |
| 484 | ||
| 485 | `internal/gitutil/transport_test.go`: | |
| 486 | ||
| 487 | ```go | |
| 488 | package gitutil | |
| 489 | ||
| 490 | import ( | |
| 491 | "io" | |
| 492 | "os/exec" | |
| 493 | "strings" | |
| 494 | "testing" | |
| 495 | "time" | |
| 496 | ) | |
| 497 | ||
| 498 | // Closing cancel kills the transport; it does not wait for the client | |
| 499 | // to hang up. Stdin ends only after the kill, as a cut connection's | |
| 500 | // does, so a clean exit here would mean the kill never happened. | |
| 501 | func TestTransportCancelKillsGit(t *testing.T) { | |
| 502 | dir := t.TempDir() | |
| 503 | if out, err := exec.Command("git", "init", "-q", "--bare", dir).CombinedOutput(); err != nil { | |
| 504 | t.Fatalf("git init: %v\n%s", err, out) | |
| 505 | } | |
| 506 | in, w := io.Pipe() | |
| 507 | cancel := make(chan struct{}) | |
| 508 | errc := make(chan error, 1) | |
| 509 | go func() { errc <- Transport("git-upload-pack", dir, in, io.Discard, io.Discard, nil, 0, cancel) }() | |
| 510 | close(cancel) | |
| 511 | time.AfterFunc(500*time.Millisecond, func() { w.Close() }) | |
| 512 | select { | |
| 513 | case err := <-errc: | |
| 514 | if err == nil || !strings.Contains(err.Error(), "killed") { | |
| 515 | t.Fatalf("Transport returned %v, want the process killed", err) | |
| 516 | } | |
| 517 | case <-time.After(5 * time.Second): | |
| 518 | t.Fatal("Transport did not return after cancel") | |
| 519 | } | |
| 520 | } | |
| 521 | ``` | |
| 522 | ||
| 523 | - [ ] **Step 2: Run it and see it fail** | |
| 524 | ||
| 525 | Run: `go test ./internal/gitutil -run TestTransportCancelKillsGit -count=1` | |
| 526 | Expected: FAIL to compile, too many arguments to `Transport`. | |
| 527 | ||
| 528 | - [ ] **Step 3: Process-group helpers** | |
| 529 | ||
| 530 | `internal/gitutil/proc_unix.go`: | |
| 531 | ||
| 532 | ```go | |
| 533 | //go:build unix | |
| 534 | ||
| 535 | package gitutil | |
| 536 | ||
| 537 | import ( | |
| 538 | "os/exec" | |
| 539 | "syscall" | |
| 540 | ) | |
| 541 | ||
| 542 | // ownProcessGroup puts cmd in a process group of its own, so killTree | |
| 543 | // ends what it started too: receive-pack runs index-pack and the hooks. | |
| 544 | func ownProcessGroup(cmd *exec.Cmd) { | |
| 545 | if cmd.SysProcAttr == nil { | |
| 546 | cmd.SysProcAttr = &syscall.SysProcAttr{} | |
| 547 | } | |
| 548 | cmd.SysProcAttr.Setpgid = true | |
| 549 | } | |
| 550 | ||
| 551 | func killTree(cmd *exec.Cmd) { | |
| 552 | if cmd.Process == nil { | |
| 553 | return | |
| 554 | } | |
| 555 | if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil { | |
| 556 | cmd.Process.Kill() | |
| 557 | } | |
| 558 | } | |
| 559 | ``` | |
| 560 | ||
| 561 | `internal/gitutil/proc_other.go`: | |
| 562 | ||
| 563 | ```go | |
| 564 | //go:build !unix | |
| 565 | ||
| 566 | package gitutil | |
| 567 | ||
| 568 | import "os/exec" | |
| 569 | ||
| 570 | func ownProcessGroup(cmd *exec.Cmd) {} | |
| 571 | ||
| 572 | func killTree(cmd *exec.Cmd) { | |
| 573 | if cmd.Process != nil { | |
| 574 | cmd.Process.Kill() | |
| 575 | } | |
| 576 | } | |
| 577 | ``` | |
| 578 | ||
| 579 | - [ ] **Step 4: Transport** | |
| 580 | ||
| 581 | Replace `Transport` in `internal/gitutil/gitutil.go`: | |
| 582 | ||
| 583 | ```go | |
| 584 | // Transport runs a git transport service against repoPath with the | |
| 585 | // client's streams. Closing cancel kills the service and everything it | |
| 586 | // started; a push killed before its pre-receive hook answers updates no | |
| 587 | // refs. | |
| 588 | func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error { | |
| 589 | var args []string | |
| 590 | switch service { | |
| 591 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": | |
| 592 | if service == "git-receive-pack" && maxPack > 0 { | |
| 593 | args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)} | |
| 594 | } | |
| 595 | args = append(args, strings.TrimPrefix(service, "git-"), repoPath) | |
| 596 | default: | |
| 597 | return fmt.Errorf("unknown service %q", service) | |
| 598 | } | |
| 599 | cmd := exec.Command(toolpath.Look("git"), args...) | |
| 600 | cmd.Env = append(os.Environ(), extraEnv...) | |
| 601 | cmd.Stdin = stdin | |
| 602 | cmd.Stdout = stdout | |
| 603 | cmd.Stderr = errW | |
| 604 | ownProcessGroup(cmd) | |
| 605 | if err := cmd.Start(); err != nil { | |
| 606 | return err | |
| 607 | } | |
| 608 | finished := make(chan struct{}) | |
| 609 | go func() { | |
| 610 | select { | |
| 611 | case <-cancel: | |
| 612 | killTree(cmd) | |
| 613 | case <-finished: | |
| 614 | } | |
| 615 | }() | |
| 616 | err := cmd.Wait() | |
| 617 | close(finished) | |
| 618 | return err | |
| 619 | } | |
| 620 | ``` | |
| 621 | ||
| 622 | If the current doc comment above `Transport` (line 38 and up) says something different, keep its first sentence and replace the rest with the above. | |
| 623 | ||
| 624 | - [ ] **Step 5: Fix the caller so the tree builds** | |
| 625 | ||
| 626 | In `internal/sshd/sshd.go:464`, pass `nil` for now (Task 1.3 wires the channel): | |
| 627 | ||
| 628 | ```go | |
| 629 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, nil); err != nil { | |
| 630 | ``` | |
| 631 | ||
| 632 | - [ ] **Step 6: Run the tests** | |
| 633 | ||
| 634 | Run: `go build ./... && go test ./internal/gitutil -count=1` | |
| 635 | Expected: PASS. | |
| 636 | ||
| 637 | - [ ] **Step 7: Commit** | |
| 638 | ||
| 639 | ```bash | |
| 640 | git add internal/gitutil internal/sshd/sshd.go | |
| 641 | git commit -S -m "gitutil: Transport takes a cancel channel and kills its process group | |
| 642 | ||
| 643 | Ref #256" | |
| 644 | ``` | |
| 645 | ||
| 646 | ### Task 1.3: sshd re-reads the key per exec and cuts revoked connections | |
| 647 | ||
| 648 | **Files:** | |
| 649 | - Modify: `internal/sshd/sshd.go` — `conn` (46-53), `New` (55-71), `Serve` (158-180), `handleConn` (214-238), `handleSession` (240-292), `runExec` (299-313), `Exec` (339-385), `runGit` (387-468) | |
| 650 | - Modify: `cmd/gitbayd/system.go:97` | |
| 651 | - Modify: `internal/sshd/sshd_test.go:20-87` (`followServer` split) | |
| 652 | - Test: `internal/sshd/revoke_test.go` (create) | |
| 653 | ||
| 654 | **Interfaces:** | |
| 655 | - Consumes: `store.Revoked`, `Store.OnRevoke`, `Store.LiveSSHKeys` (Task 1.1); `gitutil.Transport(..., cancel)` (Task 1.2). | |
| 656 | - Produces: | |
| 657 | - `func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string, stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int` — scope and audit source come from `key`. | |
| 658 | - `func (s *Server) sweepOnce()` (tests call it). | |
| 659 | - Test helpers in package `sshd`: `type testServer struct{ srv *Server; st *store.Store; client *ssh.Client; uid, keyID int64; fp string }`, `newTestServer(t) testServer`, `withBuild(t, ts)`, `execStatus(client, cmd) (int, string)`, `waitClosed(t, client)`. | |
| 660 | ||
| 661 | - [ ] **Step 1: Split the test fixture** | |
| 662 | ||
| 663 | In `internal/sshd/sshd_test.go`, replace `followServer` (lines 20-87) with: | |
| 664 | ||
| 665 | ```go | |
| 666 | // testServer is an embedded server over a fresh store holding alice | |
| 667 | // with one full-scope key, and a client connected with that key. | |
| 668 | type testServer struct { | |
| 669 | srv *Server | |
| 670 | st *store.Store | |
| 671 | client *ssh.Client | |
| 672 | uid int64 | |
| 673 | keyID int64 | |
| 674 | fp string | |
| 675 | } | |
| 676 | ||
| 677 | func newTestServer(t *testing.T) testServer { | |
| 678 | t.Helper() | |
| 679 | root := t.TempDir() | |
| 680 | st, err := store.Open(filepath.Join(root, "gitbay.db")) | |
| 681 | if err != nil { | |
| 682 | t.Fatal(err) | |
| 683 | } | |
| 684 | t.Cleanup(func() { st.Close() }) | |
| 685 | if err := st.MigrateUp(); err != nil { | |
| 686 | t.Fatal(err) | |
| 687 | } | |
| 688 | uid, err := st.CreateUser("alice", false) | |
| 689 | if err != nil { | |
| 690 | t.Fatal(err) | |
| 691 | } | |
| 692 | _, priv, err := ed25519.GenerateKey(rand.Reader) | |
| 693 | if err != nil { | |
| 694 | t.Fatal(err) | |
| 695 | } | |
| 696 | signer, err := ssh.NewSignerFromKey(priv) | |
| 697 | if err != nil { | |
| 698 | t.Fatal(err) | |
| 699 | } | |
| 700 | pub := signer.PublicKey() | |
| 701 | fp := ssh.FingerprintSHA256(pub) | |
| 702 | if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", "test"); err != nil { | |
| 703 | t.Fatal(err) | |
| 704 | } | |
| 705 | key, err := st.SSHKeyByFingerprint(fp) | |
| 706 | if err != nil { | |
| 707 | t.Fatal(err) | |
| 708 | } | |
| 709 | ||
| 710 | cfg := config.Default() | |
| 711 | cfg.Server.Root = root | |
| 712 | srv, err := New(cfg, st) | |
| 713 | if err != nil { | |
| 714 | t.Fatal(err) | |
| 715 | } | |
| 716 | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 717 | if err != nil { | |
| 718 | t.Fatal(err) | |
| 719 | } | |
| 720 | go srv.Serve(ln) | |
| 721 | t.Cleanup(func() { ln.Close() }) | |
| 722 | ||
| 723 | client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{ | |
| 724 | User: "git", | |
| 725 | Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, | |
| 726 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| 727 | Timeout: 5 * time.Second, | |
| 728 | }) | |
| 729 | if err != nil { | |
| 730 | t.Fatal(err) | |
| 731 | } | |
| 732 | t.Cleanup(func() { client.Close() }) | |
| 733 | return testServer{srv: srv, st: st, client: client, uid: uid, keyID: key.ID, fp: fp} | |
| 734 | } | |
| 735 | ||
| 736 | // withBuild gives alice the public repo alice/app and a queued build 1 | |
| 737 | // whose log has one line. | |
| 738 | func withBuild(t *testing.T, ts testServer) { | |
| 739 | t.Helper() | |
| 740 | repoID, err := ts.st.CreateRepo("user", ts.uid, "app", "public") | |
| 741 | if err != nil { | |
| 742 | t.Fatal(err) | |
| 743 | } | |
| 744 | id, err := ts.st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true) | |
| 745 | if err != nil { | |
| 746 | t.Fatal(err) | |
| 747 | } | |
| 748 | if err := ts.st.AppendBuildLog(id, []byte("queued\n")); err != nil { | |
| 749 | t.Fatal(err) | |
| 750 | } | |
| 751 | } | |
| 752 | ||
| 753 | // followServer starts an embedded server holding alice, her public repo | |
| 754 | // alice/app and a queued build 1 whose log has one line, and returns it | |
| 755 | // with a client connected as alice. | |
| 756 | func followServer(t *testing.T) (*Server, *ssh.Client) { | |
| 757 | t.Helper() | |
| 758 | ts := newTestServer(t) | |
| 759 | withBuild(t, ts) | |
| 760 | return ts.srv, ts.client | |
| 761 | } | |
| 762 | ``` | |
| 763 | ||
| 764 | Run: `go test ./internal/sshd -count=1` | |
| 765 | Expected: PASS (behaviour unchanged). | |
| 766 | ||
| 767 | - [ ] **Step 2: Write the failing tests** | |
| 768 | ||
| 769 | `internal/sshd/revoke_test.go`: | |
| 770 | ||
| 771 | ```go | |
| 772 | package sshd | |
| 773 | ||
| 774 | import ( | |
| 775 | "bytes" | |
| 776 | "errors" | |
| 777 | "strings" | |
| 778 | "testing" | |
| 779 | "time" | |
| 780 | ||
| 781 | "golang.org/x/crypto/ssh" | |
| 782 | ) | |
| 783 | ||
| 784 | // execStatus runs cmd on a new session and returns its exit status and | |
| 785 | // stderr; -1 when the session could not run. | |
| 786 | func execStatus(client *ssh.Client, cmd string) (int, string) { | |
| 787 | sess, err := client.NewSession() | |
| 788 | if err != nil { | |
| 789 | return -1, err.Error() | |
| 790 | } | |
| 791 | defer sess.Close() | |
| 792 | var stderr bytes.Buffer | |
| 793 | sess.Stderr = &stderr | |
| 794 | err = sess.Run(cmd) | |
| 795 | var exit *ssh.ExitError | |
| 796 | switch { | |
| 797 | case err == nil: | |
| 798 | return 0, stderr.String() | |
| 799 | case errors.As(err, &exit): | |
| 800 | return exit.ExitStatus(), stderr.String() | |
| 801 | } | |
| 802 | return -1, err.Error() | |
| 803 | } | |
| 804 | ||
| 805 | // waitClosed fails unless the server closes the client's connection | |
| 806 | // within five seconds. | |
| 807 | func waitClosed(t *testing.T, client *ssh.Client) { | |
| 808 | t.Helper() | |
| 809 | done := make(chan struct{}) | |
| 810 | go func() { client.Wait(); close(done) }() | |
| 811 | select { | |
| 812 | case <-done: | |
| 813 | case <-time.After(5 * time.Second): | |
| 814 | t.Fatal("the connection stayed open") | |
| 815 | } | |
| 816 | } | |
| 817 | ||
| 818 | // Each exec reads the key again. The rows change behind the store's | |
| 819 | // back here, so no revocation is announced and the connection stays up: | |
| 820 | // what refuses the command is the per-exec check alone. | |
| 821 | func TestExecRevalidatesKey(t *testing.T) { | |
| 822 | ts := newTestServer(t) | |
| 823 | if code, errOut := execStatus(ts.client, "whoami"); code != 0 { | |
| 824 | t.Fatalf("whoami: %d %s", code, errOut) | |
| 825 | } | |
| 826 | if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET scope = 'git' WHERE id = ?", ts.keyID); err != nil { | |
| 827 | t.Fatal(err) | |
| 828 | } | |
| 829 | if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "does not allow control commands") { | |
| 830 | t.Fatalf("whoami after re-scope: %d %q", code, errOut) | |
| 831 | } | |
| 832 | if _, err := ts.st.DB.Exec("DELETE FROM ssh_keys WHERE id = ?", ts.keyID); err != nil { | |
| 833 | t.Fatal(err) | |
| 834 | } | |
| 835 | if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "no longer registered") { | |
| 836 | t.Fatalf("whoami after delete: %d %q", code, errOut) | |
| 837 | } | |
| 838 | } | |
| 839 | ||
| 840 | // Removing the key cuts the connection, ending a command running on it. | |
| 841 | func TestRemoveKeyCutsConnection(t *testing.T) { | |
| 842 | ts := newTestServer(t) | |
| 843 | withBuild(t, ts) | |
| 844 | var stderr bytes.Buffer | |
| 845 | sess := startFollow(t, ts.client, &stderr) | |
| 846 | if err := ts.st.RemoveSSHKey(ts.uid, ts.fp); err != nil { | |
| 847 | t.Fatal(err) | |
| 848 | } | |
| 849 | waited := make(chan error, 1) | |
| 850 | go func() { waited <- sess.Wait() }() | |
| 851 | select { | |
| 852 | case err := <-waited: | |
| 853 | if err == nil { | |
| 854 | t.Fatal("the follow exited cleanly after its key was removed") | |
| 855 | } | |
| 856 | case <-time.After(5 * time.Second): | |
| 857 | t.Fatal("the follow outlived its key") | |
| 858 | } | |
| 859 | waitClosed(t, ts.client) | |
| 860 | } | |
| 861 | ||
| 862 | func TestDisableCutsConnection(t *testing.T) { | |
| 863 | ts := newTestServer(t) | |
| 864 | if err := ts.st.SetUserDisabled(ts.uid, true); err != nil { | |
| 865 | t.Fatal(err) | |
| 866 | } | |
| 867 | waitClosed(t, ts.client) | |
| 868 | } | |
| 869 | ||
| 870 | // A revocation made by another process is not announced here; the | |
| 871 | // sweep finds it. A live key survives the sweep. | |
| 872 | func TestSweepCutsOutOfProcessRevocation(t *testing.T) { | |
| 873 | ts := newTestServer(t) | |
| 874 | ts.srv.sweepOnce() | |
| 875 | if code, errOut := execStatus(ts.client, "whoami"); code != 0 { | |
| 876 | t.Fatalf("the sweep cut a live key: %d %s", code, errOut) | |
| 877 | } | |
| 878 | if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil { | |
| 879 | t.Fatal(err) | |
| 880 | } | |
| 881 | ts.srv.sweepOnce() | |
| 882 | waitClosed(t, ts.client) | |
| 883 | } | |
| 884 | ``` | |
| 885 | ||
| 886 | - [ ] **Step 3: Run them and see them fail** | |
| 887 | ||
| 888 | Run: `go test ./internal/sshd -run 'TestExecRevalidatesKey|TestRemoveKeyCutsConnection|TestDisableCutsConnection|TestSweepCutsOutOfProcessRevocation' -count=1` | |
| 889 | Expected: FAIL to compile, `ts.srv.sweepOnce undefined`. | |
| 890 | ||
| 891 | - [ ] **Step 4: Track the key on each connection** | |
| 892 | ||
| 893 | In `internal/sshd/sshd.go` add `"slices"` to the imports. Replace `conn`: | |
| 894 | ||
| 895 | ```go | |
| 896 | // conn is one accepted connection and how many sessions it is running. | |
| 897 | // A CLI's shared connection sits idle between commands; on shutdown an | |
| 898 | // idle connection is closed at once and only a session mid-command is | |
| 899 | // waited for (#141). | |
| 900 | type conn struct { | |
| 901 | net net.Conn | |
| 902 | active atomic.Int32 | |
| 903 | // keyID and userID are the key that authenticated the connection and | |
| 904 | // its account: 0 before the handshake and for an unregistered key. | |
| 905 | // Guarded by Server.mu. | |
| 906 | keyID, userID int64 | |
| 907 | revoked chan struct{} // closed by cut | |
| 908 | cutOnce sync.Once | |
| 909 | } | |
| 910 | ||
| 911 | // cut ends the connection because its key was revoked: a git transport | |
| 912 | // on it is killed, and every other command loses its channel. | |
| 913 | func (c *conn) cut() { | |
| 914 | c.cutOnce.Do(func() { close(c.revoked) }) | |
| 915 | c.net.Close() | |
| 916 | } | |
| 917 | ``` | |
| 918 | ||
| 919 | In `New`, after `s.sshCfg = sc`: | |
| 920 | ||
| 921 | ```go | |
| 922 | st.OnRevoke(s.revoke) | |
| 923 | ``` | |
| 924 | ||
| 925 | `Serve` becomes: | |
| 926 | ||
| 927 | ```go | |
| 928 | // Serve accepts connections on ln until it is closed. | |
| 929 | func (s *Server) Serve(ln net.Listener) error { | |
| 930 | served := make(chan struct{}) | |
| 931 | defer close(served) | |
| 932 | go s.sweep(served) | |
| 933 | for { | |
| 934 | nc, err := ln.Accept() | |
| 935 | if err != nil { | |
| 936 | return err | |
| 937 | } | |
| 938 | c := &conn{net: nc, revoked: make(chan struct{})} | |
| 939 | s.mu.Lock() | |
| 940 | s.conns[c] = struct{}{} | |
| 941 | s.mu.Unlock() | |
| 942 | s.sessions.Add(1) | |
| 943 | go func() { | |
| 944 | defer s.sessions.Done() | |
| 945 | defer func() { | |
| 946 | s.mu.Lock() | |
| 947 | delete(s.conns, c) | |
| 948 | s.mu.Unlock() | |
| 949 | }() | |
| 950 | s.handleConn(c) | |
| 951 | }() | |
| 952 | } | |
| 953 | } | |
| 954 | ``` | |
| 955 | ||
| 956 | Add after `Serve`: | |
| 957 | ||
| 958 | ```go | |
| 959 | // revoke closes the connections opened by the keys r names. | |
| 960 | func (s *Server) revoke(r store.Revoked) { | |
| 961 | s.mu.Lock() | |
| 962 | defer s.mu.Unlock() | |
| 963 | for c := range s.conns { | |
| 964 | if c.keyID == 0 { | |
| 965 | continue | |
| 966 | } | |
| 967 | if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) { | |
| 968 | c.cut() | |
| 969 | } | |
| 970 | } | |
| 971 | } | |
| 972 | ||
| 973 | // sweepInterval bounds how long a revocation this process was not told | |
| 974 | // about (gitbayd admin on the host) leaves a connection open. | |
| 975 | const sweepInterval = 15 * time.Second | |
| 976 | ||
| 977 | func (s *Server) sweep(served <-chan struct{}) { | |
| 978 | t := time.NewTicker(sweepInterval) | |
| 979 | defer t.Stop() | |
| 980 | for { | |
| 981 | select { | |
| 982 | case <-t.C: | |
| 983 | s.sweepOnce() | |
| 984 | case <-served: | |
| 985 | return | |
| 986 | case <-s.stopping: | |
| 987 | return | |
| 988 | } | |
| 989 | } | |
| 990 | } | |
| 991 | ||
| 992 | // sweepOnce cuts every connection whose key is no longer live. Only | |
| 993 | // connections whose key was asked about are judged: one that | |
| 994 | // authenticated while the query ran waits for the next sweep. | |
| 995 | func (s *Server) sweepOnce() { | |
| 996 | asked := map[int64]bool{} | |
| 997 | s.mu.Lock() | |
| 998 | for c := range s.conns { | |
| 999 | if c.keyID != 0 { | |
| 1000 | asked[c.keyID] = true | |
| 1001 | } | |
| 1002 | } | |
| 1003 | s.mu.Unlock() | |
| 1004 | if len(asked) == 0 { | |
| 1005 | return | |
| 1006 | } | |
| 1007 | live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked))) | |
| 1008 | if err != nil { | |
| 1009 | slog.Error("ssh sweep: key lookup", "err", err) | |
| 1010 | return | |
| 1011 | } | |
| 1012 | s.mu.Lock() | |
| 1013 | defer s.mu.Unlock() | |
| 1014 | for c := range s.conns { | |
| 1015 | if asked[c.keyID] && !live[c.keyID] { | |
| 1016 | c.cut() | |
| 1017 | } | |
| 1018 | } | |
| 1019 | } | |
| 1020 | ``` | |
| 1021 | ||
| 1022 | Add `"maps"` to the imports. | |
| 1023 | ||
| 1024 | In `handleConn`, after `defer sconn.Close()`: | |
| 1025 | ||
| 1026 | ```go | |
| 1027 | ext := sconn.Permissions.Extensions | |
| 1028 | s.mu.Lock() | |
| 1029 | c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64) | |
| 1030 | c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64) | |
| 1031 | s.mu.Unlock() | |
| 1032 | ``` | |
| 1033 | ||
| 1034 | and pass `c` to the session: `s.handleSession(c, sconn, ch, chReqs)`. | |
| 1035 | ||
| 1036 | `handleSession` takes the connection: | |
| 1037 | ||
| 1038 | ```go | |
| 1039 | func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) { | |
| 1040 | ``` | |
| 1041 | ||
| 1042 | and its exec case calls `code := s.runExec(c, sconn, ch, term, payload.Command, done)`. | |
| 1043 | ||
| 1044 | - [ ] **Step 5: Re-read the key per exec** | |
| 1045 | ||
| 1046 | Replace `runExec`: | |
| 1047 | ||
| 1048 | ```go | |
| 1049 | func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int { | |
| 1050 | ext := sconn.Permissions.Extensions | |
| 1051 | if blob := ext["anon-key"]; blob != "" { | |
| 1052 | return s.runAnonymous(ch, blob, cmdline) | |
| 1053 | } | |
| 1054 | userID, _ := strconv.ParseInt(ext["user-id"], 10, 64) | |
| 1055 | keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64) | |
| 1056 | // A connection outlives its commands, so the key is read again for | |
| 1057 | // each one: what it may do is what it may do now (#256). | |
| 1058 | key, err := s.st.SSHKeyByID(keyID) | |
| 1059 | if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) { | |
| 1060 | fmt.Fprintln(ch.Stderr(), "this key is no longer registered") | |
| 1061 | return protocol.ExitDenied | |
| 1062 | } | |
| 1063 | if err != nil { | |
| 1064 | slog.Error("ssh exec: key lookup", "err", err) | |
| 1065 | fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable") | |
| 1066 | return protocol.ExitFailure | |
| 1067 | } | |
| 1068 | user, err := s.st.UserByID(userID) | |
| 1069 | if err != nil { | |
| 1070 | fmt.Fprintln(ch.Stderr(), "account no longer exists") | |
| 1071 | return protocol.ExitDenied | |
| 1072 | } | |
| 1073 | _ = s.st.TouchSSHKey(keyID) | |
| 1074 | return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked) | |
| 1075 | } | |
| 1076 | ``` | |
| 1077 | ||
| 1078 | - [ ] **Step 6: `Exec` takes the key; `runGit` takes the cancel channel** | |
| 1079 | ||
| 1080 | ```go | |
| 1081 | // Exec runs one SSH exec command line for an authenticated key. It is the | |
| 1082 | // single dispatch path shared by the embedded listener and the system-sshd | |
| 1083 | // forced command (gitbayd shell). Closing revoked kills a git transport. | |
| 1084 | func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string, | |
| 1085 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { | |
| 1086 | ``` | |
| 1087 | ||
| 1088 | Inside `Exec`: `runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)`, `runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)`, and in the `Ctx` literal `Scope: key.Scope, Source: key.Fingerprint`. | |
| 1089 | ||
| 1090 | `runGit` gains a last parameter `revoked <-chan struct{}` and passes it on: | |
| 1091 | ||
| 1092 | ```go | |
| 1093 | func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string, | |
| 1094 | stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int { | |
| 1095 | ``` | |
| 1096 | ||
| 1097 | ```go | |
| 1098 | if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil { | |
| 1099 | ``` | |
| 1100 | ||
| 1101 | In `cmd/gitbayd/system.go:97`: | |
| 1102 | ||
| 1103 | ```go | |
| 1104 | code := sshd.Exec(cfg, st, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil) | |
| 1105 | ``` | |
| 1106 | ||
| 1107 | - [ ] **Step 7: Run the tests** | |
| 1108 | ||
| 1109 | Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/gitutil ./internal/store -count=1` | |
| 1110 | Expected: PASS. | |
| 1111 | ||
| 1112 | - [ ] **Step 8: Commit** | |
| 1113 | ||
| 1114 | ```bash | |
| 1115 | git add internal/sshd cmd/gitbayd/system.go | |
| 1116 | git commit -S -m "sshd: re-read the key per exec; revocation cuts its connections | |
| 1117 | ||
| 1118 | Ref #256" | |
| 1119 | ``` | |
| 1120 | ||
| 1121 | ### Task 1.4: e2e — a multiplexed connection is cut, a push in flight moves no ref | |
| 1122 | ||
| 1123 | **Files:** | |
| 1124 | - Create: `e2e/revoke_test.go` | |
| 1125 | ||
| 1126 | **Interfaces:** | |
| 1127 | - Produces (package `e2e`): `pkt(s string) string`, `readPkt(r *bufio.Reader) (string, error)`, `fingerprint(t *testing.T, pubPath string) string` — MR 2 uses `fingerprint`. | |
| 1128 | ||
| 1129 | - [ ] **Step 1: Write the test** | |
| 1130 | ||
| 1131 | ```go | |
| 1132 | package e2e | |
| 1133 | ||
| 1134 | import ( | |
| 1135 | "bufio" | |
| 1136 | "fmt" | |
| 1137 | "io" | |
| 1138 | "os" | |
| 1139 | "os/exec" | |
| 1140 | "path/filepath" | |
| 1141 | "strconv" | |
| 1142 | "strings" | |
| 1143 | "testing" | |
| 1144 | "time" | |
| 1145 | ) | |
| 1146 | ||
| 1147 | // pkt frames one pkt-line. | |
| 1148 | func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } | |
| 1149 | ||
| 1150 | // readPkt reads one pkt-line; a flush reads as "". | |
| 1151 | func readPkt(r *bufio.Reader) (string, error) { | |
| 1152 | var n [4]byte | |
| 1153 | if _, err := io.ReadFull(r, n[:]); err != nil { | |
| 1154 | return "", err | |
| 1155 | } | |
| 1156 | size, err := strconv.ParseUint(string(n[:]), 16, 16) | |
| 1157 | if err != nil { | |
| 1158 | return "", err | |
| 1159 | } | |
| 1160 | if size == 0 { | |
| 1161 | return "", nil | |
| 1162 | } | |
| 1163 | buf := make([]byte, size-4) | |
| 1164 | _, err = io.ReadFull(r, buf) | |
| 1165 | return string(buf), err | |
| 1166 | } | |
| 1167 | ||
| 1168 | // fingerprint is the SHA256 fingerprint of a public key file. | |
| 1169 | func fingerprint(t *testing.T, pubPath string) string { | |
| 1170 | t.Helper() | |
| 1171 | out, err := exec.Command("ssh-keygen", "-lf", pubPath).Output() | |
| 1172 | if err != nil { | |
| 1173 | t.Fatalf("ssh-keygen -lf: %v", err) | |
| 1174 | } | |
| 1175 | return strings.Fields(string(out))[1] | |
| 1176 | } | |
| 1177 | ||
| 1178 | // Removing a key cuts the connections it opened: every session | |
| 1179 | // multiplexed on a ControlMaster, and a push in flight, which moves no | |
| 1180 | // ref (#256). | |
| 1181 | func TestRemovedKeyCutsMultiplexedConnection(t *testing.T) { | |
| 1182 | t.Parallel() | |
| 1183 | inst := startInstance(t) | |
| 1184 | aliceKey := setupPublicRepo(t, inst, "alice/app") | |
| 1185 | spare := inst.newKey(t, "spare") | |
| 1186 | pub, err := os.ReadFile(spare + ".pub") | |
| 1187 | if err != nil { | |
| 1188 | t.Fatal(err) | |
| 1189 | } | |
| 1190 | if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 { | |
| 1191 | t.Fatalf("keys add: %s", errOut) | |
| 1192 | } | |
| 1193 | ||
| 1194 | // The control socket sits under the system temp dir: t.TempDir() on | |
| 1195 | // macOS is long enough to pass the 104-byte socket path limit. | |
| 1196 | cmDir, err := os.MkdirTemp("", "cm") | |
| 1197 | if err != nil { | |
| 1198 | t.Fatal(err) | |
| 1199 | } | |
| 1200 | t.Cleanup(func() { os.RemoveAll(cmDir) }) | |
| 1201 | muxArgs := []string{ | |
| 1202 | "-p", fmt.Sprint(inst.port), | |
| 1203 | "-i", aliceKey, | |
| 1204 | "-o", "IdentitiesOnly=yes", | |
| 1205 | "-o", "StrictHostKeyChecking=no", | |
| 1206 | "-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"), | |
| 1207 | "-o", "BatchMode=yes", | |
| 1208 | "-o", "ControlMaster=auto", | |
| 1209 | "-o", "ControlPath=" + filepath.Join(cmDir, "%C"), | |
| 1210 | "-o", "ControlPersist=60", | |
| 1211 | } | |
| 1212 | mux := func(args ...string) *exec.Cmd { | |
| 1213 | return exec.Command("ssh", append(append([]string{}, muxArgs...), args...)...) | |
| 1214 | } | |
| 1215 | t.Cleanup(func() { mux("-O", "exit", "git@127.0.0.1").Run() }) | |
| 1216 | ||
| 1217 | if out, err := mux("git@127.0.0.1", "whoami").Output(); err != nil || strings.TrimSpace(string(out)) != "alice" { | |
| 1218 | t.Fatalf("whoami over the master: %v %q", err, out) | |
| 1219 | } | |
| 1220 | ||
| 1221 | // A push held open mid-pack: the ref update is sent, the pack is not. | |
| 1222 | push := mux("git@127.0.0.1", "git-receive-pack", "alice/app") | |
| 1223 | stdin, err := push.StdinPipe() | |
| 1224 | if err != nil { | |
| 1225 | t.Fatal(err) | |
| 1226 | } | |
| 1227 | stdout, err := push.StdoutPipe() | |
| 1228 | if err != nil { | |
| 1229 | t.Fatal(err) | |
| 1230 | } | |
| 1231 | if err := push.Start(); err != nil { | |
| 1232 | t.Fatal(err) | |
| 1233 | } | |
| 1234 | adv := bufio.NewReader(stdout) | |
| 1235 | first, err := readPkt(adv) | |
| 1236 | if err != nil || len(first) < 40 { | |
| 1237 | t.Fatalf("advertisement: %q %v", first, err) | |
| 1238 | } | |
| 1239 | oldSHA := first[:40] | |
| 1240 | for { | |
| 1241 | line, err := readPkt(adv) | |
| 1242 | if err != nil { | |
| 1243 | t.Fatalf("advertisement: %v", err) | |
| 1244 | } | |
| 1245 | if line == "" { | |
| 1246 | break | |
| 1247 | } | |
| 1248 | } | |
| 1249 | newSHA := strings.Repeat("1", 40) | |
| 1250 | io.WriteString(stdin, pkt(oldSHA+" "+newSHA+" refs/heads/main\x00report-status\n")+"0000") | |
| 1251 | // A pack header announcing one object, and no object. | |
| 1252 | stdin.Write([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01")) | |
| 1253 | exited := make(chan error, 1) | |
| 1254 | go func() { | |
| 1255 | io.Copy(io.Discard, adv) | |
| 1256 | exited <- push.Wait() | |
| 1257 | }() | |
| 1258 | ||
| 1259 | if _, errOut, code := inst.ssh(t, spare, "", "keys", "remove", fingerprint(t, aliceKey+".pub")); code != 0 { | |
| 1260 | t.Fatalf("keys remove: %s", errOut) | |
| 1261 | } | |
| 1262 | select { | |
| 1263 | case err := <-exited: | |
| 1264 | if err == nil { | |
| 1265 | t.Fatal("the push exited cleanly after its key was removed") | |
| 1266 | } | |
| 1267 | case <-time.After(10 * time.Second): | |
| 1268 | t.Fatal("the push outlived its key") | |
| 1269 | } | |
| 1270 | ||
| 1271 | // The master went with the connection; a new one authenticates | |
| 1272 | // again, and the key is unknown. | |
| 1273 | if out, err := mux("git@127.0.0.1", "whoami").CombinedOutput(); err == nil { | |
| 1274 | t.Fatalf("whoami after removal succeeded: %s", out) | |
| 1275 | } | |
| 1276 | refs := mustGit(t, t.TempDir(), inst.gitEnv(spare), "ls-remote", inst.sshURL("alice/app"), "refs/heads/main") | |
| 1277 | if !strings.HasPrefix(refs, oldSHA) { | |
| 1278 | t.Fatalf("main moved: %s, want %s", refs, oldSHA) | |
| 1279 | } | |
| 1280 | } | |
| 1281 | ``` | |
| 1282 | ||
| 1283 | - [ ] **Step 2: Run it** | |
| 1284 | ||
| 1285 | Run: `go test ./e2e -run TestRemovedKeyCutsMultiplexedConnection -count=1` | |
| 1286 | Expected: PASS. To see it fail, stash Task 1.3's `st.OnRevoke(s.revoke)` line: the push then hangs until the 10-second timeout. | |
| 1287 | ||
| 1288 | - [ ] **Step 3: Commit** | |
| 1289 | ||
| 1290 | ```bash | |
| 1291 | git add e2e/revoke_test.go | |
| 1292 | git commit -S -m "e2e: removing a key cuts its multiplexed connection and a push in flight | |
| 1293 | ||
| 1294 | Ref #256" | |
| 1295 | ``` | |
| 1296 | ||
| 1297 | ### Task 1.5: docs | |
| 1298 | ||
| 1299 | **Files:** | |
| 1300 | - Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `09-Controls.org:28`, `05-Identity-and-Access.org:17-18`, `08-Operations.org:88-89`, `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Users.org` (after the keys block, ~line 70) | |
| 1301 | ||
| 1302 | - [ ] **Step 1: Edit the pages** | |
| 1303 | ||
| 1304 | `10-Known-Gaps.org`: delete the `#256` row. In the paragraph under the table, drop "#256 closes a removed key's connections, running commands included;" so it opens "Decisions already taken on these: #257 refuses ...". | |
| 1305 | ||
| 1306 | `09-Controls.org`, the revocation row becomes: | |
| 1307 | ||
| 1308 | ``` | |
| 1309 | | Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) | | |
| 1310 | ``` | |
| 1311 | ||
| 1312 | `05-Identity-and-Access.org`, the SSH user key and deploy key rows' Revocation cells become `=keys remove= (own keys); closes its connections` and `=repo deploy-key remove= (repo admin); closes its connections`. | |
| 1313 | ||
| 1314 | `08-Operations.org`: delete the two lines "Open connections of a removed key keep working until they close; see #256." | |
| 1315 | ||
| 1316 | `Threat-Model.org`, add to "Trust boundaries" after the "SSH public key = identity" bullet: | |
| 1317 | ||
| 1318 | ``` | |
| 1319 | - *Revocation is immediate.* Every exec and every git transport session | |
| 1320 | re-reads its key. Removing a key, removing a deploy key, disabling or | |
| 1321 | deleting an account closes the connections the affected keys opened: | |
| 1322 | a git transport is killed with its children, and a push killed before | |
| 1323 | its pre-receive hook answers moves no ref. A control command already | |
| 1324 | inside its database write finishes it; its output is lost. A | |
| 1325 | revocation made by =gitbayd admin= on the host, another process, is | |
| 1326 | found within 15 seconds. In =ssh.mode = "system"= each exec is its | |
| 1327 | own process: the next exec is refused, one already running is not cut. | |
| 1328 | ``` | |
| 1329 | ||
| 1330 | `Users.org`, after the paragraph that ends "Labels are one line of up to 64 bytes.": | |
| 1331 | ||
| 1332 | ``` | |
| 1333 | Removing a key closes every connection it opened, including the CLI's | |
| 1334 | shared one; removing the key the current command runs on ends that | |
| 1335 | command's connection too. | |
| 1336 | ``` | |
| 1337 | ||
| 1338 | - [ ] **Step 2: Commit, open the MR** | |
| 1339 | ||
| 1340 | ```bash | |
| 1341 | git add .gitbay/wiki | |
| 1342 | git commit -S -m "wiki: revocation closes open connections | |
| 1343 | ||
| 1344 | Closes #256" | |
| 1345 | git push -u origin revoke-closes-connections | |
| 1346 | gitbay mr create --source revoke-closes-connections --target main --title "sshd: removing a key closes its connections" | |
| 1347 | ``` | |
| 1348 | ||
| 1349 | Merge with `--strategy ff` once CI is green; delete the branch locally and on the remote. | |
| 1350 | ||
| 1351 | --- | |
| 1352 | ||
| 1353 | # MR 2: expiring credentials cannot mint; credentials record their token (branch `token-delegation`, #257) | |
| 1354 | ||
| 1355 | Decisions on the issue: a token with an expiry is refused on every | |
| 1356 | credential-minting command, marked on `Command` and checked in | |
| 1357 | `Dispatch`; tokens and keys record the token that created them; | |
| 1358 | `token revoke` lists what the token created and can revoke it too; | |
| 1359 | `token create` defaults to `--scope read`. | |
| 1360 | ||
| 1361 | Commands marked `MintsCredential`: `token create`, `keys add`, | |
| 1362 | `repo deploy-key add`, `repo runner add` (attaches or creates a key | |
| 1363 | that can claim builds), `web login` (a login link opens a seven-day | |
| 1364 | session), `admin invite`, `admin user create` (with `--key` or a | |
| 1365 | verified address it is a way in), `email verify` and | |
| 1366 | `admin email verify` (a verified address receives login links). See | |
| 1367 | open question 2. | |
| 1368 | ||
| 1369 | `created_by_token` references `api_tokens(id) ON DELETE SET NULL`: a | |
| 1370 | revoked token's id is never reused for a live row, because SQLite | |
| 1371 | reuses the highest rowid after it is deleted and a dangling integer | |
| 1372 | would then name the wrong token. Revoking without `--created` lists | |
| 1373 | what the token made and then drops the link. | |
| 1374 | ||
| 1375 | ### Task 2.1: migration 0060 and the store | |
| 1376 | ||
| 1377 | **Files:** | |
| 1378 | - Create: `internal/store/migrations/0060_credential_origin.up.sql`, `.down.sql` | |
| 1379 | - Modify: `internal/store/tokens.go` (whole file) | |
| 1380 | - Modify: `internal/store/users.go` — `SSHKey` (18-28), `AddSSHKey` (270-289), `ListSSHKeys` (335-353) | |
| 1381 | - Test: `internal/store/tokens_test.go` (create) | |
| 1382 | ||
| 1383 | **Interfaces:** | |
| 1384 | - Consumes: `Store.announce`, `Revoked` (MR 1). | |
| 1385 | - Produces: | |
| 1386 | - `type APIToken struct { ID int64; Name, Scope, CreatedAt string; ExpiresAt, LastUsedAt *time.Time; CreatedBy string }` — `CreatedBy` is the creating token's name, "" for none. | |
| 1387 | - `func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error` | |
| 1388 | - `func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error)` | |
| 1389 | - `type Created struct { Tokens []string; Keys []string }` — token names and key fingerprints. | |
| 1390 | - `func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error)` | |
| 1391 | - `type KeyOrigin struct { CreatedByToken int64 }` (MR 3 adds `ExpiresAt`) | |
| 1392 | - `func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error`; `AddSSHKey` keeps its signature and calls it with `KeyOrigin{}`. | |
| 1393 | - `SSHKey.CreatedBy string` — filled by `ListSSHKeys` only. | |
| 1394 | ||
| 1395 | - [ ] **Step 1: Write the failing test** | |
| 1396 | ||
| 1397 | `internal/store/tokens_test.go`: | |
| 1398 | ||
| 1399 | ```go | |
| 1400 | package store | |
| 1401 | ||
| 1402 | import ( | |
| 1403 | "slices" | |
| 1404 | "testing" | |
| 1405 | "time" | |
| 1406 | ) | |
| 1407 | ||
| 1408 | func tokenID(t *testing.T, s *Store, hash string) int64 { | |
| 1409 | t.Helper() | |
| 1410 | _, tok, err := s.APITokenUser(hash) | |
| 1411 | if err != nil { | |
| 1412 | t.Fatal(err) | |
| 1413 | } | |
| 1414 | return tok.ID | |
| 1415 | } | |
| 1416 | ||
| 1417 | // parent made child, child made grandchild and a key; the key belongs | |
| 1418 | // to another account, as admin user create --key makes one. | |
| 1419 | func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) { | |
| 1420 | t.Helper() | |
| 1421 | s, uid, got := revokeFixture(t) | |
| 1422 | bob, err := s.CreateUser("bob", false) | |
| 1423 | if err != nil { | |
| 1424 | t.Fatal(err) | |
| 1425 | } | |
| 1426 | if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil { | |
| 1427 | t.Fatal(err) | |
| 1428 | } | |
| 1429 | if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil { | |
| 1430 | t.Fatal(err) | |
| 1431 | } | |
| 1432 | child := tokenID(t, s, "h-child") | |
| 1433 | if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil { | |
| 1434 | t.Fatal(err) | |
| 1435 | } | |
| 1436 | if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil { | |
| 1437 | t.Fatal(err) | |
| 1438 | } | |
| 1439 | return s, uid, got | |
| 1440 | } | |
| 1441 | ||
| 1442 | func TestTokenRecordsItsCreator(t *testing.T) { | |
| 1443 | s, uid, _ := tokenChain(t) | |
| 1444 | toks, err := s.ListAPITokens(uid) | |
| 1445 | if err != nil { | |
| 1446 | t.Fatal(err) | |
| 1447 | } | |
| 1448 | by := map[string]string{} | |
| 1449 | for _, tk := range toks { | |
| 1450 | by[tk.Name] = tk.CreatedBy | |
| 1451 | } | |
| 1452 | if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" { | |
| 1453 | t.Fatalf("created by: %v", by) | |
| 1454 | } | |
| 1455 | bob, _ := s.UserByUsername("bob") | |
| 1456 | keys, err := s.ListSSHKeys(bob.ID) | |
| 1457 | if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" { | |
| 1458 | t.Fatalf("key created by: %+v %v", keys, err) | |
| 1459 | } | |
| 1460 | } | |
| 1461 | ||
| 1462 | func TestRevokeAPITokenListsWhatItCreated(t *testing.T) { | |
| 1463 | s, uid, got := tokenChain(t) | |
| 1464 | c, err := s.RevokeAPIToken(uid, "parent", false) | |
| 1465 | if err != nil { | |
| 1466 | t.Fatal(err) | |
| 1467 | } | |
| 1468 | if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) { | |
| 1469 | t.Fatalf("created = %+v", c) | |
| 1470 | } | |
| 1471 | // Listed, not removed; the link to the revoked parent is gone. | |
| 1472 | toks, _ := s.ListAPITokens(uid) | |
| 1473 | if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" { | |
| 1474 | t.Fatalf("tokens after revoke: %+v", toks) | |
| 1475 | } | |
| 1476 | if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil { | |
| 1477 | t.Fatalf("the key went: %v", err) | |
| 1478 | } | |
| 1479 | if len(*got) != 0 { | |
| 1480 | t.Fatalf("announced %+v with nothing revoked but the token", *got) | |
| 1481 | } | |
| 1482 | } | |
| 1483 | ||
| 1484 | func TestRevokeAPITokenWithCreated(t *testing.T) { | |
| 1485 | s, uid, got := tokenChain(t) | |
| 1486 | k, _ := s.SSHKeyByFingerprint("SHA256:k") | |
| 1487 | if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil { | |
| 1488 | t.Fatal(err) | |
| 1489 | } | |
| 1490 | if toks, _ := s.ListAPITokens(uid); len(toks) != 0 { | |
| 1491 | t.Fatalf("tokens left: %+v", toks) | |
| 1492 | } | |
| 1493 | if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound { | |
| 1494 | t.Fatalf("key left: %v", err) | |
| 1495 | } | |
| 1496 | if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) { | |
| 1497 | t.Fatalf("announced %+v", *got) | |
| 1498 | } | |
| 1499 | if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound { | |
| 1500 | t.Fatalf("second revoke: %v", err) | |
| 1501 | } | |
| 1502 | } | |
| 1503 | ||
| 1504 | func TestAPITokenUserCarriesExpiry(t *testing.T) { | |
| 1505 | s, uid, _ := revokeFixture(t) | |
| 1506 | exp := time.Now().Add(time.Hour) | |
| 1507 | if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil { | |
| 1508 | t.Fatal(err) | |
| 1509 | } | |
| 1510 | _, tok, err := s.APITokenUser("h-brief") | |
| 1511 | if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 { | |
| 1512 | t.Fatalf("token %+v %v", tok, err) | |
| 1513 | } | |
| 1514 | } | |
| 1515 | ``` | |
| 1516 | ||
| 1517 | - [ ] **Step 2: Run it and see it fail** | |
| 1518 | ||
| 1519 | Run: `go test ./internal/store -run 'TestTokenRecordsItsCreator|TestRevokeAPIToken|TestAPITokenUserCarriesExpiry' -count=1` | |
| 1520 | Expected: FAIL to compile. | |
| 1521 | ||
| 1522 | - [ ] **Step 3: Migration** | |
| 1523 | ||
| 1524 | `internal/store/migrations/0060_credential_origin.up.sql`: | |
| 1525 | ||
| 1526 | ```sql | |
| 1527 | -- The API token a credential was created through. NULL when it was not, | |
| 1528 | -- and once that token is revoked. | |
| 1529 | ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL; | |
| 1530 | ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL; | |
| 1531 | ``` | |
| 1532 | ||
| 1533 | `internal/store/migrations/0060_credential_origin.down.sql`: | |
| 1534 | ||
| 1535 | ```sql | |
| 1536 | ALTER TABLE ssh_keys DROP COLUMN created_by_token; | |
| 1537 | ALTER TABLE api_tokens DROP COLUMN created_by_token; | |
| 1538 | ``` | |
| 1539 | ||
| 1540 | - [ ] **Step 4: Tokens in the store** | |
| 1541 | ||
| 1542 | Replace `internal/store/tokens.go`: | |
| 1543 | ||
| 1544 | ```go | |
| 1545 | package store | |
| 1546 | ||
| 1547 | import ( | |
| 1548 | "database/sql" | |
| 1549 | "errors" | |
| 1550 | "fmt" | |
| 1551 | "strings" | |
| 1552 | "time" | |
| 1553 | ) | |
| 1554 | ||
| 1555 | type APIToken struct { | |
| 1556 | ID int64 | |
| 1557 | Name string | |
| 1558 | Scope string | |
| 1559 | CreatedAt string | |
| 1560 | ExpiresAt *time.Time | |
| 1561 | LastUsedAt *time.Time | |
| 1562 | CreatedBy string // name of the token that created this one; "" for none | |
| 1563 | } | |
| 1564 | ||
| 1565 | // nullID stores 0 as NULL. | |
| 1566 | func nullID(id int64) any { | |
| 1567 | if id == 0 { | |
| 1568 | return nil | |
| 1569 | } | |
| 1570 | return id | |
| 1571 | } | |
| 1572 | ||
| 1573 | // CreateAPIToken stores a token hash; expires nil means no expiry, | |
| 1574 | // createdByToken 0 means it was not created through a token. | |
| 1575 | func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error { | |
| 1576 | var exp any | |
| 1577 | if expires != nil { | |
| 1578 | exp = fmtTime(*expires) | |
| 1579 | } | |
| 1580 | _, err := s.DB.Exec( | |
| 1581 | "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)", | |
| 1582 | userID, name, tokenHash, scope, exp, nullID(createdByToken)) | |
| 1583 | if isUniqueErr(err) { | |
| 1584 | return fmt.Errorf("you already have a token named %q", name) | |
| 1585 | } | |
| 1586 | return err | |
| 1587 | } | |
| 1588 | ||
| 1589 | // APITokenUser resolves a presented token to its user and the token; | |
| 1590 | // expired and unknown tokens fail identically. | |
| 1591 | func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) { | |
| 1592 | var userID int64 | |
| 1593 | var t APIToken | |
| 1594 | var exp sql.NullString | |
| 1595 | err := s.DB.QueryRow(` | |
| 1596 | SELECT user_id, id, name, scope, expires_at FROM api_tokens | |
| 1597 | WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`, | |
| 1598 | tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp) | |
| 1599 | if errors.Is(err, sql.ErrNoRows) { | |
| 1600 | return User{}, APIToken{}, ErrNotFound | |
| 1601 | } | |
| 1602 | if err != nil { | |
| 1603 | return User{}, APIToken{}, err | |
| 1604 | } | |
| 1605 | t.ExpiresAt = parseTime(exp) | |
| 1606 | s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash) | |
| 1607 | u, err := s.UserByID(userID) | |
| 1608 | return u, t, err | |
| 1609 | } | |
| 1610 | ||
| 1611 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { | |
| 1612 | rows, err := s.DB.Query(` | |
| 1613 | SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '') | |
| 1614 | FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token | |
| 1615 | WHERE t.user_id = ? ORDER BY t.name`, userID) | |
| 1616 | if err != nil { | |
| 1617 | return nil, err | |
| 1618 | } | |
| 1619 | defer rows.Close() | |
| 1620 | var out []APIToken | |
| 1621 | for rows.Next() { | |
| 1622 | var t APIToken | |
| 1623 | var exp, used sql.NullString | |
| 1624 | if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil { | |
| 1625 | return nil, err | |
| 1626 | } | |
| 1627 | t.ExpiresAt = parseTime(exp) | |
| 1628 | t.LastUsedAt = parseTime(used) | |
| 1629 | out = append(out, t) | |
| 1630 | } | |
| 1631 | return out, rows.Err() | |
| 1632 | } | |
| 1633 | ||
| 1634 | // Created is what a token made, directly or through tokens it made: | |
| 1635 | // token names and SSH key fingerprints. | |
| 1636 | type Created struct { | |
| 1637 | Tokens []string `json:"tokens"` | |
| 1638 | Keys []string `json:"keys"` | |
| 1639 | } | |
| 1640 | ||
| 1641 | // chainCTE selects the token named by the first argument and every | |
| 1642 | // token created from it, at any depth. | |
| 1643 | const chainCTE = `WITH RECURSIVE chain(id) AS ( | |
| 1644 | SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)` | |
| 1645 | ||
| 1646 | // RevokeAPIToken deletes the user's token by name and returns what it | |
| 1647 | // created. withCreated deletes those too; otherwise they stay and lose | |
| 1648 | // the link to the revoked token. | |
| 1649 | func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) { | |
| 1650 | tx, err := s.DB.Begin() | |
| 1651 | if err != nil { | |
| 1652 | return Created{}, err | |
| 1653 | } | |
| 1654 | defer tx.Rollback() | |
| 1655 | var id int64 | |
| 1656 | err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id) | |
| 1657 | if errors.Is(err, sql.ErrNoRows) { | |
| 1658 | return Created{}, ErrNotFound | |
| 1659 | } | |
| 1660 | if err != nil { | |
| 1661 | return Created{}, err | |
| 1662 | } | |
| 1663 | var c Created | |
| 1664 | rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id) | |
| 1665 | if err != nil { | |
| 1666 | return Created{}, err | |
| 1667 | } | |
| 1668 | for rows.Next() { | |
| 1669 | var n string | |
| 1670 | if err := rows.Scan(&n); err != nil { | |
| 1671 | rows.Close() | |
| 1672 | return Created{}, err | |
| 1673 | } | |
| 1674 | c.Tokens = append(c.Tokens, n) | |
| 1675 | } | |
| 1676 | rows.Close() | |
| 1677 | var keyIDs []int64 | |
| 1678 | rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id) | |
| 1679 | if err != nil { | |
| 1680 | return Created{}, err | |
| 1681 | } | |
| 1682 | for rows.Next() { | |
| 1683 | var kid int64 | |
| 1684 | var fp string | |
| 1685 | if err := rows.Scan(&kid, &fp); err != nil { | |
| 1686 | rows.Close() | |
| 1687 | return Created{}, err | |
| 1688 | } | |
| 1689 | keyIDs = append(keyIDs, kid) | |
| 1690 | c.Keys = append(c.Keys, fp) | |
| 1691 | } | |
| 1692 | rows.Close() | |
| 1693 | ||
| 1694 | if !withCreated { | |
| 1695 | if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil { | |
| 1696 | return Created{}, err | |
| 1697 | } | |
| 1698 | return c, tx.Commit() | |
| 1699 | } | |
| 1700 | if len(keyIDs) > 0 { | |
| 1701 | args := make([]any, len(keyIDs)) | |
| 1702 | for i, k := range keyIDs { | |
| 1703 | args[i] = k | |
| 1704 | } | |
| 1705 | if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil { | |
| 1706 | return Created{}, err | |
| 1707 | } | |
| 1708 | if err := bumpKeyEpoch(tx); err != nil { | |
| 1709 | return Created{}, err | |
| 1710 | } | |
| 1711 | } | |
| 1712 | if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil { | |
| 1713 | return Created{}, err | |
| 1714 | } | |
| 1715 | if err := tx.Commit(); err != nil { | |
| 1716 | return Created{}, err | |
| 1717 | } | |
| 1718 | if len(keyIDs) > 0 { | |
| 1719 | s.announce(Revoked{KeyIDs: keyIDs}) | |
| 1720 | } | |
| 1721 | return c, nil | |
| 1722 | } | |
| 1723 | ``` | |
| 1724 | ||
| 1725 | Before writing `nullID`, run `grep -rn "func nullID" internal/store`; if one exists, use it and drop this copy. | |
| 1726 | ||
| 1727 | - [ ] **Step 5: Keys in the store** | |
| 1728 | ||
| 1729 | In `internal/store/users.go`, add to `SSHKey` after `LastUsedAt`: | |
| 1730 | ||
| 1731 | ```go | |
| 1732 | CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only. | |
| 1733 | ``` | |
| 1734 | ||
| 1735 | Replace `AddSSHKey`: | |
| 1736 | ||
| 1737 | ```go | |
| 1738 | // KeyOrigin is how a key came to be. | |
| 1739 | type KeyOrigin struct { | |
| 1740 | CreatedByToken int64 // the API token that added it; 0 for none | |
| 1741 | } | |
| 1742 | ||
| 1743 | // AddSSHKey registers a key and bumps the key epoch in one transaction. | |
| 1744 | func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error { | |
| 1745 | return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{}) | |
| 1746 | } | |
| 1747 | ||
| 1748 | // AddSSHKeyFrom is AddSSHKey recording where the key came from. | |
| 1749 | func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error { | |
| 1750 | tx, err := s.DB.Begin() | |
| 1751 | if err != nil { | |
| 1752 | return err | |
| 1753 | } | |
| 1754 | defer tx.Rollback() | |
| 1755 | if _, err := tx.Exec( | |
| 1756 | "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 1757 | userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil { | |
| 1758 | if isUniqueErr(err) { | |
| 1759 | return ErrDuplicateKey | |
| 1760 | } | |
| 1761 | return err | |
| 1762 | } | |
| 1763 | if err := bumpKeyEpoch(tx); err != nil { | |
| 1764 | return err | |
| 1765 | } | |
| 1766 | return tx.Commit() | |
| 1767 | } | |
| 1768 | ``` | |
| 1769 | ||
| 1770 | `ListSSHKeys`: | |
| 1771 | ||
| 1772 | ```go | |
| 1773 | func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) { | |
| 1774 | rows, err := s.DB.Query( | |
| 1775 | `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at, | |
| 1776 | COALESCE(k.last_used_at, ''), COALESCE(t.name, '') | |
| 1777 | FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token | |
| 1778 | WHERE k.user_id = ? ORDER BY k.id`, | |
| 1779 | userID) | |
| 1780 | if err != nil { | |
| 1781 | return nil, err | |
| 1782 | } | |
| 1783 | defer rows.Close() | |
| 1784 | var keys []SSHKey | |
| 1785 | for rows.Next() { | |
| 1786 | var k SSHKey | |
| 1787 | if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil { | |
| 1788 | return nil, err | |
| 1789 | } | |
| 1790 | keys = append(keys, k) | |
| 1791 | } | |
| 1792 | return keys, rows.Err() | |
| 1793 | } | |
| 1794 | ``` | |
| 1795 | ||
| 1796 | - [ ] **Step 6: Run the store tests** | |
| 1797 | ||
| 1798 | Run: `go test ./internal/store -count=1` | |
| 1799 | Expected: PASS. (`go build ./...` fails until Task 2.2 updates the callers.) | |
| 1800 | ||
| 1801 | - [ ] **Step 7: Commit** | |
| 1802 | ||
| 1803 | ```bash | |
| 1804 | git add internal/store | |
| 1805 | git commit -S -m "store: tokens and keys record the token that created them; chained revoke | |
| 1806 | ||
| 1807 | Ref #257" | |
| 1808 | ``` | |
| 1809 | ||
| 1810 | ### Task 2.2: `MintsCredential`, `Ctx.Expires`, and the API wiring | |
| 1811 | ||
| 1812 | **Files:** | |
| 1813 | - Modify: `internal/control/control.go` — `Ctx` (21-57), `Command` (79-91), `Dispatch` (after line 161) | |
| 1814 | - Modify: `internal/httpd/api.go:30-78`, `:126-144`; `internal/httpd/apiread.go:26` | |
| 1815 | - Modify: registrations in `internal/control/token.go:16`, `identity.go:33`, `deploykey.go:16`, `runnerrepo.go:21`, `web.go:16`, `adminhost.go:24`, `:53`, `:58`, `register.go:38` | |
| 1816 | - Test: `internal/control/token_test.go` (create) | |
| 1817 | ||
| 1818 | **Interfaces:** | |
| 1819 | - Consumes: `store.APITokenUser` returning `APIToken` (Task 2.1). | |
| 1820 | - Produces: | |
| 1821 | - `Command.MintsCredential bool` | |
| 1822 | - `Ctx.TokenID int64` — the API token behind the request, 0 for none. | |
| 1823 | - `Ctx.Expires *time.Time` — when the credential behind the request lapses; nil when it does not. MR 3 sets it for keys. | |
| 1824 | ||
| 1825 | - [ ] **Step 1: Write the failing tests** | |
| 1826 | ||
| 1827 | `internal/control/token_test.go`: | |
| 1828 | ||
| 1829 | ```go | |
| 1830 | package control | |
| 1831 | ||
| 1832 | import ( | |
| 1833 | "bytes" | |
| 1834 | "slices" | |
| 1835 | "strings" | |
| 1836 | "testing" | |
| 1837 | "time" | |
| 1838 | ||
| 1839 | "gitbay.org/gitbay/internal/protocol" | |
| 1840 | "gitbay.org/gitbay/internal/store" | |
| 1841 | ) | |
| 1842 | ||
| 1843 | // The minting commands, pinned: adding one to the list, or dropping | |
| 1844 | // one, is a decision this test makes someone take. | |
| 1845 | func TestMintingCommandsMarked(t *testing.T) { | |
| 1846 | want := []string{ | |
| 1847 | "admin email verify", "admin invite", "admin user create", "email verify", | |
| 1848 | "keys add", "repo deploy-key add", "repo runner add", "token create", "web login", | |
| 1849 | } | |
| 1850 | var got []string | |
| 1851 | for _, cmd := range Commands() { | |
| 1852 | if cmd.MintsCredential { | |
| 1853 | got = append(got, joinPath(cmd.Path)) | |
| 1854 | } | |
| 1855 | } | |
| 1856 | slices.Sort(got) | |
| 1857 | if !slices.Equal(got, want) { | |
| 1858 | t.Fatalf("MintsCredential on %q, want %q", got, want) | |
| 1859 | } | |
| 1860 | } | |
| 1861 | ||
| 1862 | // Dispatch refuses before the command runs, so no arguments are needed. | |
| 1863 | func TestExpiringCredentialCannotMint(t *testing.T) { | |
| 1864 | exp := time.Now().Add(time.Hour) | |
| 1865 | for _, cmd := range Commands() { | |
| 1866 | if !cmd.MintsCredential { | |
| 1867 | continue | |
| 1868 | } | |
| 1869 | var out, errOut bytes.Buffer | |
| 1870 | c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut} | |
| 1871 | if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") { | |
| 1872 | t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied) | |
| 1873 | } | |
| 1874 | } | |
| 1875 | } | |
| 1876 | ||
| 1877 | func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) { | |
| 1878 | st, _, uid := newQueueTestRepo(t) | |
| 1879 | if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil { | |
| 1880 | t.Fatal(err) | |
| 1881 | } | |
| 1882 | _, parent, err := st.APITokenUser("h-parent") | |
| 1883 | if err != nil { | |
| 1884 | t.Fatal(err) | |
| 1885 | } | |
| 1886 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | |
| 1887 | c.Cfg.Limits.WriteRate = -1 | |
| 1888 | c.TokenID = parent.ID | |
| 1889 | if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK { | |
| 1890 | t.Fatalf("exit %d: %s", code, errOut) | |
| 1891 | } | |
| 1892 | toks, err := st.ListAPITokens(uid) | |
| 1893 | if err != nil { | |
| 1894 | t.Fatal(err) | |
| 1895 | } | |
| 1896 | for _, tk := range toks { | |
| 1897 | if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") { | |
| 1898 | t.Fatalf("child: %+v", tk) | |
| 1899 | } | |
| 1900 | } | |
| 1901 | } | |
| 1902 | ``` | |
| 1903 | ||
| 1904 | - [ ] **Step 2: Run them and see them fail** | |
| 1905 | ||
| 1906 | Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint|TestTokenCreateDefaultsToRead' -count=1` | |
| 1907 | Expected: FAIL to compile, `unknown field MintsCredential`. | |
| 1908 | ||
| 1909 | - [ ] **Step 3: `Ctx`, `Command`, `Dispatch`** | |
| 1910 | ||
| 1911 | In `Ctx`, after `Source string`: | |
| 1912 | ||
| 1913 | ```go | |
| 1914 | // TokenID is the API token behind this request, 0 for none. A | |
| 1915 | // credential the request creates records it. | |
| 1916 | TokenID int64 | |
| 1917 | // Expires is when the credential behind this request lapses; nil | |
| 1918 | // when it does not. Dispatch refuses MintsCredential commands when | |
| 1919 | // it is set. | |
| 1920 | Expires *time.Time | |
| 1921 | ``` | |
| 1922 | ||
| 1923 | In `Command`, after `ReadOnly`: | |
| 1924 | ||
| 1925 | ```go | |
| 1926 | // MintsCredential marks a command that creates a credential or a way | |
| 1927 | // to obtain one: tokens, keys, login links, invites, accounts, | |
| 1928 | // verified addresses. An expiring credential may not run it. | |
| 1929 | MintsCredential bool | |
| 1930 | ``` | |
| 1931 | ||
| 1932 | In `Dispatch`, after the `c.ReadOnly && !cmd.ReadOnly` check: | |
| 1933 | ||
| 1934 | ```go | |
| 1935 | // What an expiring credential creates would outlive it (#257). | |
| 1936 | if cmd.MintsCredential && c.Expires != nil { | |
| 1937 | return c.fail(protocol.ExitDenied, | |
| 1938 | "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path)) | |
| 1939 | } | |
| 1940 | ``` | |
| 1941 | ||
| 1942 | - [ ] **Step 4: Mark the nine commands** | |
| 1943 | ||
| 1944 | Add `MintsCredential: true,` to each registration: `token create` (`token.go:16`), `keys add` (`identity.go:33`), `repo deploy-key add` (`deploykey.go:16`), `repo runner add` (`runnerrepo.go:21`), `web login` (`web.go:16`), `admin user create` (`adminhost.go:24`), `admin email verify` (`adminhost.go:53`), `admin invite` (`adminhost.go:58`), `email verify` (`register.go:38`). For example: | |
| 1945 | ||
| 1946 | ```go | |
| 1947 | register(Command{Path: []string{"web", "login"}, | |
| 1948 | Summary: "mint a one-time browser login URL", | |
| 1949 | Usage: "web login", | |
| 1950 | MintsCredential: true, | |
| 1951 | Examples: []string{"web login"}, Run: runWebLogin}) | |
| 1952 | ``` | |
| 1953 | ||
| 1954 | - [ ] **Step 5: The API passes the token** | |
| 1955 | ||
| 1956 | In `internal/httpd/api.go`, `apiAuth` returns the token: | |
| 1957 | ||
| 1958 | ```go | |
| 1959 | // apiAuth resolves the bearer token; failures are uniform 401s. | |
| 1960 | func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) { | |
| 1961 | token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") | |
| 1962 | if !ok || token == "" { | |
| 1963 | w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`) | |
| 1964 | apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>") | |
| 1965 | return store.User{}, store.APIToken{}, false | |
| 1966 | } | |
| 1967 | user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token))) | |
| 1968 | if err != nil { | |
| 1969 | if errors.Is(err, store.ErrNotFound) { | |
| 1970 | apiError(w, http.StatusUnauthorized, "invalid or expired token") | |
| 1971 | return store.User{}, store.APIToken{}, false | |
| 1972 | } | |
| 1973 | apiError(w, http.StatusInternalServerError, "internal error") | |
| 1974 | return store.User{}, store.APIToken{}, false | |
| 1975 | } | |
| 1976 | return user, tok, true | |
| 1977 | } | |
| 1978 | ``` | |
| 1979 | ||
| 1980 | In `apiCmd`: `user, tok, ok := s.apiAuth(w, r)`, and in the `Ctx` literal replace `ReadOnly: scope == "read",` with: | |
| 1981 | ||
| 1982 | ```go | |
| 1983 | ReadOnly: tok.Scope == "read", | |
| 1984 | TokenID: tok.ID, | |
| 1985 | Expires: tok.ExpiresAt, | |
| 1986 | ``` | |
| 1987 | ||
| 1988 | `apiRead` keeps `user, _, ok := s.apiAuth(w, r)`; it compiles unchanged. | |
| 1989 | ||
| 1990 | - [ ] **Step 6: Run the tests** | |
| 1991 | ||
| 1992 | Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint' -count=1` | |
| 1993 | Expected: PASS. `TestTokenCreateDefaultsToRead...` still fails until Task 2.3. | |
| 1994 | ||
| 1995 | - [ ] **Step 7: Commit** | |
| 1996 | ||
| 1997 | ```bash | |
| 1998 | git add internal/control/control.go internal/control/token_test.go internal/control/*.go internal/httpd/api.go | |
| 1999 | git commit -S -m "control: expiring credentials cannot run credential-minting commands | |
| 2000 | ||
| 2001 | Ref #257" | |
| 2002 | ``` | |
| 2003 | ||
| 2004 | ### Task 2.3: commands record their token; `token create` defaults to read; `token revoke --created` | |
| 2005 | ||
| 2006 | **Files:** | |
| 2007 | - Modify: `internal/control/token.go` (registrations 16-34, `runTokenCreate` 49-88, `runTokenList` 90-122, `runTokenRevoke` 124-137) | |
| 2008 | - Modify: `internal/control/identity.go` — `runKeysList` (76-100), `runKeysAdd` (152) | |
| 2009 | - Modify: `internal/control/deploykey.go:71`, `internal/control/runnerrepo.go:60`, `internal/control/adminhost.go:125` | |
| 2010 | - Modify: `e2e/api_test.go:50`, `:266-282` (`mintToken`) | |
| 2011 | ||
| 2012 | **Interfaces:** | |
| 2013 | - Consumes: `Ctx.TokenID`, `store.KeyOrigin`, `Store.AddSSHKeyFrom`, `Store.RevokeAPIToken` (Tasks 2.1–2.2). | |
| 2014 | ||
| 2015 | - [ ] **Step 1: `token create`** | |
| 2016 | ||
| 2017 | Registration: | |
| 2018 | ||
| 2019 | ```go | |
| 2020 | register(Command{Path: []string{"token", "create"}, | |
| 2021 | Summary: "mint an API token (shown once)", | |
| 2022 | Usage: "token create --name <n> [--scope read|full] [--ttl 30d|720h]", | |
| 2023 | Flags: []Flag{ | |
| 2024 | {"--name", "<n>", "the token's name", ""}, | |
| 2025 | {"--scope", "read|full", "what the token may do; full is needed to change anything", "read"}, | |
| 2026 | {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"}, | |
| 2027 | }, | |
| 2028 | Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"}, | |
| 2029 | MintsCredential: true, | |
| 2030 | Run: runTokenCreate}) | |
| 2031 | ``` | |
| 2032 | ||
| 2033 | In `runTokenCreate`: the `parseFlags` usage becomes `Usage: c.Cmd.Usage`; `name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")`; the store call: | |
| 2034 | ||
| 2035 | ```go | |
| 2036 | if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil { | |
| 2037 | ``` | |
| 2038 | ||
| 2039 | - [ ] **Step 2: `token list` shows the creator in JSON** | |
| 2040 | ||
| 2041 | In `runTokenList`, the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` after `LastUsedAt`, and the append becomes `out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}`. Plain output is unchanged. | |
| 2042 | ||
| 2043 | - [ ] **Step 3: `token revoke [--created]`** | |
| 2044 | ||
| 2045 | Registration: | |
| 2046 | ||
| 2047 | ```go | |
| 2048 | register(Command{Path: []string{"token", "revoke"}, | |
| 2049 | Summary: "revoke an API token by name", | |
| 2050 | Usage: "token revoke <name> [--created]", | |
| 2051 | Flags: []Flag{ | |
| 2052 | {"--created", "", "also revoke the tokens and keys it created, at any depth", ""}, | |
| 2053 | }, | |
| 2054 | Examples: []string{"token revoke laptop", "token revoke laptop --created"}, | |
| 2055 | Run: runTokenRevoke}) | |
| 2056 | ``` | |
| 2057 | ||
| 2058 | ```go | |
| 2059 | func runTokenRevoke(c *Ctx, args []string) int { | |
| 2060 | f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 2061 | if err != nil { | |
| 2062 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 2063 | } | |
| 2064 | name := f.pos(0) | |
| 2065 | if name == "" { | |
| 2066 | return c.usage() | |
| 2067 | } | |
| 2068 | withCreated := f.Has("--created") | |
| 2069 | created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated) | |
| 2070 | if err != nil { | |
| 2071 | if errors.Is(err, store.ErrNotFound) { | |
| 2072 | return c.fail(protocol.ExitNotFound, "no token named %q", name) | |
| 2073 | } | |
| 2074 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 2075 | } | |
| 2076 | type out struct { | |
| 2077 | Revoked string `json:"revoked"` | |
| 2078 | Created store.Created `json:"created"` | |
| 2079 | CreatedRevoked bool `json:"created_revoked"` | |
| 2080 | } | |
| 2081 | d := out{name, created, withCreated} | |
| 2082 | return c.emit(d, func(w io.Writer) { | |
| 2083 | fmt.Fprintf(w, "revoked %s\n", name) | |
| 2084 | if len(created.Tokens)+len(created.Keys) == 0 { | |
| 2085 | return | |
| 2086 | } | |
| 2087 | if withCreated { | |
| 2088 | fmt.Fprintln(w, "and what it created:") | |
| 2089 | } else { | |
| 2090 | fmt.Fprintln(w, "it created these, still in place:") | |
| 2091 | } | |
| 2092 | for _, n := range created.Tokens { | |
| 2093 | fmt.Fprintf(w, " token %s\n", n) | |
| 2094 | } | |
| 2095 | for _, fp := range created.Keys { | |
| 2096 | fmt.Fprintf(w, " key %s\n", fp) | |
| 2097 | } | |
| 2098 | }) | |
| 2099 | } | |
| 2100 | ``` | |
| 2101 | ||
| 2102 | - [ ] **Step 4: Key-adding commands record the token** | |
| 2103 | ||
| 2104 | `identity.go`, `runKeysAdd`: | |
| 2105 | ||
| 2106 | ```go | |
| 2107 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | |
| 2108 | ``` | |
| 2109 | ||
| 2110 | `deploykey.go:71`: | |
| 2111 | ||
| 2112 | ```go | |
| 2113 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | |
| 2114 | ``` | |
| 2115 | ||
| 2116 | `runnerrepo.go:60`: | |
| 2117 | ||
| 2118 | ```go | |
| 2119 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | |
| 2120 | ``` | |
| 2121 | ||
| 2122 | `adminhost.go:125`: | |
| 2123 | ||
| 2124 | ```go | |
| 2125 | if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil { | |
| 2126 | ``` | |
| 2127 | ||
| 2128 | `keys list` JSON: in `runKeysList` the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` and the append passes `k.CreatedBy`. Plain output is unchanged in this MR. | |
| 2129 | ||
| 2130 | - [ ] **Step 5: e2e callers that write with a default-scope token** | |
| 2131 | ||
| 2132 | `e2e/api_test.go:50`: `"token", "create", "--name", "ci", "--scope", "full", "--json"`. | |
| 2133 | `mintToken` (`e2e/api_test.go:268`): `"token", "create", "--name", name, "--scope", "full", "--json"`. | |
| 2134 | Then `grep -rn '"token", "create"' e2e` and check each remaining call: a token only used for reads, or for a refusal, needs nothing. | |
| 2135 | ||
| 2136 | - [ ] **Step 6: Run the tests** | |
| 2137 | ||
| 2138 | Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/httpd -count=1` | |
| 2139 | Expected: PASS, including `TestHelpIsComplete` (every flag in the usage is described) and `TestTokenCreateDefaultsToReadAndRecordsCreator`. | |
| 2140 | ||
| 2141 | - [ ] **Step 7: Commit** | |
| 2142 | ||
| 2143 | ```bash | |
| 2144 | git add internal/control e2e/api_test.go | |
| 2145 | git commit -S -m "token: default --scope read; record the creating token; revoke --created | |
| 2146 | ||
| 2147 | Ref #257" | |
| 2148 | ``` | |
| 2149 | ||
| 2150 | ### Task 2.4: e2e — delegation over the API | |
| 2151 | ||
| 2152 | **Files:** | |
| 2153 | - Create: `e2e/tokenorigin_test.go` | |
| 2154 | ||
| 2155 | **Interfaces:** | |
| 2156 | - Consumes: `fingerprint` (MR 1, `e2e/revoke_test.go`), `inst.apiCall`. | |
| 2157 | ||
| 2158 | - [ ] **Step 1: Write the test** | |
| 2159 | ||
| 2160 | ```go | |
| 2161 | package e2e | |
| 2162 | ||
| 2163 | import ( | |
| 2164 | "encoding/json" | |
| 2165 | "fmt" | |
| 2166 | "os" | |
| 2167 | "strings" | |
| 2168 | "testing" | |
| 2169 | ) | |
| 2170 | ||
| 2171 | // An expiring token cannot mint a credential that outlives it, and | |
| 2172 | // revoking a token can take what it created with it (#257). | |
| 2173 | func TestTokenDelegation(t *testing.T) { | |
| 2174 | t.Parallel() | |
| 2175 | inst := startInstanceWith(t, "[api]\nenabled = true\n") | |
| 2176 | aliceKey := inst.newKey(t, "alice") | |
| 2177 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 2178 | ||
| 2179 | mint := func(args ...string) (token, scope string) { | |
| 2180 | t.Helper() | |
| 2181 | out, errOut, code := inst.ssh(t, aliceKey, "", append([]string{"token", "create", "--json"}, args...)...) | |
| 2182 | if code != 0 { | |
| 2183 | t.Fatalf("token create %v: %s", args, errOut) | |
| 2184 | } | |
| 2185 | var env struct { | |
| 2186 | Data struct { | |
| 2187 | Token string `json:"token"` | |
| 2188 | Scope string `json:"scope"` | |
| 2189 | } `json:"data"` | |
| 2190 | } | |
| 2191 | if err := json.Unmarshal([]byte(out), &env); err != nil { | |
| 2192 | t.Fatalf("token create output: %v %s", err, out) | |
| 2193 | } | |
| 2194 | return env.Data.Token, env.Data.Scope | |
| 2195 | } | |
| 2196 | if _, scope := mint("--name", "plain"); scope != "read" { | |
| 2197 | t.Fatalf("default scope %q, want read", scope) | |
| 2198 | } | |
| 2199 | brief, _ := mint("--name", "brief", "--scope", "full", "--ttl", "1h") | |
| 2200 | lasting, _ := mint("--name", "lasting", "--scope", "full") | |
| 2201 | ||
| 2202 | spare := inst.newKey(t, "spare") | |
| 2203 | pub, err := os.ReadFile(spare + ".pub") | |
| 2204 | if err != nil { | |
| 2205 | t.Fatal(err) | |
| 2206 | } | |
| 2207 | status, body := inst.apiCall(t, brief, []string{"keys", "add"}, string(pub)) | |
| 2208 | if status != 403 || !strings.Contains(fmt.Sprint(body["error"]), "expires") { | |
| 2209 | t.Fatalf("expiring token added a key: %d %v", status, body) | |
| 2210 | } | |
| 2211 | if status, _ := inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 { | |
| 2212 | t.Fatalf("expiring token refused a read: %d", status) | |
| 2213 | } | |
| 2214 | if status, body := inst.apiCall(t, lasting, []string{"keys", "add"}, string(pub)); status != 200 { | |
| 2215 | t.Fatalf("keys add: %d %v", status, body) | |
| 2216 | } | |
| 2217 | if status, body := inst.apiCall(t, lasting, []string{"token", "create", "--name", "child"}, ""); status != 200 { | |
| 2218 | t.Fatalf("token create: %d %v", status, body) | |
| 2219 | } | |
| 2220 | if _, errOut, code := inst.ssh(t, spare, "", "whoami"); code != 0 { | |
| 2221 | t.Fatalf("the added key does not work: %s", errOut) | |
| 2222 | } | |
| 2223 | ||
| 2224 | out, errOut, code := inst.ssh(t, aliceKey, "", "token", "revoke", "lasting", "--created") | |
| 2225 | if code != 0 || !strings.Contains(out, "token child") || !strings.Contains(out, fingerprint(t, spare+".pub")) { | |
| 2226 | t.Fatalf("revoke --created: exit %d\n%s%s", code, out, errOut) | |
| 2227 | } | |
| 2228 | if _, _, code := inst.ssh(t, spare, "", "whoami"); code == 0 { | |
| 2229 | t.Fatal("a key the revoked token created still works") | |
| 2230 | } | |
| 2231 | if out, _, _ := inst.ssh(t, aliceKey, "", "token", "list"); strings.Contains(out, "child") { | |
| 2232 | t.Fatalf("the child token survived:\n%s", out) | |
| 2233 | } | |
| 2234 | } | |
| 2235 | ``` | |
| 2236 | ||
| 2237 | - [ ] **Step 2: Run it** | |
| 2238 | ||
| 2239 | Run: `go test ./e2e -run TestTokenDelegation -count=1` | |
| 2240 | Expected: PASS. | |
| 2241 | ||
| 2242 | - [ ] **Step 3: Commit** | |
| 2243 | ||
| 2244 | ```bash | |
| 2245 | git add e2e/tokenorigin_test.go | |
| 2246 | git commit -S -m "e2e: expiring tokens refused on minting; revoke --created | |
| 2247 | ||
| 2248 | Ref #257" | |
| 2249 | ``` | |
| 2250 | ||
| 2251 | ### Task 2.5: docs and release note | |
| 2252 | ||
| 2253 | **Files:** | |
| 2254 | - Modify: `.gitbay/wiki/API.org:14-33` (Tokens), `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:20`, `09-Controls.org:29`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org:358`, `CHANGELOG.org`, `internal/web/templates/account.html:194` | |
| 2255 | ||
| 2256 | - [ ] **Step 1: Edit the pages** | |
| 2257 | ||
| 2258 | `API.org`, the Tokens section's first paragraph and code block become: | |
| 2259 | ||
| 2260 | ``` | |
| 2261 | Tokens are minted wherever the registry is reached: over SSH, on the | |
| 2262 | API, anywhere. =token create= makes a =read= token unless =--scope full= | |
| 2263 | is given; a read token runs only commands marked read-only. A full-scope | |
| 2264 | token can mint another, but a token with a =--ttl= cannot run any | |
| 2265 | command that creates a credential — =token create=, =keys add=, | |
| 2266 | =repo deploy-key add=, =repo runner add=, =web login=, =admin invite=, | |
| 2267 | =admin user create=, =email verify=, =admin email verify= — since what | |
| 2268 | it made would outlive it. Give a token the narrowest scope and shortest | |
| 2269 | TTL that does its job, and revoke it when the job is over. | |
| 2270 | ||
| 2271 | #+begin_src sh | |
| 2272 | gitbay auth token create --name ci [--scope read|full] [--ttl 30d] | |
| 2273 | gitbay auth token list | |
| 2274 | gitbay auth token revoke ci [--created] | |
| 2275 | #+end_src | |
| 2276 | ||
| 2277 | Tokens and keys record the token they were created through. =token | |
| 2278 | revoke= prints what the token created, at any depth; with =--created= | |
| 2279 | it revokes those too, and their SSH connections close. Without it they | |
| 2280 | stay and the link is dropped. | |
| 2281 | ``` | |
| 2282 | ||
| 2283 | `Threat-Model.org`, in Trust boundaries, replace "so a bearer token is worth exactly its scope and no more." with "so a bearer token is worth exactly its scope and no more, and a credential with an expiry cannot create one that outlives it." | |
| 2284 | ||
| 2285 | `05-Identity-and-Access.org`: the API token row's Scope cell becomes `=read= (default) or =full=; with an expiry, no credential-minting command`, and its Revocation cell `=token revoke [--created]=`. | |
| 2286 | ||
| 2287 | `09-Controls.org`: | |
| 2288 | ||
| 2289 | ``` | |
| 2290 | | Delegation bounded by the delegating credential | in place | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=) | | |
| 2291 | ``` | |
| 2292 | ||
| 2293 | `10-Known-Gaps.org`: delete the `#257` row and the "#257 refuses credential creation ..." sentence, leaving the paragraph out if nothing remains in it. | |
| 2294 | ||
| 2295 | `Parity.org`, after the `API token mint` row: | |
| 2296 | ||
| 2297 | ``` | |
| 2298 | | API token revoke with what it created | yes | no | no | | |
| 2299 | ``` | |
| 2300 | ||
| 2301 | `account.html:194`: `gitbay auth token create --name laptop # API tokens, read-only unless --scope full`. | |
| 2302 | ||
| 2303 | `CHANGELOG.org`, under the unreleased heading (see Global constraints): | |
| 2304 | ||
| 2305 | ``` | |
| 2306 | *Upgrade note.* =token create= makes a =read= token unless given | |
| 2307 | =--scope full=. A script that mints a token and then writes with it | |
| 2308 | must add =--scope full=. Existing tokens keep their scope. | |
| 2309 | ||
| 2310 | - A token with a =--ttl= is refused on every command that creates a | |
| 2311 | credential: tokens, keys, deploy keys, runner keys, login links, | |
| 2312 | invites, accounts and verified addresses (#257). | |
| 2313 | - Tokens and SSH keys record the token they were created through. | |
| 2314 | =token revoke <name>= lists what it created; =--created= revokes | |
| 2315 | those too. | |
| 2316 | - Removing an SSH key, a deploy key, or disabling an account closes the | |
| 2317 | connections the key opened, a push in flight included (#256). | |
| 2318 | ``` | |
| 2319 | ||
| 2320 | (The #256 line belongs to MR 1's changes; add it here if MR 1 did not touch the changelog.) | |
| 2321 | ||
| 2322 | - [ ] **Step 2: Commit, open the MR** | |
| 2323 | ||
| 2324 | ```bash | |
| 2325 | git add .gitbay/wiki CHANGELOG.org internal/web/templates/account.html | |
| 2326 | git commit -S -m "wiki: token delegation, read default; release note | |
| 2327 | ||
| 2328 | Closes #257" | |
| 2329 | git push -u origin token-delegation | |
| 2330 | gitbay mr create --source token-delegation --target main --title "token: expiring tokens cannot mint credentials; record creator; default read scope" | |
| 2331 | ``` | |
| 2332 | ||
| 2333 | --- | |
| 2334 | ||
| 2335 | # MR 3: optional expiry for SSH and deploy keys (branch `key-expiry`, #277) | |
| 2336 | ||
| 2337 | The issue says to consider this with #257. An expiring key is treated | |
| 2338 | like an expiring token: `Exec` sets `Ctx.Expires`, so `Dispatch` refuses | |
| 2339 | the minting commands to it (open question 1). | |
| 2340 | ||
| 2341 | ### Task 3.1: migration 0061 and the store | |
| 2342 | ||
| 2343 | **Files:** | |
| 2344 | - Create: `internal/store/migrations/0061_ssh_key_expiry.up.sql`, `.down.sql` | |
| 2345 | - Modify: `internal/store/users.go` — `SSHKey`, `KeyOrigin`, `AddSSHKeyFrom`, `SSHKeyByFingerprint` (324-333), `SSHKeyByID` (502-511), `ListSSHKeys`, `ListDeployKeys` (514-532) | |
| 2346 | - Modify: `internal/store/revoke.go` — `LiveSSHKeys` | |
| 2347 | - Test: `internal/store/keyexpiry_test.go` (create) | |
| 2348 | ||
| 2349 | **Interfaces:** | |
| 2350 | - Produces: | |
| 2351 | - `SSHKey.ExpiresAt *time.Time` — nil when the key never expires; filled by every key query. | |
| 2352 | - `func (k SSHKey) Expired(now time.Time) bool` | |
| 2353 | - `KeyOrigin.ExpiresAt *time.Time` | |
| 2354 | - `LiveSSHKeys` also excludes expired keys. | |
| 2355 | ||
| 2356 | - [ ] **Step 1: Write the failing test** | |
| 2357 | ||
| 2358 | ```go | |
| 2359 | package store | |
| 2360 | ||
| 2361 | import ( | |
| 2362 | "testing" | |
| 2363 | "time" | |
| 2364 | ) | |
| 2365 | ||
| 2366 | func TestKeyExpiry(t *testing.T) { | |
| 2367 | s, uid, _ := revokeFixture(t) | |
| 2368 | past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour) | |
| 2369 | for fp, exp := range map[string]*time.Time{"SHA256:old": &past, "SHA256:new": &future, "SHA256:ever": nil} { | |
| 2370 | if err := s.AddSSHKeyFrom(uid, fp, "ssh-ed25519", []byte(fp), "full", "", KeyOrigin{ExpiresAt: exp}); err != nil { | |
| 2371 | t.Fatal(err) | |
| 2372 | } | |
| 2373 | } | |
| 2374 | now := time.Now() | |
| 2375 | ids := map[string]int64{} | |
| 2376 | for _, fp := range []string{"SHA256:old", "SHA256:new", "SHA256:ever"} { | |
| 2377 | k, err := s.SSHKeyByFingerprint(fp) | |
| 2378 | if err != nil { | |
| 2379 | t.Fatal(err) | |
| 2380 | } | |
| 2381 | ids[fp] = k.ID | |
| 2382 | byID, err := s.SSHKeyByID(k.ID) | |
| 2383 | if err != nil || (byID.ExpiresAt == nil) != (k.ExpiresAt == nil) { | |
| 2384 | t.Fatalf("%s by id: %+v %v", fp, byID, err) | |
| 2385 | } | |
| 2386 | if got, want := k.Expired(now), fp == "SHA256:old"; got != want { | |
| 2387 | t.Errorf("%s Expired = %v, want %v", fp, got, want) | |
| 2388 | } | |
| 2389 | } | |
| 2390 | live, err := s.LiveSSHKeys([]int64{ids["SHA256:old"], ids["SHA256:new"], ids["SHA256:ever"]}) | |
| 2391 | if err != nil { | |
| 2392 | t.Fatal(err) | |
| 2393 | } | |
| 2394 | if live[ids["SHA256:old"]] || !live[ids["SHA256:new"]] || !live[ids["SHA256:ever"]] { | |
| 2395 | t.Fatalf("live = %v", live) | |
| 2396 | } | |
| 2397 | keys, err := s.ListSSHKeys(uid) | |
| 2398 | if err != nil || len(keys) != 3 || keys[2].ExpiresAt != nil { | |
| 2399 | t.Fatalf("list: %+v %v", keys, err) | |
| 2400 | } | |
| 2401 | } | |
| 2402 | ``` | |
| 2403 | ||
| 2404 | - [ ] **Step 2: Run it and see it fail** | |
| 2405 | ||
| 2406 | Run: `go test ./internal/store -run TestKeyExpiry -count=1` | |
| 2407 | Expected: FAIL to compile, `unknown field ExpiresAt in struct literal of type KeyOrigin`. | |
| 2408 | ||
| 2409 | - [ ] **Step 3: Migration** | |
| 2410 | ||
| 2411 | `0061_ssh_key_expiry.up.sql`: | |
| 2412 | ||
| 2413 | ```sql | |
| 2414 | -- When the key stops authenticating; NULL for never. | |
| 2415 | ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT; | |
| 2416 | ``` | |
| 2417 | ||
| 2418 | `0061_ssh_key_expiry.down.sql`: | |
| 2419 | ||
| 2420 | ```sql | |
| 2421 | ALTER TABLE ssh_keys DROP COLUMN expires_at; | |
| 2422 | ``` | |
| 2423 | ||
| 2424 | - [ ] **Step 4: Store** | |
| 2425 | ||
| 2426 | `SSHKey` gains, after `CreatedBy`: | |
| 2427 | ||
| 2428 | ```go | |
| 2429 | ExpiresAt *time.Time // nil when the key never expires | |
| 2430 | ``` | |
| 2431 | ||
| 2432 | and the method: | |
| 2433 | ||
| 2434 | ```go | |
| 2435 | // Expired reports whether the key has lapsed at now. | |
| 2436 | func (k SSHKey) Expired(now time.Time) bool { | |
| 2437 | return k.ExpiresAt != nil && !k.ExpiresAt.After(now) | |
| 2438 | } | |
| 2439 | ``` | |
| 2440 | ||
| 2441 | `KeyOrigin`: | |
| 2442 | ||
| 2443 | ```go | |
| 2444 | // KeyOrigin is how a key came to be. | |
| 2445 | type KeyOrigin struct { | |
| 2446 | CreatedByToken int64 // the API token that added it; 0 for none | |
| 2447 | ExpiresAt *time.Time // when it stops authenticating; nil for never | |
| 2448 | } | |
| 2449 | ``` | |
| 2450 | ||
| 2451 | `AddSSHKeyFrom`'s insert: | |
| 2452 | ||
| 2453 | ```go | |
| 2454 | var exp any | |
| 2455 | if o.ExpiresAt != nil { | |
| 2456 | exp = fmtTime(*o.ExpiresAt) | |
| 2457 | } | |
| 2458 | if _, err := tx.Exec( | |
| 2459 | "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", | |
| 2460 | userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil { | |
| 2461 | ``` | |
| 2462 | ||
| 2463 | `SSHKeyByFingerprint` and `SSHKeyByID` select `expires_at` last and scan it through a `sql.NullString`: | |
| 2464 | ||
| 2465 | ```go | |
| 2466 | func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) { | |
| 2467 | var k SSHKey | |
| 2468 | var exp sql.NullString | |
| 2469 | err := s.DB.QueryRow( | |
| 2470 | "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?", | |
| 2471 | fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp) | |
| 2472 | if errors.Is(err, sql.ErrNoRows) { | |
| 2473 | return k, ErrNotFound | |
| 2474 | } | |
| 2475 | k.ExpiresAt = parseTime(exp) | |
| 2476 | return k, err | |
| 2477 | } | |
| 2478 | ``` | |
| 2479 | ||
| 2480 | `SSHKeyByID` is the same with `WHERE id = ?` and `id`. | |
| 2481 | ||
| 2482 | `ListSSHKeys`: add `k.expires_at` after `COALESCE(t.name, '')`, scan into `var exp sql.NullString` declared per row, then `k.ExpiresAt = parseTime(exp)`. | |
| 2483 | ||
| 2484 | `ListDeployKeys`: | |
| 2485 | ||
| 2486 | ```go | |
| 2487 | func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) { | |
| 2488 | rows, err := s.DB.Query( | |
| 2489 | `SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at | |
| 2490 | FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`, | |
| 2491 | repoID) | |
| 2492 | if err != nil { | |
| 2493 | return nil, err | |
| 2494 | } | |
| 2495 | defer rows.Close() | |
| 2496 | var keys []SSHKey | |
| 2497 | for rows.Next() { | |
| 2498 | var k SSHKey | |
| 2499 | var exp sql.NullString | |
| 2500 | if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil { | |
| 2501 | return nil, err | |
| 2502 | } | |
| 2503 | k.ExpiresAt = parseTime(exp) | |
| 2504 | keys = append(keys, k) | |
| 2505 | } | |
| 2506 | return keys, rows.Err() | |
| 2507 | } | |
| 2508 | ``` | |
| 2509 | ||
| 2510 | `LiveSSHKeys` in `revoke.go`: | |
| 2511 | ||
| 2512 | ```go | |
| 2513 | // LiveSSHKeys reports which of ids still name a registered, unexpired | |
| 2514 | // key on an account that is not disabled. | |
| 2515 | func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) { | |
| 2516 | live := map[int64]bool{} | |
| 2517 | if len(ids) == 0 { | |
| 2518 | return live, nil | |
| 2519 | } | |
| 2520 | args := []any{fmtTime(time.Now())} | |
| 2521 | for _, id := range ids { | |
| 2522 | args = append(args, id) | |
| 2523 | } | |
| 2524 | rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id | |
| 2525 | WHERE u.disabled = 0 AND (k.expires_at IS NULL OR k.expires_at > ?) | |
| 2526 | AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...) | |
| 2527 | if err != nil { | |
| 2528 | return nil, err | |
| 2529 | } | |
| 2530 | defer rows.Close() | |
| 2531 | for rows.Next() { | |
| 2532 | var id int64 | |
| 2533 | if err := rows.Scan(&id); err != nil { | |
| 2534 | return nil, err | |
| 2535 | } | |
| 2536 | live[id] = true | |
| 2537 | } | |
| 2538 | return live, rows.Err() | |
| 2539 | } | |
| 2540 | ``` | |
| 2541 | ||
| 2542 | Add `"time"` to `revoke.go`'s imports. | |
| 2543 | ||
| 2544 | - [ ] **Step 5: Run the tests** | |
| 2545 | ||
| 2546 | Run: `go test ./internal/store -count=1` | |
| 2547 | Expected: PASS. | |
| 2548 | ||
| 2549 | - [ ] **Step 6: Commit** | |
| 2550 | ||
| 2551 | ```bash | |
| 2552 | git add internal/store | |
| 2553 | git commit -S -m "store: ssh_keys.expires_at; expired keys are not live | |
| 2554 | ||
| 2555 | Ref #277" | |
| 2556 | ``` | |
| 2557 | ||
| 2558 | ### Task 3.2: expired keys refused at authentication, per exec, and in system mode | |
| 2559 | ||
| 2560 | **Files:** | |
| 2561 | - Modify: `internal/sshd/sshd.go` — `authenticate` (after line 148), `runExec`, `Exec` (the `Ctx` literal) | |
| 2562 | - Modify: `cmd/gitbayd/system.go:45-48`, `:80-84` | |
| 2563 | - Test: `internal/sshd/revoke_test.go` (append) | |
| 2564 | ||
| 2565 | **Interfaces:** | |
| 2566 | - Consumes: `SSHKey.Expired`, `SSHKey.ExpiresAt` (Task 3.1); `Ctx.Expires` (MR 2); `newTestServer`, `execStatus`, `waitClosed` (MR 1). | |
| 2567 | ||
| 2568 | - [ ] **Step 1: Write the failing tests** | |
| 2569 | ||
| 2570 | Append to `internal/sshd/revoke_test.go`: | |
| 2571 | ||
| 2572 | ```go | |
| 2573 | // A key that expires while connected: the next exec is refused, and | |
| 2574 | // the sweep closes the connection. | |
| 2575 | func TestExpiredKeyRefusedAndCut(t *testing.T) { | |
| 2576 | ts := newTestServer(t) | |
| 2577 | past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z") | |
| 2578 | if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil { | |
| 2579 | t.Fatal(err) | |
| 2580 | } | |
| 2581 | if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") { | |
| 2582 | t.Fatalf("whoami with an expired key: %d %q", code, errOut) | |
| 2583 | } | |
| 2584 | ts.srv.sweepOnce() | |
| 2585 | waitClosed(t, ts.client) | |
| 2586 | } | |
| 2587 | ||
| 2588 | // An expiring key may not mint. | |
| 2589 | func TestExpiringKeyCannotMint(t *testing.T) { | |
| 2590 | ts := newTestServer(t) | |
| 2591 | future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z") | |
| 2592 | if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil { | |
| 2593 | t.Fatal(err) | |
| 2594 | } | |
| 2595 | if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") { | |
| 2596 | t.Fatalf("token create with an expiring key: %d %q", code, errOut) | |
| 2597 | } | |
| 2598 | } | |
| 2599 | ``` | |
| 2600 | ||
| 2601 | And a handshake test, which needs its own key: add to `revoke_test.go` | |
| 2602 | ||
| 2603 | ```go | |
| 2604 | func TestExpiredKeyRefusedAtAuth(t *testing.T) { | |
| 2605 | ts := newTestServer(t) | |
| 2606 | _, priv, err := ed25519.GenerateKey(rand.Reader) | |
| 2607 | if err != nil { | |
| 2608 | t.Fatal(err) | |
| 2609 | } | |
| 2610 | signer, err := ssh.NewSignerFromKey(priv) | |
| 2611 | if err != nil { | |
| 2612 | t.Fatal(err) | |
| 2613 | } | |
| 2614 | pub := signer.PublicKey() | |
| 2615 | past := time.Now().Add(-time.Minute) | |
| 2616 | if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil { | |
| 2617 | t.Fatal(err) | |
| 2618 | } | |
| 2619 | _, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{ | |
| 2620 | User: "git", | |
| 2621 | Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, | |
| 2622 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), | |
| 2623 | Timeout: 5 * time.Second, | |
| 2624 | }) | |
| 2625 | if err == nil { | |
| 2626 | t.Fatal("an expired key authenticated") | |
| 2627 | } | |
| 2628 | } | |
| 2629 | ``` | |
| 2630 | ||
| 2631 | with `"crypto/ed25519"`, `"crypto/rand"` and `"gitbay.org/gitbay/internal/store"` in the imports. | |
| 2632 | ||
| 2633 | - [ ] **Step 2: Run them and see them fail** | |
| 2634 | ||
| 2635 | Run: `go test ./internal/sshd -run 'TestExpiredKey|TestExpiringKeyCannotMint' -count=1` | |
| 2636 | Expected: FAIL: the expired key authenticates and whoami exits 0. | |
| 2637 | ||
| 2638 | - [ ] **Step 3: sshd** | |
| 2639 | ||
| 2640 | In `authenticate`, after the `if err != nil { ... }` block that handles unknown keys and before `s.authLimiter.success(ip)`: | |
| 2641 | ||
| 2642 | ```go | |
| 2643 | if key.Expired(time.Now()) { | |
| 2644 | s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp}) | |
| 2645 | return nil, fmt.Errorf("key %s has expired", fp) | |
| 2646 | } | |
| 2647 | ``` | |
| 2648 | ||
| 2649 | In `runExec`, after the lookup's error handling and before `UserByID`: | |
| 2650 | ||
| 2651 | ```go | |
| 2652 | if key.Expired(time.Now()) { | |
| 2653 | fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one") | |
| 2654 | return protocol.ExitDenied | |
| 2655 | } | |
| 2656 | ``` | |
| 2657 | ||
| 2658 | In `Exec`, the `Ctx` literal gains `Expires: key.ExpiresAt,`. | |
| 2659 | ||
| 2660 | - [ ] **Step 4: System mode** | |
| 2661 | ||
| 2662 | `cmd/gitbayd/system.go`, `authorized-keys`: | |
| 2663 | ||
| 2664 | ```go | |
| 2665 | key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub)) | |
| 2666 | if err != nil || key.Expired(time.Now()) { | |
| 2667 | return nil // unknown or expired key: no output, auth fails | |
| 2668 | } | |
| 2669 | ``` | |
| 2670 | ||
| 2671 | `shell`, after the `SSHKeyByID` error check: | |
| 2672 | ||
| 2673 | ```go | |
| 2674 | if key.Expired(time.Now()) { | |
| 2675 | fmt.Fprintln(os.Stderr, "this key has expired; remove it and add a new one") | |
| 2676 | os.Exit(protocol.ExitDenied) | |
| 2677 | } | |
| 2678 | ``` | |
| 2679 | ||
| 2680 | Add `"time"` to the imports. | |
| 2681 | ||
| 2682 | - [ ] **Step 5: Run the tests** | |
| 2683 | ||
| 2684 | Run: `go build ./... && go test ./internal/sshd -count=1` | |
| 2685 | Expected: PASS. | |
| 2686 | ||
| 2687 | - [ ] **Step 6: Commit** | |
| 2688 | ||
| 2689 | ```bash | |
| 2690 | git add internal/sshd cmd/gitbayd/system.go | |
| 2691 | git commit -S -m "sshd: refuse expired keys at auth and per exec; expiring keys cannot mint | |
| 2692 | ||
| 2693 | Ref #277" | |
| 2694 | ``` | |
| 2695 | ||
| 2696 | ### Task 3.3: `--ttl` on `keys add` and `repo deploy-key add`; lists show last use and expiry | |
| 2697 | ||
| 2698 | **Files:** | |
| 2699 | - Modify: `internal/control/token.go` (add `ttlFlag` after `parseTTL`) | |
| 2700 | - Modify: `internal/control/identity.go` — `keys add` registration (33-44), `runKeysList`, `runKeysAdd` | |
| 2701 | - Modify: `internal/control/deploykey.go` — registration (16-23), `runDeployKeyAdd` (36-80), `runDeployKeyList` (82-115) | |
| 2702 | - Modify: `e2e/ssh_test.go:267`, `:276` | |
| 2703 | - Test: `internal/control/keyexpiry_test.go` (create) | |
| 2704 | ||
| 2705 | **Interfaces:** | |
| 2706 | - Produces: | |
| 2707 | - `func (c *Ctx) ttlFlag(f flags) (*time.Time, int)` — nil when `--ttl` is absent; code -1 when the caller may go on. | |
| 2708 | - `func (c *Ctx) usedText(ts string) string`, `func expiresText(t *time.Time, now time.Time) string` | |
| 2709 | ||
| 2710 | - [ ] **Step 1: Write the failing test** | |
| 2711 | ||
| 2712 | `internal/control/keyexpiry_test.go`: | |
| 2713 | ||
| 2714 | ```go | |
| 2715 | package control | |
| 2716 | ||
| 2717 | import ( | |
| 2718 | "bytes" | |
| 2719 | "crypto/ed25519" | |
| 2720 | "crypto/rand" | |
| 2721 | "strings" | |
| 2722 | "testing" | |
| 2723 | "time" | |
| 2724 | ||
| 2725 | "golang.org/x/crypto/ssh" | |
| 2726 | ||
| 2727 | "gitbay.org/gitbay/internal/protocol" | |
| 2728 | "gitbay.org/gitbay/internal/store" | |
| 2729 | ) | |
| 2730 | ||
| 2731 | // authorizedKey is a fresh public key as an authorized_keys line. | |
| 2732 | func authorizedKey(t *testing.T, comment string) string { | |
| 2733 | t.Helper() | |
| 2734 | pub, _, err := ed25519.GenerateKey(rand.Reader) | |
| 2735 | if err != nil { | |
| 2736 | t.Fatal(err) | |
| 2737 | } | |
| 2738 | sp, err := ssh.NewPublicKey(pub) | |
| 2739 | if err != nil { | |
| 2740 | t.Fatal(err) | |
| 2741 | } | |
| 2742 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n" | |
| 2743 | } | |
| 2744 | ||
| 2745 | func TestKeysAddTTLAndList(t *testing.T) { | |
| 2746 | st, repo, uid := newQueueTestRepo(t) | |
| 2747 | user := store.User{ID: uid, Username: "alice"} | |
| 2748 | run := func(stdin string, argv ...string) (string, string, int) { | |
| 2749 | c, errOut := pruneCtx(st, t.TempDir(), user) | |
| 2750 | c.Cfg.Limits.WriteRate = -1 | |
| 2751 | c.Stdin = strings.NewReader(stdin) | |
| 2752 | code := Dispatch(c, argv) | |
| 2753 | return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code | |
| 2754 | } | |
| 2755 | if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK { | |
| 2756 | t.Fatalf("keys add --ttl: %d %s", code, errOut) | |
| 2757 | } | |
| 2758 | if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK { | |
| 2759 | t.Fatalf("deploy-key add --ttl: %d %s", code, errOut) | |
| 2760 | } | |
| 2761 | if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage { | |
| 2762 | t.Fatalf("bad ttl: exit %d", code) | |
| 2763 | } | |
| 2764 | ||
| 2765 | keys, err := st.ListSSHKeys(uid) | |
| 2766 | if err != nil || len(keys) != 2 { | |
| 2767 | t.Fatalf("keys: %+v %v", keys, err) | |
| 2768 | } | |
| 2769 | for _, k := range keys { | |
| 2770 | if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) { | |
| 2771 | t.Errorf("%s expires %v", k.Label, k.ExpiresAt) | |
| 2772 | } | |
| 2773 | } | |
| 2774 | out, _, _ := run("", "keys", "list") | |
| 2775 | if !strings.Contains(out, "\tlaptop\tnever used\texpires ") { | |
| 2776 | t.Fatalf("keys list:\n%s", out) | |
| 2777 | } | |
| 2778 | out, _, _ = run("", "repo", "deploy-key", "list", repo.Path()) | |
| 2779 | if !strings.Contains(out, "\tci\tnever used\texpires ") { | |
| 2780 | t.Fatalf("deploy-key list:\n%s", out) | |
| 2781 | } | |
| 2782 | } | |
| 2783 | ``` | |
| 2784 | ||
| 2785 | - [ ] **Step 2: Run it and see it fail** | |
| 2786 | ||
| 2787 | Run: `go test ./internal/control -run TestKeysAddTTLAndList -count=1` | |
| 2788 | Expected: FAIL, `keys add --ttl` exits 2 (unknown flag). | |
| 2789 | ||
| 2790 | - [ ] **Step 3: Helpers** | |
| 2791 | ||
| 2792 | In `internal/control/token.go` after `parseTTL`: | |
| 2793 | ||
| 2794 | ```go | |
| 2795 | // ttlFlag reads --ttl as an expiry; nil when the flag is absent. The | |
| 2796 | // code is -1 when the caller may go on. | |
| 2797 | func (c *Ctx) ttlFlag(f flags) (*time.Time, int) { | |
| 2798 | if !f.Has("--ttl") { | |
| 2799 | return nil, -1 | |
| 2800 | } | |
| 2801 | d, err := parseTTL(f.Value("--ttl")) | |
| 2802 | if err != nil || d <= 0 { | |
| 2803 | return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl")) | |
| 2804 | } | |
| 2805 | t := time.Now().Add(d) | |
| 2806 | return &t, -1 | |
| 2807 | } | |
| 2808 | ``` | |
| 2809 | ||
| 2810 | In `internal/control/identity.go` after `keyLabel`: | |
| 2811 | ||
| 2812 | ```go | |
| 2813 | // usedText is a key's last use as a list shows it. | |
| 2814 | func (c *Ctx) usedText(ts string) string { | |
| 2815 | switch { | |
| 2816 | case ts == "": | |
| 2817 | return "never used" | |
| 2818 | case c.Term.Cols == 0: | |
| 2819 | return "used " + stamp(ts) | |
| 2820 | } | |
| 2821 | return "used " + relAge(ts, termNow()) | |
| 2822 | } | |
| 2823 | ||
| 2824 | // expiresText is a credential's expiry as a list shows it. It is | |
| 2825 | // absolute at a terminal too: relAge reads only the past. | |
| 2826 | func expiresText(t *time.Time, now time.Time) string { | |
| 2827 | if t == nil { | |
| 2828 | return "never expires" | |
| 2829 | } | |
| 2830 | s := stamp(t.UTC().Format(time.RFC3339Nano)) | |
| 2831 | if !t.After(now) { | |
| 2832 | return "expired " + s | |
| 2833 | } | |
| 2834 | return "expires " + s | |
| 2835 | } | |
| 2836 | ``` | |
| 2837 | ||
| 2838 | Add `"time"` to `identity.go`'s imports. | |
| 2839 | ||
| 2840 | - [ ] **Step 4: `keys add --ttl`** | |
| 2841 | ||
| 2842 | Registration: | |
| 2843 | ||
| 2844 | ```go | |
| 2845 | register(Command{ | |
| 2846 | Path: []string{"keys", "add"}, | |
| 2847 | Summary: "register an SSH public key (authorized_keys format)", | |
| 2848 | Usage: "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub", | |
| 2849 | Flags: []Flag{ | |
| 2850 | {"--scope", "full|git|runner", "what the key may do", "full"}, | |
| 2851 | {"--label", "<text>", "a name for the key", ""}, | |
| 2852 | {"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"}, | |
| 2853 | }, | |
| 2854 | Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"}, | |
| 2855 | ReadsStdin: true, | |
| 2856 | MintsCredential: true, | |
| 2857 | Run: runKeysAdd, | |
| 2858 | }) | |
| 2859 | ``` | |
| 2860 | ||
| 2861 | In `runKeysAdd`: `parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})`; after the scope check: | |
| 2862 | ||
| 2863 | ```go | |
| 2864 | expires, code := c.ttlFlag(f) | |
| 2865 | if code >= 0 { | |
| 2866 | return code | |
| 2867 | } | |
| 2868 | ``` | |
| 2869 | ||
| 2870 | the store call: | |
| 2871 | ||
| 2872 | ```go | |
| 2873 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil { | |
| 2874 | ``` | |
| 2875 | ||
| 2876 | and the output: | |
| 2877 | ||
| 2878 | ```go | |
| 2879 | type out struct { | |
| 2880 | Fingerprint string `json:"fingerprint"` | |
| 2881 | Scope string `json:"scope"` | |
| 2882 | Label string `json:"label"` | |
| 2883 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 2884 | } | |
| 2885 | d := out{fp, scope, label, expires} | |
| 2886 | return c.emit(d, func(w io.Writer) { | |
| 2887 | line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope) | |
| 2888 | if d.Label != "" { | |
| 2889 | line += " " + d.Label | |
| 2890 | } | |
| 2891 | if d.ExpiresAt != nil { | |
| 2892 | line += ", " + expiresText(d.ExpiresAt, time.Now()) | |
| 2893 | } | |
| 2894 | fmt.Fprintln(w, line) | |
| 2895 | }) | |
| 2896 | ``` | |
| 2897 | ||
| 2898 | - [ ] **Step 5: `keys list` columns** | |
| 2899 | ||
| 2900 | ```go | |
| 2901 | type out struct { | |
| 2902 | Fingerprint string `json:"fingerprint"` | |
| 2903 | Algo string `json:"algo"` | |
| 2904 | Scope string `json:"scope"` | |
| 2905 | Label string `json:"label"` | |
| 2906 | CreatedBy string `json:"created_by,omitempty"` | |
| 2907 | LastUsedAt string `json:"last_used_at,omitempty"` | |
| 2908 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 2909 | } | |
| 2910 | var ds []out | |
| 2911 | for _, k := range keys { | |
| 2912 | ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt}) | |
| 2913 | } | |
| 2914 | now := time.Now() | |
| 2915 | return c.emit(ds, func(w io.Writer) { | |
| 2916 | tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES") | |
| 2917 | for _, d := range ds { | |
| 2918 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label), | |
| 2919 | cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) | |
| 2920 | } | |
| 2921 | tb.flush() | |
| 2922 | }) | |
| 2923 | ``` | |
| 2924 | ||
| 2925 | - [ ] **Step 6: `repo deploy-key add --ttl`, list columns** | |
| 2926 | ||
| 2927 | Registration: | |
| 2928 | ||
| 2929 | ```go | |
| 2930 | register(Command{Path: []string{"repo", "deploy-key", "add"}, | |
| 2931 | Summary: "bind a read-only (or --rw) key to one repository", | |
| 2932 | Usage: "repo deploy-key add <owner/name> [--rw] [--ttl 30d|720h] < key.pub", | |
| 2933 | Flags: []Flag{ | |
| 2934 | {"--rw", "", "the key may push, not just fetch", ""}, | |
| 2935 | {"--ttl", "30d|720h", "how long the key authenticates", "never expires"}, | |
| 2936 | }, | |
| 2937 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"}, | |
| 2938 | ReadsStdin: true, | |
| 2939 | MintsCredential: true, | |
| 2940 | Run: runDeployKeyAdd}) | |
| 2941 | ``` | |
| 2942 | ||
| 2943 | `runDeployKeyAdd`, replacing its argument loop (lines 37-52): | |
| 2944 | ||
| 2945 | ```go | |
| 2946 | func runDeployKeyAdd(c *Ctx, args []string) int { | |
| 2947 | f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage}) | |
| 2948 | if err != nil { | |
| 2949 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 2950 | } | |
| 2951 | path := f.pos(0) | |
| 2952 | if path == "" { | |
| 2953 | return c.usage() | |
| 2954 | } | |
| 2955 | mode := "ro" | |
| 2956 | if f.Has("--rw") { | |
| 2957 | mode = "rw" | |
| 2958 | } | |
| 2959 | expires, code := c.ttlFlag(f) | |
| 2960 | if code >= 0 { | |
| 2961 | return code | |
| 2962 | } | |
| 2963 | repo, code := resolveRepo(c, path, policy.CanAdmin) | |
| 2964 | if code >= 0 { | |
| 2965 | return code | |
| 2966 | } | |
| 2967 | ``` | |
| 2968 | ||
| 2969 | The rest is as before except the store call and output: | |
| 2970 | ||
| 2971 | ```go | |
| 2972 | if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil { | |
| 2973 | if errors.Is(err, store.ErrDuplicateKey) { | |
| 2974 | return c.failErr(err) | |
| 2975 | } | |
| 2976 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 2977 | } | |
| 2978 | d := map[string]any{"fingerprint": fp, "mode": mode} | |
| 2979 | if expires != nil { | |
| 2980 | d["expires_at"] = expires | |
| 2981 | } | |
| 2982 | return c.emit(d, func(w io.Writer) { | |
| 2983 | line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path()) | |
| 2984 | if expires != nil { | |
| 2985 | line += ", " + expiresText(expires, time.Now()) | |
| 2986 | } | |
| 2987 | fmt.Fprintln(w, line) | |
| 2988 | }) | |
| 2989 | ``` | |
| 2990 | ||
| 2991 | `runDeployKeyList`: | |
| 2992 | ||
| 2993 | ```go | |
| 2994 | type out struct { | |
| 2995 | Fingerprint string `json:"fingerprint"` | |
| 2996 | Algo string `json:"algo"` | |
| 2997 | Mode string `json:"mode"` | |
| 2998 | Label string `json:"label"` | |
| 2999 | LastUsedAt string `json:"last_used_at,omitempty"` | |
| 3000 | ExpiresAt *time.Time `json:"expires_at,omitempty"` | |
| 3001 | } | |
| 3002 | var ds []out | |
| 3003 | for _, k := range keys { | |
| 3004 | mode := "ro" | |
| 3005 | if policy.DeployScopeAllows(k.Scope, repo.ID, true) { | |
| 3006 | mode = "rw" | |
| 3007 | } | |
| 3008 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt}) | |
| 3009 | } | |
| 3010 | now := time.Now() | |
| 3011 | return c.emit(ds, func(w io.Writer) { | |
| 3012 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES") | |
| 3013 | for _, d := range ds { | |
| 3014 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label), | |
| 3015 | cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now))) | |
| 3016 | } | |
| 3017 | tb.flush() | |
| 3018 | }) | |
| 3019 | ``` | |
| 3020 | ||
| 3021 | Add `"time"` to `deploykey.go`'s imports. | |
| 3022 | ||
| 3023 | - [ ] **Step 7: The e2e rows that end at the label** | |
| 3024 | ||
| 3025 | `e2e/ssh_test.go:267`: `if !strings.Contains(out, "\tgit\talice2\t") {` | |
| 3026 | `e2e/ssh_test.go:276`: `if !strings.Contains(out, "\tgit\tbuild box\t") {` | |
| 3027 | ||
| 3028 | - [ ] **Step 8: Run the tests** | |
| 3029 | ||
| 3030 | Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/sshd -count=1 && go test ./e2e -run TestSSHControlPlane -count=1` | |
| 3031 | ||
| 3032 | (Check the name of the test holding `e2e/ssh_test.go:255-276` with `grep -n "^func Test" e2e/ssh_test.go` and run that one.) | |
| 3033 | Expected: PASS. | |
| 3034 | ||
| 3035 | - [ ] **Step 9: Commit** | |
| 3036 | ||
| 3037 | ```bash | |
| 3038 | git add internal/control e2e/ssh_test.go | |
| 3039 | git commit -S -m "keys: --ttl on keys add and repo deploy-key add; lists show last use and expiry | |
| 3040 | ||
| 3041 | Ref #277" | |
| 3042 | ``` | |
| 3043 | ||
| 3044 | ### Task 3.4: docs | |
| 3045 | ||
| 3046 | **Files:** | |
| 3047 | - Modify: `.gitbay/wiki/Users.org` (keys block ~61-66 and the scopes paragraph), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:17-18`, `09-Controls.org:27`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org` (Accounts), `.gitbay/wiki/API.org` (the paragraph added in MR 2), `CHANGELOG.org` | |
| 3048 | ||
| 3049 | - [ ] **Step 1: Edit the pages** | |
| 3050 | ||
| 3051 | `Users.org`, add to the keys code block: | |
| 3052 | ||
| 3053 | ``` | |
| 3054 | gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub | |
| 3055 | ``` | |
| 3056 | ||
| 3057 | and after the labels paragraph: | |
| 3058 | ||
| 3059 | ``` | |
| 3060 | =--ttl 90d= (or any Go duration, =720h=) makes a key stop | |
| 3061 | authenticating after that long; =repo deploy-key add= takes the same | |
| 3062 | flag. An expiring key cannot create credentials: tokens, keys, login | |
| 3063 | links. =keys list= shows when each key was last used and when it | |
| 3064 | expires, so a key nobody uses is easy to spot. | |
| 3065 | ``` | |
| 3066 | ||
| 3067 | `05-Identity-and-Access.org`: SSH user key and deploy key Expiry cells become `optional =--ttl=, refused at auth`. | |
| 3068 | ||
| 3069 | `09-Controls.org`: | |
| 3070 | ||
| 3071 | ``` | |
| 3072 | | Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | | |
| 3073 | ``` | |
| 3074 | ||
| 3075 | `10-Known-Gaps.org`: delete the `#277` row. | |
| 3076 | ||
| 3077 | `Parity.org`, after `SSH key label`: | |
| 3078 | ||
| 3079 | ``` | |
| 3080 | | SSH key expiry and last use | yes | no | no | | |
| 3081 | ``` | |
| 3082 | ||
| 3083 | `API.org`: in the paragraph MR 2 added, "a token with a =--ttl= cannot run" becomes "a token or SSH key with a =--ttl= cannot run". | |
| 3084 | ||
| 3085 | `CHANGELOG.org`: | |
| 3086 | ||
| 3087 | ``` | |
| 3088 | - =keys add= and =repo deploy-key add= take =--ttl=; an expired key is | |
| 3089 | refused at authentication, and an open connection on it closes within | |
| 3090 | 15 seconds. An expiring key cannot create credentials, like an | |
| 3091 | expiring token. =keys list= and =repo deploy-key list= gain =USED= and | |
| 3092 | =EXPIRES= columns, after the label (#277). | |
| 3093 | ``` | |
| 3094 | ||
| 3095 | - [ ] **Step 2: Commit, open the MR** | |
| 3096 | ||
| 3097 | ```bash | |
| 3098 | git add .gitbay/wiki CHANGELOG.org | |
| 3099 | git commit -S -m "wiki: key expiry | |
| 3100 | ||
| 3101 | Closes #277" | |
| 3102 | git push -u origin key-expiry | |
| 3103 | gitbay mr create --source key-expiry --target main --title "keys: optional expiry for SSH and deploy keys" | |
| 3104 | ``` | |
| 3105 | ||
| 3106 | --- | |
| 3107 | ||
| 3108 | # MR 4: idle timeout for browser sessions (branch `session-idle`, #276) | |
| 3109 | ||
| 3110 | A session lapses after 12 hours without a request and after seven | |
| 3111 | days regardless. `expires_at` holds the sliding expiry, so the auth | |
| 3112 | query and the retention sweep (`expires_at <= ?`, | |
| 3113 | `internal/store/retention.go:51`) need no change; | |
| 3114 | `absolute_expires_at` holds the cap. Renewal writes at most once a | |
| 3115 | minute per session. The cookie's `MaxAge` stays seven days. | |
| 3116 | ||
| 3117 | ### Task 4.1: migration 0062 and the store | |
| 3118 | ||
| 3119 | **Files:** | |
| 3120 | - Create: `internal/store/migrations/0062_web_session_idle.up.sql`, `.down.sql` | |
| 3121 | - Modify: `internal/store/sessions.go:67-121` | |
| 3122 | - Test: `internal/store/sessions_test.go` (append) | |
| 3123 | ||
| 3124 | **Interfaces:** | |
| 3125 | - Produces: | |
| 3126 | - `const WebSessionIdle = 12 * time.Hour` | |
| 3127 | - `CreateWebSession(hash string, userID int64, ttl time.Duration) error` — unchanged signature; `ttl` is now the absolute cap. | |
| 3128 | - `WebSessionUser(hash string) (User, error)` — unchanged signature; renews. | |
| 3129 | - `WebSession.LastUsedAt string `json:"last_used_at"`` | |
| 3130 | ||
| 3131 | - [ ] **Step 1: Write the failing tests** | |
| 3132 | ||
| 3133 | Append to `internal/store/sessions_test.go`: | |
| 3134 | ||
| 3135 | ```go | |
| 3136 | func sessionFixture(t *testing.T) (*Store, int64) { | |
| 3137 | t.Helper() | |
| 3138 | s := open(t) | |
| 3139 | if err := s.MigrateUp(); err != nil { | |
| 3140 | t.Fatal(err) | |
| 3141 | } | |
| 3142 | uid, err := s.CreateUser("cmc", false) | |
| 3143 | if err != nil { | |
| 3144 | t.Fatal(err) | |
| 3145 | } | |
| 3146 | return s, uid | |
| 3147 | } | |
| 3148 | ||
| 3149 | func sessionTimes(t *testing.T, s *Store, hash string) (expires, absolute time.Time) { | |
| 3150 | t.Helper() | |
| 3151 | var e, a string | |
| 3152 | if err := s.DB.QueryRow("SELECT expires_at, absolute_expires_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&e, &a); err != nil { | |
| 3153 | t.Fatal(err) | |
| 3154 | } | |
| 3155 | return *parseTime(sql.NullString{String: e, Valid: true}), *parseTime(sql.NullString{String: a, Valid: true}) | |
| 3156 | } | |
| 3157 | ||
| 3158 | func TestWebSessionIdleExpiry(t *testing.T) { | |
| 3159 | s, uid := sessionFixture(t) | |
| 3160 | if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil { | |
| 3161 | t.Fatal(err) | |
| 3162 | } | |
| 3163 | exp, abs := sessionTimes(t, s, "h") | |
| 3164 | if d := time.Until(exp); d < WebSessionIdle-time.Minute || d > WebSessionIdle { | |
| 3165 | t.Fatalf("a new session expires in %s, want %s", d, WebSessionIdle) | |
| 3166 | } | |
| 3167 | if d := time.Until(abs); d < 7*24*time.Hour-time.Minute { | |
| 3168 | t.Fatalf("absolute cap in %s", d) | |
| 3169 | } | |
| 3170 | // Idle past the window: gone. | |
| 3171 | old := fmtTime(time.Now().Add(-time.Second)) | |
| 3172 | s.DB.Exec("UPDATE web_sessions SET expires_at = ? WHERE token_hash = 'h'", old) | |
| 3173 | if _, err := s.WebSessionUser("h"); err != ErrNotFound { | |
| 3174 | t.Fatalf("idle session: %v", err) | |
| 3175 | } | |
| 3176 | } | |
| 3177 | ||
| 3178 | func TestWebSessionRenewsUpToTheCap(t *testing.T) { | |
| 3179 | s, uid := sessionFixture(t) | |
| 3180 | if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil { | |
| 3181 | t.Fatal(err) | |
| 3182 | } | |
| 3183 | // Last used two minutes ago, one minute left: a request renews it. | |
| 3184 | s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = 'h'", | |
| 3185 | fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(time.Now().Add(time.Minute))) | |
| 3186 | if _, err := s.WebSessionUser("h"); err != nil { | |
| 3187 | t.Fatal(err) | |
| 3188 | } | |
| 3189 | if exp, _ := sessionTimes(t, s, "h"); time.Until(exp) < WebSessionIdle-time.Minute { | |
| 3190 | t.Fatalf("not renewed: expires in %s", time.Until(exp)) | |
| 3191 | } | |
| 3192 | // Near the cap, renewal stops at it. | |
| 3193 | capAt := time.Now().Add(time.Hour) | |
| 3194 | s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, absolute_expires_at = ? WHERE token_hash = 'h'", | |
| 3195 | fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(capAt)) | |
| 3196 | if _, err := s.WebSessionUser("h"); err != nil { | |
| 3197 | t.Fatal(err) | |
| 3198 | } | |
| 3199 | if exp, _ := sessionTimes(t, s, "h"); exp.After(capAt) { | |
| 3200 | t.Fatalf("renewed past the cap: %s > %s", exp, capAt) | |
| 3201 | } | |
| 3202 | list, err := s.ListWebSessions(uid) | |
| 3203 | if err != nil || len(list) != 1 || list[0].LastUsedAt == "" { | |
| 3204 | t.Fatalf("list: %+v %v", list, err) | |
| 3205 | } | |
| 3206 | } | |
| 3207 | ``` | |
| 3208 | ||
| 3209 | Add `"database/sql"` to the file's imports. | |
| 3210 | ||
| 3211 | - [ ] **Step 2: Run them and see them fail** | |
| 3212 | ||
| 3213 | Run: `go test ./internal/store -run 'TestWebSession' -count=1` | |
| 3214 | Expected: FAIL to compile, `undefined: WebSessionIdle`. | |
| 3215 | ||
| 3216 | - [ ] **Step 3: Migration** | |
| 3217 | ||
| 3218 | `0062_web_session_idle.up.sql`: | |
| 3219 | ||
| 3220 | ```sql | |
| 3221 | -- expires_at slides forward on use, never past absolute_expires_at. | |
| 3222 | -- Sessions open now keep their cap and get a full idle window from here. | |
| 3223 | ALTER TABLE web_sessions ADD COLUMN absolute_expires_at TEXT; | |
| 3224 | ALTER TABLE web_sessions ADD COLUMN last_used_at TEXT; | |
| 3225 | UPDATE web_sessions SET | |
| 3226 | absolute_expires_at = expires_at, | |
| 3227 | last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), | |
| 3228 | expires_at = min(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ','now','+12 hours')); | |
| 3229 | ``` | |
| 3230 | ||
| 3231 | `0062_web_session_idle.down.sql`: | |
| 3232 | ||
| 3233 | ```sql | |
| 3234 | UPDATE web_sessions SET expires_at = absolute_expires_at; | |
| 3235 | ALTER TABLE web_sessions DROP COLUMN last_used_at; | |
| 3236 | ALTER TABLE web_sessions DROP COLUMN absolute_expires_at; | |
| 3237 | ``` | |
| 3238 | ||
| 3239 | - [ ] **Step 4: Store** | |
| 3240 | ||
| 3241 | Replace `CreateWebSession` and `WebSessionUser`: | |
| 3242 | ||
| 3243 | ```go | |
| 3244 | // WebSessionIdle is how long a browser session lasts without a request. | |
| 3245 | // Each use moves its expiry this far ahead, never past the cap it was | |
| 3246 | // created with. Migration 0062 repeats the value for sessions it | |
| 3247 | // converts. | |
| 3248 | const WebSessionIdle = 12 * time.Hour | |
| 3249 | ||
| 3250 | // CreateWebSession stores a session that lapses after WebSessionIdle | |
| 3251 | // without use, and after ttl regardless. | |
| 3252 | func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error { | |
| 3253 | now := time.Now() | |
| 3254 | _, err := s.DB.Exec( | |
| 3255 | "INSERT INTO web_sessions (token_hash, user_id, expires_at, absolute_expires_at, last_used_at) VALUES (?, ?, ?, ?, ?)", | |
| 3256 | hash, userID, fmtTime(now.Add(min(ttl, WebSessionIdle))), fmtTime(now.Add(ttl)), fmtTime(now)) | |
| 3257 | return err | |
| 3258 | } | |
| 3259 | ||
| 3260 | // WebSessionUser resolves a session cookie hash to its user and renews | |
| 3261 | // the session's idle expiry. A session is written at most once a | |
| 3262 | // minute, so a burst of requests costs one UPDATE. | |
| 3263 | func (s *Store) WebSessionUser(hash string) (User, error) { | |
| 3264 | now := time.Now() | |
| 3265 | var userID int64 | |
| 3266 | err := s.DB.QueryRow( | |
| 3267 | "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?", | |
| 3268 | hash, fmtTime(now)).Scan(&userID) | |
| 3269 | if errors.Is(err, sql.ErrNoRows) { | |
| 3270 | return User{}, ErrNotFound | |
| 3271 | } | |
| 3272 | if err != nil { | |
| 3273 | return User{}, err | |
| 3274 | } | |
| 3275 | s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?) | |
| 3276 | WHERE token_hash = ? AND last_used_at < ?`, | |
| 3277 | fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute))) | |
| 3278 | return s.UserByID(userID) | |
| 3279 | } | |
| 3280 | ``` | |
| 3281 | ||
| 3282 | `WebSession` and `ListWebSessions`: | |
| 3283 | ||
| 3284 | ```go | |
| 3285 | type WebSession struct { | |
| 3286 | ID string `json:"id"` | |
| 3287 | CreatedAt string `json:"created_at"` | |
| 3288 | ExpiresAt string `json:"expires_at"` | |
| 3289 | LastUsedAt string `json:"last_used_at"` | |
| 3290 | } | |
| 3291 | ||
| 3292 | // ListWebSessions lists the user's unexpired browser sessions, newest first. | |
| 3293 | func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) { | |
| 3294 | rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at) | |
| 3295 | FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`, | |
| 3296 | userID, fmtTime(time.Now())) | |
| 3297 | if err != nil { | |
| 3298 | return nil, err | |
| 3299 | } | |
| 3300 | defer rows.Close() | |
| 3301 | var out []WebSession | |
| 3302 | for rows.Next() { | |
| 3303 | var ws WebSession | |
| 3304 | if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil { | |
| 3305 | return nil, err | |
| 3306 | } | |
| 3307 | out = append(out, ws) | |
| 3308 | } | |
| 3309 | return out, rows.Err() | |
| 3310 | } | |
| 3311 | ``` | |
| 3312 | ||
| 3313 | - [ ] **Step 5: Run the tests** | |
| 3314 | ||
| 3315 | Run: `go test ./internal/store -count=1` | |
| 3316 | Expected: PASS, including `TestSweepRemovesExpiredSessionsAndTokens` (a `-time.Hour` ttl still makes a dead session). | |
| 3317 | ||
| 3318 | - [ ] **Step 6: Commit** | |
| 3319 | ||
| 3320 | ```bash | |
| 3321 | git add internal/store | |
| 3322 | git commit -S -m "store: web sessions lapse after 12 hours idle, under the absolute cap | |
| 3323 | ||
| 3324 | Ref #276" | |
| 3325 | ``` | |
| 3326 | ||
| 3327 | ### Task 4.2: `web sessions list` shows last use; docs | |
| 3328 | ||
| 3329 | **Files:** | |
| 3330 | - Modify: `internal/control/web.go:33-54` | |
| 3331 | - Modify: `internal/httpd/accounts.go:147` (comment only) | |
| 3332 | - Modify: `.gitbay/wiki/Users.org:672-678`, `.gitbay/wiki/Architecture/05-Identity-and-Access.org:21`, `:36-38`, `09-Controls.org:26`, `10-Known-Gaps.org`, `CHANGELOG.org` | |
| 3333 | ||
| 3334 | - [ ] **Step 1: The list** | |
| 3335 | ||
| 3336 | ```go | |
| 3337 | return c.emit(sessions, func(w io.Writer) { | |
| 3338 | tb := c.table(w, "ID", "SINCE", "UNTIL", "USED") | |
| 3339 | for _, s := range sessions { | |
| 3340 | since, until, used := s.CreatedAt, s.ExpiresAt, s.LastUsedAt | |
| 3341 | if c.Term.Cols == 0 { | |
| 3342 | since, until, used = stamp(since), stamp(until), stamp(used) | |
| 3343 | } else { | |
| 3344 | since, until, used = relAge(since, termNow()), relAge(until, termNow()), relAge(used, termNow()) | |
| 3345 | } | |
| 3346 | tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText("used "+used)) | |
| 3347 | } | |
| 3348 | tb.flush() | |
| 3349 | }) | |
| 3350 | ``` | |
| 3351 | ||
| 3352 | - [ ] **Step 2: The call site says what the ttl is** | |
| 3353 | ||
| 3354 | `internal/httpd/accounts.go`, above line 147: | |
| 3355 | ||
| 3356 | ```go | |
| 3357 | // Seven days is the cap; the store ends it sooner after | |
| 3358 | // store.WebSessionIdle without a request. | |
| 3359 | ``` | |
| 3360 | ||
| 3361 | - [ ] **Step 3: Run the tests** | |
| 3362 | ||
| 3363 | Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1 && go test ./e2e -run TestWebSessionsListRevoke -count=1` | |
| 3364 | Expected: PASS. | |
| 3365 | ||
| 3366 | - [ ] **Step 4: Docs** | |
| 3367 | ||
| 3368 | `Users.org`, the Browser sessions paragraph: | |
| 3369 | ||
| 3370 | ``` | |
| 3371 | =gitbay web login= mints a one-time URL; the session it opens ends | |
| 3372 | after twelve hours without a request, and after seven days in any case. | |
| 3373 | =gitbay web sessions list= shows each of yours by a short id with its | |
| 3374 | creation, expiry and last use, and =gitbay web sessions revoke <id>= | |
| 3375 | or =--all= ends them from the terminal, which is where a lost laptop is | |
| 3376 | handled. | |
| 3377 | ``` | |
| 3378 | ||
| 3379 | `05-Identity-and-Access.org`: the Web session Expiry cell becomes `12 h idle, 7 days absolute`; in the paragraph under the table, "=MaxAge= 7 days" becomes "=MaxAge= 7 days (the session itself also ends after 12 hours idle)". | |
| 3380 | ||
| 3381 | `09-Controls.org`: | |
| 3382 | ||
| 3383 | ``` | |
| 3384 | | Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | | |
| 3385 | ``` | |
| 3386 | ||
| 3387 | `10-Known-Gaps.org`: delete the `#276` row. | |
| 3388 | ||
| 3389 | `CHANGELOG.org`: | |
| 3390 | ||
| 3391 | ``` | |
| 3392 | - Browser sessions end after twelve hours without a request, and after | |
| 3393 | seven days as before. Sessions open at upgrade get a fresh twelve | |
| 3394 | hours. =web sessions list= shows when each was last used (#276). | |
| 3395 | ``` | |
| 3396 | ||
| 3397 | - [ ] **Step 5: Commit, open the MR** | |
| 3398 | ||
| 3399 | ```bash | |
| 3400 | git add internal/control/web.go internal/httpd/accounts.go .gitbay/wiki CHANGELOG.org | |
| 3401 | git commit -S -m "web: sessions list shows last use; docs for the idle timeout | |
| 3402 | ||
| 3403 | Closes #276" | |
| 3404 | git push -u origin session-idle | |
| 3405 | gitbay mr create --source session-idle --target main --title "web: idle timeout for browser sessions" | |
| 3406 | ``` | |
| 3407 | ||
| 3408 | --- | |
| 3409 | ||
| 3410 | # MR 5: `web login` over SSH spends the login-link budget (branch `weblogin-limit`, #278) | |
| 3411 | ||
| 3412 | ### Task 5.1: the limit in `runWebLogin` | |
| 3413 | ||
| 3414 | **Files:** | |
| 3415 | - Modify: `internal/control/web.go:80-99` | |
| 3416 | - Modify: `internal/control/loginlink.go:12-19` (comment) | |
| 3417 | - Test: `internal/control/weblogin_test.go` (create) | |
| 3418 | - Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `CHANGELOG.org` | |
| 3419 | ||
| 3420 | **Interfaces:** | |
| 3421 | - Consumes: `maxLoginLinksPerHour` (`loginlink.go:19`), `Store.CountLoginTokensSince`. | |
| 3422 | ||
| 3423 | - [ ] **Step 1: Write the failing test** | |
| 3424 | ||
| 3425 | ```go | |
| 3426 | package control | |
| 3427 | ||
| 3428 | import ( | |
| 3429 | "strings" | |
| 3430 | "testing" | |
| 3431 | ||
| 3432 | "gitbay.org/gitbay/internal/protocol" | |
| 3433 | "gitbay.org/gitbay/internal/store" | |
| 3434 | ) | |
| 3435 | ||
| 3436 | // web login over SSH counts against the same hourly bound as the | |
| 3437 | // mailed links, since both insert into login_tokens (#278). | |
| 3438 | func TestWebLoginSharesTheLoginLinkLimit(t *testing.T) { | |
| 3439 | st, _, uid := newQueueTestRepo(t) | |
| 3440 | for i := 0; i <= maxLoginLinksPerHour; i++ { | |
| 3441 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | |
| 3442 | c.Cfg.Web.Mode = "accounts" | |
| 3443 | c.Cfg.Server.SiteURL = "https://gitbay.test" | |
| 3444 | c.Cfg.Limits.WriteRate = -1 | |
| 3445 | code := Dispatch(c, []string{"web", "login"}) | |
| 3446 | switch { | |
| 3447 | case i < maxLoginLinksPerHour && code != protocol.ExitOK: | |
| 3448 | t.Fatalf("link %d: exit %d %s", i+1, code, errOut) | |
| 3449 | case i == maxLoginLinksPerHour && (code != protocol.ExitDenied || !strings.Contains(errOut.String(), "login links")): | |
| 3450 | t.Fatalf("link %d: exit %d %q, want refused", i+1, code, errOut) | |
| 3451 | } | |
| 3452 | } | |
| 3453 | } | |
| 3454 | ``` | |
| 3455 | ||
| 3456 | - [ ] **Step 2: Run it and see it fail** | |
| 3457 | ||
| 3458 | Run: `go test ./internal/control -run TestWebLoginSharesTheLoginLinkLimit -count=1` | |
| 3459 | Expected: FAIL, the sixth link exits 0. | |
| 3460 | ||
| 3461 | - [ ] **Step 3: Implement** | |
| 3462 | ||
| 3463 | In `runWebLogin`, after the web-mode check: | |
| 3464 | ||
| 3465 | ```go | |
| 3466 | n, err := c.Store.CountLoginTokensSince(c.User.ID, time.Now().Add(-time.Hour)) | |
| 3467 | if err != nil { | |
| 3468 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 3469 | } | |
| 3470 | if n >= maxLoginLinksPerHour { | |
| 3471 | return c.fail(protocol.ExitDenied, | |
| 3472 | "%d login links in the last hour is the most an account gets; use one of those, or wait", maxLoginLinksPerHour) | |
| 3473 | } | |
| 3474 | ``` | |
| 3475 | ||
| 3476 | `loginlink.go`, the comment above `maxLoginLinksPerHour`: | |
| 3477 | ||
| 3478 | ```go | |
| 3479 | // maxLoginLinksPerHour bounds what one account's address can be made to | |
| 3480 | // receive. It matches maxEmailAddsPerHour: enough for a person who mistypes | |
| 3481 | // and retries, nothing for a script. CountLoginTokensSince counts every row | |
| 3482 | // in login_tokens, so links minted with "web login" over SSH and links | |
| 3483 | // mailed from the login page share the budget, and both refuse past it. | |
| 3484 | ``` | |
| 3485 | ||
| 3486 | - [ ] **Step 4: Run the tests** | |
| 3487 | ||
| 3488 | Run: `go test ./internal/control -count=1` | |
| 3489 | Expected: PASS. Then `grep -c '\.login(t\|loginBrowser(t\|"web", "login"' e2e/*.go` and confirm no single e2e test logs one account in more than five times; `TestMRWebReviewLoop` logs alice in once and carol once, and each e2e test runs its own instance. | |
| 3490 | ||
| 3491 | - [ ] **Step 5: Docs** | |
| 3492 | ||
| 3493 | `10-Known-Gaps.org`: delete the `#278` row. | |
| 3494 | ||
| 3495 | `CHANGELOG.org`: | |
| 3496 | ||
| 3497 | ``` | |
| 3498 | - =web login= over SSH refuses a sixth link in an hour, the same bound | |
| 3499 | the login page's mailed links have (#278). | |
| 3500 | ``` | |
| 3501 | ||
| 3502 | - [ ] **Step 6: Commit, open the MR** | |
| 3503 | ||
| 3504 | ```bash | |
| 3505 | git add internal/control/web.go internal/control/loginlink.go internal/control/weblogin_test.go .gitbay/wiki CHANGELOG.org | |
| 3506 | git commit -S -m "web: login over SSH applies the login-link limit | |
| 3507 | ||
| 3508 | Closes #278" | |
| 3509 | git push -u origin weblogin-limit | |
| 3510 | gitbay mr create --source weblogin-limit --target main --title "web login over SSH applies the login-link rate limit" | |
| 3511 | ``` | |
| 3512 | ||
| 3513 | --- | |
| 3514 | ||
| 3515 | ## Open questions | |
| 3516 | ||
| 3517 | 1. **Expiring SSH keys and minting.** #277 says to consider it with | |
| 3518 | #257; this plan treats an expiring key like an expiring token and | |
| 3519 | refuses it the nine minting commands (MR 3, Task 3.2). One effect: | |
| 3520 | a person whose only key has a TTL cannot run `web login`, and must | |
| 3521 | use the mailed link. Confirm, or drop `Expires: key.ExpiresAt` from | |
| 3522 | `Exec` and `TestExpiringKeyCannotMint`. | |
| 3523 | 2. **Which commands mint.** The issue names token create, keys add, | |
| 3524 | repo deploy-key add, admin invite and web login. The plan adds | |
| 3525 | `repo runner add` (creates or attaches a key that claims builds), | |
| 3526 | `admin user create` (with `--key` or a verified address it is a way | |
| 3527 | in), `email verify` and `admin email verify` (a verified address | |
| 3528 | receives login links, so a token could plant a lasting way in). | |
| 3529 | `TestMintingCommandsMarked` pins the list. | |
| 3530 | 3. **LFS transfer tokens.** `git-lfs-authenticate` hands out an HMAC | |
| 3531 | token valid for an hour (`internal/lfs`), not stored, revocable only | |
| 3532 | by expiry. A key removed after minting one leaves LFS access for up | |
| 3533 | to an hour. Not in any of these issues; file separately? | |
| 3534 | 4. **System mode.** With `ssh.mode = "system"`, each exec is its own | |
| 3535 | forced-command process: the per-exec check applies, a running one is | |
| 3536 | not cut. bay1 runs embedded. Closing it would take a poll in | |
| 3537 | `gitbayd shell`; the plan documents the limit instead. | |
| 3538 | 5. **Commands already past dispatch.** "Running commands included" is | |
| 3539 | implemented as: git transports are killed, commands watching `Done` | |
| 3540 | stop, and a control command already inside its store write finishes | |
| 3541 | it with its output lost. Cancelling arbitrary control commands would | |
| 3542 | need a context threaded through every handler. | |
| 3543 | 6. **Removing the key you are on.** `keys remove <the key this session | |
| 3544 | uses>` cuts its own connection, so the CLI reports a connection | |
| 3545 | error instead of "removed". The removal has committed. Acceptable, | |
| 3546 | or should `revoke` skip the connection running the removal? | |
| 3547 | 7. **Idle window as a constant.** 12 hours is `store.WebSessionIdle`, | |
| 3548 | repeated in migration 0062. Should it be configurable? | |
| 3549 | ||
| 3550 | Noticed, not in scope: `token list` at a terminal shows a future | |
| 3551 | expiry through `relAge`, which clamps to zero and prints "just now" | |
| 3552 | (`internal/control/token.go:112-116`). `expiresText` (MR 3) would fix | |
| 3553 | it if applied there. | |
| 3554 | ||
| 3555 | ## Self-review | |
| 3556 | ||
| 3557 | - **Coverage.** #256: per-exec re-read (Task 1.3 `runExec`), git | |
| 3558 | transport session re-read (same path; `Exec` dispatches git), | |
| 3559 | connection tracking and closing on keys remove / deploy-key remove / | |
| 3560 | disable / delete (1.1, 1.3), cancelling running commands and pushes | |
| 3561 | (1.2, 1.3), interrupted receive-pack moves no ref (1.4), e2e with a | |
| 3562 | multiplexed connection (1.4). #257: `MintsCredential` checked in | |
| 3563 | `Dispatch` (2.2), migration recording the creator for tokens and keys | |
| 3564 | (2.1), `token revoke` listing and revoking with `--created` (2.1, | |
| 3565 | 2.3), default `--scope read` and release note (2.3, 2.5), API and | |
| 3566 | Threat-Model pages (2.5). #277: `--ttl` on both add commands (3.3), | |
| 3567 | enforced at authentication (3.2), last use in `keys list` (3.3), | |
| 3568 | considered with #257 (3.2, open question 1), migration designed with | |
| 3569 | 0060 (both nullable `ADD COLUMN`, one insert path via `KeyOrigin`). | |
| 3570 | #276: idle timeout with renewal, absolute cap kept, last use listed | |
| 3571 | (4.1, 4.2). #278: limit applied, comment corrected (5.1). | |
| 3572 | - **Placeholders.** None; every code step carries the code. Two steps | |
| 3573 | ask the executor to confirm a name with grep (`nullID`, the | |
| 3574 | `ssh_test.go` test name) because they were not unique facts to pin. | |
| 3575 | - **Types.** `Revoked{KeyIDs []int64; UserID int64}`, `OnRevoke`, | |
| 3576 | `announce`, `LiveSSHKeys([]int64) (map[int64]bool, error)` are used | |
| 3577 | with those shapes in 1.1, 1.3, 2.1, 3.1. `Exec(..., key store.SSHKey, | |
| 3578 | ..., done, stopping, revoked)` matches in 1.3, 3.2 and `system.go`. | |
| 3579 | `APITokenUser` returns `(User, APIToken, error)` in 2.1, 2.2 and the | |
| 3580 | tests. `KeyOrigin{CreatedByToken, ExpiresAt}` is introduced in 2.1 | |
| 3581 | and extended in 3.1. `Ctx.TokenID int64`, `Ctx.Expires *time.Time` | |
| 3582 | match across 2.2, 2.3, 3.2, 3.3. `SSHKey.CreatedBy` (name) and | |
| 3583 | `KeyOrigin.CreatedByToken` (id) are distinct on purpose. | |
docs/plans/2026-09-27-data-at-rest-and-backup.md added +3302
| @@ -0,0 +1,3302 @@ | ||
| 1 | # Data at rest and backup implementation plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Seal the four secret columns under a key file outside the | |
| 6 | database (#273), encrypt backup archives to an age recipient (#274), | |
| 7 | and make `--verify` check git connectivity while repository moves and | |
| 8 | deletions wait for a running backup (#259), ending with a restore drill | |
| 9 | the operator runs and records. | |
| 10 | ||
| 11 | **Architecture:** A new `internal/seal` package holds AES-256-GCM keys | |
| 12 | read from `server.secret_key_file` (default `/etc/gitbay/secret.key`, | |
| 13 | mode 0600, outside `server.root`). The store seals on write and opens | |
| 14 | on read, so no caller above `internal/store` changes. Every value | |
| 15 | carries `gbs1:<key id>:`; `serve` seals leftover clear values and | |
| 16 | values under retired keys at startup, and `gitbayd admin secrets | |
| 17 | rotate` adds a key, reseals, and retires the old one. Backups wrap the | |
| 18 | tar.gz in `filippo.io/age` when `[backup] age_recipients` is set. | |
| 19 | `--verify` extracts repositories and runs `git fsck | |
| 20 | --connectivity-only` on each. A `flock(2)` on `<root>/backup.lock` | |
| 21 | keeps deletes, renames and transfers (daemon process) out of a full | |
| 22 | backup (separate `gitbayd admin backup` process). | |
| 23 | ||
| 24 | **Tech Stack:** Go 1.27, `crypto/aes` + `crypto/cipher` (GCM), | |
| 25 | `filippo.io/age` (new dependency), `syscall.Flock`, SQLite via | |
| 26 | `modernc.org/sqlite`, cobra. | |
| 27 | ||
| 28 | **Spec:** the issue texts of #273, #274 and #259 on krz/gitbay, and the | |
| 29 | decisions recorded in the brief: AES-GCM, key file under `/etc/gitbay` | |
| 30 | mode 0600 excluded from backups, key id prefix on each value, rotation | |
| 31 | command, re-encryption of existing rows; age recipients, `--verify` | |
| 32 | takes an identity file; the clean-host drill is an operator runbook | |
| 33 | recorded on the Admin wiki page. | |
| 34 | ||
| 35 | ## Global Constraints | |
| 36 | ||
| 37 | - Each MR on its own branch off `main`. Commits are signed (the repo | |
| 38 | refuses unsigned), messages reference issues (`Ref #N`, and | |
| 39 | `Closes #N` on the commit that finishes one). No attribution to any | |
| 40 | assistant, model or AI anywhere: commits, MR bodies, comments. | |
| 41 | - MR: `gitbay mr create --source <branch> --target main --title "..."`; | |
| 42 | merge with `gitbay mr merge <n> --strategy ff` once CI is green, then | |
| 43 | delete the branch locally and on the remote. Behind main → rebase, | |
| 44 | force-push, merge again. | |
| 45 | - Locally: `go build ./...`, `go vet ./...`, unit tests of touched | |
| 46 | packages, and at most the one e2e test being written | |
| 47 | (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite. | |
| 48 | - Registries that fail CI when a new thing lacks its row: top-level route | |
| 49 | word in `internal/policy/names.go`; new page template in the width map | |
| 50 | of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly` | |
| 51 | command in `readArgs` in `e2e/readonly_test.go`; new control command | |
| 52 | needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test); | |
| 53 | a command reading stdin needs `ReadsStdin: true`. This plan adds no | |
| 54 | control command, route or template: `gitbayd admin secrets` is a | |
| 55 | host-local cobra command like `admin backup` and `admin gc`, so none | |
| 56 | of the registries gains a row. | |
| 57 | - Migrations: the highest today is 0059. This plan owns 0072–0074 and | |
| 58 | uses one, `0072_push_token_hash`. Plans 1–3 own 0060–0071; whoever | |
| 59 | lands second renumbers to the next free number at execution time | |
| 60 | (`loadMigrations` in `internal/store/store.go` refuses a gap, so 0072 | |
| 61 | cannot land before 0060–0071 exist: rename it to the next free | |
| 62 | number). Migrations come in `.up.sql`/`.down.sql` pairs. Hand-written | |
| 63 | SQL, no ORM. | |
| 64 | - Secrets travel on stdin, never argv; never logged or echoed. The key | |
| 65 | file's contents are never printed; commands print key ids only. | |
| 66 | - Wiki pages live in `.gitbay/wiki/`. Update the page in the same MR | |
| 67 | that changes the behaviour it describes, and close the matching | |
| 68 | Known-Gaps row (`.gitbay/wiki/Architecture/10-Known-Gaps.org`) and | |
| 69 | controls-matrix row (`Architecture/09-Controls.org`). | |
| 70 | - Writing style: plain, direct, no hype; code comments match the | |
| 71 | surrounding density. Comments and docs state facts, never | |
| 72 | before/after narration. | |
| 73 | - Plan-specific: | |
| 74 | - Sealed value format: `gbs1:<8 lowercase hex key id>:<base64 raw | |
| 75 | std (nonce ‖ ciphertext ‖ tag)>`. Additional data is | |
| 76 | `<table>.<column>`. An empty value is never sealed (empty means | |
| 77 | "none" for `webhooks.secret` and `mirrors.token`). | |
| 78 | - Key file format: one `<id> <base64 32 bytes>` per line, `#` | |
| 79 | comments; the last key seals, all keys open. Mode must be 0600 or | |
| 80 | stricter; anything group- or world-readable is refused. | |
| 81 | - `server.secret_key_file` must not be inside `server.root` | |
| 82 | (validation error), so neither the archive nor the restic snapshot | |
| 83 | of `/var/lib/gitbay` can carry it. | |
| 84 | - A missing key file is fatal for every process that opens the | |
| 85 | database through `openStore` (serve, shell, authorized-keys, host | |
| 86 | admin commands), with a message naming the path and | |
| 87 | `gitbayd admin secrets init`. `gitbayd migrate` does not need it. | |
| 88 | - Encrypted archives end in `.age`; `--out` without the suffix gets | |
| 89 | it appended when `[backup] age_recipients` is set. | |
| 90 | ||
| 91 | ## Order and dependencies | |
| 92 | ||
| 93 | | MR | Branch | Issue | Contents | | |
| 94 | |----|--------|-------|----------| | |
| 95 | | 1 | `secrets-at-rest` | Closes #273 | `internal/seal`, `server.secret_key_file`, store sealing, migration 0072, reseal at startup, `admin secrets init/rotate/check`, install.sh, e2e harness key, wiki | | |
| 96 | | 2 | `backup-age` | Closes #274 | `[backup] age_recipients`, age-wrapped archives, `--verify --identity`, backup script globs, wiki | | |
| 97 | | 3 | `backup-verify-lock` | Ref #259 | `gitutil.FsckConnectivity`, `--verify` extracts and checks each repository, `internal/backuplock`, delete/rename/transfer/org rename refused during a full backup, drill procedure and record table on the Admin page | | |
| 98 | | — | `restore-drill-record` (operator) | Closes #259 | the first drill's numbers in the Admin page, Known-Gaps and Controls rows closed | | |
| 99 | ||
| 100 | MR 2 and MR 3 both edit `cmd/gitbayd/backup.go`; land them in order. | |
| 101 | MR 2's `testConfig` helper comes from MR 1. | |
| 102 | ||
| 103 | Other plans: no hard dependency. Soft overlaps, resolved by rebase: | |
| 104 | plan 3 (#279) changes `internal/mirror/mirror.go`, which reads | |
| 105 | `store.Mirror.Token` — the field stays a plain string after this plan, | |
| 106 | so its code is unaffected. Plan 5 (#261) touches the migration runner | |
| 107 | in `internal/store/store.go`; migration 0072 does not use the | |
| 108 | `-- foreign_keys: off` directive. | |
| 109 | ||
| 110 | ## File map | |
| 111 | ||
| 112 | | File | MR | Responsibility | | |
| 113 | |---|---|---| | |
| 114 | | `internal/seal/seal.go` (create) | 1 | key file read/write, `Keyring`, `Seal`/`Open`, `KeyID` | | |
| 115 | | `internal/seal/seal_test.go` (create) | 1 | round trip, AAD binding, reload on change, mode refusal | | |
| 116 | | `internal/config/config.go` | 1, 2 | `Server.SecretKeyFile`, `Backup.AgeRecipients`, validation | | |
| 117 | | `internal/store/secrets.go` (create) | 1 | `SetKeyring`, `sealValue`/`openValue`, `ResealSecrets`, `SecretKeyUse`, `tokenHash` | | |
| 118 | | `internal/store/store.go` | 1 | `secrets` field on `Store` | | |
| 119 | | `internal/store/cisecrets.go`, `webhooks.go`, `mirrors.go`, `push.go` | 1 | seal in the write transaction, open on read, token hash lookups | | |
| 120 | | `internal/store/migrations/0072_push_token_hash.{up,down}.sql` (create) | 1 | `push_devices.token_hash` + unique index | | |
| 121 | | `cmd/gitbayd/main.go` | 1 | `openStore` loads the keyring; `serve` reseals; `admin secrets` wired | | |
| 122 | | `cmd/gitbayd/secrets.go` (create) | 1 | `admin secrets init|rotate|check` | | |
| 123 | | `cmd/gitbayd/testconfig_test.go` (create) | 1 | `testConfig` helper | | |
| 124 | | `cmd/gitbayd/backup.go` | 2, 3 | age wrap, `--identity`, lock, connectivity | | |
| 125 | | `internal/gitutil/merge.go` | 3 | `FsckConnectivity` | | |
| 126 | | `internal/backuplock/backuplock.go` (create) | 3 | `Hold`, `TryShared`, `ErrBusy` | | |
| 127 | | `internal/control/repo.go`, `org.go` | 3 | `holdOffBackup` in delete/rename/transfer/org rename | | |
| 128 | | `deploy/install.sh` | 1 | create the key file once | | |
| 129 | | `deploy/cloud-init.yaml` | 2 | backup script and monitor globs for `.age` | | |
| 130 | | `e2e/ssh_test.go`, `acme_test.go`, `system_test.go`, `backup_test.go` | 1, 3 | key file in every config; sealed-at-rest and connectivity checks | | |
| 131 | | `.gitbay/wiki/Admin.org`, `Threat-Model.org`, `Architecture/03,06,08,09,10` | 1–3 | docs | | |
| 132 | | `CHANGELOG.org` | 1, 2 | upgrade notes | | |
| 133 | ||
| 134 | --- | |
| 135 | ||
| 136 | # MR 1: secrets at rest (branch `secrets-at-rest`, closes #273) | |
| 137 | ||
| 138 | ### Task 1.1: `internal/seal` | |
| 139 | ||
| 140 | **Files:** | |
| 141 | - Create: `internal/seal/seal.go` | |
| 142 | - Test: `internal/seal/seal_test.go` | |
| 143 | ||
| 144 | **Interfaces:** | |
| 145 | - Produces: | |
| 146 | - `const Prefix = "gbs1:"` | |
| 147 | - `type Key struct { ID string; Secret []byte }` | |
| 148 | - `func NewKey() (Key, error)` | |
| 149 | - `func ReadKeys(path string) ([]Key, error)` — refuses a mode with any group/other bit | |
| 150 | - `func WriteKeys(path string, keys []Key) error` — atomic, 0600, keeps an existing file's owner | |
| 151 | - `type Keyring`; `func Load(path string) (*Keyring, error)` | |
| 152 | - `func (k *Keyring) Seal(aad, plain string) (string, error)` | |
| 153 | - `func (k *Keyring) Open(aad, sealed string) (string, error)` | |
| 154 | - `func (k *Keyring) CurrentID() (string, error)` | |
| 155 | - `func IsSealed(v string) bool`, `func KeyID(v string) (string, bool)` | |
| 156 | ||
| 157 | - [ ] **Step 1: Write the failing tests** | |
| 158 | ||
| 159 | ```go | |
| 160 | package seal | |
| 161 | ||
| 162 | import ( | |
| 163 | "os" | |
| 164 | "path/filepath" | |
| 165 | "strings" | |
| 166 | "testing" | |
| 167 | ) | |
| 168 | ||
| 169 | func keyFile(t *testing.T, keys ...Key) string { | |
| 170 | t.Helper() | |
| 171 | path := filepath.Join(t.TempDir(), "secret.key") | |
| 172 | if err := WriteKeys(path, keys); err != nil { | |
| 173 | t.Fatal(err) | |
| 174 | } | |
| 175 | return path | |
| 176 | } | |
| 177 | ||
| 178 | func newKey(t *testing.T) Key { | |
| 179 | t.Helper() | |
| 180 | k, err := NewKey() | |
| 181 | if err != nil { | |
| 182 | t.Fatal(err) | |
| 183 | } | |
| 184 | return k | |
| 185 | } | |
| 186 | ||
| 187 | func TestSealOpenRoundTrip(t *testing.T) { | |
| 188 | k := newKey(t) | |
| 189 | ring, err := Load(keyFile(t, k)) | |
| 190 | if err != nil { | |
| 191 | t.Fatal(err) | |
| 192 | } | |
| 193 | v, err := ring.Seal("build_secrets.value", "hunter2") | |
| 194 | if err != nil { | |
| 195 | t.Fatal(err) | |
| 196 | } | |
| 197 | if !strings.HasPrefix(v, Prefix+k.ID+":") || strings.Contains(v, "hunter2") { | |
| 198 | t.Fatalf("sealed value %q", v) | |
| 199 | } | |
| 200 | if id, ok := KeyID(v); !ok || id != k.ID { | |
| 201 | t.Fatalf("KeyID = %q, %v", id, ok) | |
| 202 | } | |
| 203 | got, err := ring.Open("build_secrets.value", v) | |
| 204 | if err != nil || got != "hunter2" { | |
| 205 | t.Fatalf("Open = %q, %v", got, err) | |
| 206 | } | |
| 207 | // Two seals of one value differ: the nonce is random. | |
| 208 | if w, _ := ring.Seal("build_secrets.value", "hunter2"); w == v { | |
| 209 | t.Fatal("two seals produced the same value") | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | // A value moved to another column does not open there. | |
| 214 | func TestOpenChecksAdditionalData(t *testing.T) { | |
| 215 | ring, err := Load(keyFile(t, newKey(t))) | |
| 216 | if err != nil { | |
| 217 | t.Fatal(err) | |
| 218 | } | |
| 219 | v, _ := ring.Seal("mirrors.token", "tok") | |
| 220 | if _, err := ring.Open("webhooks.secret", v); err == nil { | |
| 221 | t.Fatal("opened under the wrong column") | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | // A running daemon sees a rotation without a restart: the ring re-reads | |
| 226 | // the file when it changes. | |
| 227 | func TestKeyringFollowsTheFile(t *testing.T) { | |
| 228 | old, next := newKey(t), newKey(t) | |
| 229 | path := keyFile(t, old) | |
| 230 | ring, err := Load(path) | |
| 231 | if err != nil { | |
| 232 | t.Fatal(err) | |
| 233 | } | |
| 234 | before, _ := ring.Seal("webhooks.secret", "s") | |
| 235 | if err := WriteKeys(path, []Key{old, next}); err != nil { | |
| 236 | t.Fatal(err) | |
| 237 | } | |
| 238 | after, err := ring.Seal("webhooks.secret", "s") | |
| 239 | if err != nil { | |
| 240 | t.Fatal(err) | |
| 241 | } | |
| 242 | if id, _ := KeyID(after); id != next.ID { | |
| 243 | t.Fatalf("sealed under %s after rotation, want %s", id, next.ID) | |
| 244 | } | |
| 245 | if got, err := ring.Open("webhooks.secret", before); err != nil || got != "s" { | |
| 246 | t.Fatalf("old value after rotation: %q, %v", got, err) | |
| 247 | } | |
| 248 | if err := WriteKeys(path, []Key{next}); err != nil { | |
| 249 | t.Fatal(err) | |
| 250 | } | |
| 251 | if _, err := ring.Open("webhooks.secret", before); err == nil || !strings.Contains(err.Error(), old.ID) { | |
| 252 | t.Fatalf("a retired key's value opened, or the error does not name the key: %v", err) | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | func TestReadKeysRefusesAReadableFile(t *testing.T) { | |
| 257 | path := keyFile(t, newKey(t)) | |
| 258 | if err := os.Chmod(path, 0o640); err != nil { | |
| 259 | t.Fatal(err) | |
| 260 | } | |
| 261 | if _, err := ReadKeys(path); err == nil || !strings.Contains(err.Error(), "0600") { | |
| 262 | t.Fatalf("group-readable key file: %v", err) | |
| 263 | } | |
| 264 | } | |
| 265 | ||
| 266 | func TestWriteKeysMode(t *testing.T) { | |
| 267 | path := keyFile(t, newKey(t)) | |
| 268 | fi, err := os.Stat(path) | |
| 269 | if err != nil { | |
| 270 | t.Fatal(err) | |
| 271 | } | |
| 272 | if fi.Mode().Perm() != 0o600 { | |
| 273 | t.Fatalf("mode %04o", fi.Mode().Perm()) | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | func TestReadKeysRejectsMalformedLines(t *testing.T) { | |
| 278 | for _, body := range []string{ | |
| 279 | "", | |
| 280 | "# only a comment\n", | |
| 281 | "XYZ12345 AAAA\n", | |
| 282 | "0123abcd bm90IDMyIGJ5dGVz\n", | |
| 283 | } { | |
| 284 | path := filepath.Join(t.TempDir(), "k") | |
| 285 | if err := os.WriteFile(path, []byte(body), 0o600); err != nil { | |
| 286 | t.Fatal(err) | |
| 287 | } | |
| 288 | if _, err := ReadKeys(path); err == nil { | |
| 289 | t.Errorf("accepted %q", body) | |
| 290 | } | |
| 291 | } | |
| 292 | } | |
| 293 | ``` | |
| 294 | ||
| 295 | - [ ] **Step 2: Run the tests to verify they fail** | |
| 296 | ||
| 297 | Run: `go test ./internal/seal/ -count=1` | |
| 298 | Expected: FAIL, package does not compile (`undefined: WriteKeys`). | |
| 299 | ||
| 300 | - [ ] **Step 3: Write the implementation** | |
| 301 | ||
| 302 | ```go | |
| 303 | // Package seal encrypts the secret columns of the database with | |
| 304 | // AES-256-GCM under keys held in a file outside the database and outside | |
| 305 | // server.root, so neither a copy of the database nor a backup opens them | |
| 306 | // (#273). | |
| 307 | package seal | |
| 308 | ||
| 309 | import ( | |
| 310 | "bufio" | |
| 311 | "bytes" | |
| 312 | "crypto/aes" | |
| 313 | "crypto/cipher" | |
| 314 | "crypto/rand" | |
| 315 | "encoding/base64" | |
| 316 | "encoding/hex" | |
| 317 | "errors" | |
| 318 | "fmt" | |
| 319 | "os" | |
| 320 | "path/filepath" | |
| 321 | "strings" | |
| 322 | "sync" | |
| 323 | "syscall" | |
| 324 | ) | |
| 325 | ||
| 326 | // Prefix marks a sealed value: "gbs1:<key id>:<base64 nonce||ciphertext>". | |
| 327 | const Prefix = "gbs1:" | |
| 328 | ||
| 329 | // Key is one line of the key file. | |
| 330 | type Key struct { | |
| 331 | ID string // 8 lowercase hex characters | |
| 332 | Secret []byte // 32 bytes | |
| 333 | } | |
| 334 | ||
| 335 | // NewKey returns a key with a random id and secret. | |
| 336 | func NewKey() (Key, error) { | |
| 337 | id := make([]byte, 4) | |
| 338 | secret := make([]byte, 32) | |
| 339 | if _, err := rand.Read(id); err != nil { | |
| 340 | return Key{}, err | |
| 341 | } | |
| 342 | if _, err := rand.Read(secret); err != nil { | |
| 343 | return Key{}, err | |
| 344 | } | |
| 345 | return Key{ID: hex.EncodeToString(id), Secret: secret}, nil | |
| 346 | } | |
| 347 | ||
| 348 | // ReadKeys reads the key file. The last key seals; every key opens. | |
| 349 | func ReadKeys(path string) ([]Key, error) { | |
| 350 | fi, err := os.Stat(path) | |
| 351 | if err != nil { | |
| 352 | return nil, err | |
| 353 | } | |
| 354 | if perm := fi.Mode().Perm(); perm&0o077 != 0 { | |
| 355 | return nil, fmt.Errorf("%s is mode %04o; it must be readable by its owner alone (0600)", path, perm) | |
| 356 | } | |
| 357 | data, err := os.ReadFile(path) | |
| 358 | if err != nil { | |
| 359 | return nil, err | |
| 360 | } | |
| 361 | var keys []Key | |
| 362 | seen := map[string]bool{} | |
| 363 | sc := bufio.NewScanner(bytes.NewReader(data)) | |
| 364 | for n := 1; sc.Scan(); n++ { | |
| 365 | line := strings.TrimSpace(sc.Text()) | |
| 366 | if line == "" || strings.HasPrefix(line, "#") { | |
| 367 | continue | |
| 368 | } | |
| 369 | f := strings.Fields(line) | |
| 370 | if len(f) != 2 || !validID(f[0]) { | |
| 371 | return nil, fmt.Errorf("%s:%d: want \"<8 hex id> <base64 32-byte key>\"", path, n) | |
| 372 | } | |
| 373 | secret, err := base64.StdEncoding.DecodeString(f[1]) | |
| 374 | if err != nil || len(secret) != 32 { | |
| 375 | return nil, fmt.Errorf("%s:%d: key is not 32 bytes of base64", path, n) | |
| 376 | } | |
| 377 | if seen[f[0]] { | |
| 378 | return nil, fmt.Errorf("%s:%d: key id %s appears twice", path, n, f[0]) | |
| 379 | } | |
| 380 | seen[f[0]] = true | |
| 381 | keys = append(keys, Key{ID: f[0], Secret: secret}) | |
| 382 | } | |
| 383 | if err := sc.Err(); err != nil { | |
| 384 | return nil, err | |
| 385 | } | |
| 386 | if len(keys) == 0 { | |
| 387 | return nil, fmt.Errorf("%s holds no keys", path) | |
| 388 | } | |
| 389 | return keys, nil | |
| 390 | } | |
| 391 | ||
| 392 | // WriteKeys replaces the key file: a temporary file in the same | |
| 393 | // directory, mode 0600, given the existing file's owner when there is | |
| 394 | // one (rotation runs as root; the daemon reads the file as its own | |
| 395 | // user), then renamed over it. | |
| 396 | func WriteKeys(path string, keys []Key) error { | |
| 397 | var b strings.Builder | |
| 398 | b.WriteString("# gitbay secret keys, \"<id> <base64 key>\" per line. The last line seals\n") | |
| 399 | b.WriteString("# new values; the others open values sealed before a rotation.\n") | |
| 400 | b.WriteString("# Keep a copy off this host: backups do not carry this file.\n") | |
| 401 | for _, k := range keys { | |
| 402 | fmt.Fprintf(&b, "%s %s\n", k.ID, base64.StdEncoding.EncodeToString(k.Secret)) | |
| 403 | } | |
| 404 | tmp, err := os.CreateTemp(filepath.Dir(path), ".secret-key-*") | |
| 405 | if err != nil { | |
| 406 | return err | |
| 407 | } | |
| 408 | defer os.Remove(tmp.Name()) | |
| 409 | fail := func(err error) error { | |
| 410 | tmp.Close() | |
| 411 | return err | |
| 412 | } | |
| 413 | if err := tmp.Chmod(0o600); err != nil { | |
| 414 | return fail(err) | |
| 415 | } | |
| 416 | if fi, err := os.Stat(path); err == nil { | |
| 417 | if st, ok := fi.Sys().(*syscall.Stat_t); ok { | |
| 418 | if err := tmp.Chown(int(st.Uid), int(st.Gid)); err != nil { | |
| 419 | return fail(err) | |
| 420 | } | |
| 421 | } | |
| 422 | } | |
| 423 | if _, err := tmp.WriteString(b.String()); err != nil { | |
| 424 | return fail(err) | |
| 425 | } | |
| 426 | if err := tmp.Sync(); err != nil { | |
| 427 | return fail(err) | |
| 428 | } | |
| 429 | if err := tmp.Close(); err != nil { | |
| 430 | return err | |
| 431 | } | |
| 432 | return os.Rename(tmp.Name(), path) | |
| 433 | } | |
| 434 | ||
| 435 | // Keyring is the loaded key file. It re-reads the file whenever the file | |
| 436 | // changes, so a running daemon follows a rotation without a restart. | |
| 437 | type Keyring struct { | |
| 438 | path string | |
| 439 | ||
| 440 | mu sync.Mutex | |
| 441 | fi os.FileInfo | |
| 442 | cur string | |
| 443 | aead map[string]cipher.AEAD | |
| 444 | } | |
| 445 | ||
| 446 | func Load(path string) (*Keyring, error) { | |
| 447 | k := &Keyring{path: path} | |
| 448 | if err := k.refresh(); err != nil { | |
| 449 | return nil, err | |
| 450 | } | |
| 451 | return k, nil | |
| 452 | } | |
| 453 | ||
| 454 | // refresh reloads the file unless it is the one last read. Callers hold k.mu. | |
| 455 | func (k *Keyring) refresh() error { | |
| 456 | fi, err := os.Stat(k.path) | |
| 457 | if err != nil { | |
| 458 | return err | |
| 459 | } | |
| 460 | if k.fi != nil && os.SameFile(k.fi, fi) && fi.ModTime().Equal(k.fi.ModTime()) && fi.Size() == k.fi.Size() { | |
| 461 | return nil | |
| 462 | } | |
| 463 | keys, err := ReadKeys(k.path) | |
| 464 | if err != nil { | |
| 465 | return err | |
| 466 | } | |
| 467 | aead := make(map[string]cipher.AEAD, len(keys)) | |
| 468 | for _, key := range keys { | |
| 469 | block, err := aes.NewCipher(key.Secret) | |
| 470 | if err != nil { | |
| 471 | return err | |
| 472 | } | |
| 473 | g, err := cipher.NewGCM(block) | |
| 474 | if err != nil { | |
| 475 | return err | |
| 476 | } | |
| 477 | aead[key.ID] = g | |
| 478 | } | |
| 479 | k.fi, k.cur, k.aead = fi, keys[len(keys)-1].ID, aead | |
| 480 | return nil | |
| 481 | } | |
| 482 | ||
| 483 | // CurrentID is the id of the key that seals new values. | |
| 484 | func (k *Keyring) CurrentID() (string, error) { | |
| 485 | k.mu.Lock() | |
| 486 | defer k.mu.Unlock() | |
| 487 | if err := k.refresh(); err != nil { | |
| 488 | return "", err | |
| 489 | } | |
| 490 | return k.cur, nil | |
| 491 | } | |
| 492 | ||
| 493 | // Seal encrypts plain under the current key. aad names the column, so a | |
| 494 | // value copied into another column does not open there. | |
| 495 | func (k *Keyring) Seal(aad, plain string) (string, error) { | |
| 496 | k.mu.Lock() | |
| 497 | defer k.mu.Unlock() | |
| 498 | if err := k.refresh(); err != nil { | |
| 499 | return "", err | |
| 500 | } | |
| 501 | g := k.aead[k.cur] | |
| 502 | nonce := make([]byte, g.NonceSize()) | |
| 503 | if _, err := rand.Read(nonce); err != nil { | |
| 504 | return "", err | |
| 505 | } | |
| 506 | ct := g.Seal(nonce, nonce, []byte(plain), []byte(aad)) | |
| 507 | return Prefix + k.cur + ":" + base64.RawStdEncoding.EncodeToString(ct), nil | |
| 508 | } | |
| 509 | ||
| 510 | // Open decrypts a value Seal produced under any key the file holds. | |
| 511 | func (k *Keyring) Open(aad, sealed string) (string, error) { | |
| 512 | id, body, ok := split(sealed) | |
| 513 | if !ok { | |
| 514 | return "", errors.New("not a sealed value") | |
| 515 | } | |
| 516 | k.mu.Lock() | |
| 517 | defer k.mu.Unlock() | |
| 518 | if err := k.refresh(); err != nil { | |
| 519 | return "", err | |
| 520 | } | |
| 521 | g, ok := k.aead[id] | |
| 522 | if !ok { | |
| 523 | return "", fmt.Errorf("sealed with key %s, which %s does not hold", id, k.path) | |
| 524 | } | |
| 525 | ct, err := base64.RawStdEncoding.DecodeString(body) | |
| 526 | if err != nil || len(ct) < g.NonceSize() { | |
| 527 | return "", fmt.Errorf("value sealed with key %s is malformed", id) | |
| 528 | } | |
| 529 | plain, err := g.Open(nil, ct[:g.NonceSize()], ct[g.NonceSize():], []byte(aad)) | |
| 530 | if err != nil { | |
| 531 | return "", fmt.Errorf("value sealed with key %s does not open: wrong key or altered value", id) | |
| 532 | } | |
| 533 | return string(plain), nil | |
| 534 | } | |
| 535 | ||
| 536 | // IsSealed reports whether v carries the sealed prefix. | |
| 537 | func IsSealed(v string) bool { return strings.HasPrefix(v, Prefix) } | |
| 538 | ||
| 539 | // KeyID is the id of the key that sealed v. | |
| 540 | func KeyID(v string) (string, bool) { | |
| 541 | id, _, ok := split(v) | |
| 542 | return id, ok | |
| 543 | } | |
| 544 | ||
| 545 | func split(v string) (id, body string, ok bool) { | |
| 546 | rest, ok := strings.CutPrefix(v, Prefix) | |
| 547 | if !ok { | |
| 548 | return "", "", false | |
| 549 | } | |
| 550 | id, body, ok = strings.Cut(rest, ":") | |
| 551 | return id, body, ok && validID(id) | |
| 552 | } | |
| 553 | ||
| 554 | func validID(s string) bool { | |
| 555 | if len(s) != 8 || strings.ToLower(s) != s { | |
| 556 | return false | |
| 557 | } | |
| 558 | _, err := hex.DecodeString(s) | |
| 559 | return err == nil | |
| 560 | } | |
| 561 | ``` | |
| 562 | ||
| 563 | - [ ] **Step 4: Run the tests to verify they pass** | |
| 564 | ||
| 565 | Run: `go test ./internal/seal/ -count=1 && go vet ./internal/seal/` | |
| 566 | Expected: PASS. | |
| 567 | ||
| 568 | - [ ] **Step 5: Commit** | |
| 569 | ||
| 570 | ```bash | |
| 571 | git add internal/seal | |
| 572 | git commit -S -m "seal: AES-256-GCM keyring for secret columns | |
| 573 | ||
| 574 | Ref #273" | |
| 575 | ``` | |
| 576 | ||
| 577 | ### Task 1.2: `server.secret_key_file` | |
| 578 | ||
| 579 | **Files:** | |
| 580 | - Modify: `internal/config/config.go:4-16` (imports), `:48-57` (`Server`), `:271-273` (`Default`), `:319-335` (`Validate`) | |
| 581 | - Test: `internal/config/config_test.go` | |
| 582 | ||
| 583 | **Interfaces:** | |
| 584 | - Produces: `Config.Server.SecretKeyFile string` (`toml:"secret_key_file"`), default `/etc/gitbay/secret.key`. | |
| 585 | ||
| 586 | - [ ] **Step 1: Write the failing test** (append to `config_test.go`) | |
| 587 | ||
| 588 | ```go | |
| 589 | func TestSecretKeyFile(t *testing.T) { | |
| 590 | cfg, err := Load(writeConfig(t, minimal)) | |
| 591 | if err != nil { | |
| 592 | t.Fatal(err) | |
| 593 | } | |
| 594 | if cfg.Server.SecretKeyFile != "/etc/gitbay/secret.key" { | |
| 595 | t.Errorf("default secret_key_file = %q", cfg.Server.SecretKeyFile) | |
| 596 | } | |
| 597 | for body, want := range map[string]string{ | |
| 598 | minimal + "secret_key_file = \"/var/lib/gitbay/secret.key\"\n": "inside server.root", | |
| 599 | minimal + "secret_key_file = \"/var/lib/gitbay\"\n": "inside server.root", | |
| 600 | minimal + "secret_key_file = \"\"\n": "server.secret_key_file is required", | |
| 601 | } { | |
| 602 | if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) { | |
| 603 | t.Errorf("%q: got %v, want an error containing %q", body, err, want) | |
| 604 | } | |
| 605 | } | |
| 606 | if _, err := Load(writeConfig(t, minimal+"secret_key_file = \"/var/lib/gitbay-keys/secret.key\"\n")); err != nil { | |
| 607 | t.Errorf("a sibling directory of the root is outside it: %v", err) | |
| 608 | } | |
| 609 | } | |
| 610 | ``` | |
| 611 | ||
| 612 | - [ ] **Step 2: Run it to verify it fails** | |
| 613 | ||
| 614 | Run: `go test ./internal/config/ -run TestSecretKeyFile -count=1` | |
| 615 | Expected: FAIL, `unknown config key "server.secret_key_file"`. | |
| 616 | ||
| 617 | - [ ] **Step 3: Implement** | |
| 618 | ||
| 619 | Add `"path/filepath"` to the imports. In `Server`, after `SiteURL`: | |
| 620 | ||
| 621 | ```go | |
| 622 | // SecretKeyFile holds the keys that seal the secret columns of the | |
| 623 | // database (internal/seal). It lives outside Root, so neither a | |
| 624 | // backup archive nor a snapshot of Root carries it. | |
| 625 | SecretKeyFile string `toml:"secret_key_file"` | |
| 626 | ``` | |
| 627 | ||
| 628 | In `Default()`: | |
| 629 | ||
| 630 | ```go | |
| 631 | Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"}, | |
| 632 | ``` | |
| 633 | ||
| 634 | In `Validate()`, after the `server.site_url is required` check: | |
| 635 | ||
| 636 | ```go | |
| 637 | switch { | |
| 638 | case c.Server.SecretKeyFile == "": | |
| 639 | errs = append(errs, errors.New("server.secret_key_file is required")) | |
| 640 | case within(c.Server.Root, c.Server.SecretKeyFile): | |
| 641 | errs = append(errs, fmt.Errorf("server.secret_key_file %q is inside server.root: backups of the root would carry the key beside the values it seals", c.Server.SecretKeyFile)) | |
| 642 | } | |
| 643 | ``` | |
| 644 | ||
| 645 | And below `oneOf`: | |
| 646 | ||
| 647 | ```go | |
| 648 | // within reports whether path is dir or below it. | |
| 649 | func within(dir, path string) bool { | |
| 650 | rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) | |
| 651 | return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 652 | } | |
| 653 | ``` | |
| 654 | ||
| 655 | - [ ] **Step 4: Run the package tests** | |
| 656 | ||
| 657 | Run: `go test ./internal/config/ -count=1` | |
| 658 | Expected: PASS (existing tests use `minimal`, which gets the default). | |
| 659 | ||
| 660 | - [ ] **Step 5: Commit** | |
| 661 | ||
| 662 | ```bash | |
| 663 | git add internal/config | |
| 664 | git commit -S -m "config: server.secret_key_file, outside server.root | |
| 665 | ||
| 666 | Ref #273" | |
| 667 | ``` | |
| 668 | ||
| 669 | ### Task 1.3: the store seals and opens | |
| 670 | ||
| 671 | **Files:** | |
| 672 | - Create: `internal/store/secrets.go`, `internal/store/migrations/0072_push_token_hash.up.sql`, `internal/store/migrations/0072_push_token_hash.down.sql` | |
| 673 | - Modify: `internal/store/store.go:23-30` (`Store`), `internal/store/cisecrets.go:3-58`, `internal/store/webhooks.go:41-113`, `internal/store/mirrors.go:23-66`, `internal/store/push.go:32-78,153-202` | |
| 674 | - Test: `internal/store/secrets_test.go` (create) | |
| 675 | ||
| 676 | **Interfaces:** | |
| 677 | - Consumes: `seal.Keyring`, `seal.IsSealed`, `seal.KeyID`, `seal.NewKey`, `seal.WriteKeys`, `seal.Load` (Task 1.1). | |
| 678 | - Produces: | |
| 679 | - `func (s *Store) SetKeyring(k *seal.Keyring)` | |
| 680 | - `func (s *Store) ResealSecrets() (int, error)` — seals clear values, reseals values not under the current key, fills missing `push_devices.token_hash`; one transaction; returns values rewritten. | |
| 681 | - `func (s *Store) SecretKeyUse() (map[string]int, error)` — count per key id (`""` = clear), opening each value. | |
| 682 | - Unchanged signatures for every existing store function; `Mirror.Token`, `Webhook.Secret`, `Delivery.Secret`, `PushDevice.Token`, `QueuedPush.Token` hold plaintext as before. | |
| 683 | ||
| 684 | - [ ] **Step 1: Write the failing tests** | |
| 685 | ||
| 686 | ```go | |
| 687 | package store | |
| 688 | ||
| 689 | import ( | |
| 690 | "path/filepath" | |
| 691 | "strings" | |
| 692 | "testing" | |
| 693 | ||
| 694 | "gitbay.org/gitbay/internal/seal" | |
| 695 | ) | |
| 696 | ||
| 697 | // keyedStore is a migrated store with a key file of one key. | |
| 698 | func keyedStore(t *testing.T) (*Store, string, int64, int64) { | |
| 699 | t.Helper() | |
| 700 | s := open(t) | |
| 701 | if err := s.MigrateUp(); err != nil { | |
| 702 | t.Fatal(err) | |
| 703 | } | |
| 704 | path := filepath.Join(t.TempDir(), "secret.key") | |
| 705 | k, err := seal.NewKey() | |
| 706 | if err != nil { | |
| 707 | t.Fatal(err) | |
| 708 | } | |
| 709 | if err := seal.WriteKeys(path, []seal.Key{k}); err != nil { | |
| 710 | t.Fatal(err) | |
| 711 | } | |
| 712 | ring, err := seal.Load(path) | |
| 713 | if err != nil { | |
| 714 | t.Fatal(err) | |
| 715 | } | |
| 716 | s.SetKeyring(ring) | |
| 717 | uid, err := s.CreateUser("alice", false) | |
| 718 | if err != nil { | |
| 719 | t.Fatal(err) | |
| 720 | } | |
| 721 | repoID, err := s.CreateRepo("user", uid, "app", "public") | |
| 722 | if err != nil { | |
| 723 | t.Fatal(err) | |
| 724 | } | |
| 725 | return s, path, uid, repoID | |
| 726 | } | |
| 727 | ||
| 728 | // raw reads every stored value of the secret columns. | |
| 729 | func raw(t *testing.T, s *Store) []string { | |
| 730 | t.Helper() | |
| 731 | var out []string | |
| 732 | for _, sc := range secretColumns { | |
| 733 | rows, err := s.DB.Query("SELECT " + sc.column + " FROM " + sc.table + " WHERE " + sc.column + " != ''") | |
| 734 | if err != nil { | |
| 735 | t.Fatal(err) | |
| 736 | } | |
| 737 | for rows.Next() { | |
| 738 | var v string | |
| 739 | if err := rows.Scan(&v); err != nil { | |
| 740 | t.Fatal(err) | |
| 741 | } | |
| 742 | out = append(out, v) | |
| 743 | } | |
| 744 | rows.Close() | |
| 745 | } | |
| 746 | return out | |
| 747 | } | |
| 748 | ||
| 749 | func TestSecretColumnsAreSealed(t *testing.T) { | |
| 750 | s, _, uid, repoID := keyedStore(t) | |
| 751 | if err := s.SetBuildSecret(repoID, "DEPLOY", "ci-secret"); err != nil { | |
| 752 | t.Fatal(err) | |
| 753 | } | |
| 754 | if _, err := s.AddWebhook(repoID, "https://hook.example/x", "hook-secret", "*"); err != nil { | |
| 755 | t.Fatal(err) | |
| 756 | } | |
| 757 | if _, err := s.AddMirror(repoID, "push", "https://mirror.example/r.git", "u", "mirror-token"); err != nil { | |
| 758 | t.Fatal(err) | |
| 759 | } | |
| 760 | if _, err := s.AddPushDevice(uid, "apns-token", "phone"); err != nil { | |
| 761 | t.Fatal(err) | |
| 762 | } | |
| 763 | ||
| 764 | vals := raw(t, s) | |
| 765 | if len(vals) != 4 { | |
| 766 | t.Fatalf("stored %d values, want 4: %v", len(vals), vals) | |
| 767 | } | |
| 768 | for _, v := range vals { | |
| 769 | if !seal.IsSealed(v) { | |
| 770 | t.Errorf("stored in clear: %q", v) | |
| 771 | } | |
| 772 | for _, plain := range []string{"ci-secret", "hook-secret", "mirror-token", "apns-token"} { | |
| 773 | if strings.Contains(v, plain) { | |
| 774 | t.Errorf("%q carries %q", v, plain) | |
| 775 | } | |
| 776 | } | |
| 777 | } | |
| 778 | ||
| 779 | secrets, err := s.BuildSecrets(repoID) | |
| 780 | if err != nil || secrets["DEPLOY"] != "ci-secret" { | |
| 781 | t.Fatalf("BuildSecrets = %v, %v", secrets, err) | |
| 782 | } | |
| 783 | hooks, err := s.ListWebhooks(repoID) | |
| 784 | if err != nil || len(hooks) != 1 || hooks[0].Secret != "hook-secret" { | |
| 785 | t.Fatalf("ListWebhooks = %+v, %v", hooks, err) | |
| 786 | } | |
| 787 | ms, err := s.ListMirrors(repoID) | |
| 788 | if err != nil || len(ms) != 1 || ms[0].Token != "mirror-token" { | |
| 789 | t.Fatalf("ListMirrors = %+v, %v", ms, err) | |
| 790 | } | |
| 791 | ds, err := s.PushDevices(uid) | |
| 792 | if err != nil || len(ds) != 1 || ds[0].Token != "apns-token" { | |
| 793 | t.Fatalf("PushDevices = %+v, %v", ds, err) | |
| 794 | } | |
| 795 | ||
| 796 | // An empty webhook secret or mirror token stays empty: it means none. | |
| 797 | if _, err := s.AddWebhook(repoID, "https://hook.example/y", "", "*"); err != nil { | |
| 798 | t.Fatal(err) | |
| 799 | } | |
| 800 | var empty int | |
| 801 | s.DB.QueryRow("SELECT COUNT(*) FROM webhooks WHERE secret = ''").Scan(&empty) | |
| 802 | if empty != 1 { | |
| 803 | t.Errorf("empty secret stored as %d rows of ''", empty) | |
| 804 | } | |
| 805 | } | |
| 806 | ||
| 807 | // A token re-registered under another account changes hands by its | |
| 808 | // hash, since two seals of one token differ. | |
| 809 | func TestPushDeviceUpsertBySealedToken(t *testing.T) { | |
| 810 | s, _, uid, _ := keyedStore(t) | |
| 811 | bob, err := s.CreateUser("bob", false) | |
| 812 | if err != nil { | |
| 813 | t.Fatal(err) | |
| 814 | } | |
| 815 | first, err := s.AddPushDevice(uid, "tok", "phone") | |
| 816 | if err != nil { | |
| 817 | t.Fatal(err) | |
| 818 | } | |
| 819 | second, err := s.AddPushDevice(bob, "tok", "ipad") | |
| 820 | if err != nil { | |
| 821 | t.Fatal(err) | |
| 822 | } | |
| 823 | if first != second { | |
| 824 | t.Fatalf("re-registration made row %d beside %d", second, first) | |
| 825 | } | |
| 826 | if err := s.DeletePushDeviceByToken("tok"); err != nil { | |
| 827 | t.Fatal(err) | |
| 828 | } | |
| 829 | if d, _ := s.PushDevices(bob); len(d) != 0 { | |
| 830 | t.Fatalf("device left after delete by token: %+v", d) | |
| 831 | } | |
| 832 | } | |
| 833 | ||
| 834 | // Rows written before sealing existed, and rows under a retired key, | |
| 835 | // end up under the current key. | |
| 836 | func TestResealSecrets(t *testing.T) { | |
| 837 | s, path, uid, repoID := keyedStore(t) | |
| 838 | if _, err := s.DB.Exec("INSERT INTO build_secrets (repo_id, name, value) VALUES (?, 'OLD', 'clear-value')", repoID); err != nil { | |
| 839 | t.Fatal(err) | |
| 840 | } | |
| 841 | if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'clear-token', '')", uid); err != nil { | |
| 842 | t.Fatal(err) | |
| 843 | } | |
| 844 | n, err := s.ResealSecrets() | |
| 845 | if err != nil || n != 2 { | |
| 846 | t.Fatalf("ResealSecrets = %d, %v; want 2", n, err) | |
| 847 | } | |
| 848 | for _, v := range raw(t, s) { | |
| 849 | if !seal.IsSealed(v) { | |
| 850 | t.Errorf("still clear: %q", v) | |
| 851 | } | |
| 852 | } | |
| 853 | var hash string | |
| 854 | s.DB.QueryRow("SELECT COALESCE(token_hash, '') FROM push_devices").Scan(&hash) | |
| 855 | if hash != tokenHash("clear-token") { | |
| 856 | t.Errorf("token_hash = %q", hash) | |
| 857 | } | |
| 858 | if n, _ := s.ResealSecrets(); n != 0 { | |
| 859 | t.Errorf("second reseal rewrote %d values", n) | |
| 860 | } | |
| 861 | ||
| 862 | // Rotation: add a key, reseal, drop the old key; the value still opens. | |
| 863 | old, err := seal.ReadKeys(path) | |
| 864 | if err != nil { | |
| 865 | t.Fatal(err) | |
| 866 | } | |
| 867 | next, _ := seal.NewKey() | |
| 868 | if err := seal.WriteKeys(path, append(old, next)); err != nil { | |
| 869 | t.Fatal(err) | |
| 870 | } | |
| 871 | if n, err := s.ResealSecrets(); err != nil || n != 2 { | |
| 872 | t.Fatalf("reseal after rotation = %d, %v; want 2", n, err) | |
| 873 | } | |
| 874 | if err := seal.WriteKeys(path, []seal.Key{next}); err != nil { | |
| 875 | t.Fatal(err) | |
| 876 | } | |
| 877 | use, err := s.SecretKeyUse() | |
| 878 | if err != nil || use[next.ID] != 2 || len(use) != 1 { | |
| 879 | t.Fatalf("SecretKeyUse = %v, %v", use, err) | |
| 880 | } | |
| 881 | if got, _ := s.BuildSecrets(repoID); got["OLD"] != "clear-value" { | |
| 882 | t.Fatalf("value after rotation: %v", got) | |
| 883 | } | |
| 884 | } | |
| 885 | ||
| 886 | func TestSealedValueWithoutKeyFails(t *testing.T) { | |
| 887 | s, _, _, repoID := keyedStore(t) | |
| 888 | if err := s.SetBuildSecret(repoID, "X", "v"); err != nil { | |
| 889 | t.Fatal(err) | |
| 890 | } | |
| 891 | s.SetKeyring(nil) | |
| 892 | if _, err := s.BuildSecrets(repoID); err == nil { | |
| 893 | t.Fatal("opened a sealed value with no key loaded") | |
| 894 | } | |
| 895 | } | |
| 896 | ``` | |
| 897 | ||
| 898 | - [ ] **Step 2: Run them to verify they fail** | |
| 899 | ||
| 900 | Run: `go test ./internal/store/ -run 'TestSecretColumnsAreSealed|TestPushDeviceUpsertBySealedToken|TestResealSecrets|TestSealedValueWithoutKeyFails' -count=1` | |
| 901 | Expected: FAIL to compile, `s.SetKeyring undefined`. | |
| 902 | ||
| 903 | - [ ] **Step 3: Migration 0072** | |
| 904 | ||
| 905 | `internal/store/migrations/0072_push_token_hash.up.sql`: | |
| 906 | ||
| 907 | ```sql | |
| 908 | -- APNs tokens are sealed with a random nonce (internal/seal), so two | |
| 909 | -- stores of one token differ; lookups and the re-registration upsert go | |
| 910 | -- by this SHA-256 of the token instead. Rows from before it are filled | |
| 911 | -- by Store.ResealSecrets when the daemon starts. | |
| 912 | ALTER TABLE push_devices ADD COLUMN token_hash TEXT; | |
| 913 | CREATE UNIQUE INDEX push_devices_token_hash ON push_devices(token_hash); | |
| 914 | ``` | |
| 915 | ||
| 916 | `internal/store/migrations/0072_push_token_hash.down.sql`: | |
| 917 | ||
| 918 | ```sql | |
| 919 | DROP INDEX push_devices_token_hash; | |
| 920 | ALTER TABLE push_devices DROP COLUMN token_hash; | |
| 921 | ``` | |
| 922 | ||
| 923 | - [ ] **Step 4: `Store.secrets` and `internal/store/secrets.go`** | |
| 924 | ||
| 925 | In `store.go`, add the import `"gitbay.org/gitbay/internal/seal"` and a field on `Store` after `logWait`: | |
| 926 | ||
| 927 | ```go | |
| 928 | // secrets seals and opens the secret columns (secrets.go). Nil | |
| 929 | // stores values as given; only tests leave it nil, since openStore | |
| 930 | // in cmd/gitbayd refuses to run without a key file. | |
| 931 | secrets *seal.Keyring | |
| 932 | ``` | |
| 933 | ||
| 934 | `internal/store/secrets.go`: | |
| 935 | ||
| 936 | ```go | |
| 937 | package store | |
| 938 | ||
| 939 | import ( | |
| 940 | "crypto/sha256" | |
| 941 | "database/sql" | |
| 942 | "encoding/hex" | |
| 943 | "errors" | |
| 944 | "fmt" | |
| 945 | ||
| 946 | "gitbay.org/gitbay/internal/seal" | |
| 947 | ) | |
| 948 | ||
| 949 | // The additional data of each sealed value is its "<table>.<column>". | |
| 950 | const ( | |
| 951 | aadBuildSecret = "build_secrets.value" | |
| 952 | aadWebhook = "webhooks.secret" | |
| 953 | aadMirror = "mirrors.token" | |
| 954 | aadPushToken = "push_devices.token" | |
| 955 | ) | |
| 956 | ||
| 957 | type secretColumn struct{ table, column string } | |
| 958 | ||
| 959 | func (c secretColumn) aad() string { return c.table + "." + c.column } | |
| 960 | ||
| 961 | // secretColumns are the columns sealed under the key file (#273). | |
| 962 | var secretColumns = []secretColumn{ | |
| 963 | {"build_secrets", "value"}, | |
| 964 | {"webhooks", "secret"}, | |
| 965 | {"mirrors", "token"}, | |
| 966 | {"push_devices", "token"}, | |
| 967 | } | |
| 968 | ||
| 969 | func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k } | |
| 970 | ||
| 971 | // sealValue seals v for storage. An empty value stays empty: for | |
| 972 | // webhooks and mirrors it means there is no secret. | |
| 973 | func (s *Store) sealValue(aad, v string) (string, error) { | |
| 974 | if s.secrets == nil || v == "" { | |
| 975 | return v, nil | |
| 976 | } | |
| 977 | return s.secrets.Seal(aad, v) | |
| 978 | } | |
| 979 | ||
| 980 | // openValue returns a stored value in clear. A value not yet sealed is | |
| 981 | // returned as stored: rows from before sealing existed stay readable | |
| 982 | // until ResealSecrets reaches them. | |
| 983 | func (s *Store) openValue(aad, v string) (string, error) { | |
| 984 | if !seal.IsSealed(v) { | |
| 985 | return v, nil | |
| 986 | } | |
| 987 | if s.secrets == nil { | |
| 988 | return "", errors.New("value is sealed and no secret key is loaded") | |
| 989 | } | |
| 990 | return s.secrets.Open(aad, v) | |
| 991 | } | |
| 992 | ||
| 993 | // tokenHash is the lookup key for a push device token. | |
| 994 | func tokenHash(token string) string { | |
| 995 | sum := sha256.Sum256([]byte(token)) | |
| 996 | return hex.EncodeToString(sum[:]) | |
| 997 | } | |
| 998 | ||
| 999 | type secretRow struct { | |
| 1000 | rowid int64 | |
| 1001 | value string | |
| 1002 | } | |
| 1003 | ||
| 1004 | type queryer interface { | |
| 1005 | Query(query string, args ...any) (*sql.Rows, error) | |
| 1006 | } | |
| 1007 | ||
| 1008 | func secretRows(q queryer, c secretColumn) ([]secretRow, error) { | |
| 1009 | rows, err := q.Query(fmt.Sprintf("SELECT rowid, %s FROM %s WHERE %s != ''", c.column, c.table, c.column)) | |
| 1010 | if err != nil { | |
| 1011 | return nil, err | |
| 1012 | } | |
| 1013 | defer rows.Close() | |
| 1014 | var out []secretRow | |
| 1015 | for rows.Next() { | |
| 1016 | var r secretRow | |
| 1017 | if err := rows.Scan(&r.rowid, &r.value); err != nil { | |
| 1018 | return nil, err | |
| 1019 | } | |
| 1020 | out = append(out, r) | |
| 1021 | } | |
| 1022 | return out, rows.Err() | |
| 1023 | } | |
| 1024 | ||
| 1025 | // ResealSecrets seals every clear value in the secret columns and | |
| 1026 | // reseals every value not under the key file's current key, then fills | |
| 1027 | // push_devices.token_hash where it is missing. It runs in one write | |
| 1028 | // transaction: every store write of a secret seals inside its own | |
| 1029 | // transaction, so a write either lands before this one and is resealed, | |
| 1030 | // or after it and is sealed under the key this one saw. It returns how | |
| 1031 | // many values it rewrote. | |
| 1032 | func (s *Store) ResealSecrets() (int, error) { | |
| 1033 | if s.secrets == nil { | |
| 1034 | return 0, errors.New("no secret key loaded") | |
| 1035 | } | |
| 1036 | tx, err := s.DB.Begin() | |
| 1037 | if err != nil { | |
| 1038 | return 0, err | |
| 1039 | } | |
| 1040 | defer tx.Rollback() | |
| 1041 | cur, err := s.secrets.CurrentID() | |
| 1042 | if err != nil { | |
| 1043 | return 0, err | |
| 1044 | } | |
| 1045 | n := 0 | |
| 1046 | for _, c := range secretColumns { | |
| 1047 | rows, err := secretRows(tx, c) | |
| 1048 | if err != nil { | |
| 1049 | return 0, err | |
| 1050 | } | |
| 1051 | for _, r := range rows { | |
| 1052 | if id, ok := seal.KeyID(r.value); ok && id == cur { | |
| 1053 | continue | |
| 1054 | } | |
| 1055 | plain, err := s.openValue(c.aad(), r.value) | |
| 1056 | if err != nil { | |
| 1057 | return 0, fmt.Errorf("%s row %d: %w", c.aad(), r.rowid, err) | |
| 1058 | } | |
| 1059 | sealed, err := s.secrets.Seal(c.aad(), plain) | |
| 1060 | if err != nil { | |
| 1061 | return 0, err | |
| 1062 | } | |
| 1063 | if _, err := tx.Exec(fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", c.table, c.column), sealed, r.rowid); err != nil { | |
| 1064 | return 0, err | |
| 1065 | } | |
| 1066 | n++ | |
| 1067 | } | |
| 1068 | } | |
| 1069 | rows, err := tx.Query("SELECT id, token FROM push_devices WHERE token_hash IS NULL") | |
| 1070 | if err != nil { | |
| 1071 | return 0, err | |
| 1072 | } | |
| 1073 | var missing []secretRow | |
| 1074 | for rows.Next() { | |
| 1075 | var r secretRow | |
| 1076 | if err := rows.Scan(&r.rowid, &r.value); err != nil { | |
| 1077 | rows.Close() | |
| 1078 | return 0, err | |
| 1079 | } | |
| 1080 | missing = append(missing, r) | |
| 1081 | } | |
| 1082 | rows.Close() | |
| 1083 | if err := rows.Err(); err != nil { | |
| 1084 | return 0, err | |
| 1085 | } | |
| 1086 | for _, r := range missing { | |
| 1087 | plain, err := s.openValue(aadPushToken, r.value) | |
| 1088 | if err != nil { | |
| 1089 | return 0, fmt.Errorf("push_devices row %d: %w", r.rowid, err) | |
| 1090 | } | |
| 1091 | if _, err := tx.Exec("UPDATE push_devices SET token_hash = ? WHERE id = ?", tokenHash(plain), r.rowid); err != nil { | |
| 1092 | return 0, err | |
| 1093 | } | |
| 1094 | } | |
| 1095 | return n, tx.Commit() | |
| 1096 | } | |
| 1097 | ||
| 1098 | // SecretKeyUse counts the values in the secret columns by the id of the | |
| 1099 | // key that sealed them ("" for a value still in clear), opening each | |
| 1100 | // one, so a wrong or incomplete key file is an error naming the row. | |
| 1101 | func (s *Store) SecretKeyUse() (map[string]int, error) { | |
| 1102 | use := map[string]int{} | |
| 1103 | for _, c := range secretColumns { | |
| 1104 | rows, err := secretRows(s.DB, c) | |
| 1105 | if err != nil { | |
| 1106 | return nil, err | |
| 1107 | } | |
| 1108 | for _, r := range rows { | |
| 1109 | if _, err := s.openValue(c.aad(), r.value); err != nil { | |
| 1110 | return nil, fmt.Errorf("%s row %d: %w", c.aad(), r.rowid, err) | |
| 1111 | } | |
| 1112 | id, _ := seal.KeyID(r.value) | |
| 1113 | use[id]++ | |
| 1114 | } | |
| 1115 | } | |
| 1116 | return use, nil | |
| 1117 | } | |
| 1118 | ``` | |
| 1119 | ||
| 1120 | - [ ] **Step 5: Seal in the writers, open in the readers** | |
| 1121 | ||
| 1122 | `cisecrets.go` — replace `SetBuildSecret` and `BuildSecrets`: | |
| 1123 | ||
| 1124 | ```go | |
| 1125 | // SetBuildSecret stores or replaces one secret. The value never leaves the | |
| 1126 | // server except inside a claimed build's environment. It is sealed inside | |
| 1127 | // the write transaction; see ResealSecrets. | |
| 1128 | func (s *Store) SetBuildSecret(repoID int64, name, value string) error { | |
| 1129 | tx, err := s.DB.Begin() | |
| 1130 | if err != nil { | |
| 1131 | return err | |
| 1132 | } | |
| 1133 | defer tx.Rollback() | |
| 1134 | sealed, err := s.sealValue(aadBuildSecret, value) | |
| 1135 | if err != nil { | |
| 1136 | return err | |
| 1137 | } | |
| 1138 | if _, err := tx.Exec(` | |
| 1139 | INSERT INTO build_secrets (repo_id, name, value) VALUES (?, ?, ?) | |
| 1140 | ON CONFLICT (repo_id, name) DO UPDATE SET value = excluded.value`, | |
| 1141 | repoID, name, sealed); err != nil { | |
| 1142 | return err | |
| 1143 | } | |
| 1144 | return tx.Commit() | |
| 1145 | } | |
| 1146 | ``` | |
| 1147 | ||
| 1148 | ```go | |
| 1149 | // BuildSecrets returns the values, for injection into a claimed build. | |
| 1150 | func (s *Store) BuildSecrets(repoID int64) (map[string]string, error) { | |
| 1151 | rows, err := s.DB.Query("SELECT name, value FROM build_secrets WHERE repo_id = ?", repoID) | |
| 1152 | if err != nil { | |
| 1153 | return nil, err | |
| 1154 | } | |
| 1155 | defer rows.Close() | |
| 1156 | out := map[string]string{} | |
| 1157 | for rows.Next() { | |
| 1158 | var n, v string | |
| 1159 | if err := rows.Scan(&n, &v); err != nil { | |
| 1160 | return nil, err | |
| 1161 | } | |
| 1162 | if out[n], err = s.openValue(aadBuildSecret, v); err != nil { | |
| 1163 | return nil, fmt.Errorf("build secret %s: %w", n, err) | |
| 1164 | } | |
| 1165 | } | |
| 1166 | return out, rows.Err() | |
| 1167 | } | |
| 1168 | ``` | |
| 1169 | ||
| 1170 | (add `import "fmt"` to `cisecrets.go`). | |
| 1171 | ||
| 1172 | `webhooks.go` — `AddWebhook`: | |
| 1173 | ||
| 1174 | ```go | |
| 1175 | func (s *Store) AddWebhook(repoID int64, url, secret, events string) (int64, error) { | |
| 1176 | tx, err := s.DB.Begin() | |
| 1177 | if err != nil { | |
| 1178 | return 0, err | |
| 1179 | } | |
| 1180 | defer tx.Rollback() | |
| 1181 | sealed, err := s.sealValue(aadWebhook, secret) | |
| 1182 | if err != nil { | |
| 1183 | return 0, err | |
| 1184 | } | |
| 1185 | res, err := tx.Exec( | |
| 1186 | "INSERT INTO webhooks (repo_id, url, secret, events) VALUES (?, ?, ?, ?)", | |
| 1187 | repoID, url, sealed, events) | |
| 1188 | if err != nil { | |
| 1189 | return 0, err | |
| 1190 | } | |
| 1191 | id, err := res.LastInsertId() | |
| 1192 | if err != nil { | |
| 1193 | return 0, err | |
| 1194 | } | |
| 1195 | return id, tx.Commit() | |
| 1196 | } | |
| 1197 | ``` | |
| 1198 | ||
| 1199 | In `ListWebhooks`, after the `rows.Scan(...)` of `&w.Secret`: | |
| 1200 | ||
| 1201 | ```go | |
| 1202 | if w.Secret, err = s.openValue(aadWebhook, w.Secret); err != nil { | |
| 1203 | return nil, fmt.Errorf("webhook %d: %w", w.ID, err) | |
| 1204 | } | |
| 1205 | ``` | |
| 1206 | ||
| 1207 | In `DueDeliveries`, after its `rows.Scan(...)`: | |
| 1208 | ||
| 1209 | ```go | |
| 1210 | if d.Secret, err = s.openValue(aadWebhook, d.Secret); err != nil { | |
| 1211 | return nil, fmt.Errorf("webhook %d: %w", d.WebhookID, err) | |
| 1212 | } | |
| 1213 | ``` | |
| 1214 | ||
| 1215 | (`webhooks.go` imports `"fmt"` and `"time"`.) | |
| 1216 | ||
| 1217 | `mirrors.go` — `AddMirror`: | |
| 1218 | ||
| 1219 | ```go | |
| 1220 | func (s *Store) AddMirror(repoID int64, direction, url, username, token string) (int64, error) { | |
| 1221 | tx, err := s.DB.Begin() | |
| 1222 | if err != nil { | |
| 1223 | return 0, err | |
| 1224 | } | |
| 1225 | defer tx.Rollback() | |
| 1226 | sealed, err := s.sealValue(aadMirror, token) | |
| 1227 | if err != nil { | |
| 1228 | return 0, err | |
| 1229 | } | |
| 1230 | res, err := tx.Exec( | |
| 1231 | "INSERT INTO mirrors (repo_id, direction, url, username, token) VALUES (?, ?, ?, ?, ?)", | |
| 1232 | repoID, direction, url, username, sealed) | |
| 1233 | if err != nil { | |
| 1234 | if isUniqueErr(err) { | |
| 1235 | return 0, ErrExists | |
| 1236 | } | |
| 1237 | return 0, err | |
| 1238 | } | |
| 1239 | id, err := res.LastInsertId() | |
| 1240 | if err != nil { | |
| 1241 | return 0, err | |
| 1242 | } | |
| 1243 | return id, tx.Commit() | |
| 1244 | } | |
| 1245 | ``` | |
| 1246 | ||
| 1247 | `scanMirror` becomes a method that opens the token, and `mirrorQuery` calls `s.scanMirror(rows)`: | |
| 1248 | ||
| 1249 | ```go | |
| 1250 | func (s *Store) scanMirror(row interface{ Scan(...any) error }) (Mirror, error) { | |
| 1251 | var m Mirror | |
| 1252 | if err := row.Scan(&m.ID, &m.RepoID, &m.Direction, &m.URL, &m.Username, &m.Token, | |
| 1253 | &m.Dirty, &m.LastSync, &m.LastError); err != nil { | |
| 1254 | return m, err | |
| 1255 | } | |
| 1256 | var err error | |
| 1257 | if m.Token, err = s.openValue(aadMirror, m.Token); err != nil { | |
| 1258 | return m, fmt.Errorf("mirror %d: %w", m.ID, err) | |
| 1259 | } | |
| 1260 | return m, nil | |
| 1261 | } | |
| 1262 | ``` | |
| 1263 | ||
| 1264 | (`mirrors.go` imports `"errors"` and `"fmt"`.) | |
| 1265 | ||
| 1266 | `push.go` — `AddPushDevice` body between `defer tx.Rollback()` and `if prev != 0 ...`: | |
| 1267 | ||
| 1268 | ```go | |
| 1269 | h := tokenHash(token) | |
| 1270 | var prev int64 | |
| 1271 | if err := tx.QueryRow("SELECT user_id FROM push_devices WHERE token_hash = ?", h).Scan(&prev); err != nil && !errors.Is(err, sql.ErrNoRows) { | |
| 1272 | return 0, err | |
| 1273 | } | |
| 1274 | sealed, err := s.sealValue(aadPushToken, token) | |
| 1275 | if err != nil { | |
| 1276 | return 0, err | |
| 1277 | } | |
| 1278 | if _, err := tx.Exec(` | |
| 1279 | INSERT INTO push_devices (user_id, token, token_hash, label) VALUES (?, ?, ?, ?) | |
| 1280 | ON CONFLICT(token_hash) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`, | |
| 1281 | userID, sealed, h, label); err != nil { | |
| 1282 | return 0, err | |
| 1283 | } | |
| 1284 | var id int64 | |
| 1285 | if err := tx.QueryRow("SELECT id FROM push_devices WHERE token_hash = ?", h).Scan(&id); err != nil { | |
| 1286 | return 0, err | |
| 1287 | } | |
| 1288 | ``` | |
| 1289 | ||
| 1290 | Extend the doc comment's second sentence: "The token is sealed (secrets.go), so the lookup and the upsert go by its hash." | |
| 1291 | ||
| 1292 | `PushDevices`, after `rows.Scan(...)`: | |
| 1293 | ||
| 1294 | ```go | |
| 1295 | if d.Token, err = s.openValue(aadPushToken, d.Token); err != nil { | |
| 1296 | return nil, fmt.Errorf("push device %d: %w", d.ID, err) | |
| 1297 | } | |
| 1298 | ``` | |
| 1299 | ||
| 1300 | `DuePush`, after `rows.Scan(...)`: | |
| 1301 | ||
| 1302 | ```go | |
| 1303 | if p.Token, err = s.openValue(aadPushToken, p.Token); err != nil { | |
| 1304 | return nil, fmt.Errorf("push device %d: %w", p.DeviceID, err) | |
| 1305 | } | |
| 1306 | ``` | |
| 1307 | ||
| 1308 | `DeletePushDeviceByToken`: | |
| 1309 | ||
| 1310 | ```go | |
| 1311 | _, err := s.DB.Exec("DELETE FROM push_devices WHERE token_hash = ?", tokenHash(token)) | |
| 1312 | ``` | |
| 1313 | ||
| 1314 | (`push.go` adds `"fmt"` to its imports.) | |
| 1315 | ||
| 1316 | - [ ] **Step 6: Run the store tests** | |
| 1317 | ||
| 1318 | Run: `go test ./internal/store/ -count=1` | |
| 1319 | Expected: PASS, including `TestMigrateUpDown` (0072 down drops the index before the column) and the existing `TestPushDevices` (nil keyring, hash lookups). | |
| 1320 | ||
| 1321 | - [ ] **Step 7: Build and vet everything above the store** | |
| 1322 | ||
| 1323 | Run: `go build ./... && go vet ./...` | |
| 1324 | Expected: no output. No caller changed signature. | |
| 1325 | ||
| 1326 | - [ ] **Step 8: Commit** | |
| 1327 | ||
| 1328 | ```bash | |
| 1329 | git add internal/store | |
| 1330 | git commit -S -m "store: seal CI secrets, webhook secrets, mirror tokens and device tokens | |
| 1331 | ||
| 1332 | Values are AES-256-GCM under the key file; push devices are looked up | |
| 1333 | by token hash (migration 0072). | |
| 1334 | ||
| 1335 | Ref #273" | |
| 1336 | ``` | |
| 1337 | ||
| 1338 | ### Task 1.4: `openStore` loads the key; `serve` reseals; `admin secrets` | |
| 1339 | ||
| 1340 | **Files:** | |
| 1341 | - Modify: `cmd/gitbayd/main.go:40-66` (`openStore`), `:138-142` (`serve`), `:408-422` (`adminCmd`) | |
| 1342 | - Create: `cmd/gitbayd/secrets.go`, `cmd/gitbayd/testconfig_test.go`, `cmd/gitbayd/secrets_test.go` | |
| 1343 | - Modify tests: `cmd/gitbayd/main_test.go:17`, `cmd/gitbayd/backup_test.go:46-47` | |
| 1344 | ||
| 1345 | **Interfaces:** | |
| 1346 | - Consumes: `seal.Load`, `seal.ReadKeys`, `seal.WriteKeys`, `seal.NewKey`, `Store.SetKeyring`, `Store.ResealSecrets`, `Store.SecretKeyUse`. | |
| 1347 | - Produces: `func testConfig(t *testing.T) config.Config` (test helper, cmd/gitbayd), `func rotateSecrets(cfg config.Config) error`, `func secretsCmd() *cobra.Command`. | |
| 1348 | ||
| 1349 | - [ ] **Step 1: The test helper and failing tests** | |
| 1350 | ||
| 1351 | `cmd/gitbayd/testconfig_test.go`: | |
| 1352 | ||
| 1353 | ```go | |
| 1354 | package main | |
| 1355 | ||
| 1356 | import ( | |
| 1357 | "path/filepath" | |
| 1358 | "testing" | |
| 1359 | ||
| 1360 | "gitbay.org/gitbay/internal/config" | |
| 1361 | "gitbay.org/gitbay/internal/seal" | |
| 1362 | ) | |
| 1363 | ||
| 1364 | // testConfig is a config with a fresh root and a key file outside it, | |
| 1365 | // the minimum openStore accepts. | |
| 1366 | func testConfig(t *testing.T) config.Config { | |
| 1367 | t.Helper() | |
| 1368 | key := filepath.Join(t.TempDir(), "secret.key") | |
| 1369 | k, err := seal.NewKey() | |
| 1370 | if err != nil { | |
| 1371 | t.Fatal(err) | |
| 1372 | } | |
| 1373 | if err := seal.WriteKeys(key, []seal.Key{k}); err != nil { | |
| 1374 | t.Fatal(err) | |
| 1375 | } | |
| 1376 | return config.Config{Server: config.Server{Root: t.TempDir(), SecretKeyFile: key}} | |
| 1377 | } | |
| 1378 | ``` | |
| 1379 | ||
| 1380 | In `main_test.go:17` replace the config line with `cfg := testConfig(t)`. | |
| 1381 | In `backup_test.go:46-47` replace the two lines with: | |
| 1382 | ||
| 1383 | ```go | |
| 1384 | cfg := testConfig(t) | |
| 1385 | root := cfg.Server.Root | |
| 1386 | ``` | |
| 1387 | ||
| 1388 | Both files then no longer use `internal/config`; drop that import from | |
| 1389 | each (MR 2 adds it back to `backup_test.go` for `TestArchivePath`). | |
| 1390 | ||
| 1391 | `cmd/gitbayd/secrets_test.go`: | |
| 1392 | ||
| 1393 | ```go | |
| 1394 | package main | |
| 1395 | ||
| 1396 | import ( | |
| 1397 | "path/filepath" | |
| 1398 | "strings" | |
| 1399 | "testing" | |
| 1400 | ||
| 1401 | "gitbay.org/gitbay/internal/seal" | |
| 1402 | ) | |
| 1403 | ||
| 1404 | func TestOpenStoreRefusesWithoutKeyFile(t *testing.T) { | |
| 1405 | cfg := testConfig(t) | |
| 1406 | cfg.Server.SecretKeyFile = filepath.Join(t.TempDir(), "absent.key") | |
| 1407 | _, err := openStore(cfg) | |
| 1408 | if err == nil || !strings.Contains(err.Error(), "gitbayd admin secrets init") { | |
| 1409 | t.Fatalf("openStore without a key file: %v", err) | |
| 1410 | } | |
| 1411 | } | |
| 1412 | ||
| 1413 | func TestRotateSecrets(t *testing.T) { | |
| 1414 | cfg := testConfig(t) | |
| 1415 | before, err := seal.ReadKeys(cfg.Server.SecretKeyFile) | |
| 1416 | if err != nil { | |
| 1417 | t.Fatal(err) | |
| 1418 | } | |
| 1419 | st, err := openStore(cfg) | |
| 1420 | if err != nil { | |
| 1421 | t.Fatal(err) | |
| 1422 | } | |
| 1423 | uid, err := st.CreateUser("alice", false) | |
| 1424 | if err != nil { | |
| 1425 | t.Fatal(err) | |
| 1426 | } | |
| 1427 | repoID, err := st.CreateRepo("user", uid, "app", "public") | |
| 1428 | if err != nil { | |
| 1429 | t.Fatal(err) | |
| 1430 | } | |
| 1431 | if err := st.SetBuildSecret(repoID, "TOKEN", "v1"); err != nil { | |
| 1432 | t.Fatal(err) | |
| 1433 | } | |
| 1434 | st.Close() | |
| 1435 | ||
| 1436 | if err := rotateSecrets(cfg); err != nil { | |
| 1437 | t.Fatalf("rotate: %v", err) | |
| 1438 | } | |
| 1439 | after, err := seal.ReadKeys(cfg.Server.SecretKeyFile) | |
| 1440 | if err != nil { | |
| 1441 | t.Fatal(err) | |
| 1442 | } | |
| 1443 | if len(after) != 1 || after[0].ID == before[0].ID { | |
| 1444 | t.Fatalf("key file after rotation holds %v, before %v", after, before) | |
| 1445 | } | |
| 1446 | st, err = openStore(cfg) | |
| 1447 | if err != nil { | |
| 1448 | t.Fatal(err) | |
| 1449 | } | |
| 1450 | defer st.Close() | |
| 1451 | use, err := st.SecretKeyUse() | |
| 1452 | if err != nil || use[after[0].ID] != 1 || len(use) != 1 { | |
| 1453 | t.Fatalf("SecretKeyUse after rotation = %v, %v", use, err) | |
| 1454 | } | |
| 1455 | if got, _ := st.BuildSecrets(repoID); got["TOKEN"] != "v1" { | |
| 1456 | t.Fatalf("value after rotation: %v", got) | |
| 1457 | } | |
| 1458 | } | |
| 1459 | ``` | |
| 1460 | ||
| 1461 | - [ ] **Step 2: Run them to verify they fail** | |
| 1462 | ||
| 1463 | Run: `go test ./cmd/gitbayd/ -run 'TestOpenStore|TestRotateSecrets|TestBackupDBOnly' -count=1` | |
| 1464 | Expected: FAIL to compile, `undefined: rotateSecrets`. | |
| 1465 | ||
| 1466 | - [ ] **Step 3: `openStore` and `serve`** | |
| 1467 | ||
| 1468 | `openStore` begins with the key file (add imports `"errors"`, `"io/fs"` and `"gitbay.org/gitbay/internal/seal"`): | |
| 1469 | ||
| 1470 | ```go | |
| 1471 | func openStore(cfg config.Config) (*store.Store, error) { | |
| 1472 | // The key file seals the secret columns (#273). Without it the | |
| 1473 | // database's secrets cannot be read or written, so nothing that | |
| 1474 | // opens the database runs. | |
| 1475 | keys, err := seal.Load(cfg.Server.SecretKeyFile) | |
| 1476 | if errors.Is(err, fs.ErrNotExist) { | |
| 1477 | return nil, fmt.Errorf("secret key file %s does not exist: create it with gitbayd admin secrets init, or restore it from its off-host copy (backups do not carry it)", cfg.Server.SecretKeyFile) | |
| 1478 | } | |
| 1479 | if err != nil { | |
| 1480 | return nil, fmt.Errorf("secret key file: %w", err) | |
| 1481 | } | |
| 1482 | s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db")) | |
| 1483 | if err != nil { | |
| 1484 | return nil, err | |
| 1485 | } | |
| 1486 | s.SetKeyring(keys) | |
| 1487 | ``` | |
| 1488 | ||
| 1489 | (the rest of the function is unchanged). | |
| 1490 | ||
| 1491 | In `serve`, after `defer st.Close()` (line 142): | |
| 1492 | ||
| 1493 | ```go | |
| 1494 | // Values stored before sealing existed, or under a key a | |
| 1495 | // rotation retired, are sealed under the current key before | |
| 1496 | // anything reads them. A value the key file cannot open | |
| 1497 | // stops the start here rather than failing each delivery. | |
| 1498 | if n, err := st.ResealSecrets(); err != nil { | |
| 1499 | return fmt.Errorf("sealing secrets: %w", err) | |
| 1500 | } else if n > 0 { | |
| 1501 | slog.Info("sealed secret values", "count", n) | |
| 1502 | } | |
| 1503 | ``` | |
| 1504 | ||
| 1505 | - [ ] **Step 4: `cmd/gitbayd/secrets.go`** | |
| 1506 | ||
| 1507 | ```go | |
| 1508 | package main | |
| 1509 | ||
| 1510 | import ( | |
| 1511 | "fmt" | |
| 1512 | "os" | |
| 1513 | "sort" | |
| 1514 | "strings" | |
| 1515 | ||
| 1516 | "github.com/spf13/cobra" | |
| 1517 | ||
| 1518 | "gitbay.org/gitbay/internal/config" | |
| 1519 | "gitbay.org/gitbay/internal/seal" | |
| 1520 | ) | |
| 1521 | ||
| 1522 | // secretsCmd manages the key file that seals CI secrets, webhook | |
| 1523 | // secrets, mirror tokens and push device tokens in the database. | |
| 1524 | func secretsCmd() *cobra.Command { | |
| 1525 | cmd := &cobra.Command{ | |
| 1526 | Use: "secrets", | |
| 1527 | Short: "the key file that seals secrets stored in the database", | |
| 1528 | } | |
| 1529 | cmd.AddCommand( | |
| 1530 | &cobra.Command{ | |
| 1531 | Use: "init", | |
| 1532 | Short: "create the key file (server.secret_key_file) with one new key", | |
| 1533 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 1534 | cfg, err := config.Load(configPath) | |
| 1535 | if err != nil { | |
| 1536 | return err | |
| 1537 | } | |
| 1538 | path := cfg.Server.SecretKeyFile | |
| 1539 | if _, err := os.Stat(path); err == nil { | |
| 1540 | return fmt.Errorf("%s already exists; gitbayd admin secrets rotate replaces its key", path) | |
| 1541 | } | |
| 1542 | k, err := seal.NewKey() | |
| 1543 | if err != nil { | |
| 1544 | return err | |
| 1545 | } | |
| 1546 | if err := seal.WriteKeys(path, []seal.Key{k}); err != nil { | |
| 1547 | return err | |
| 1548 | } | |
| 1549 | fmt.Printf("wrote %s (key %s). Copy it off this host: backups do not carry it, and a restored database's secrets do not open without it.\n", path, k.ID) | |
| 1550 | return nil | |
| 1551 | }, | |
| 1552 | }, | |
| 1553 | &cobra.Command{ | |
| 1554 | Use: "rotate", | |
| 1555 | Short: "seal every secret under a new key and retire the old ones", | |
| 1556 | Long: `Adds a new key to the key file, reseals every value under it in one | |
| 1557 | transaction, then removes the old keys from the file. A running daemon | |
| 1558 | re-reads the file when it changes, so no restart is needed. Run as the | |
| 1559 | user that can replace the key file (root, for /etc/gitbay); the file | |
| 1560 | keeps its owner. Copy the new file off the host afterwards.`, | |
| 1561 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 1562 | cfg, err := config.Load(configPath) | |
| 1563 | if err != nil { | |
| 1564 | return err | |
| 1565 | } | |
| 1566 | return rotateSecrets(cfg) | |
| 1567 | }, | |
| 1568 | }, | |
| 1569 | &cobra.Command{ | |
| 1570 | Use: "check", | |
| 1571 | Short: "open every sealed value and count them by key", | |
| 1572 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 1573 | cfg, err := config.Load(configPath) | |
| 1574 | if err != nil { | |
| 1575 | return err | |
| 1576 | } | |
| 1577 | st, err := openStore(cfg) | |
| 1578 | if err != nil { | |
| 1579 | return err | |
| 1580 | } | |
| 1581 | defer st.Close() | |
| 1582 | use, err := st.SecretKeyUse() | |
| 1583 | if err != nil { | |
| 1584 | return err | |
| 1585 | } | |
| 1586 | ids := make([]string, 0, len(use)) | |
| 1587 | for id := range use { | |
| 1588 | ids = append(ids, id) | |
| 1589 | } | |
| 1590 | sort.Strings(ids) | |
| 1591 | for _, id := range ids { | |
| 1592 | if id == "" { | |
| 1593 | fmt.Printf("clear: %d values (sealed when the daemon next starts)\n", use[id]) | |
| 1594 | } else { | |
| 1595 | fmt.Printf("key %s: %d sealed\n", id, use[id]) | |
| 1596 | } | |
| 1597 | } | |
| 1598 | if len(ids) == 0 { | |
| 1599 | fmt.Println("no secrets stored") | |
| 1600 | } | |
| 1601 | return nil | |
| 1602 | }, | |
| 1603 | }, | |
| 1604 | ) | |
| 1605 | return cmd | |
| 1606 | } | |
| 1607 | ||
| 1608 | // rotateSecrets adds a key, reseals under it, then drops the old keys. | |
| 1609 | // Each step leaves a file that opens every stored value: after the first | |
| 1610 | // write the file holds old and new keys; the reseal is one transaction; | |
| 1611 | // the last write happens only after the reseal committed. Interrupted | |
| 1612 | // anywhere, running it again finishes the job. | |
| 1613 | func rotateSecrets(cfg config.Config) error { | |
| 1614 | path := cfg.Server.SecretKeyFile | |
| 1615 | old, err := seal.ReadKeys(path) | |
| 1616 | if err != nil { | |
| 1617 | return err | |
| 1618 | } | |
| 1619 | next, err := seal.NewKey() | |
| 1620 | if err != nil { | |
| 1621 | return err | |
| 1622 | } | |
| 1623 | if err := seal.WriteKeys(path, append(old, next)); err != nil { | |
| 1624 | return err | |
| 1625 | } | |
| 1626 | st, err := openStore(cfg) | |
| 1627 | if err != nil { | |
| 1628 | return err | |
| 1629 | } | |
| 1630 | defer st.Close() | |
| 1631 | n, err := st.ResealSecrets() | |
| 1632 | if err != nil { | |
| 1633 | return fmt.Errorf("resealing: %w (the key file holds the old keys and %s; run rotate again)", err, next.ID) | |
| 1634 | } | |
| 1635 | if err := seal.WriteKeys(path, []seal.Key{next}); err != nil { | |
| 1636 | return err | |
| 1637 | } | |
| 1638 | retired := make([]string, len(old)) | |
| 1639 | for i, k := range old { | |
| 1640 | retired[i] = k.ID | |
| 1641 | } | |
| 1642 | fmt.Printf("key %s: resealed %d values; retired %s. Copy %s off this host.\n", next.ID, n, strings.Join(retired, ", "), path) | |
| 1643 | return nil | |
| 1644 | } | |
| 1645 | ``` | |
| 1646 | ||
| 1647 | In `adminCmd` add `secretsCmd(),` after `backupCmd(),` (line 417). | |
| 1648 | ||
| 1649 | - [ ] **Step 5: Run the package tests** | |
| 1650 | ||
| 1651 | Run: `go test ./cmd/gitbayd/ -count=1 && go vet ./cmd/gitbayd/` | |
| 1652 | Expected: PASS. | |
| 1653 | ||
| 1654 | - [ ] **Step 6: Commit** | |
| 1655 | ||
| 1656 | ```bash | |
| 1657 | git add cmd/gitbayd | |
| 1658 | git commit -S -m "gitbayd: load the secret key file; admin secrets init, rotate, check | |
| 1659 | ||
| 1660 | serve seals values still in clear, or under a retired key, before it | |
| 1661 | starts listening. | |
| 1662 | ||
| 1663 | Ref #273" | |
| 1664 | ``` | |
| 1665 | ||
| 1666 | ### Task 1.5: e2e harness key and a sealed-at-rest check through a backup | |
| 1667 | ||
| 1668 | **Files:** | |
| 1669 | - Modify: `e2e/ssh_test.go:17-27` (`instance`), `:81-117` (`startInstanceWith`) | |
| 1670 | - Modify: `e2e/acme_test.go:28-39`, `e2e/system_test.go:32-41`, `e2e/backup_test.go:14-157` | |
| 1671 | ||
| 1672 | **Interfaces:** | |
| 1673 | - Produces: `instance.keyFile string`. | |
| 1674 | ||
| 1675 | - [ ] **Step 1: Harness** | |
| 1676 | ||
| 1677 | Add to `instance`: | |
| 1678 | ||
| 1679 | ```go | |
| 1680 | keyFile string // server.secret_key_file, outside root | |
| 1681 | ``` | |
| 1682 | ||
| 1683 | In `startInstanceWith`, before building `cfg`: | |
| 1684 | ||
| 1685 | ```go | |
| 1686 | inst.keyFile = filepath.Join(t.TempDir(), "secret.key") | |
| 1687 | ``` | |
| 1688 | ||
| 1689 | and change the config's `[server]` table and its `Sprintf` arguments: | |
| 1690 | ||
| 1691 | ```go | |
| 1692 | [server] | |
| 1693 | root = %q | |
| 1694 | site_url = "https://gitbay.test" | |
| 1695 | secret_key_file = %q | |
| 1696 | ``` | |
| 1697 | ||
| 1698 | ```go | |
| 1699 | `, inst.root, inst.keyFile, inst.port, inst.httpPort, inst.gitPort) | |
| 1700 | ``` | |
| 1701 | ||
| 1702 | After writing the config file and before `inst.proc = exec.Command(...)`: | |
| 1703 | ||
| 1704 | ```go | |
| 1705 | inst.admin(t, "admin", "secrets", "init") | |
| 1706 | ``` | |
| 1707 | ||
| 1708 | In `acme_test.go` and `system_test.go`, add `secret_key_file = %q` under `site_url` and `inst.keyFile` as the second `Sprintf` argument. In `backup_test.go`'s restore config (line 76-85) do the same. | |
| 1709 | ||
| 1710 | - [ ] **Step 2: Extend `TestAdminBackup`** | |
| 1711 | ||
| 1712 | Add `"bytes"` to its imports. After the issue create (line 37): | |
| 1713 | ||
| 1714 | ```go | |
| 1715 | // A build secret, to show the archive carries it sealed and the key | |
| 1716 | // file not at all. | |
| 1717 | if _, errOut, code := inst.ssh(t, aliceKey, "hunter2-at-rest", "repo", "secret", "set", "alice/keep", "DEPLOY_TOKEN"); code != 0 { | |
| 1718 | t.Fatalf("secret set: %s", errOut) | |
| 1719 | } | |
| 1720 | ``` | |
| 1721 | ||
| 1722 | After the transient-state loop (line 66): | |
| 1723 | ||
| 1724 | ```go | |
| 1725 | if strings.Contains(names, "secret.key") { | |
| 1726 | t.Fatalf("archive carries the key file:\n%s", names) | |
| 1727 | } | |
| 1728 | db, err := exec.Command("tar", "-xzOf", archive, "gitbay.db").Output() | |
| 1729 | if err != nil { | |
| 1730 | t.Fatal(err) | |
| 1731 | } | |
| 1732 | if bytes.Contains(db, []byte("hunter2-at-rest")) { | |
| 1733 | t.Fatal("the archived database carries the build secret in clear") | |
| 1734 | } | |
| 1735 | ``` | |
| 1736 | ||
| 1737 | After the restored instance answers `whoami` (line 151): | |
| 1738 | ||
| 1739 | ```go | |
| 1740 | // With the original key the restored secrets open; with another key | |
| 1741 | // they do not. | |
| 1742 | if out, err := exec.Command(inst.gitbayd, "--config", config2, "admin", "secrets", "check").CombinedOutput(); err != nil || !strings.Contains(string(out), ": 1 sealed") { | |
| 1743 | t.Fatalf("secrets check on the restored instance: %v\n%s", err, out) | |
| 1744 | } | |
| 1745 | config3 := filepath.Join(root2, "config-wrong-key.toml") | |
| 1746 | wrong := strings.Replace(cfg, fmt.Sprintf("secret_key_file = %q", inst.keyFile), | |
| 1747 | fmt.Sprintf("secret_key_file = %q", filepath.Join(t.TempDir(), "other.key")), 1) | |
| 1748 | if err := os.WriteFile(config3, []byte(wrong), 0o600); err != nil { | |
| 1749 | t.Fatal(err) | |
| 1750 | } | |
| 1751 | if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "init").CombinedOutput(); err != nil { | |
| 1752 | t.Fatalf("init the wrong key: %v\n%s", err, out) | |
| 1753 | } | |
| 1754 | if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "check").CombinedOutput(); err == nil || !strings.Contains(string(out), "does not hold") { | |
| 1755 | t.Fatalf("secrets check with the wrong key: %v\n%s", err, out) | |
| 1756 | } | |
| 1757 | ``` | |
| 1758 | ||
| 1759 | - [ ] **Step 3: Run the one e2e test** | |
| 1760 | ||
| 1761 | Run: `go test ./e2e -run TestAdminBackup -count=1` | |
| 1762 | Expected: PASS. (The other e2e tests pick up the harness change in CI.) | |
| 1763 | ||
| 1764 | - [ ] **Step 4: Commit** | |
| 1765 | ||
| 1766 | ```bash | |
| 1767 | git add e2e | |
| 1768 | git commit -S -m "e2e: a key file per instance; the archive carries secrets sealed and no key | |
| 1769 | ||
| 1770 | Ref #273" | |
| 1771 | ``` | |
| 1772 | ||
| 1773 | ### Task 1.6: install.sh, wiki, changelog | |
| 1774 | ||
| 1775 | **Files:** | |
| 1776 | - Modify: `deploy/install.sh:12-20` | |
| 1777 | - Modify: `.gitbay/wiki/Admin.org` (Install block lines 17-22, `** [server]` 55-64, a new `** Secret key` under `* Backup and restore`) | |
| 1778 | - Modify: `.gitbay/wiki/Architecture/06-Data-and-Cryptography.org` (inventory rows 17-19, "Outside the database" table, "At rest" table and the paragraph after it, migration count line 5) | |
| 1779 | - Modify: `.gitbay/wiki/Architecture/03-Deployment.org` (file table, after the `config.toml` row) | |
| 1780 | - Modify: `.gitbay/wiki/Architecture/09-Controls.org:59`, `.gitbay/wiki/Architecture/10-Known-Gaps.org` (#273 row) | |
| 1781 | - Modify: `CHANGELOG.org` | |
| 1782 | ||
| 1783 | - [ ] **Step 1: install.sh creates the key once** | |
| 1784 | ||
| 1785 | Replace the remote script with: | |
| 1786 | ||
| 1787 | ```sh | |
| 1788 | ssh -p "$port" "root@$host" ' | |
| 1789 | set -eu | |
| 1790 | chmod 755 /usr/local/bin/gitbayd.new | |
| 1791 | mv /usr/local/bin/gitbayd.new /usr/local/bin/gitbayd | |
| 1792 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml check-config --no-host-checks | |
| 1793 | # The key that seals secrets in the database. Created on the first | |
| 1794 | # install, never replaced here; gitbayd refuses to start without it. | |
| 1795 | if [ ! -e /etc/gitbay/secret.key ]; then | |
| 1796 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin secrets init | |
| 1797 | chown gitbay:gitbay /etc/gitbay/secret.key | |
| 1798 | fi | |
| 1799 | systemctl restart gitbayd | |
| 1800 | sleep 1 | |
| 1801 | systemctl --no-pager --lines=5 status gitbayd | |
| 1802 | ' | |
| 1803 | ``` | |
| 1804 | ||
| 1805 | - [ ] **Step 2: Admin.org** | |
| 1806 | ||
| 1807 | Install block becomes: | |
| 1808 | ||
| 1809 | ```sh | |
| 1810 | install -m 755 gitbayd /usr/local/bin/ | |
| 1811 | adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay | |
| 1812 | install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay | |
| 1813 | gitbayd --config /etc/gitbay/config.toml check-config | |
| 1814 | gitbayd --config /etc/gitbay/config.toml admin secrets init | |
| 1815 | chown gitbay:gitbay /etc/gitbay/secret.key | |
| 1816 | ``` | |
| 1817 | ||
| 1818 | Under `** [server]`, after `site_url`: | |
| 1819 | ||
| 1820 | ```org | |
| 1821 | - =secret_key_file= (default =/etc/gitbay/secret.key=) — the keys that | |
| 1822 | seal CI secrets, webhook secrets, mirror tokens and push device | |
| 1823 | tokens in the database. Must be outside =root=, mode 0600, readable | |
| 1824 | by the daemon's user. See "Secret key" below. | |
| 1825 | ``` | |
| 1826 | ||
| 1827 | New subsection at the end of `* Backup and restore` (before `* Upgrades`): | |
| 1828 | ||
| 1829 | ```org | |
| 1830 | ** Secret key | |
| 1831 | ||
| 1832 | CI secrets, webhook secrets, mirror tokens and APNs device tokens are | |
| 1833 | stored sealed: AES-256-GCM under a key in =server.secret_key_file=, | |
| 1834 | each value prefixed with the id of the key that sealed it | |
| 1835 | (=gbs1:<id>:=). The key file is not in the database, not under | |
| 1836 | =server.root=, and therefore in neither the local archives nor the | |
| 1837 | restic snapshots. Keep a copy off the host; without it a restored | |
| 1838 | database's secrets cannot be opened, and gitbayd refuses to start | |
| 1839 | against them. | |
| 1840 | ||
| 1841 | #+begin_src sh | |
| 1842 | gitbayd admin secrets init # once; deploy/install.sh does it on first install | |
| 1843 | gitbayd admin secrets check # open every value, count by key | |
| 1844 | gitbayd admin secrets rotate # new key, reseal, retire the old one (as root) | |
| 1845 | #+end_src | |
| 1846 | ||
| 1847 | - Missing file: every gitbayd process that opens the database refuses | |
| 1848 | to run and names the path, including =serve= and, in system mode, | |
| 1849 | =authorized-keys=. =migrate= does not need it. | |
| 1850 | - Wrong key: =serve= stops at startup naming the first row that does | |
| 1851 | not open; =secrets check= does the same without starting anything. | |
| 1852 | - Upgrade: the first start after the upgrade seals every value still | |
| 1853 | in clear and logs =sealed secret values=. | |
| 1854 | - Rotation: =rotate= adds a key, reseals every value under it in one | |
| 1855 | transaction, then removes the old keys. The daemon re-reads the file | |
| 1856 | when it changes, so it needs no restart. Copy the new file off the | |
| 1857 | host afterwards. | |
| 1858 | - Push devices are looked up by the SHA-256 of their token | |
| 1859 | (=push_devices.token_hash=), since two seals of one token differ. | |
| 1860 | ``` | |
| 1861 | ||
| 1862 | - [ ] **Step 3: Architecture pages** | |
| 1863 | ||
| 1864 | `06-Data-and-Cryptography.org`: in the inventory, the CI secrets note becomes `sealed (AES-256-GCM)`, Integrations `webhook secret and mirror token sealed`, Notifications `device tokens sealed; looked up by SHA-256`. Add a row to "Outside the database": | |
| 1865 | ||
| 1866 | ```org | |
| 1867 | | Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C | | |
| 1868 | ``` | |
| 1869 | ||
| 1870 | In "At rest", replace the secrets row with: | |
| 1871 | ||
| 1872 | ```org | |
| 1873 | | CI secrets, webhook secrets, mirror tokens, APNs device tokens | AES-256-GCM under a key file outside the database and outside =server.root=; additional data is the column name; key id on each value (=internal/seal=, =internal/store/secrets.go=) | | |
| 1874 | ``` | |
| 1875 | ||
| 1876 | and replace the paragraph "The code base contains no symmetric encryption. ..." with: | |
| 1877 | ||
| 1878 | ```org | |
| 1879 | The database file or a backup read by anyone other than the =gitbay= | |
| 1880 | user discloses no CI secret, webhook secret, mirror token or device | |
| 1881 | token without the key file, which neither carries. Rotation: | |
| 1882 | =gitbayd admin secrets rotate= (Admin wiki). | |
| 1883 | ``` | |
| 1884 | ||
| 1885 | Update line 5's migration count to the number of files in | |
| 1886 | `internal/store/migrations/` divided by two after this MR. | |
| 1887 | ||
| 1888 | `03-Deployment.org`, file table, after the `config.toml` row: | |
| 1889 | ||
| 1890 | ```org | |
| 1891 | | =/etc/gitbay/secret.key= | keys sealing secret columns | 0600, owner =gitbay= (=deploy/install.sh=) | | |
| 1892 | ``` | |
| 1893 | ||
| 1894 | `09-Controls.org:59`: | |
| 1895 | ||
| 1896 | ```org | |
| 1897 | | Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and backups (=internal/seal=) | | |
| 1898 | ``` | |
| 1899 | ||
| 1900 | `10-Known-Gaps.org`: delete the `#273` row. | |
| 1901 | ||
| 1902 | - [ ] **Step 4: CHANGELOG.org** | |
| 1903 | ||
| 1904 | If the file has no `* Unreleased` heading above the latest version, add one under the header paragraph; under it: | |
| 1905 | ||
| 1906 | ```org | |
| 1907 | *Upgrade note.* gitbayd needs =server.secret_key_file= (default | |
| 1908 | =/etc/gitbay/secret.key=) and refuses to start without it. Before | |
| 1909 | replacing the binary, run =gitbayd admin secrets init= as root and | |
| 1910 | =chown gitbay:gitbay /etc/gitbay/secret.key= (=deploy/install.sh= does | |
| 1911 | both when the file is missing). The first start seals the stored | |
| 1912 | secrets. Copy the key file off the host: backups do not carry it. | |
| 1913 | ||
| 1914 | - CI secrets, webhook secrets, mirror tokens and push device tokens are | |
| 1915 | stored sealed with AES-256-GCM (#273). =gitbayd admin secrets | |
| 1916 | init|rotate|check=. | |
| 1917 | ``` | |
| 1918 | ||
| 1919 | - [ ] **Step 5: Verify and commit** | |
| 1920 | ||
| 1921 | Run: `go build ./... && go vet ./... && go test ./internal/seal/ ./internal/config/ ./internal/store/ ./cmd/gitbayd/ -count=1` | |
| 1922 | Expected: PASS. | |
| 1923 | ||
| 1924 | ```bash | |
| 1925 | git add deploy/install.sh .gitbay/wiki CHANGELOG.org | |
| 1926 | git commit -S -m "deploy, wiki: provision and document the secret key file | |
| 1927 | ||
| 1928 | Closes #273" | |
| 1929 | ``` | |
| 1930 | ||
| 1931 | - [ ] **Step 6: MR** | |
| 1932 | ||
| 1933 | ```bash | |
| 1934 | git push -u origin secrets-at-rest | |
| 1935 | gitbay mr create --source secrets-at-rest --target main --title "Seal secret columns under a key file outside the database" | |
| 1936 | ``` | |
| 1937 | ||
| 1938 | After CI is green: `gitbay mr merge <n> --strategy ff`, delete the | |
| 1939 | branch locally and remotely. The operator steps for bay1 are in | |
| 1940 | "Operator runbook", part A. | |
| 1941 | ||
| 1942 | --- | |
| 1943 | ||
| 1944 | # MR 2: encrypted backup archives (branch `backup-age`, closes #274) | |
| 1945 | ||
| 1946 | ### Task 2.1: `[backup] age_recipients` | |
| 1947 | ||
| 1948 | **Files:** | |
| 1949 | - Modify: `go.mod`, `go.sum` (`filippo.io/age`) | |
| 1950 | - Modify: `internal/config/config.go` (`Config`, new `Backup` type, `Validate`) | |
| 1951 | - Test: `internal/config/config_test.go` | |
| 1952 | ||
| 1953 | **Interfaces:** | |
| 1954 | - Produces: `Config.Backup Backup` (`toml:"backup"`), `type Backup struct { AgeRecipients []string }`, `func (b Backup) Recipients() ([]age.Recipient, error)`. | |
| 1955 | ||
| 1956 | - [ ] **Step 1: Add the dependency** | |
| 1957 | ||
| 1958 | Run: `go get filippo.io/age@latest && go mod tidy` | |
| 1959 | Expected: `filippo.io/age` in `go.mod`'s require block. Record the version in the commit message. | |
| 1960 | ||
| 1961 | - [ ] **Step 2: Write the failing test** | |
| 1962 | ||
| 1963 | ```go | |
| 1964 | func TestBackupRecipients(t *testing.T) { | |
| 1965 | id, err := age.GenerateX25519Identity() | |
| 1966 | if err != nil { | |
| 1967 | t.Fatal(err) | |
| 1968 | } | |
| 1969 | cfg, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\""+id.Recipient().String()+"\"]\n")) | |
| 1970 | if err != nil { | |
| 1971 | t.Fatal(err) | |
| 1972 | } | |
| 1973 | rs, err := cfg.Backup.Recipients() | |
| 1974 | if err != nil || len(rs) != 1 { | |
| 1975 | t.Fatalf("Recipients = %v, %v", rs, err) | |
| 1976 | } | |
| 1977 | if _, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\"age1notakey\"]\n")); err == nil || !strings.Contains(err.Error(), "backup.age_recipients") { | |
| 1978 | t.Fatalf("a malformed recipient: %v", err) | |
| 1979 | } | |
| 1980 | if cfg, err := Load(writeConfig(t, minimal)); err != nil || len(cfg.Backup.AgeRecipients) != 0 { | |
| 1981 | t.Fatalf("default: %v, %v", cfg.Backup, err) | |
| 1982 | } | |
| 1983 | } | |
| 1984 | ``` | |
| 1985 | ||
| 1986 | (add `"filippo.io/age"` to the test imports). | |
| 1987 | ||
| 1988 | - [ ] **Step 3: Run it to verify it fails** | |
| 1989 | ||
| 1990 | Run: `go test ./internal/config/ -run TestBackupRecipients -count=1` | |
| 1991 | Expected: FAIL, `cfg.Backup undefined`. | |
| 1992 | ||
| 1993 | - [ ] **Step 4: Implement** | |
| 1994 | ||
| 1995 | In `Config`, after `Push`: | |
| 1996 | ||
| 1997 | ```go | |
| 1998 | Backup Backup `toml:"backup"` | |
| 1999 | ``` | |
| 2000 | ||
| 2001 | After the `Push` type's methods: | |
| 2002 | ||
| 2003 | ```go | |
| 2004 | // Backup configures gitbayd admin backup. | |
| 2005 | type Backup struct { | |
| 2006 | // AgeRecipients, when set, encrypts every archive to these age | |
| 2007 | // public keys (age1...). The matching identities stay off the host, | |
| 2008 | // so the host writes archives it cannot read. | |
| 2009 | AgeRecipients []string `toml:"age_recipients"` | |
| 2010 | } | |
| 2011 | ||
| 2012 | // Recipients parses AgeRecipients. | |
| 2013 | func (b Backup) Recipients() ([]age.Recipient, error) { | |
| 2014 | var rs []age.Recipient | |
| 2015 | for _, s := range b.AgeRecipients { | |
| 2016 | r, err := age.ParseX25519Recipient(s) | |
| 2017 | if err != nil { | |
| 2018 | return nil, fmt.Errorf("backup.age_recipients: %q: %w", s, err) | |
| 2019 | } | |
| 2020 | rs = append(rs, r) | |
| 2021 | } | |
| 2022 | return rs, nil | |
| 2023 | } | |
| 2024 | ``` | |
| 2025 | ||
| 2026 | In `Validate`, before `// Contradictions.`: | |
| 2027 | ||
| 2028 | ```go | |
| 2029 | if _, err := c.Backup.Recipients(); err != nil { | |
| 2030 | errs = append(errs, err) | |
| 2031 | } | |
| 2032 | ``` | |
| 2033 | ||
| 2034 | Import `"filippo.io/age"`. | |
| 2035 | ||
| 2036 | - [ ] **Step 5: Run and commit** | |
| 2037 | ||
| 2038 | Run: `go test ./internal/config/ -count=1` | |
| 2039 | Expected: PASS. | |
| 2040 | ||
| 2041 | ```bash | |
| 2042 | git add go.mod go.sum internal/config | |
| 2043 | v=$(go list -m -f '{{.Version}}' filippo.io/age) | |
| 2044 | git commit -S -m "config: [backup] age_recipients (filippo.io/age $v) | |
| 2045 | ||
| 2046 | Ref #274" | |
| 2047 | ``` | |
| 2048 | ||
| 2049 | ### Task 2.2: age-wrapped archives and `--verify --identity` | |
| 2050 | ||
| 2051 | **Files:** | |
| 2052 | - Modify: `cmd/gitbayd/backup.go:28-64` (`backupCmd`), `:66-151` (`runBackup`), `:187-197` (`verifyBackup` opening) | |
| 2053 | - Test: `cmd/gitbayd/backup_test.go` | |
| 2054 | ||
| 2055 | **Interfaces:** | |
| 2056 | - Consumes: `cfg.Backup.Recipients()` (Task 2.1), `testConfig` (Task 1.4). | |
| 2057 | - Produces: `func archivePath(out string, cfg config.Config, now time.Time) string`; `func verifyBackup(path, identity string) error` (was `verifyBackup(path string)`); `func archiveReader(f io.Reader, path, identity string) (io.Reader, error)`. | |
| 2058 | ||
| 2059 | - [ ] **Step 1: Write the failing tests** | |
| 2060 | ||
| 2061 | ```go | |
| 2062 | func TestBackupEncryptedToAgeRecipient(t *testing.T) { | |
| 2063 | cfg := testConfig(t) | |
| 2064 | id, err := age.GenerateX25519Identity() | |
| 2065 | if err != nil { | |
| 2066 | t.Fatal(err) | |
| 2067 | } | |
| 2068 | cfg.Backup.AgeRecipients = []string{id.Recipient().String()} | |
| 2069 | s, err := openStore(cfg) | |
| 2070 | if err != nil { | |
| 2071 | t.Fatal(err) | |
| 2072 | } | |
| 2073 | s.Close() | |
| 2074 | ||
| 2075 | out := filepath.Join(t.TempDir(), "b.tar.gz.age") | |
| 2076 | if err := runBackup(cfg, out, true); err != nil { | |
| 2077 | t.Fatal(err) | |
| 2078 | } | |
| 2079 | head := make([]byte, 22) | |
| 2080 | f, err := os.Open(out) | |
| 2081 | if err != nil { | |
| 2082 | t.Fatal(err) | |
| 2083 | } | |
| 2084 | io.ReadFull(f, head) | |
| 2085 | f.Close() | |
| 2086 | if string(head) != "age-encryption.org/v1\n" { | |
| 2087 | t.Fatalf("archive is not age-encrypted: %q", head) | |
| 2088 | } | |
| 2089 | ||
| 2090 | if err := verifyBackup(out, ""); err == nil || !strings.Contains(err.Error(), "--identity") { | |
| 2091 | t.Fatalf("verify without an identity: %v", err) | |
| 2092 | } | |
| 2093 | idFile := filepath.Join(t.TempDir(), "backup-identity.txt") | |
| 2094 | if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil { | |
| 2095 | t.Fatal(err) | |
| 2096 | } | |
| 2097 | if err := verifyBackup(out, idFile); err != nil { | |
| 2098 | t.Fatalf("verify with the identity: %v", err) | |
| 2099 | } | |
| 2100 | other, _ := age.GenerateX25519Identity() | |
| 2101 | otherFile := filepath.Join(t.TempDir(), "other.txt") | |
| 2102 | os.WriteFile(otherFile, []byte(other.String()+"\n"), 0o600) | |
| 2103 | if err := verifyBackup(out, otherFile); err == nil { | |
| 2104 | t.Fatal("verify with another identity succeeded") | |
| 2105 | } | |
| 2106 | } | |
| 2107 | ||
| 2108 | func TestArchivePath(t *testing.T) { | |
| 2109 | now := time.Date(2026, 9, 27, 9, 0, 0, 0, time.UTC) | |
| 2110 | plain := testConfig(t) | |
| 2111 | enc := plain | |
| 2112 | enc.Backup.AgeRecipients = []string{"age1x"} | |
| 2113 | for _, c := range []struct { | |
| 2114 | out string | |
| 2115 | cfg config.Config | |
| 2116 | want string | |
| 2117 | }{ | |
| 2118 | {"", plain, "gitbay-backup-20260927-090000.tar.gz"}, | |
| 2119 | {"", enc, "gitbay-backup-20260927-090000.tar.gz.age"}, | |
| 2120 | {"/b/x.tar.gz", enc, "/b/x.tar.gz.age"}, | |
| 2121 | {"/b/x.tar.gz.age", enc, "/b/x.tar.gz.age"}, | |
| 2122 | {"/b/x.tar.gz", plain, "/b/x.tar.gz"}, | |
| 2123 | } { | |
| 2124 | if got := archivePath(c.out, c.cfg, now); got != c.want { | |
| 2125 | t.Errorf("archivePath(%q) = %q, want %q", c.out, got, c.want) | |
| 2126 | } | |
| 2127 | } | |
| 2128 | } | |
| 2129 | ``` | |
| 2130 | ||
| 2131 | (add `"filippo.io/age"`, `"strings"`, `"time"` and | |
| 2132 | `"gitbay.org/gitbay/internal/config"` to the test imports). | |
| 2133 | ||
| 2134 | - [ ] **Step 2: Run them to verify they fail** | |
| 2135 | ||
| 2136 | Run: `go test ./cmd/gitbayd/ -run 'TestBackupEncrypted|TestArchivePath' -count=1` | |
| 2137 | Expected: FAIL to compile (`undefined: archivePath`; `verifyBackup` takes one argument). | |
| 2138 | ||
| 2139 | - [ ] **Step 3: Implement** | |
| 2140 | ||
| 2141 | `backupCmd`: add `identity` to the `var` line, replace the `RunE` body and add a flag: | |
| 2142 | ||
| 2143 | ```go | |
| 2144 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 2145 | if verify != "" { | |
| 2146 | return verifyBackup(verify, identity) | |
| 2147 | } | |
| 2148 | cfg, err := config.Load(configPath) | |
| 2149 | if err != nil { | |
| 2150 | return err | |
| 2151 | } | |
| 2152 | return runBackup(cfg, archivePath(out, cfg, time.Now()), dbOnly) | |
| 2153 | }, | |
| 2154 | ``` | |
| 2155 | ||
| 2156 | ```go | |
| 2157 | cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive") | |
| 2158 | ``` | |
| 2159 | ||
| 2160 | Append to the `Long` text: | |
| 2161 | ||
| 2162 | ``` | |
| 2163 | With [backup] age_recipients set, the archive is encrypted to those age | |
| 2164 | public keys and its name ends in .age. --verify then needs --identity | |
| 2165 | <file> holding a matching private key, which is kept off the host. | |
| 2166 | ``` | |
| 2167 | ||
| 2168 | New function: | |
| 2169 | ||
| 2170 | ```go | |
| 2171 | // archivePath is where the archive goes: out, or a timestamped name, | |
| 2172 | // ending in .age when the archive is encrypted. | |
| 2173 | func archivePath(out string, cfg config.Config, now time.Time) string { | |
| 2174 | if out == "" { | |
| 2175 | out = fmt.Sprintf("gitbay-backup-%s.tar.gz", now.UTC().Format("20060102-150405")) | |
| 2176 | } | |
| 2177 | if len(cfg.Backup.AgeRecipients) > 0 && !strings.HasSuffix(out, ".age") { | |
| 2178 | out += ".age" | |
| 2179 | } | |
| 2180 | return out | |
| 2181 | } | |
| 2182 | ``` | |
| 2183 | ||
| 2184 | In `runBackup`, replace lines 86-87 (`gz := gzip.NewWriter(f)` and `tw := tar.NewWriter(gz)`) with: | |
| 2185 | ||
| 2186 | ```go | |
| 2187 | var sink io.Writer = f | |
| 2188 | var enc io.WriteCloser | |
| 2189 | if len(cfg.Backup.AgeRecipients) > 0 { | |
| 2190 | rs, err := cfg.Backup.Recipients() | |
| 2191 | if err != nil { | |
| 2192 | return err | |
| 2193 | } | |
| 2194 | if enc, err = age.Encrypt(f, rs...); err != nil { | |
| 2195 | return err | |
| 2196 | } | |
| 2197 | sink = enc | |
| 2198 | } | |
| 2199 | gz := gzip.NewWriter(sink) | |
| 2200 | tw := tar.NewWriter(gz) | |
| 2201 | ``` | |
| 2202 | ||
| 2203 | and after `gz.Close()` (line 137-139): | |
| 2204 | ||
| 2205 | ```go | |
| 2206 | if enc != nil { | |
| 2207 | if err := enc.Close(); err != nil { | |
| 2208 | return err | |
| 2209 | } | |
| 2210 | } | |
| 2211 | ``` | |
| 2212 | ||
| 2213 | In `verifyBackup`, change the signature to `func verifyBackup(path, identity string) error` and replace `gz, err := gzip.NewReader(f)` with: | |
| 2214 | ||
| 2215 | ```go | |
| 2216 | plain, err := archiveReader(f, path, identity) | |
| 2217 | if err != nil { | |
| 2218 | return err | |
| 2219 | } | |
| 2220 | gz, err := gzip.NewReader(plain) | |
| 2221 | ``` | |
| 2222 | ||
| 2223 | New function (import `"bufio"` and `"filippo.io/age"`): | |
| 2224 | ||
| 2225 | ```go | |
| 2226 | const ageHeader = "age-encryption.org/v1\n" | |
| 2227 | ||
| 2228 | // archiveReader returns the archive's gzip stream, decrypting it first | |
| 2229 | // when it is an age file. | |
| 2230 | func archiveReader(f io.Reader, path, identity string) (io.Reader, error) { | |
| 2231 | br := bufio.NewReader(f) | |
| 2232 | head, _ := br.Peek(len(ageHeader)) | |
| 2233 | if string(head) != ageHeader { | |
| 2234 | return br, nil | |
| 2235 | } | |
| 2236 | if identity == "" { | |
| 2237 | return nil, fmt.Errorf("%s is encrypted; pass --identity <file> with the private key for one of its recipients", path) | |
| 2238 | } | |
| 2239 | idf, err := os.Open(identity) | |
| 2240 | if err != nil { | |
| 2241 | return nil, err | |
| 2242 | } | |
| 2243 | defer idf.Close() | |
| 2244 | ids, err := age.ParseIdentities(idf) | |
| 2245 | if err != nil { | |
| 2246 | return nil, fmt.Errorf("%s: %w", identity, err) | |
| 2247 | } | |
| 2248 | r, err := age.Decrypt(br, ids...) | |
| 2249 | if err != nil { | |
| 2250 | return nil, fmt.Errorf("%s: decrypting: %w", path, err) | |
| 2251 | } | |
| 2252 | return r, nil | |
| 2253 | } | |
| 2254 | ``` | |
| 2255 | ||
| 2256 | Update the verify doc comment's first sentence to "verifyBackup reads an archive back, decrypting it with identity when it is encrypted:". | |
| 2257 | ||
| 2258 | - [ ] **Step 4: Run the package tests** | |
| 2259 | ||
| 2260 | Run: `go test ./cmd/gitbayd/ -count=1 && go vet ./cmd/gitbayd/` | |
| 2261 | Expected: PASS. | |
| 2262 | ||
| 2263 | - [ ] **Step 5: Commit** | |
| 2264 | ||
| 2265 | ```bash | |
| 2266 | git add cmd/gitbayd | |
| 2267 | git commit -S -m "backup: encrypt archives to [backup] age_recipients; --verify --identity | |
| 2268 | ||
| 2269 | Ref #274" | |
| 2270 | ``` | |
| 2271 | ||
| 2272 | ### Task 2.3: scripts, wiki, changelog | |
| 2273 | ||
| 2274 | **Files:** | |
| 2275 | - Modify: `deploy/cloud-init.yaml:96-100` (`age_h`), `:258-259`, `:276-277` (prune globs) | |
| 2276 | - Modify: `.gitbay/wiki/Admin.org` (Configuration reference: new `** [backup]` after `** [push]`; `* Backup and restore` 373-393) | |
| 2277 | - Modify: `.gitbay/wiki/Architecture/06-Data-and-Cryptography.org` (At rest, Backups row), `Architecture/08-Operations.org` (Backup table), `Architecture/09-Controls.org:61`, `Architecture/10-Known-Gaps.org` (#274 row) | |
| 2278 | - Modify: `CHANGELOG.org` | |
| 2279 | ||
| 2280 | - [ ] **Step 1: cloud-init** | |
| 2281 | ||
| 2282 | `age_h`: | |
| 2283 | ||
| 2284 | ```sh | |
| 2285 | age_h() { | |
| 2286 | f=$(ls -t "$1"/*.tar.gz "$1"/*.tar.gz.age 2>/dev/null | head -1) | |
| 2287 | ``` | |
| 2288 | ||
| 2289 | Full backup prune (line 259): | |
| 2290 | ||
| 2291 | ```sh | |
| 2292 | ls -1t "$dir"/gitbay-*.tar.gz* | tail -n +8 | xargs -r rm -- | |
| 2293 | ``` | |
| 2294 | ||
| 2295 | Database backup prune (line 277): | |
| 2296 | ||
| 2297 | ```sh | |
| 2298 | ls -1t "$dir"/gitbay-db-*.tar.gz* | tail -n +49 | xargs -r rm -- | |
| 2299 | ``` | |
| 2300 | ||
| 2301 | - [ ] **Step 2: Admin.org** | |
| 2302 | ||
| 2303 | New `** [backup]` section after `** [push]`: | |
| 2304 | ||
| 2305 | ```org | |
| 2306 | ** [backup] | |
| 2307 | - =age_recipients= (optional) — age public keys (=age1...=). When set, | |
| 2308 | =admin backup= encrypts every archive to them and appends =.age= to | |
| 2309 | its name. Generate the pair off the host with =age-keygen=; only the | |
| 2310 | public key goes here, so the host writes archives it cannot read. | |
| 2311 | The restic copy is unaffected: it snapshots =/var/lib/gitbay=, not | |
| 2312 | the archives. | |
| 2313 | ``` | |
| 2314 | ||
| 2315 | In `* Backup and restore`, after the `--verify` paragraph: | |
| 2316 | ||
| 2317 | ```org | |
| 2318 | With =[backup] age_recipients= set the archive is =<name>.tar.gz.age= | |
| 2319 | and =--verify= needs the private key: | |
| 2320 | ||
| 2321 | #+begin_src sh | |
| 2322 | gitbayd admin backup --verify gitbay-20260927-090000.tar.gz.age --identity ~/.config/gitbay/backup-identity.txt | |
| 2323 | age -d -i ~/.config/gitbay/backup-identity.txt gitbay-20260927-090000.tar.gz.age | tar -xz -C /new/root | |
| 2324 | #+end_src | |
| 2325 | ||
| 2326 | The identity lives off the host (with the secret key file and the | |
| 2327 | restic credentials), so verifying an encrypted archive happens there | |
| 2328 | or on a restore host. | |
| 2329 | ``` | |
| 2330 | ||
| 2331 | - [ ] **Step 3: Architecture pages** | |
| 2332 | ||
| 2333 | `06-Data-and-Cryptography.org`, At rest, Backups row: | |
| 2334 | ||
| 2335 | ```org | |
| 2336 | | Backups | local archives age-encrypted when =[backup] age_recipients= is set; restic encrypts the offsite copy; neither carries the secret key file | | |
| 2337 | ``` | |
| 2338 | ||
| 2339 | `08-Operations.org`, Full archive and Database only rows: append `; age-encrypted when =[backup] age_recipients= is set` to Contents. | |
| 2340 | ||
| 2341 | `09-Controls.org:61`: | |
| 2342 | ||
| 2343 | ```org | |
| 2344 | | Local backups encrypted | in place | age to =[backup] age_recipients= (=cmd/gitbayd/backup.go=); offsite copy by restic | | |
| 2345 | ``` | |
| 2346 | ||
| 2347 | `10-Known-Gaps.org`: delete the `#274` row. | |
| 2348 | ||
| 2349 | - [ ] **Step 4: CHANGELOG.org** under `* Unreleased`: | |
| 2350 | ||
| 2351 | ```org | |
| 2352 | - =gitbayd admin backup= encrypts archives to =[backup] age_recipients= | |
| 2353 | when set (#274); =--verify= takes =--identity <file>=. Archive names | |
| 2354 | gain =.age=; the shipped backup scripts and monitor match both. | |
| 2355 | ``` | |
| 2356 | ||
| 2357 | - [ ] **Step 5: Verify and commit** | |
| 2358 | ||
| 2359 | Run: `go build ./... && go vet ./...` | |
| 2360 | Expected: no output. | |
| 2361 | ||
| 2362 | ```bash | |
| 2363 | git add deploy/cloud-init.yaml .gitbay/wiki CHANGELOG.org | |
| 2364 | git commit -S -m "deploy, wiki: encrypted archives in the backup scripts and docs | |
| 2365 | ||
| 2366 | Closes #274" | |
| 2367 | git push -u origin backup-age | |
| 2368 | gitbay mr create --source backup-age --target main --title "Encrypt backup archives to an age recipient" | |
| 2369 | ``` | |
| 2370 | ||
| 2371 | Merge with `--strategy ff` after CI, delete the branch both places. | |
| 2372 | ||
| 2373 | --- | |
| 2374 | ||
| 2375 | # MR 3: verify connectivity, hold moves during a backup (branch `backup-verify-lock`, ref #259) | |
| 2376 | ||
| 2377 | ### Task 3.1: `gitutil.FsckConnectivity` | |
| 2378 | ||
| 2379 | **Files:** | |
| 2380 | - Modify: `internal/gitutil/merge.go` (after `PruneNow`, line 68) | |
| 2381 | - Test: `internal/gitutil/fsck_test.go` (create) | |
| 2382 | ||
| 2383 | **Interfaces:** | |
| 2384 | - Produces: `func FsckConnectivity(dir string) error`. | |
| 2385 | ||
| 2386 | - [ ] **Step 1: Failing test** | |
| 2387 | ||
| 2388 | ```go | |
| 2389 | package gitutil | |
| 2390 | ||
| 2391 | import ( | |
| 2392 | "os" | |
| 2393 | "os/exec" | |
| 2394 | "path/filepath" | |
| 2395 | "strings" | |
| 2396 | "testing" | |
| 2397 | ) | |
| 2398 | ||
| 2399 | func TestFsckConnectivityFindsAMissingObject(t *testing.T) { | |
| 2400 | dir := t.TempDir() | |
| 2401 | git(t, dir, "init", "-q", "-b", "main") | |
| 2402 | write(t, dir, "a.txt", "a\n") | |
| 2403 | git(t, dir, "add", "a.txt") | |
| 2404 | git(t, dir, "commit", "-q", "-m", "one") | |
| 2405 | if err := FsckConnectivity(dir); err != nil { | |
| 2406 | t.Fatalf("intact repository: %v", err) | |
| 2407 | } | |
| 2408 | out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD:a.txt").Output() | |
| 2409 | if err != nil { | |
| 2410 | t.Fatal(err) | |
| 2411 | } | |
| 2412 | blob := strings.TrimSpace(string(out)) | |
| 2413 | if err := os.Remove(filepath.Join(dir, ".git", "objects", blob[:2], blob[2:])); err != nil { | |
| 2414 | t.Fatal(err) | |
| 2415 | } | |
| 2416 | if err := FsckConnectivity(dir); err == nil { | |
| 2417 | t.Fatal("a repository missing a blob passed") | |
| 2418 | } | |
| 2419 | } | |
| 2420 | ``` | |
| 2421 | ||
| 2422 | - [ ] **Step 2: Run it** | |
| 2423 | ||
| 2424 | Run: `go test ./internal/gitutil/ -run TestFsckConnectivity -count=1` | |
| 2425 | Expected: FAIL, `undefined: FsckConnectivity`. | |
| 2426 | ||
| 2427 | - [ ] **Step 3: Implement** (in `merge.go`, after `PruneNow`) | |
| 2428 | ||
| 2429 | ```go | |
| 2430 | // FsckConnectivity checks that every object reachable from the | |
| 2431 | // repository's refs is present, without reading blob contents. A backup | |
| 2432 | // verify runs it on each archived repository (#259). | |
| 2433 | func FsckConnectivity(dir string) error { | |
| 2434 | cmd := exec.Command(toolpath.Look("git"), "-C", dir, "fsck", "--connectivity-only", "--no-progress", "--no-dangling") | |
| 2435 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 2436 | return fmt.Errorf("fsck --connectivity-only: %v\n%s", err, out) | |
| 2437 | } | |
| 2438 | return nil | |
| 2439 | } | |
| 2440 | ``` | |
| 2441 | ||
| 2442 | - [ ] **Step 4: Run and commit** | |
| 2443 | ||
| 2444 | Run: `go test ./internal/gitutil/ -count=1` | |
| 2445 | Expected: PASS. | |
| 2446 | ||
| 2447 | ```bash | |
| 2448 | git add internal/gitutil | |
| 2449 | git commit -S -m "gitutil: FsckConnectivity | |
| 2450 | ||
| 2451 | Ref #259" | |
| 2452 | ``` | |
| 2453 | ||
| 2454 | ### Task 3.2: `internal/backuplock` | |
| 2455 | ||
| 2456 | **Files:** | |
| 2457 | - Create: `internal/backuplock/backuplock.go`, `internal/backuplock/backuplock_test.go` | |
| 2458 | ||
| 2459 | **Interfaces:** | |
| 2460 | - Produces: `const Name = "backup.lock"`, `var ErrBusy error`, `func Hold(root string) (func(), error)`, `func TryShared(root string) (func(), error)`. | |
| 2461 | ||
| 2462 | - [ ] **Step 1: Failing tests** | |
| 2463 | ||
| 2464 | ```go | |
| 2465 | package backuplock | |
| 2466 | ||
| 2467 | import ( | |
| 2468 | "errors" | |
| 2469 | "testing" | |
| 2470 | "time" | |
| 2471 | ) | |
| 2472 | ||
| 2473 | func TestTrySharedRefusedWhileHeld(t *testing.T) { | |
| 2474 | root := t.TempDir() | |
| 2475 | release, err := Hold(root) | |
| 2476 | if err != nil { | |
| 2477 | t.Fatal(err) | |
| 2478 | } | |
| 2479 | if _, err := TryShared(root); !errors.Is(err, ErrBusy) { | |
| 2480 | t.Fatalf("TryShared during a backup: %v", err) | |
| 2481 | } | |
| 2482 | release() | |
| 2483 | r, err := TryShared(root) | |
| 2484 | if err != nil { | |
| 2485 | t.Fatalf("TryShared after the backup: %v", err) | |
| 2486 | } | |
| 2487 | r() | |
| 2488 | } | |
| 2489 | ||
| 2490 | func TestSharedHoldersCoexist(t *testing.T) { | |
| 2491 | root := t.TempDir() | |
| 2492 | a, err := TryShared(root) | |
| 2493 | if err != nil { | |
| 2494 | t.Fatal(err) | |
| 2495 | } | |
| 2496 | defer a() | |
| 2497 | b, err := TryShared(root) | |
| 2498 | if err != nil { | |
| 2499 | t.Fatalf("second shared holder: %v", err) | |
| 2500 | } | |
| 2501 | b() | |
| 2502 | } | |
| 2503 | ||
| 2504 | // A backup waits for a delete already under way. | |
| 2505 | func TestHoldWaitsForSharedHolder(t *testing.T) { | |
| 2506 | root := t.TempDir() | |
| 2507 | shared, err := TryShared(root) | |
| 2508 | if err != nil { | |
| 2509 | t.Fatal(err) | |
| 2510 | } | |
| 2511 | got := make(chan struct{}) | |
| 2512 | go func() { | |
| 2513 | release, err := Hold(root) | |
| 2514 | if err != nil { | |
| 2515 | t.Error(err) | |
| 2516 | close(got) | |
| 2517 | return | |
| 2518 | } | |
| 2519 | close(got) | |
| 2520 | release() | |
| 2521 | }() | |
| 2522 | select { | |
| 2523 | case <-got: | |
| 2524 | t.Fatal("Hold returned while a shared holder was in") | |
| 2525 | case <-time.After(100 * time.Millisecond): | |
| 2526 | } | |
| 2527 | shared() | |
| 2528 | select { | |
| 2529 | case <-got: | |
| 2530 | case <-time.After(5 * time.Second): | |
| 2531 | t.Fatal("Hold never returned after the shared holder left") | |
| 2532 | } | |
| 2533 | } | |
| 2534 | ``` | |
| 2535 | ||
| 2536 | - [ ] **Step 2: Run them** | |
| 2537 | ||
| 2538 | Run: `go test ./internal/backuplock/ -count=1` | |
| 2539 | Expected: FAIL to compile. | |
| 2540 | ||
| 2541 | - [ ] **Step 3: Implement** | |
| 2542 | ||
| 2543 | ```go | |
| 2544 | // Package backuplock keeps repository deletes, renames and transfers | |
| 2545 | // out of a full backup's way (#259). The backup runs in its own process | |
| 2546 | // (gitbayd admin backup) and a delete in the daemon's, so the lock is | |
| 2547 | // flock(2) on a file under server.root: the backup holds it exclusively | |
| 2548 | // from its database snapshot until the last repository is archived, and | |
| 2549 | // each delete or move holds it shared while it runs. | |
| 2550 | package backuplock | |
| 2551 | ||
| 2552 | import ( | |
| 2553 | "errors" | |
| 2554 | "os" | |
| 2555 | "path/filepath" | |
| 2556 | "syscall" | |
| 2557 | ) | |
| 2558 | ||
| 2559 | // Name is the lock file under server.root. Backups skip it. | |
| 2560 | const Name = "backup.lock" | |
| 2561 | ||
| 2562 | // ErrBusy is TryShared's answer while a backup holds the lock. | |
| 2563 | var ErrBusy = errors.New("a backup is running; repositories cannot be deleted, renamed or moved until it finishes, usually within minutes") | |
| 2564 | ||
| 2565 | // open opens the lock file read-only, which is all flock needs, so the | |
| 2566 | // daemon's user can lock a file a root-run backup created. | |
| 2567 | func open(root string) (*os.File, error) { | |
| 2568 | return os.OpenFile(filepath.Join(root, Name), os.O_RDONLY|os.O_CREATE, 0o644) | |
| 2569 | } | |
| 2570 | ||
| 2571 | // Hold takes the lock exclusively, waiting for deletes and moves under | |
| 2572 | // way to finish. Closing the file releases it. | |
| 2573 | func Hold(root string) (func(), error) { | |
| 2574 | f, err := open(root) | |
| 2575 | if err != nil { | |
| 2576 | return nil, err | |
| 2577 | } | |
| 2578 | if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { | |
| 2579 | f.Close() | |
| 2580 | return nil, err | |
| 2581 | } | |
| 2582 | return func() { f.Close() }, nil | |
| 2583 | } | |
| 2584 | ||
| 2585 | // TryShared takes the lock shared without waiting: ErrBusy while a | |
| 2586 | // backup holds it. | |
| 2587 | func TryShared(root string) (func(), error) { | |
| 2588 | f, err := open(root) | |
| 2589 | if err != nil { | |
| 2590 | return nil, err | |
| 2591 | } | |
| 2592 | if err := syscall.Flock(int(f.Fd()), syscall.LOCK_SH|syscall.LOCK_NB); err != nil { | |
| 2593 | f.Close() | |
| 2594 | if errors.Is(err, syscall.EWOULDBLOCK) { | |
| 2595 | return nil, ErrBusy | |
| 2596 | } | |
| 2597 | return nil, err | |
| 2598 | } | |
| 2599 | return func() { f.Close() }, nil | |
| 2600 | } | |
| 2601 | ``` | |
| 2602 | ||
| 2603 | - [ ] **Step 4: Run and commit** | |
| 2604 | ||
| 2605 | Run: `go test ./internal/backuplock/ -count=1 -race` | |
| 2606 | Expected: PASS. | |
| 2607 | ||
| 2608 | ```bash | |
| 2609 | git add internal/backuplock | |
| 2610 | git commit -S -m "backuplock: flock between a full backup and repository moves | |
| 2611 | ||
| 2612 | Ref #259" | |
| 2613 | ``` | |
| 2614 | ||
| 2615 | ### Task 3.3: deletes and moves refuse during a full backup | |
| 2616 | ||
| 2617 | **Files:** | |
| 2618 | - Modify: `internal/control/repo.go` (`runRepoTransfer` 481-538, `runRepoRename` 540-575, `deleteRepo` 611-626; new `holdOffBackup`) | |
| 2619 | - Modify: `internal/control/org.go` (`runOrgRename` 152-184) | |
| 2620 | - Test: `internal/control/backuplock_test.go` (create) | |
| 2621 | ||
| 2622 | **Interfaces:** | |
| 2623 | - Consumes: `backuplock.TryShared`, `backuplock.Hold`, `backuplock.ErrBusy`. | |
| 2624 | - Produces: `func holdOffBackup(c *Ctx) (func(), int)` in package control. | |
| 2625 | ||
| 2626 | - [ ] **Step 1: Failing test** | |
| 2627 | ||
| 2628 | ```go | |
| 2629 | package control | |
| 2630 | ||
| 2631 | import ( | |
| 2632 | "strings" | |
| 2633 | "testing" | |
| 2634 | ||
| 2635 | "gitbay.org/gitbay/internal/backuplock" | |
| 2636 | "gitbay.org/gitbay/internal/protocol" | |
| 2637 | ) | |
| 2638 | ||
| 2639 | func TestRepoDeleteAndRenameRefusedDuringBackup(t *testing.T) { | |
| 2640 | st, repo, uid := newQueueTestRepo(t) | |
| 2641 | owner, err := st.UserByID(uid) | |
| 2642 | if err != nil { | |
| 2643 | t.Fatal(err) | |
| 2644 | } | |
| 2645 | root := t.TempDir() | |
| 2646 | release, err := backuplock.Hold(root) | |
| 2647 | if err != nil { | |
| 2648 | t.Fatal(err) | |
| 2649 | } | |
| 2650 | for _, argv := range [][]string{ | |
| 2651 | {"repo", "rename", repo.Path(), "renamed"}, | |
| 2652 | {"repo", "delete", repo.Path(), "--yes"}, | |
| 2653 | } { | |
| 2654 | c, errOut := pruneCtx(st, root, owner) | |
| 2655 | if code := Dispatch(c, argv); code != protocol.ExitFailure || !strings.Contains(errOut.String(), "a backup is running") { | |
| 2656 | t.Fatalf("%v during a backup: exit %d, %s", argv, code, errOut) | |
| 2657 | } | |
| 2658 | } | |
| 2659 | if got, err := st.RepoByID(repo.ID); err != nil || got.Name != repo.Name { | |
| 2660 | t.Fatalf("repository changed during a backup: %+v, %v", got, err) | |
| 2661 | } | |
| 2662 | release() | |
| 2663 | ||
| 2664 | c, errOut := pruneCtx(st, root, owner) | |
| 2665 | if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitOK { | |
| 2666 | t.Fatalf("delete after the backup: exit %d, %s", code, errOut) | |
| 2667 | } | |
| 2668 | } | |
| 2669 | ``` | |
| 2670 | ||
| 2671 | Transfer and org rename get the same call; the test covers rename and | |
| 2672 | delete because a transfer target needs an org fixture this test does | |
| 2673 | not build, and the call is identical. | |
| 2674 | ||
| 2675 | - [ ] **Step 2: Run it** | |
| 2676 | ||
| 2677 | Run: `go test ./internal/control/ -run TestRepoDeleteAndRenameRefusedDuringBackup -count=1` | |
| 2678 | Expected: FAIL, rename exits 0 during the backup. | |
| 2679 | ||
| 2680 | - [ ] **Step 3: Implement** | |
| 2681 | ||
| 2682 | `repo.go`, import `"gitbay.org/gitbay/internal/backuplock"` and add after `deleteRepo`: | |
| 2683 | ||
| 2684 | ```go | |
| 2685 | // holdOffBackup keeps a full backup from starting while a repository | |
| 2686 | // directory moves or goes, and refuses while one runs: the backup's | |
| 2687 | // database snapshot names every repository its walk then archives | |
| 2688 | // (#259). The caller defers the returned release. | |
| 2689 | func holdOffBackup(c *Ctx) (func(), int) { | |
| 2690 | release, err := backuplock.TryShared(c.Cfg.Server.Root) | |
| 2691 | if err != nil { | |
| 2692 | return nil, c.fail(protocol.ExitFailure, "%v", err) | |
| 2693 | } | |
| 2694 | return release, -1 | |
| 2695 | } | |
| 2696 | ``` | |
| 2697 | ||
| 2698 | Call it with this block: | |
| 2699 | ||
| 2700 | ```go | |
| 2701 | release, code := holdOffBackup(c) | |
| 2702 | if code >= 0 { | |
| 2703 | return code | |
| 2704 | } | |
| 2705 | defer release() | |
| 2706 | ``` | |
| 2707 | ||
| 2708 | - `deleteRepo`: first statement of the function. | |
| 2709 | - `runRepoRename`: after the `os.Stat(newDir)` check, before `c.Store.RenameRepo` (line 560). `code` is already declared there by `resolveRepo`, so this call uses `lockCode`: | |
| 2710 | ||
| 2711 | ```go | |
| 2712 | release, lockCode := holdOffBackup(c) | |
| 2713 | if lockCode >= 0 { | |
| 2714 | return lockCode | |
| 2715 | } | |
| 2716 | defer release() | |
| 2717 | ``` | |
| 2718 | ||
| 2719 | - `runRepoTransfer`: the same `lockCode` block after the `os.Stat(newDir)` check, before `os.MkdirAll` (line 522). | |
| 2720 | - `org.go` `runOrgRename`: the same `lockCode` block after its `os.Stat(newDir)` check, before `c.Store.RenameOrg` (line 170). | |
| 2721 | ||
| 2722 | Repository creation, forks and imports are not held: a repository the | |
| 2723 | snapshot does not name is reported by `--verify` as extra, which is | |
| 2724 | harmless. | |
| 2725 | ||
| 2726 | - [ ] **Step 4: Run and commit** | |
| 2727 | ||
| 2728 | Run: `go test ./internal/control/ -count=1 && go vet ./internal/control/` | |
| 2729 | Expected: PASS. | |
| 2730 | ||
| 2731 | ```bash | |
| 2732 | git add internal/control | |
| 2733 | git commit -S -m "control: repository delete, rename, transfer and org rename wait out a full backup | |
| 2734 | ||
| 2735 | Ref #259" | |
| 2736 | ``` | |
| 2737 | ||
| 2738 | ### Task 3.4: the backup holds the lock; `--verify` checks connectivity | |
| 2739 | ||
| 2740 | **Files:** | |
| 2741 | - Modify: `cmd/gitbayd/backup.go` (`runBackup`, `verifyBackup`) | |
| 2742 | - Test: `cmd/gitbayd/backup_test.go` | |
| 2743 | ||
| 2744 | **Interfaces:** | |
| 2745 | - Consumes: `backuplock.Hold`, `backuplock.Name`, `gitutil.FsckConnectivity`, `archiveReader` (Task 2.2). | |
| 2746 | - Produces: `verifyBackup(path, identity string) error` now also runs `FsckConnectivity` per repository. | |
| 2747 | ||
| 2748 | - [ ] **Step 1: Failing tests** | |
| 2749 | ||
| 2750 | ```go | |
| 2751 | func gitIn(t *testing.T, dir string, args ...string) string { | |
| 2752 | t.Helper() | |
| 2753 | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |
| 2754 | cmd.Env = append(os.Environ(), | |
| 2755 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", | |
| 2756 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e") | |
| 2757 | out, err := cmd.CombinedOutput() | |
| 2758 | if err != nil { | |
| 2759 | t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 2760 | } | |
| 2761 | return strings.TrimSpace(string(out)) | |
| 2762 | } | |
| 2763 | ||
| 2764 | // verify runs git's connectivity check on every repository the | |
| 2765 | // database names: a repository missing an object fails it. | |
| 2766 | func TestVerifyChecksConnectivity(t *testing.T) { | |
| 2767 | cfg := testConfig(t) | |
| 2768 | st, err := openStore(cfg) | |
| 2769 | if err != nil { | |
| 2770 | t.Fatal(err) | |
| 2771 | } | |
| 2772 | uid, err := st.CreateUser("krz", false) | |
| 2773 | if err != nil { | |
| 2774 | t.Fatal(err) | |
| 2775 | } | |
| 2776 | if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil { | |
| 2777 | t.Fatal(err) | |
| 2778 | } | |
| 2779 | st.Close() | |
| 2780 | ||
| 2781 | work := t.TempDir() | |
| 2782 | gitIn(t, work, "init", "-q", "-b", "main") | |
| 2783 | if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil { | |
| 2784 | t.Fatal(err) | |
| 2785 | } | |
| 2786 | gitIn(t, work, "add", "a.txt") | |
| 2787 | gitIn(t, work, "commit", "-q", "-m", "one") | |
| 2788 | dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git") | |
| 2789 | gitIn(t, work, "clone", "-q", "--bare", work, dir) | |
| 2790 | ||
| 2791 | good := filepath.Join(t.TempDir(), "good.tar.gz") | |
| 2792 | if err := runBackup(cfg, good, false); err != nil { | |
| 2793 | t.Fatal(err) | |
| 2794 | } | |
| 2795 | if err := verifyBackup(good, ""); err != nil { | |
| 2796 | t.Fatalf("intact archive: %v", err) | |
| 2797 | } | |
| 2798 | ||
| 2799 | blob := gitIn(t, dir, "rev-parse", "HEAD:a.txt") | |
| 2800 | if err := os.Remove(filepath.Join(dir, "objects", blob[:2], blob[2:])); err != nil { | |
| 2801 | t.Fatal(err) | |
| 2802 | } | |
| 2803 | bad := filepath.Join(t.TempDir(), "bad.tar.gz") | |
| 2804 | if err := runBackup(cfg, bad, false); err != nil { | |
| 2805 | t.Fatal(err) | |
| 2806 | } | |
| 2807 | err = verifyBackup(bad, "") | |
| 2808 | if err == nil || !strings.Contains(err.Error(), "krz/thing") || !strings.Contains(err.Error(), "connectivity") { | |
| 2809 | t.Fatalf("archive with a missing blob: %v", err) | |
| 2810 | } | |
| 2811 | } | |
| 2812 | ||
| 2813 | // A full backup waits for a delete under way, and does not archive its | |
| 2814 | // own lock file. | |
| 2815 | func TestFullBackupWaitsForRepositoryMoves(t *testing.T) { | |
| 2816 | cfg := testConfig(t) | |
| 2817 | s, err := openStore(cfg) | |
| 2818 | if err != nil { | |
| 2819 | t.Fatal(err) | |
| 2820 | } | |
| 2821 | s.Close() | |
| 2822 | inFlight, err := backuplock.TryShared(cfg.Server.Root) | |
| 2823 | if err != nil { | |
| 2824 | t.Fatal(err) | |
| 2825 | } | |
| 2826 | out := filepath.Join(t.TempDir(), "b.tar.gz") | |
| 2827 | done := make(chan error, 1) | |
| 2828 | go func() { done <- runBackup(cfg, out, false) }() | |
| 2829 | select { | |
| 2830 | case err := <-done: | |
| 2831 | t.Fatalf("backup finished while a delete held the lock: %v", err) | |
| 2832 | case <-time.After(200 * time.Millisecond): | |
| 2833 | } | |
| 2834 | inFlight() | |
| 2835 | select { | |
| 2836 | case err := <-done: | |
| 2837 | if err != nil { | |
| 2838 | t.Fatal(err) | |
| 2839 | } | |
| 2840 | case <-time.After(10 * time.Second): | |
| 2841 | t.Fatal("backup never started after the delete finished") | |
| 2842 | } | |
| 2843 | for _, n := range members(t, out) { | |
| 2844 | if n == backuplock.Name { | |
| 2845 | t.Fatalf("archive carries %s", n) | |
| 2846 | } | |
| 2847 | } | |
| 2848 | } | |
| 2849 | ``` | |
| 2850 | ||
| 2851 | (`backup_test.go` imports gain `"os/exec"` and `"gitbay.org/gitbay/internal/backuplock"`; `strings` and `time` came with MR 2.) | |
| 2852 | ||
| 2853 | `TestVerifyChecksConnectivity` relies on a local `git clone --bare` | |
| 2854 | hardlinking loose objects into `dir`, so removing the blob there leaves | |
| 2855 | `work` intact. If git packs instead, the `os.Remove` fails and the test | |
| 2856 | says so. | |
| 2857 | ||
| 2858 | - [ ] **Step 2: Run them** | |
| 2859 | ||
| 2860 | Run: `go test ./cmd/gitbayd/ -run 'TestVerifyChecksConnectivity|TestFullBackupWaitsForRepositoryMoves' -count=1` | |
| 2861 | Expected: FAIL: the bad archive verifies; the backup finishes while the lock is held. | |
| 2862 | ||
| 2863 | - [ ] **Step 3: `runBackup` holds the lock** | |
| 2864 | ||
| 2865 | At the top of `runBackup`, before `openStore`: | |
| 2866 | ||
| 2867 | ```go | |
| 2868 | // Deletes, renames and transfers wait until the walk finishes, so | |
| 2869 | // every repository the snapshot names is still on disk when the walk | |
| 2870 | // reaches it (#259). A database-only archive reads no repository. | |
| 2871 | if !dbOnly { | |
| 2872 | release, err := backuplock.Hold(cfg.Server.Root) | |
| 2873 | if err != nil { | |
| 2874 | return fmt.Errorf("backup lock: %w", err) | |
| 2875 | } | |
| 2876 | defer release() | |
| 2877 | } | |
| 2878 | ``` | |
| 2879 | ||
| 2880 | Add `backuplock.Name: true` to the `skip` map. | |
| 2881 | ||
| 2882 | - [ ] **Step 4: `verifyBackup` extracts and checks repositories** | |
| 2883 | ||
| 2884 | Replace the function (keeping `archiveReader` from Task 2.2): | |
| 2885 | ||
| 2886 | ```go | |
| 2887 | // verifyBackup reads an archive back, decrypting it with identity when | |
| 2888 | // it is encrypted: the database snapshot must pass SQLite's integrity | |
| 2889 | // check, every repository it names must be in the archive, and each of | |
| 2890 | // those must pass git fsck --connectivity-only. Repositories are | |
| 2891 | // extracted to a temporary directory for the check, so it needs free | |
| 2892 | // space for them. A database-only archive is checked for integrity | |
| 2893 | // alone and says so. | |
| 2894 | func verifyBackup(path, identity string) error { | |
| 2895 | f, err := os.Open(path) | |
| 2896 | if err != nil { | |
| 2897 | return err | |
| 2898 | } | |
| 2899 | defer f.Close() | |
| 2900 | plain, err := archiveReader(f, path, identity) | |
| 2901 | if err != nil { | |
| 2902 | return err | |
| 2903 | } | |
| 2904 | gz, err := gzip.NewReader(plain) | |
| 2905 | if err != nil { | |
| 2906 | return fmt.Errorf("%s: not a gzip archive: %w", path, err) | |
| 2907 | } | |
| 2908 | tr := tar.NewReader(gz) | |
| 2909 | tmp, err := os.MkdirTemp("", "gitbay-verify-") | |
| 2910 | if err != nil { | |
| 2911 | return err | |
| 2912 | } | |
| 2913 | defer os.RemoveAll(tmp) | |
| 2914 | dbPath := "" | |
| 2915 | inArchive := map[string]bool{} | |
| 2916 | members := 0 | |
| 2917 | for { | |
| 2918 | h, err := tr.Next() | |
| 2919 | if err == io.EOF { | |
| 2920 | break | |
| 2921 | } | |
| 2922 | if err != nil { | |
| 2923 | return fmt.Errorf("%s: archive damaged after %d members: %w", path, members, err) | |
| 2924 | } | |
| 2925 | members++ | |
| 2926 | switch { | |
| 2927 | case h.Name == "gitbay.db": | |
| 2928 | dbPath = filepath.Join(tmp, "gitbay.db") | |
| 2929 | if err := extractTo(tr, dbPath); err != nil { | |
| 2930 | return fmt.Errorf("%s: extracting the database: %w", path, err) | |
| 2931 | } | |
| 2932 | case strings.HasPrefix(h.Name, "repos/"): | |
| 2933 | // repos/<owner>/<name>.git/HEAD marks one repository present. | |
| 2934 | parts := strings.Split(h.Name, "/") | |
| 2935 | if len(parts) == 4 && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") { | |
| 2936 | inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true | |
| 2937 | } | |
| 2938 | if h.Typeflag != tar.TypeReg { | |
| 2939 | continue | |
| 2940 | } | |
| 2941 | if !filepath.IsLocal(h.Name) { | |
| 2942 | return fmt.Errorf("%s: member %q leaves the archive root", path, h.Name) | |
| 2943 | } | |
| 2944 | if err := extractTo(tr, filepath.Join(tmp, filepath.FromSlash(h.Name))); err != nil { | |
| 2945 | return fmt.Errorf("%s: extracting %s: %w", path, h.Name, err) | |
| 2946 | } | |
| 2947 | } | |
| 2948 | } | |
| 2949 | if dbPath == "" { | |
| 2950 | return fmt.Errorf("%s: no gitbay.db in the archive", path) | |
| 2951 | } | |
| 2952 | st, err := store.Open(dbPath) | |
| 2953 | if err != nil { | |
| 2954 | return fmt.Errorf("%s: database does not open: %w", path, err) | |
| 2955 | } | |
| 2956 | defer st.Close() | |
| 2957 | var integrity string | |
| 2958 | if err := st.DB.QueryRow("PRAGMA integrity_check").Scan(&integrity); err != nil { | |
| 2959 | return fmt.Errorf("%s: integrity check: %w", path, err) | |
| 2960 | } | |
| 2961 | if integrity != "ok" { | |
| 2962 | return fmt.Errorf("%s: database integrity: %s", path, integrity) | |
| 2963 | } | |
| 2964 | repos, err := st.ListAllRepos() | |
| 2965 | if err != nil { | |
| 2966 | return err | |
| 2967 | } | |
| 2968 | if len(inArchive) == 0 { | |
| 2969 | fmt.Printf("%s: database only; integrity ok, %d repositories in the database, none in the archive\n", path, len(repos)) | |
| 2970 | return nil | |
| 2971 | } | |
| 2972 | var missing []string | |
| 2973 | for _, r := range repos { | |
| 2974 | if !inArchive[r.Path()] { | |
| 2975 | missing = append(missing, r.Path()) | |
| 2976 | } | |
| 2977 | } | |
| 2978 | extra := len(inArchive) - (len(repos) - len(missing)) | |
| 2979 | fmt.Printf("%s: integrity ok, %d repositories in the database, %d in the archive\n", path, len(repos), len(inArchive)) | |
| 2980 | if len(missing) > 0 { | |
| 2981 | return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", ")) | |
| 2982 | } | |
| 2983 | if extra > 0 { | |
| 2984 | fmt.Printf("%d repositories in the archive that the database does not name (created after the snapshot)\n", extra) | |
| 2985 | } | |
| 2986 | var broken []string | |
| 2987 | for _, r := range repos { | |
| 2988 | dir := filepath.Join(tmp, "repos", r.OwnerName, r.Name+".git") | |
| 2989 | if err := gitutil.FsckConnectivity(dir); err != nil { | |
| 2990 | fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err) | |
| 2991 | broken = append(broken, r.Path()) | |
| 2992 | } | |
| 2993 | } | |
| 2994 | if len(broken) > 0 { | |
| 2995 | return fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", ")) | |
| 2996 | } | |
| 2997 | fmt.Printf("connectivity ok on %d repositories\n", len(repos)) | |
| 2998 | return nil | |
| 2999 | } | |
| 3000 | ||
| 3001 | // extractTo writes one archive member to dest, owner-only. | |
| 3002 | func extractTo(r io.Reader, dest string) error { | |
| 3003 | if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { | |
| 3004 | return err | |
| 3005 | } | |
| 3006 | w, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) | |
| 3007 | if err != nil { | |
| 3008 | return err | |
| 3009 | } | |
| 3010 | if _, err := io.Copy(w, r); err != nil { | |
| 3011 | w.Close() | |
| 3012 | return err | |
| 3013 | } | |
| 3014 | return w.Close() | |
| 3015 | } | |
| 3016 | ``` | |
| 3017 | ||
| 3018 | Imports gain `"gitbay.org/gitbay/internal/backuplock"` and | |
| 3019 | `"gitbay.org/gitbay/internal/gitutil"`. The "extra" message changes | |
| 3020 | from "deleted after the snapshot" to "created after the snapshot", | |
| 3021 | since a delete can no longer land mid-backup. | |
| 3022 | ||
| 3023 | Update `backupCmd`'s `--verify` flag text: | |
| 3024 | ||
| 3025 | ```go | |
| 3026 | cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, its repositories against the archive's, and git connectivity of each") | |
| 3027 | ``` | |
| 3028 | ||
| 3029 | - [ ] **Step 5: Run the package tests** | |
| 3030 | ||
| 3031 | Run: `go test ./cmd/gitbayd/ -count=1 && go vet ./cmd/gitbayd/` | |
| 3032 | Expected: PASS, including `TestBackupDBOnlyOmitsRepositories` (its repository is not in the database, so no fsck runs on its HEAD-only directory). | |
| 3033 | ||
| 3034 | - [ ] **Step 6: e2e** | |
| 3035 | ||
| 3036 | In `e2e/backup_test.go`, after the transient-state checks: | |
| 3037 | ||
| 3038 | ```go | |
| 3039 | if out := inst.admin(t, "admin", "backup", "--verify", archive); !strings.Contains(out, "connectivity ok on 1 repositories") { | |
| 3040 | t.Fatalf("verify: %s", out) | |
| 3041 | } | |
| 3042 | ``` | |
| 3043 | ||
| 3044 | Run: `go test ./e2e -run TestAdminBackup -count=1` | |
| 3045 | Expected: PASS. | |
| 3046 | ||
| 3047 | - [ ] **Step 7: Commit** | |
| 3048 | ||
| 3049 | ```bash | |
| 3050 | git add cmd/gitbayd e2e/backup_test.go | |
| 3051 | git commit -S -m "backup: hold repository moves off during a full backup; verify git connectivity | |
| 3052 | ||
| 3053 | Ref #259" | |
| 3054 | ``` | |
| 3055 | ||
| 3056 | ### Task 3.5: wiki: behaviour and the restore drill procedure | |
| 3057 | ||
| 3058 | **Files:** | |
| 3059 | - Modify: `.gitbay/wiki/Admin.org` (`* Backup and restore`: the `--verify` paragraph; new `** Restore drill`) | |
| 3060 | - Modify: `.gitbay/wiki/Architecture/08-Operations.org:55-70` | |
| 3061 | - Modify: `.gitbay/wiki/Threat-Model.org:218-220` | |
| 3062 | ||
| 3063 | - [ ] **Step 1: Admin.org** | |
| 3064 | ||
| 3065 | Replace the `--verify` paragraph with: | |
| 3066 | ||
| 3067 | ```org | |
| 3068 | =--verify= reads an archive back: the snapshot must pass SQLite's | |
| 3069 | integrity check, every repository the snapshot names must be in the | |
| 3070 | archive, and each must pass =git fsck --connectivity-only=. It | |
| 3071 | extracts the repositories to a temporary directory for that, so it | |
| 3072 | needs free space the size of the repositories. A database-only archive | |
| 3073 | is checked for integrity and says so. Exit is non-zero on damage, a | |
| 3074 | missing repository or a missing object. | |
| 3075 | ||
| 3076 | A full backup holds =<root>/backup.lock= from its database snapshot to | |
| 3077 | its last repository. While it runs, =repo delete=, =repo rename=, | |
| 3078 | =repo transfer=, =admin repo delete= and =org rename= refuse with "a | |
| 3079 | backup is running"; retry when it finishes. Database-only backups take | |
| 3080 | no lock. | |
| 3081 | ``` | |
| 3082 | ||
| 3083 | Add a new subsection after `** Secret key`: | |
| 3084 | ||
| 3085 | ```org | |
| 3086 | ** Restore drill | |
| 3087 | ||
| 3088 | A restore onto a clean host, run on a schedule and recorded below. | |
| 3089 | The disaster it rehearses is losing bay1, so the local archives are | |
| 3090 | gone with it and the sources are the offsite restic repository and | |
| 3091 | what the operator keeps off the host (=~/.config/gitbay/=: =offsite.env=, | |
| 3092 | =secret.key=, =config.toml=, =backup-identity.txt=). The steps are in | |
| 3093 | the data-at-rest plan's operator runbook | |
| 3094 | (=docs/plans/2026-09-27-data-at-rest-and-backup.md=). | |
| 3095 | ||
| 3096 | Time to service runs from the clean host's first root login to the | |
| 3097 | first successful =git clone= over SSH from it. The recovery point is | |
| 3098 | the time of the newest restic snapshot restored. | |
| 3099 | ||
| 3100 | | Date | Host | Snapshot restored (UTC) | Time to service | DB integrity | Connectivity | LFS | Release assets | Host key | Secrets | Notes | | |
| 3101 | |------+------+-------------------------+-----------------+--------------+--------------+-----+----------------+----------+---------+-------| | |
| 3102 | ``` | |
| 3103 | ||
| 3104 | - [ ] **Step 2: Architecture/08 and Threat-Model** | |
| 3105 | ||
| 3106 | `08-Operations.org`: replace the `--verify` bullet (lines 60-62) with: | |
| 3107 | ||
| 3108 | ```org | |
| 3109 | - =gitbayd admin backup --verify= checks SQLite integrity, that every | |
| 3110 | repository the database names is present, and =git fsck | |
| 3111 | --connectivity-only= on each (=backup.go=). | |
| 3112 | - Repository deletes, renames and transfers refuse while a full backup | |
| 3113 | runs (=internal/backuplock=), so the snapshot and the walk agree. | |
| 3114 | ``` | |
| 3115 | ||
| 3116 | Replace the "Recovery time" bullet with: | |
| 3117 | ||
| 3118 | ```org | |
| 3119 | - Recovery time: see the Admin wiki's Restore drill table. | |
| 3120 | ``` | |
| 3121 | ||
| 3122 | `Threat-Model.org:218-220` becomes: | |
| 3123 | ||
| 3124 | ```org | |
| 3125 | - Backups are consistent per the DB-snapshot-first ordering, and | |
| 3126 | repository deletes and moves wait out a full backup; a push during | |
| 3127 | one leaves only unreferenced objects (see [[Admin]]). | |
| 3128 | ``` | |
| 3129 | ||
| 3130 | - [ ] **Step 3: Commit and MR** | |
| 3131 | ||
| 3132 | ```bash | |
| 3133 | git add .gitbay/wiki | |
| 3134 | git commit -S -m "wiki: backup verify, backup lock, restore drill record | |
| 3135 | ||
| 3136 | Ref #259" | |
| 3137 | git push -u origin backup-verify-lock | |
| 3138 | gitbay mr create --source backup-verify-lock --target main --title "Backup verify checks git connectivity; moves wait out a backup" | |
| 3139 | ``` | |
| 3140 | ||
| 3141 | Merge with `--strategy ff` after CI, delete the branch both places. | |
| 3142 | #259 stays open until the drill below is recorded. | |
| 3143 | ||
| 3144 | --- | |
| 3145 | ||
| 3146 | # Operator runbook (cmc) | |
| 3147 | ||
| 3148 | Run on bay1 and a clean host. Nothing here is automated by the MRs. | |
| 3149 | One forge write per shell call; bay1 root is `ssh -p 2222 root@gitbay.org`. | |
| 3150 | ||
| 3151 | ## A. After MR 1 deploys | |
| 3152 | ||
| 3153 | 1. `make deploy` runs `deploy/install.sh`, which creates | |
| 3154 | `/etc/gitbay/secret.key` (missing on bay1) before the restart. | |
| 3155 | Confirm: `ssh -p 2222 root@gitbay.org 'ls -l /etc/gitbay/secret.key; journalctl -u gitbayd -n 50 | grep "sealed secret values"'` | |
| 3156 | → mode `-rw-------`, owner `gitbay gitbay`, one log line with a count. | |
| 3157 | 2. `ssh -p 2222 root@gitbay.org 'gitbayd --config /etc/gitbay/config.toml admin secrets check'` | |
| 3158 | → one `key <id>: N sealed` line, no `clear:` line. | |
| 3159 | 3. Escrow: `scp -P 2222 root@gitbay.org:/etc/gitbay/secret.key ~/.config/gitbay/secret.key && chmod 600 ~/.config/gitbay/secret.key`. | |
| 3160 | Also copy `/etc/gitbay/config.toml` to `~/.config/gitbay/config.toml` | |
| 3161 | (mode 600) if no copy exists off the host; the drill needs it. | |
| 3162 | 4. Check CI builds that use secrets (blotter, hutch, orgo) still run, | |
| 3163 | and a webhook delivery still verifies. | |
| 3164 | ||
| 3165 | ## B. After MR 2 deploys | |
| 3166 | ||
| 3167 | 1. On the laptop: `age-keygen -o ~/.config/gitbay/backup-identity.txt` | |
| 3168 | (mode 600). Note the printed `age1...` public key. | |
| 3169 | 2. On bay1, add to `/etc/gitbay/config.toml`: | |
| 3170 | ```toml | |
| 3171 | [backup] | |
| 3172 | age_recipients = ["age1..."] | |
| 3173 | ``` | |
| 3174 | then `gitbayd --config /etc/gitbay/config.toml check-config --no-host-checks`. | |
| 3175 | 3. bay1's installed `/usr/local/bin/gitbay-backup.sh`, | |
| 3176 | `/usr/local/bin/gitbay-db-backup.sh` and `/usr/local/bin/gitbay-monitor.sh` | |
| 3177 | predate the cloud-init change (cloud-init runs once). Apply the same | |
| 3178 | glob edits as Task 2.3 Step 1 by hand. | |
| 3179 | 4. Before relying on encryption, find how `/var/lib/gitbay-stage` (the | |
| 3180 | staged database restic copies) is produced. If it reads a local | |
| 3181 | archive, it must decrypt, which the host cannot; switch it to its own | |
| 3182 | `VACUUM INTO` or an unencrypted database-only snapshot kept under | |
| 3183 | `/var/lib/gitbay-stage` only. If it snapshots the live database | |
| 3184 | directly, nothing changes. | |
| 3185 | 5. After the next hourly run: `ls -l /var/backups/gitbay/db | tail -2` | |
| 3186 | shows `.tar.gz.age`; the monitor's `db_snapshot_h` stays under 2. | |
| 3187 | Copy one archive to the laptop and run | |
| 3188 | `gitbayd admin backup --verify <file> --identity ~/.config/gitbay/backup-identity.txt` | |
| 3189 | (a local gitbayd build; `--verify` reads no config). | |
| 3190 | 6. Old unencrypted archives age out of the 7/48 rotation on their own. | |
| 3191 | ||
| 3192 | ## C. Restore drill (after MR 3 deploys; closes #259) | |
| 3193 | ||
| 3194 | Record every timestamp as you go. Start the clock at step 2. | |
| 3195 | ||
| 3196 | 1. Pick the snapshot: `set -a; . ~/.config/gitbay/offsite.env; set +a; restic $RESTIC_OPTS snapshots --latest 1`. | |
| 3197 | Note its time (recovery point). `restic $RESTIC_OPTS ls latest /var/lib/gitbay-stage` | |
| 3198 | to find the staged database file name. | |
| 3199 | 2. Provision a clean Ubuntu 24.04 host (throwaway VPS or local VM) with | |
| 3200 | `deploy/cloud-init.yaml`. First root login: **clock starts**. | |
| 3201 | 3. Before gitbayd ever starts, block outbound traffic so the restored | |
| 3202 | instance cannot send mail, deliver webhooks, push mirrors or call | |
| 3203 | APNs: `ufw default deny outgoing; ufw allow out 53; ufw allow out to <restic endpoint> port 443; ufw reload`. | |
| 3204 | (Allow the restic endpoint only for the restore, then remove it.) | |
| 3205 | 4. Install restic, restore: `restic $RESTIC_OPTS restore latest --target / --include /var/lib/gitbay --include /var/lib/gitbay-stage`. | |
| 3206 | Replace `/var/lib/gitbay/gitbay.db` with the staged copy (the live | |
| 3207 | file in the snapshot may be mid-write); remove any `gitbay.db-wal` | |
| 3208 | and `gitbay.db-shm`. `chown -R gitbay:gitbay /var/lib/gitbay`. | |
| 3209 | 5. Config and key: copy `~/.config/gitbay/config.toml` to | |
| 3210 | `/etc/gitbay/config.toml` and `~/.config/gitbay/secret.key` to | |
| 3211 | `/etc/gitbay/secret.key` (`chown gitbay:gitbay`, mode 600). In the | |
| 3212 | config for the drill only: `site_url` to `http://<drill-ip>:8080`, | |
| 3213 | `[http] addr = ":8080"`, `tls = "off"`, remove `[mail]`, set | |
| 3214 | `registration.mode = "closed"`, `[push] enabled = false`, remove | |
| 3215 | `[backup]` (step 7's archive is local and read back at once). If | |
| 3216 | `[lfs] root` is set outside `server.root`, note it: neither the | |
| 3217 | archive nor this restore carries those objects. | |
| 3218 | 6. Install the gitbayd binary of the tag bay1 runs (`/healthz` names the | |
| 3219 | commit) with `deploy/install.sh <drill-ip> 2222`; it will not create | |
| 3220 | a key because one is present. | |
| 3221 | 7. Checks, each recorded in the table: | |
| 3222 | - Database integrity and connectivity: as `gitbay`, | |
| 3223 | `gitbayd --config /etc/gitbay/config.toml admin backup --out /tmp/drill.tar.gz && gitbayd admin backup --verify /tmp/drill.tar.gz` | |
| 3224 | → `integrity ok`, `connectivity ok on N repositories`; N equals | |
| 3225 | `gitbayd admin stats --json` repository count on bay1 at the | |
| 3226 | snapshot. | |
| 3227 | - Secrets: `gitbayd --config /etc/gitbay/config.toml admin secrets check` | |
| 3228 | → every value under one key, no error. The journal shows no | |
| 3229 | `sealing secrets` failure. | |
| 3230 | - LFS: `cd /var/lib/gitbay/lfs && find . -type f | while read f; do [ "$(sha256sum < "$f" | cut -c1-64)" = "$(basename "$f")" ] || echo "BAD $f"; done` → no output; object count against bay1's. | |
| 3231 | - Release assets: every row's file exists with its digest: | |
| 3232 | ```sh | |
| 3233 | sqlite3 /var/lib/gitbay/gitbay.db "SELECT COALESCE(u.username, o.name) || '/' || r.name || '.git/gitbay-releases/' || a.release_id || '/' || a.name, a.sha256 FROM release_assets a JOIN releases rl ON rl.id = a.release_id JOIN repos r ON r.id = rl.repo_id LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id" | | |
| 3234 | while IFS='|' read p sum; do [ "$(sha256sum < "/var/lib/gitbay/repos/$p" | cut -c1-64)" = "$sum" ] || echo "BAD $p"; done | |
| 3235 | ``` | |
| 3236 | → no output. | |
| 3237 | - Host key: `ssh-keyscan -p 22 <drill-ip>` fingerprint equals | |
| 3238 | `ssh-keyscan -p 22 gitbay.org`'s. | |
| 3239 | - Config: `gitbayd --config /etc/gitbay/config.toml check-config` → `config ok`. | |
| 3240 | 8. Service: from the laptop, `ssh -p 22 git@<drill-ip> whoami` → | |
| 3241 | `cmc`; `git clone ssh://git@<drill-ip>/krz/gitbay.git` succeeds. | |
| 3242 | **Clock stops** at the clone. | |
| 3243 | 9. Record on `.gitbay/wiki/Admin.org`, Restore drill table: date, host, | |
| 3244 | snapshot time, time to service (step 2 → step 8), each check's | |
| 3245 | result, and anything that needed a manual fix in Notes. Update: | |
| 3246 | - `Architecture/10-Known-Gaps.org`: delete the `#259` row; the | |
| 3247 | "measured recovery time" question row reads the measured figure | |
| 3248 | and the date. | |
| 3249 | - `Architecture/09-Controls.org:102`: | |
| 3250 | `| Restore tested | in place | drill <date>, Admin wiki "Restore drill" |`. | |
| 3251 | - `Architecture/08-Operations.org`: the "Recovery time" bullet names | |
| 3252 | the figure. | |
| 3253 | Branch `restore-drill-record`, one signed commit ending | |
| 3254 | `Closes #259`, MR, ff merge, delete the branch. | |
| 3255 | 10. Destroy the drill host. Repeat the drill every quarter and after | |
| 3256 | any change to `cmd/gitbayd/backup.go` or the restic job, adding a | |
| 3257 | row each time. | |
| 3258 | ||
| 3259 | --- | |
| 3260 | ||
| 3261 | ## Open questions | |
| 3262 | ||
| 3263 | 1. How `/var/lib/gitbay-stage` is populated on bay1 is not in the | |
| 3264 | repository. If the staging step reads the local archives, enabling | |
| 3265 | `age_recipients` breaks the restic path (runbook B.4 checks this | |
| 3266 | before it matters). | |
| 3267 | 2. Is `/etc/gitbay/config.toml` kept anywhere off the host today? The | |
| 3268 | repository does not say; runbook A.3 creates a copy, and the drill | |
| 3269 | depends on it. | |
| 3270 | 3. Drill cadence: the plan proposes quarterly and after backup changes; | |
| 3271 | #259 says only "on a schedule". | |
| 3272 | 4. Where the clean host runs (throwaway VPS or local VM) is left to the | |
| 3273 | operator; the runbook works for either. | |
| 3274 | 5. `lfs.root` on bay1: if it points outside `server.root`, LFS objects | |
| 3275 | are in neither the archive nor the restic snapshot of | |
| 3276 | `/var/lib/gitbay`. The drill records it; fixing it is not in this | |
| 3277 | plan. | |
| 3278 | 6. Out of scope, noted while reading: `webhook add` takes `--secret` | |
| 3279 | on argv (`internal/control/webhook.go:16-23`), against the | |
| 3280 | stdin-only rule for secrets. | |
| 3281 | ||
| 3282 | ## Self-review | |
| 3283 | ||
| 3284 | - #273: encryption of the four columns (Task 1.3), key file outside the | |
| 3285 | database and root (1.2), key id prefix (1.1), rotation (1.4 | |
| 3286 | `rotate`), existing clear rows sealed at startup (1.4 `serve`, | |
| 3287 | `ResealSecrets`), missing key behaviour (1.4 `openStore`, documented | |
| 3288 | 1.6), backups do not carry it (validation 1.2, e2e 1.5), install | |
| 3289 | provisioning (1.6). | |
| 3290 | - #274: age recipients config (2.1), encryption (2.2), `--verify | |
| 3291 | --identity` (2.2), restic path unaffected by the archives and checked | |
| 3292 | for its staging step (runbook B.4), scripts (2.3). | |
| 3293 | - #259: `--verify` connectivity (3.1, 3.4), delete/rename/transfer held | |
| 3294 | (3.2, 3.3, 3.4), drill covering database integrity, connectivity, | |
| 3295 | LFS, release assets, config, host keys, secrets and the key, with time | |
| 3296 | to service on the Admin page (3.5, runbook C). | |
| 3297 | - Names used across tasks: `seal.Keyring`/`Load`/`Seal`/`Open`/ | |
| 3298 | `CurrentID`/`KeyID`/`IsSealed`/`NewKey`/`ReadKeys`/`WriteKeys`; | |
| 3299 | `Store.SetKeyring`/`ResealSecrets`/`SecretKeyUse`; `testConfig`; | |
| 3300 | `archivePath`/`archiveReader`/`verifyBackup(path, identity)`; | |
| 3301 | `backuplock.Hold`/`TryShared`/`ErrBusy`/`Name`; `holdOffBackup`; | |
| 3302 | `gitutil.FsckConnectivity`. Consistent. | |
docs/plans/2026-09-27-server-hardening.md added +3686
| @@ -0,0 +1,3686 @@ | ||
| 1 | # Server hardening implementation plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Close #281, #280, #279, #282, #275 and #262 from the | |
| 6 | 2026-09-27 architecture review: an explicit TLS floor, mail that | |
| 7 | refuses plaintext to a remote relay, mirror syncs that connect only to | |
| 8 | an address checked at sync time, an authenticated hook socket, audited | |
| 9 | refusals with a tamper-evident audit log, and a shared limit on git | |
| 10 | pack generation. | |
| 11 | ||
| 12 | **Architecture:** Six MRs, each small enough to review alone. The TLS | |
| 13 | and mail changes are local to `cmd/gitbayd/main.go` and | |
| 14 | `internal/mail`. Mirror sync resolves and checks the host itself, then | |
| 15 | pins git's connection to those addresses with `http.curloptResolve`. | |
| 16 | The hook socket gets mode 0600, a Linux peer-uid check behind build | |
| 17 | tags, and a per-push token that sshd stores (hashed) in a new | |
| 18 | `push_tokens` table and hookd requires. Audit rows gain a SHA-256 | |
| 19 | chain over an immutable actor column, refusals of mutating commands | |
| 20 | are recorded through a per-actor limiter, and the daemon writes each | |
| 21 | row to its log. A new `internal/packlimit` package holds one limiter | |
| 22 | shared by the SSH, smart HTTP and git:// transports. | |
| 23 | ||
| 24 | **Tech stack:** Go 1.27, SQLite (modernc, `_txlock=immediate`), | |
| 25 | `golang.org/x/crypto/ssh`, `net/smtp`, `crypto/tls`, `syscall` | |
| 26 | (Linux `SO_PEERCRED`), git ≥ 2.37 (`http.curloptResolve`), cobra. | |
| 27 | ||
| 28 | **Spec:** the issue texts of #262, #275, #279, #280, #281, #282 on | |
| 29 | krz/gitbay, and the decisions recorded in the brief for this plan set | |
| 30 | (copied under "Decisions" below). | |
| 31 | ||
| 32 | ## Global constraints | |
| 33 | ||
| 34 | - Each MR on its own branch off `main`. Commits are signed (the repo | |
| 35 | refuses unsigned), messages reference issues (`Ref #N`, and | |
| 36 | `Closes #N` on the commit that finishes one). No attribution to any | |
| 37 | assistant, model or AI anywhere: commits, MR bodies, comments. | |
| 38 | - MR: `gitbay mr create --source <branch> --target main --title "..."`; | |
| 39 | merge with `gitbay mr merge <n> --strategy ff` once CI is green, then | |
| 40 | delete the branch locally and on the remote. Behind main → rebase, | |
| 41 | force-push, merge again. | |
| 42 | - Locally: `go build ./...`, `go vet ./...`, unit tests of touched | |
| 43 | packages, and at most the one e2e test being written | |
| 44 | (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite. | |
| 45 | - Registries that fail CI when a new thing lacks its row: top-level route | |
| 46 | word in `internal/policy/names.go`; new page template in the width map | |
| 47 | of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly` | |
| 48 | command in `readArgs` in `e2e/readonly_test.go`; new control command | |
| 49 | needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test); | |
| 50 | a command reading stdin needs `ReadsStdin: true`. | |
| 51 | This plan adds no route, no template and no control command: | |
| 52 | `gitbayd admin audit verify` is a host-local cobra command, not a | |
| 53 | registry entry. | |
| 54 | - New migrations: the highest today is 0059. Six plans are written in | |
| 55 | parallel, so numbers are pre-assigned: plan 1 uses 0060–0064, plan 2 | |
| 56 | 0065–0068, plan 3 0069–0071, plan 4 0072–0074, plan 5 0075–0077, | |
| 57 | plan 6 0078–0079. Whoever lands second renumbers to the next free | |
| 58 | number at execution time. Migrations come in `.up.sql`/`.down.sql` | |
| 59 | pairs. Hand-written SQL, no ORM. This plan uses 0069 | |
| 60 | (`push_tokens`, MR 4) and 0070 (`audit_chain`, MR 5); 0071 is unused. | |
| 61 | - Secrets travel on stdin, never argv; never logged or echoed. The | |
| 62 | push token is minted per receive-pack and passed only in the hook's | |
| 63 | environment; only its SHA-256 is stored. | |
| 64 | - Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model, | |
| 65 | CI, Users, Performance, and the `Architecture/` folder with its | |
| 66 | Known-Gaps table and controls matrix). Update the page in the same MR | |
| 67 | that changes the behaviour it describes, and close the matching | |
| 68 | Known-Gaps row (`Architecture/10-Known-Gaps.org`) and controls row | |
| 69 | (`Architecture/09-Controls.org`). | |
| 70 | - Writing style: plain, direct, no hype; code comments match the | |
| 71 | surrounding density. Comments and docs state facts, never | |
| 72 | before/after narration. | |
| 73 | - The worktree may carry another session's edits; work in a fresh | |
| 74 | worktree per MR (`git worktree add ../gitbay-<branch> -b <branch> main`). | |
| 75 | - `CHANGELOG.org` is written at release time (`CHANGELOG: vX.Y.Z` | |
| 76 | commits), not in these MRs. The release notes each MR needs are | |
| 77 | listed at the end of this plan. | |
| 78 | ||
| 79 | ## Decisions (from the brief) | |
| 80 | ||
| 81 | - #275: audit refused mutating commands (rate-limited per actor); each | |
| 82 | audit row carries a hash of the previous row, `gitbayd admin audit | |
| 83 | verify` checks the chain, and rows are also written to the journal | |
| 84 | (a slog line on the daemon's stderr, which the unit's journal | |
| 85 | collects). | |
| 86 | - #282: chmod 0600, SO_PEERCRED uid check, and a per-push token in the | |
| 87 | hook environment that hookd requires. | |
| 88 | - #279: resolve and check before each sync and pin the address for git. | |
| 89 | ||
| 90 | ## Findings from the code that shape this plan | |
| 91 | ||
| 92 | - Mirror URLs are http/https only. `runMirrorAdd` | |
| 93 | (`internal/control/mirrorcmd.go:58`) calls `webhook.ValidateURL`, | |
| 94 | which refuses any other scheme (`internal/webhook/webhook.go:31-33`). | |
| 95 | There is no ssh mirror URL to pin. Sync (`internal/mirror/mirror.go:76-116`) | |
| 96 | runs `git fetch|push <url>` with a replaced environment (no proxy | |
| 97 | variables), so the pin is `-c http.curloptResolve=<host>:<port>:<addrs>` | |
| 98 | plus `-c http.followRedirects=false` (git's default follows a | |
| 99 | redirect on the first request, which would reach an unchecked | |
| 100 | host). Sync also refuses a non-http(s) scheme, for rows that | |
| 101 | predate the save-time check. | |
| 102 | - Hook environment is set in exactly one place, | |
| 103 | `runGit` (`internal/sshd/sshd.go:441-447`). `runGit` runs in the | |
| 104 | daemon (embedded SSH) and in `gitbayd shell` (system SSH mode, | |
| 105 | `cmd/gitbayd/system.go:97`), a separate process. The push token must | |
| 106 | therefore be visible across processes: it goes in SQLite, not in | |
| 107 | daemon memory. | |
| 108 | - In both SSH modes the hook runs as the uid git runs as, which is the | |
| 109 | daemon's (`User=gitbay`, `deploy/cloud-init.yaml:210`; system mode | |
| 110 | logs in as the account that owns `<root>`). So the peer-uid rule is | |
| 111 | "equal to `os.Getuid()`". | |
| 112 | - `audit_log.actor_id` is `REFERENCES users(id) ON DELETE SET NULL` | |
| 113 | (`0001_init.up.sql:186`). Hashing it would break the chain when an | |
| 114 | account is deleted, so migration 0070 adds `actor_ref`, the id as | |
| 115 | written, and the hash covers that. | |
| 116 | - Retention deletes the oldest audit rows (`internal/store/retention.go:75`). | |
| 117 | Verification therefore takes the first remaining chained row's | |
| 118 | `prev_hash` as given. | |
| 119 | - Audit rows are written from three kinds of process: the daemon, | |
| 120 | `gitbayd shell`, and `gitbayd admin …`. The chain is computed inside | |
| 121 | one `BEGIN IMMEDIATE` transaction (the store's DSN sets | |
| 122 | `_txlock=immediate`, `internal/store/store.go:47`), which serialises | |
| 123 | writers across processes. The journal line is written only where | |
| 124 | stderr is the journal: `gitbayd serve`. | |
| 125 | - Pack generation runs in four places: SSH `gitutil.Transport` | |
| 126 | (`internal/gitutil/gitutil.go:39`), smart-HTTP `uploadPack` | |
| 127 | (`internal/httpd/smart.go:122`), git:// (`internal/gitd/gitd.go:71-77`), | |
| 128 | and SSH `git-upload-archive`. The info/refs advertisement | |
| 129 | (`smart.go:89`) and protocol-v2 `ls-refs` POSTs are ref listings and | |
| 130 | stay outside the limit. Web archives are already bounded by | |
| 131 | `archiveTimeout` and `MaxArchiveBytes` (`internal/gitutil/read.go:124-130`) | |
| 132 | and stay outside. receive-pack stays outside: killing or queueing | |
| 133 | it risks losing post-receive, which runs after the client has its | |
| 134 | report. | |
| 135 | - `done` in `handleSession` (`internal/sshd/sshd.go:263-270`) closes | |
| 136 | when the client closes the channel *or* the server stops. A queued | |
| 137 | clone should give up on either; a running clone should be killed | |
| 138 | only when the client left, never on a restart drain. | |
| 139 | - Dispatcher tests call `Dispatch` with a nil `Store` and expect | |
| 140 | refusals (`internal/control/control_test.go:192-231`). Auditing a | |
| 141 | refusal must tolerate a nil store. | |
| 142 | ||
| 143 | ## Order and dependencies | |
| 144 | ||
| 145 | | # | Branch | Closes | Migration | Depends on | | |
| 146 | |---|---|---|---|---| | |
| 147 | | 1 | `https-tls-minimum` | #281 | — | — | | |
| 148 | | 2 | `mail-require-tls` | #280 | — | — | | |
| 149 | | 3 | `mirror-pin-address` | #279 | — | — | | |
| 150 | | 4 | `hook-socket-auth` | #282 | 0069 | — | | |
| 151 | | 5 | `audit-refusals-chain` | #275 | 0070 | — | | |
| 152 | | 6 | `pack-limit` | #262 | — | MR 5 (both edit `sshd.Exec`) | | |
| 153 | ||
| 154 | Cross-plan overlaps, all textual (rebase, no design dependency): | |
| 155 | ||
| 156 | - Plan 1 (credentials-and-sessions, #256) closes connections when a key | |
| 157 | is removed; it edits `internal/sshd/sshd.go`, as do MRs 4, 5 and 6. | |
| 158 | - Plan 4 (data-at-rest-and-backup, #273) decrypts `m.Token` in | |
| 159 | `internal/mirror/mirror.go`'s `sync`; MR 3 edits the same function. | |
| 160 | Whichever lands second keeps both: decryption of the token and the | |
| 161 | resolve-check-pin block. | |
| 162 | - Plan 5 (web-ux, #261 doc drift) may edit the `[limits]` section of | |
| 163 | `Admin.org`, which MR 6 also edits (its "reserved, not yet enforced" | |
| 164 | line for `max_pack_bytes`/`ssh_auth_rate` is stale; leave that line | |
| 165 | to #261 unless it has not landed when MR 6 merges). | |
| 166 | ||
| 167 | ## File map | |
| 168 | ||
| 169 | | File | MR | Responsibility | | |
| 170 | |---|---|---| | |
| 171 | | `cmd/gitbayd/main.go` | 1, 5, 6 | `serverTLS`; `st.AuditJournal`; `admin audit verify` wiring; one `packlimit.Limiter` | | |
| 172 | | `cmd/gitbayd/tls_test.go` | 1 | TLS floor | | |
| 173 | | `internal/config/config.go` | 2, 6 | `Mail.RequireTLS`, `Mail.TLS`, `Mail.TLSRequired`; `Limits.Pack*`, `PackLimits` | | |
| 174 | | `internal/config/config_test.go` | 2, 6 | validation and defaults | | |
| 175 | | `internal/mail/mail.go`, `mail_test.go` | 2 | require TLS, implicit TLS | | |
| 176 | | `internal/webhook/webhook.go` | 3 | `CheckAddrs` | | |
| 177 | | `internal/mirror/mirror.go`, `mirror_test.go` | 3 | resolve, check, pin | | |
| 178 | | `internal/store/migrations/0069_push_tokens.*.sql` | 4 | table | | |
| 179 | | `internal/store/pushtokens.go`, `pushtokens_test.go` | 4 | create, look up, delete | | |
| 180 | | `internal/store/retention.go` | 4 | sweep expired push tokens | | |
| 181 | | `internal/hookd/hookd.go`, `peercred_linux.go`, `peercred_other.go`, `socket_test.go` | 4 | 0600, peer uid, token check | | |
| 182 | | `internal/sshd/sshd.go` | 4, 5, 6 | mint token; audit refused push; acquire pack slot | | |
| 183 | | `cmd/gitbayd/hook.go` | 4 | send the token | | |
| 184 | | `internal/store/migrations/0070_audit_chain.*.sql` | 5 | chain columns | | |
| 185 | | `internal/store/audit.go`, `auditchain_test.go` | 5 | chained append, journal, verify | | |
| 186 | | `internal/control/control.go`, `auditrefusal.go`, `auditrefusal_test.go` | 5 | refusal auditing | | |
| 187 | | `cmd/gitbayd/auditverify.go` | 5 | `gitbayd admin audit verify` | | |
| 188 | | `internal/sshd/refusal_test.go` | 5, 6 | refused push audited; busy clone | | |
| 189 | | `e2e/audit_test.go` | 5 | `TestAuditChainVerify` | | |
| 190 | | `internal/packlimit/packlimit.go`, `packlimit_test.go` | 6 | the limiter | | |
| 191 | | `internal/gitutil/gitutil.go` | 6 | `Transport` takes a context | | |
| 192 | | `internal/httpd/smart.go` (`Server`, `New`, `uploadPack`), `packlimit_test.go` | 6 | limit on POST upload-pack, `ls-refs` outside | | |
| 193 | | `internal/gitd/gitd.go`, `gitd_test.go` | 6 | limit on git:// | | |
| 194 | | `cmd/gitbayd/system.go` | 5, 6 | `Exec` signature | | |
| 195 | | `deploy/clonebench.sh` | 6 | concurrent-clone benchmark | | |
| 196 | | `.gitbay/wiki/Admin.org`, `Performance.org`, `Threat-Model.org`, `Architecture/03-Deployment.org`, `04-Trust-Boundaries.org`, `06-Data-and-Cryptography.org`, `09-Controls.org`, `10-Known-Gaps.org` | 1–6 | docs per MR | | |
| 197 | ||
| 198 | --- | |
| 199 | ||
| 200 | # MR 1: explicit TLS minimum (branch `https-tls-minimum`, closes #281) | |
| 201 | ||
| 202 | ### Task 1.1: `serverTLS` sets TLS 1.2 on both TLS modes | |
| 203 | ||
| 204 | **Files:** | |
| 205 | - Create: `cmd/gitbayd/tls.go` | |
| 206 | - Create: `cmd/gitbayd/tls_test.go` | |
| 207 | - Modify: `cmd/gitbayd/main.go:241-242` (`case "files"`), `:301-302` (`case "acme"`) | |
| 208 | - Modify: `.gitbay/wiki/Admin.org` (`** [http]`, lines 72-80), | |
| 209 | `.gitbay/wiki/Architecture/06-Data-and-Cryptography.org` (HTTPS row, line 59), | |
| 210 | `.gitbay/wiki/Architecture/10-Known-Gaps.org` (#281 row) | |
| 211 | ||
| 212 | **Interfaces:** | |
| 213 | - Produces: `func serverTLS(c *tls.Config) *tls.Config` in package `main` — sets `MinVersion = tls.VersionTLS12` on `c` (a new config when nil) and returns it. | |
| 214 | ||
| 215 | - [ ] **Step 1: Write the failing test** | |
| 216 | ||
| 217 | `cmd/gitbayd/tls_test.go`: | |
| 218 | ||
| 219 | ```go | |
| 220 | package main | |
| 221 | ||
| 222 | import ( | |
| 223 | "crypto/tls" | |
| 224 | "testing" | |
| 225 | ) | |
| 226 | ||
| 227 | // The floor is stated in code rather than inherited from the Go | |
| 228 | // release the binary was built with (#281). | |
| 229 | func TestServerTLSMinimum(t *testing.T) { | |
| 230 | if got := serverTLS(nil).MinVersion; got != tls.VersionTLS12 { | |
| 231 | t.Fatalf("files mode: MinVersion %#x, want %#x", got, tls.VersionTLS12) | |
| 232 | } | |
| 233 | // autocert's config carries the ALPN protocols TLS-ALPN-01 needs; | |
| 234 | // setting the floor must keep them. | |
| 235 | base := &tls.Config{NextProtos: []string{"h2", "http/1.1", "acme-tls/1"}} | |
| 236 | got := serverTLS(base) | |
| 237 | if got.MinVersion != tls.VersionTLS12 || len(got.NextProtos) != 3 { | |
| 238 | t.Fatalf("acme mode: %+v", got) | |
| 239 | } | |
| 240 | } | |
| 241 | ``` | |
| 242 | ||
| 243 | - [ ] **Step 2: Run it and see it fail** | |
| 244 | ||
| 245 | Run: `go test ./cmd/gitbayd -run TestServerTLSMinimum -count=1` | |
| 246 | Expected: build failure, `undefined: serverTLS`. | |
| 247 | ||
| 248 | - [ ] **Step 3: Implement** | |
| 249 | ||
| 250 | `cmd/gitbayd/tls.go`: | |
| 251 | ||
| 252 | ```go | |
| 253 | package main | |
| 254 | ||
| 255 | import "crypto/tls" | |
| 256 | ||
| 257 | // serverTLS sets the HTTPS listener's protocol floor: TLS 1.2 and 1.3, | |
| 258 | // with Go's default cipher suites. | |
| 259 | func serverTLS(c *tls.Config) *tls.Config { | |
| 260 | if c == nil { | |
| 261 | c = &tls.Config{} | |
| 262 | } | |
| 263 | c.MinVersion = tls.VersionTLS12 | |
| 264 | return c | |
| 265 | } | |
| 266 | ``` | |
| 267 | ||
| 268 | In `cmd/gitbayd/main.go`, `case "files":` becomes: | |
| 269 | ||
| 270 | ```go | |
| 271 | case "files": | |
| 272 | hs.TLSConfig = serverTLS(nil) | |
| 273 | errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile) | |
| 274 | ``` | |
| 275 | ||
| 276 | and in `case "acme":` replace `hs.TLSConfig = m.TLSConfig()` with: | |
| 277 | ||
| 278 | ```go | |
| 279 | hs.TLSConfig = serverTLS(m.TLSConfig()) | |
| 280 | ``` | |
| 281 | ||
| 282 | `ListenAndServeTLS` clones `TLSConfig` and loads the certificate files | |
| 283 | into the clone, so setting it in files mode changes nothing else. | |
| 284 | ||
| 285 | - [ ] **Step 4: Run the test and the package** | |
| 286 | ||
| 287 | Run: `go test ./cmd/gitbayd -count=1 && go vet ./cmd/gitbayd` | |
| 288 | Expected: PASS. | |
| 289 | ||
| 290 | - [ ] **Step 5: Docs** | |
| 291 | ||
| 292 | `Admin.org`, append to the `** [http]` bullet list, after the `=off=` bullet: | |
| 293 | ||
| 294 | ```org | |
| 295 | - The HTTPS listener accepts TLS 1.2 and 1.3 only (=serverTLS= in | |
| 296 | =cmd/gitbayd/tls.go=), with the default cipher suites of the Go | |
| 297 | release the binary was built with. =openssl s_client -connect | |
| 298 | <host>:443 -tls1_1= fails the handshake. | |
| 299 | ``` | |
| 300 | ||
| 301 | `Architecture/06-Data-and-Cryptography.org`, HTTPS row: | |
| 302 | ||
| 303 | ```org | |
| 304 | | HTTPS | TLS 1.2 minimum (=cmd/gitbayd/tls.go=), ACME or operator certificates; HSTS one year | | |
| 305 | ``` | |
| 306 | ||
| 307 | `Architecture/10-Known-Gaps.org`: delete the `#281` row. | |
| 308 | ||
| 309 | - [ ] **Step 6: Commit** | |
| 310 | ||
| 311 | ```bash | |
| 312 | git add cmd/gitbayd/tls.go cmd/gitbayd/tls_test.go cmd/gitbayd/main.go \ | |
| 313 | .gitbay/wiki/Admin.org .gitbay/wiki/Architecture/06-Data-and-Cryptography.org \ | |
| 314 | .gitbay/wiki/Architecture/10-Known-Gaps.org | |
| 315 | git commit -S -m "https: TLS 1.2 minimum, set explicitly | |
| 316 | ||
| 317 | Closes #281" | |
| 318 | ``` | |
| 319 | ||
| 320 | - [ ] **Step 7: MR and merge** | |
| 321 | ||
| 322 | ```bash | |
| 323 | git push -u origin https-tls-minimum | |
| 324 | gitbay mr create --source https-tls-minimum --target main --title "https: TLS 1.2 minimum, set explicitly" | |
| 325 | ``` | |
| 326 | ||
| 327 | After CI is green: `gitbay mr merge <n> --strategy ff`, then | |
| 328 | `git branch -d https-tls-minimum && git push origin --delete https-tls-minimum`. | |
| 329 | ||
| 330 | --- | |
| 331 | ||
| 332 | # MR 2: mail requires TLS to a remote relay (branch `mail-require-tls`, closes #280) | |
| 333 | ||
| 334 | ### Task 2.1: config `mail.require_tls` and `mail.tls` | |
| 335 | ||
| 336 | **Files:** | |
| 337 | - Modify: `internal/config/config.go:211-216` (`Mail`), `Validate` (after the `[mail] from` check, line 406-408) | |
| 338 | - Test: `internal/config/config_test.go` | |
| 339 | ||
| 340 | **Interfaces:** | |
| 341 | - Produces: `Mail.RequireTLS *bool` (`toml:"require_tls,omitempty"`), `Mail.TLS string` (`toml:"tls,omitempty"`, `""`/`"starttls"`/`"implicit"`), `func (m Mail) TLSRequired() bool`. | |
| 342 | ||
| 343 | - [ ] **Step 1: Write the failing tests** | |
| 344 | ||
| 345 | Append to `internal/config/config_test.go`: | |
| 346 | ||
| 347 | ```go | |
| 348 | func TestMailTLSRequired(t *testing.T) { | |
| 349 | off, on := false, true | |
| 350 | for _, tc := range []struct { | |
| 351 | m Mail | |
| 352 | want bool | |
| 353 | }{ | |
| 354 | {Mail{SMTPHost: "smtp.example.com:587"}, true}, | |
| 355 | {Mail{SMTPHost: "smtp.example.com"}, true}, | |
| 356 | {Mail{SMTPHost: "localhost:25"}, false}, | |
| 357 | {Mail{SMTPHost: "localhost"}, false}, | |
| 358 | {Mail{SMTPHost: "127.0.0.1:25"}, false}, | |
| 359 | {Mail{SMTPHost: "[::1]:25"}, false}, | |
| 360 | {Mail{SMTPHost: "smtp.example.com:587", RequireTLS: &off}, false}, | |
| 361 | {Mail{SMTPHost: "127.0.0.1:25", RequireTLS: &on}, true}, | |
| 362 | } { | |
| 363 | if got := tc.m.TLSRequired(); got != tc.want { | |
| 364 | t.Errorf("%+v: TLSRequired = %v, want %v", tc.m, got, tc.want) | |
| 365 | } | |
| 366 | } | |
| 367 | } | |
| 368 | ``` | |
| 369 | ||
| 370 | Add one case to the `cases` table in `TestContradictions`: | |
| 371 | ||
| 372 | ```go | |
| 373 | { | |
| 374 | "unknown mail.tls", | |
| 375 | minimal + "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\ntls = \"ssl\"\n", | |
| 376 | "mail.tls must be starttls or implicit", | |
| 377 | }, | |
| 378 | ``` | |
| 379 | ||
| 380 | - [ ] **Step 2: Run them and see them fail** | |
| 381 | ||
| 382 | Run: `go test ./internal/config -run 'TestMailTLSRequired|TestContradictions' -count=1` | |
| 383 | Expected: build failure, `unknown field RequireTLS` / `TLSRequired undefined`. | |
| 384 | ||
| 385 | - [ ] **Step 3: Implement** | |
| 386 | ||
| 387 | `Mail` in `internal/config/config.go`: | |
| 388 | ||
| 389 | ```go | |
| 390 | type Mail struct { | |
| 391 | SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587, 465 with tls = "implicit") | |
| 392 | From string `toml:"from"` | |
| 393 | SMTPUser string `toml:"smtp_user,omitempty"` | |
| 394 | SMTPPass string `toml:"smtp_pass,omitempty"` | |
| 395 | // RequireTLS fails delivery when the relay does not offer STARTTLS, | |
| 396 | // instead of sending in clear. Unset, it is on for any relay but | |
| 397 | // localhost or a loopback address (TLSRequired). | |
| 398 | RequireTLS *bool `toml:"require_tls,omitempty"` | |
| 399 | // TLS is "starttls" (the default, also when empty) or "implicit": | |
| 400 | // TLS from the first byte, as relays on port 465 expect. | |
| 401 | TLS string `toml:"tls,omitempty"` | |
| 402 | } | |
| 403 | ||
| 404 | // TLSRequired reports whether mail must not go to the relay in clear. | |
| 405 | func (m Mail) TLSRequired() bool { | |
| 406 | if m.RequireTLS != nil { | |
| 407 | return *m.RequireTLS | |
| 408 | } | |
| 409 | host := m.SMTPHost | |
| 410 | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 411 | host = h | |
| 412 | } | |
| 413 | host = strings.Trim(host, "[]") | |
| 414 | if host == "localhost" { | |
| 415 | return false | |
| 416 | } | |
| 417 | ip := net.ParseIP(host) | |
| 418 | return ip == nil || !ip.IsLoopback() | |
| 419 | } | |
| 420 | ``` | |
| 421 | ||
| 422 | In `Validate`, after the `[mail] from is required` check: | |
| 423 | ||
| 424 | ```go | |
| 425 | if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" { | |
| 426 | errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t)) | |
| 427 | } | |
| 428 | ``` | |
| 429 | ||
| 430 | - [ ] **Step 4: Run the package** | |
| 431 | ||
| 432 | Run: `go test ./internal/config -count=1` | |
| 433 | Expected: PASS. | |
| 434 | ||
| 435 | - [ ] **Step 5: Commit** | |
| 436 | ||
| 437 | ```bash | |
| 438 | git add internal/config/config.go internal/config/config_test.go | |
| 439 | git commit -S -m "config: mail.require_tls and mail.tls | |
| 440 | ||
| 441 | Ref #280" | |
| 442 | ``` | |
| 443 | ||
| 444 | ### Task 2.2: `mail.Send` refuses plaintext when TLS is required; implicit TLS | |
| 445 | ||
| 446 | **Files:** | |
| 447 | - Modify: `internal/mail/mail.go` (whole `Send`, package comment lines 1-3) | |
| 448 | - Create: `internal/mail/mail_test.go` | |
| 449 | ||
| 450 | **Interfaces:** | |
| 451 | - Consumes: `config.Mail.TLSRequired()`, `config.Mail.TLS` (Task 2.1). | |
| 452 | - Produces: unexported `var rootCAs *x509.CertPool` (tests set it); `Send` signature unchanged. | |
| 453 | ||
| 454 | - [ ] **Step 1: Write the failing tests** | |
| 455 | ||
| 456 | `internal/mail/mail_test.go`: | |
| 457 | ||
| 458 | ```go | |
| 459 | package mail | |
| 460 | ||
| 461 | import ( | |
| 462 | "bufio" | |
| 463 | "crypto/tls" | |
| 464 | "crypto/x509" | |
| 465 | "fmt" | |
| 466 | "net" | |
| 467 | "net/http" | |
| 468 | "net/http/httptest" | |
| 469 | "strings" | |
| 470 | "sync" | |
| 471 | "testing" | |
| 472 | ||
| 473 | "gitbay.org/gitbay/internal/config" | |
| 474 | ) | |
| 475 | ||
| 476 | // fakeRelay is an SMTP server that never offers STARTTLS. Given a TLS | |
| 477 | // config it speaks TLS from the first byte, as a port-465 relay does. | |
| 478 | type fakeRelay struct { | |
| 479 | addr string | |
| 480 | mu sync.Mutex | |
| 481 | data []string | |
| 482 | } | |
| 483 | ||
| 484 | func startRelay(t *testing.T, tlsCfg *tls.Config) *fakeRelay { | |
| 485 | t.Helper() | |
| 486 | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 487 | if err != nil { | |
| 488 | t.Fatal(err) | |
| 489 | } | |
| 490 | if tlsCfg != nil { | |
| 491 | ln = tls.NewListener(ln, tlsCfg) | |
| 492 | } | |
| 493 | t.Cleanup(func() { ln.Close() }) | |
| 494 | f := &fakeRelay{addr: ln.Addr().String()} | |
| 495 | go func() { | |
| 496 | for { | |
| 497 | conn, err := ln.Accept() | |
| 498 | if err != nil { | |
| 499 | return | |
| 500 | } | |
| 501 | go f.serve(conn) | |
| 502 | } | |
| 503 | }() | |
| 504 | return f | |
| 505 | } | |
| 506 | ||
| 507 | func (f *fakeRelay) serve(conn net.Conn) { | |
| 508 | defer conn.Close() | |
| 509 | r := bufio.NewReader(conn) | |
| 510 | fmt.Fprint(conn, "220 fake\r\n") | |
| 511 | var body strings.Builder | |
| 512 | inData := false | |
| 513 | for { | |
| 514 | line, err := r.ReadString('\n') | |
| 515 | if err != nil { | |
| 516 | return | |
| 517 | } | |
| 518 | line = strings.TrimRight(line, "\r\n") | |
| 519 | switch { | |
| 520 | case inData && line == ".": | |
| 521 | f.mu.Lock() | |
| 522 | f.data = append(f.data, body.String()) | |
| 523 | f.mu.Unlock() | |
| 524 | inData = false | |
| 525 | fmt.Fprint(conn, "250 ok\r\n") | |
| 526 | case inData: | |
| 527 | body.WriteString(line + "\n") | |
| 528 | case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): | |
| 529 | fmt.Fprint(conn, "250-fake\r\n250 SIZE 1000000\r\n") | |
| 530 | case line == "DATA": | |
| 531 | inData = true | |
| 532 | fmt.Fprint(conn, "354 go\r\n") | |
| 533 | case line == "QUIT": | |
| 534 | fmt.Fprint(conn, "221 bye\r\n") | |
| 535 | return | |
| 536 | default: | |
| 537 | fmt.Fprint(conn, "250 ok\r\n") | |
| 538 | } | |
| 539 | } | |
| 540 | } | |
| 541 | ||
| 542 | func (f *fakeRelay) delivered() int { | |
| 543 | f.mu.Lock() | |
| 544 | defer f.mu.Unlock() | |
| 545 | return len(f.data) | |
| 546 | } | |
| 547 | ||
| 548 | func mailCfg(host string) config.Config { | |
| 549 | var cfg config.Config | |
| 550 | cfg.Mail.SMTPHost, cfg.Mail.From = host, "gitbay@example.test" | |
| 551 | return cfg | |
| 552 | } | |
| 553 | ||
| 554 | func TestRequireTLSRefusesPlaintextRelay(t *testing.T) { | |
| 555 | relay := startRelay(t, nil) | |
| 556 | cfg := mailCfg(relay.addr) | |
| 557 | on := true | |
| 558 | cfg.Mail.RequireTLS = &on | |
| 559 | err := Send(cfg, "a@example.test", "subject", "body") | |
| 560 | if err == nil || !strings.Contains(err.Error(), "STARTTLS") { | |
| 561 | t.Fatalf("Send = %v, want a refusal naming STARTTLS", err) | |
| 562 | } | |
| 563 | if n := relay.delivered(); n != 0 { | |
| 564 | t.Fatalf("%d message(s) sent in clear", n) | |
| 565 | } | |
| 566 | } | |
| 567 | ||
| 568 | // A loopback relay has no network to cross; the default leaves it in | |
| 569 | // clear, which is what the e2e suite's fake relay relies on. | |
| 570 | func TestLoopbackRelayDefaultsToPlaintext(t *testing.T) { | |
| 571 | relay := startRelay(t, nil) | |
| 572 | if err := Send(mailCfg(relay.addr), "a@example.test", "subject", "body"); err != nil { | |
| 573 | t.Fatal(err) | |
| 574 | } | |
| 575 | if n := relay.delivered(); n != 1 { | |
| 576 | t.Fatalf("delivered %d, want 1", n) | |
| 577 | } | |
| 578 | } | |
| 579 | ||
| 580 | func TestImplicitTLS(t *testing.T) { | |
| 581 | ts := httptest.NewTLSServer(http.NotFoundHandler()) | |
| 582 | defer ts.Close() | |
| 583 | pool := x509.NewCertPool() | |
| 584 | pool.AddCert(ts.Certificate()) | |
| 585 | prev := rootCAs | |
| 586 | rootCAs = pool | |
| 587 | defer func() { rootCAs = prev }() | |
| 588 | ||
| 589 | relay := startRelay(t, &tls.Config{Certificates: ts.TLS.Certificates}) | |
| 590 | cfg := mailCfg(relay.addr) | |
| 591 | cfg.Mail.TLS = "implicit" | |
| 592 | on := true | |
| 593 | cfg.Mail.RequireTLS = &on | |
| 594 | if err := Send(cfg, "a@example.test", "subject", "body"); err != nil { | |
| 595 | t.Fatal(err) | |
| 596 | } | |
| 597 | if n := relay.delivered(); n != 1 { | |
| 598 | t.Fatalf("delivered %d, want 1", n) | |
| 599 | } | |
| 600 | } | |
| 601 | ``` | |
| 602 | ||
| 603 | `httptest`'s certificate carries `127.0.0.1` as an IP SAN, so | |
| 604 | verification against `ServerName: "127.0.0.1"` passes. | |
| 605 | ||
| 606 | - [ ] **Step 2: Run them and see them fail** | |
| 607 | ||
| 608 | Run: `go test ./internal/mail -count=1` | |
| 609 | Expected: build failure, `undefined: rootCAs`. | |
| 610 | ||
| 611 | - [ ] **Step 3: Implement** | |
| 612 | ||
| 613 | `internal/mail/mail.go`: | |
| 614 | ||
| 615 | ```go | |
| 616 | // Package mail sends transactional email over SMTP: verification codes and | |
| 617 | // invites. The connection is encrypted with STARTTLS, or with TLS from the | |
| 618 | // first byte when mail.tls = "implicit"; a relay that offers neither gets | |
| 619 | // nothing unless mail.require_tls is off. PLAIN auth when credentials are | |
| 620 | // configured. | |
| 621 | package mail | |
| 622 | ||
| 623 | import ( | |
| 624 | "crypto/tls" | |
| 625 | "crypto/x509" | |
| 626 | "fmt" | |
| 627 | "net" | |
| 628 | "net/smtp" | |
| 629 | "strings" | |
| 630 | "time" | |
| 631 | ||
| 632 | "gitbay.org/gitbay/internal/config" | |
| 633 | ) | |
| 634 | ||
| 635 | // rootCAs verifies the relay's certificate; nil is the system pool. | |
| 636 | var rootCAs *x509.CertPool | |
| 637 | ||
| 638 | // Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port. | |
| 639 | func Send(cfg config.Config, to, subject, body string) error { | |
| 640 | m := cfg.Mail | |
| 641 | if m.SMTPHost == "" || m.From == "" { | |
| 642 | return fmt.Errorf("[mail] smtp_host and from must be configured") | |
| 643 | } | |
| 644 | implicit := m.TLS == "implicit" | |
| 645 | host := m.SMTPHost | |
| 646 | if !strings.Contains(host, ":") { | |
| 647 | if implicit { | |
| 648 | host += ":465" | |
| 649 | } else { | |
| 650 | host += ":587" | |
| 651 | } | |
| 652 | } | |
| 653 | hostname, _, _ := net.SplitHostPort(host) | |
| 654 | tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs} | |
| 655 | ||
| 656 | msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf( | |
| 657 | "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n", | |
| 658 | m.From, to, subject, time.Now().Format(time.RFC1123Z), body)) | |
| 659 | ||
| 660 | c, err := dial(host, hostname, implicit, tlsCfg) | |
| 661 | if err != nil { | |
| 662 | return fmt.Errorf("smtp dial %s: %w", host, err) | |
| 663 | } | |
| 664 | defer c.Close() | |
| 665 | if !implicit { | |
| 666 | if ok, _ := c.Extension("STARTTLS"); ok { | |
| 667 | if err := c.StartTLS(tlsCfg); err != nil { | |
| 668 | return fmt.Errorf("starttls: %w", err) | |
| 669 | } | |
| 670 | } else if m.TLSRequired() { | |
| 671 | return fmt.Errorf("%s does not offer STARTTLS and mail.require_tls is on; not sending in clear", host) | |
| 672 | } | |
| 673 | } | |
| 674 | if m.SMTPUser != "" { | |
| 675 | if err := c.Auth(smtp.PlainAuth("", m.SMTPUser, m.SMTPPass, hostname)); err != nil { | |
| 676 | return fmt.Errorf("smtp auth: %w", err) | |
| 677 | } | |
| 678 | } | |
| 679 | if err := c.Mail(m.From); err != nil { | |
| 680 | return err | |
| 681 | } | |
| 682 | if err := c.Rcpt(to); err != nil { | |
| 683 | return err | |
| 684 | } | |
| 685 | w, err := c.Data() | |
| 686 | if err != nil { | |
| 687 | return err | |
| 688 | } | |
| 689 | if _, err := w.Write([]byte(msg)); err != nil { | |
| 690 | return err | |
| 691 | } | |
| 692 | if err := w.Close(); err != nil { | |
| 693 | return err | |
| 694 | } | |
| 695 | return c.Quit() | |
| 696 | } | |
| 697 | ||
| 698 | // dial opens the SMTP session: plain TCP for STARTTLS, or TLS from the | |
| 699 | // first byte. | |
| 700 | func dial(addr, hostname string, implicit bool, tlsCfg *tls.Config) (*smtp.Client, error) { | |
| 701 | if !implicit { | |
| 702 | return smtp.Dial(addr) | |
| 703 | } | |
| 704 | conn, err := tls.Dial("tcp", addr, tlsCfg) | |
| 705 | if err != nil { | |
| 706 | return nil, err | |
| 707 | } | |
| 708 | c, err := smtp.NewClient(conn, hostname) | |
| 709 | if err != nil { | |
| 710 | conn.Close() | |
| 711 | return nil, err | |
| 712 | } | |
| 713 | return c, nil | |
| 714 | } | |
| 715 | ``` | |
| 716 | ||
| 717 | - [ ] **Step 4: Run the package** | |
| 718 | ||
| 719 | Run: `go test ./internal/mail ./internal/config -count=1 && go vet ./internal/mail` | |
| 720 | Expected: PASS. | |
| 721 | ||
| 722 | - [ ] **Step 5: Docs** | |
| 723 | ||
| 724 | `Admin.org`, `** [mail]` becomes: | |
| 725 | ||
| 726 | ```org | |
| 727 | ** [mail] | |
| 728 | - =smtp_host= (host:port; 587 assumed, 465 with =tls = "implicit"=), | |
| 729 | =from=, optional =smtp_user= / =smtp_pass=. Required for invite/open | |
| 730 | registration and self-service =email add=; in closed mode you may omit | |
| 731 | it entirely and assert addresses by hand (below). | |
| 732 | - =tls= — =starttls= (default) or =implicit= (TLS from the first byte, | |
| 733 | for relays on 465). | |
| 734 | - =require_tls= — with =starttls=, a relay that does not offer STARTTLS | |
| 735 | gets no mail: delivery fails and retries, and the admin page's Mail | |
| 736 | table shows the error. Unset, it is on for every relay except | |
| 737 | =localhost= and loopback addresses; set =false= to allow plaintext | |
| 738 | to a remote relay. | |
| 739 | ``` | |
| 740 | ||
| 741 | `Architecture/03-Deployment.org`, SMTP relay row: | |
| 742 | ||
| 743 | ```org | |
| 744 | | SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) | | |
| 745 | ``` | |
| 746 | ||
| 747 | `Architecture/06-Data-and-Cryptography.org`, SMTP row: | |
| 748 | ||
| 749 | ```org | |
| 750 | | SMTP | STARTTLS required unless the relay is local (=mail.require_tls=), or implicit TLS (=mail.tls=) | | |
| 751 | ``` | |
| 752 | ||
| 753 | `Architecture/09-Controls.org`, "SMTP credentials protected in transit" row: | |
| 754 | ||
| 755 | ```org | |
| 756 | | SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) | | |
| 757 | ``` | |
| 758 | ||
| 759 | `Architecture/10-Known-Gaps.org`: delete the `#280` row. | |
| 760 | ||
| 761 | - [ ] **Step 6: Commit, MR, merge** | |
| 762 | ||
| 763 | ```bash | |
| 764 | git add internal/mail .gitbay/wiki/Admin.org .gitbay/wiki/Architecture/03-Deployment.org \ | |
| 765 | .gitbay/wiki/Architecture/06-Data-and-Cryptography.org \ | |
| 766 | .gitbay/wiki/Architecture/09-Controls.org .gitbay/wiki/Architecture/10-Known-Gaps.org | |
| 767 | git commit -S -m "mail: require TLS to a non-local relay; implicit TLS option | |
| 768 | ||
| 769 | Closes #280" | |
| 770 | git push -u origin mail-require-tls | |
| 771 | gitbay mr create --source mail-require-tls --target main --title "mail: require TLS to a non-local relay" | |
| 772 | ``` | |
| 773 | ||
| 774 | Before merging, run the runbook's #280 check (bay1's relay must offer | |
| 775 | STARTTLS or be local). Merge `--strategy ff` after CI, delete the branch | |
| 776 | both places. | |
| 777 | ||
| 778 | --- | |
| 779 | ||
| 780 | # MR 3: mirrors connect only to an address checked at sync time (branch `mirror-pin-address`, closes #279) | |
| 781 | ||
| 782 | ### Task 3.1: `webhook.CheckAddrs` | |
| 783 | ||
| 784 | **Files:** | |
| 785 | - Modify: `internal/webhook/webhook.go` (add after `isForbidden`, line 55) | |
| 786 | - Create: `internal/webhook/webhook_test.go` | |
| 787 | ||
| 788 | **Interfaces:** | |
| 789 | - Produces: `func CheckAddrs(host string, ips []net.IP, allowLocal bool) error`. | |
| 790 | ||
| 791 | - [ ] **Step 1: Write the failing test** | |
| 792 | ||
| 793 | ```go | |
| 794 | package webhook | |
| 795 | ||
| 796 | import ( | |
| 797 | "net" | |
| 798 | "strings" | |
| 799 | "testing" | |
| 800 | ) | |
| 801 | ||
| 802 | func TestCheckAddrs(t *testing.T) { | |
| 803 | public := []net.IP{net.ParseIP("203.0.113.5")} | |
| 804 | mixed := []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("10.1.2.3")} | |
| 805 | if err := CheckAddrs("git.example", public, false); err != nil { | |
| 806 | t.Fatalf("public: %v", err) | |
| 807 | } | |
| 808 | if err := CheckAddrs("git.example", mixed, false); err == nil || !strings.Contains(err.Error(), "10.1.2.3") { | |
| 809 | t.Fatalf("mixed: %v", err) | |
| 810 | } | |
| 811 | if err := CheckAddrs("git.example", mixed, true); err != nil { | |
| 812 | t.Fatalf("allow_local: %v", err) | |
| 813 | } | |
| 814 | } | |
| 815 | ``` | |
| 816 | ||
| 817 | - [ ] **Step 2: Run it and see it fail** | |
| 818 | ||
| 819 | Run: `go test ./internal/webhook -run TestCheckAddrs -count=1` | |
| 820 | Expected: `undefined: CheckAddrs`. | |
| 821 | ||
| 822 | - [ ] **Step 3: Implement** | |
| 823 | ||
| 824 | ```go | |
| 825 | // CheckAddrs refuses host when any of its resolved addresses is | |
| 826 | // loopback, private or link-local, unless allowLocal. A caller resolves | |
| 827 | // immediately before connecting and connects only to the addresses it | |
| 828 | // checked. | |
| 829 | func CheckAddrs(host string, ips []net.IP, allowLocal bool) error { | |
| 830 | if allowLocal { | |
| 831 | return nil | |
| 832 | } | |
| 833 | for _, ip := range ips { | |
| 834 | if isForbidden(ip) { | |
| 835 | return fmt.Errorf("%s resolves to private or local address %s; refusing (SSRF)", host, ip) | |
| 836 | } | |
| 837 | } | |
| 838 | return nil | |
| 839 | } | |
| 840 | ``` | |
| 841 | ||
| 842 | - [ ] **Step 4: Run it** | |
| 843 | ||
| 844 | Run: `go test ./internal/webhook -count=1` | |
| 845 | Expected: PASS. | |
| 846 | ||
| 847 | - [ ] **Step 5: Commit** | |
| 848 | ||
| 849 | ```bash | |
| 850 | git add internal/webhook | |
| 851 | git commit -S -m "webhook: CheckAddrs for callers that resolve before connecting | |
| 852 | ||
| 853 | Ref #279" | |
| 854 | ``` | |
| 855 | ||
| 856 | ### Task 3.2: mirror sync resolves, checks and pins | |
| 857 | ||
| 858 | **Files:** | |
| 859 | - Modify: `internal/mirror/mirror.go:30-44` (`Worker`, `New`), `:76-116` (`sync`) | |
| 860 | - Create: `internal/mirror/mirror_test.go` | |
| 861 | ||
| 862 | **Interfaces:** | |
| 863 | - Consumes: `webhook.CheckAddrs` (Task 3.1). | |
| 864 | - Produces: `Worker.Lookup func(ctx context.Context, host string) ([]net.IP, error)` (set by `New`); unexported `pinArgs(u *url.URL, ips []net.IP) []string`. | |
| 865 | ||
| 866 | - [ ] **Step 1: Write the failing tests** | |
| 867 | ||
| 868 | `internal/mirror/mirror_test.go`: | |
| 869 | ||
| 870 | ```go | |
| 871 | package mirror | |
| 872 | ||
| 873 | import ( | |
| 874 | "context" | |
| 875 | "net" | |
| 876 | "net/http/cgi" | |
| 877 | "net/http/httptest" | |
| 878 | "net/url" | |
| 879 | "os" | |
| 880 | "os/exec" | |
| 881 | "path/filepath" | |
| 882 | "slices" | |
| 883 | "strings" | |
| 884 | "testing" | |
| 885 | ||
| 886 | "gitbay.org/gitbay/internal/config" | |
| 887 | "gitbay.org/gitbay/internal/control" | |
| 888 | "gitbay.org/gitbay/internal/store" | |
| 889 | ) | |
| 890 | ||
| 891 | func git(t *testing.T, dir string, args ...string) string { | |
| 892 | t.Helper() | |
| 893 | cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) | |
| 894 | cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+t.TempDir(), | |
| 895 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test", | |
| 896 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test") | |
| 897 | out, err := cmd.CombinedOutput() | |
| 898 | if err != nil { | |
| 899 | t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 900 | } | |
| 901 | return strings.TrimSpace(string(out)) | |
| 902 | } | |
| 903 | ||
| 904 | // upstream serves a bare repository with one commit on main over smart | |
| 905 | // HTTP and returns its URL and that commit. | |
| 906 | func upstream(t *testing.T) (string, string) { | |
| 907 | t.Helper() | |
| 908 | parent := t.TempDir() | |
| 909 | bare := filepath.Join(parent, "remote.git") | |
| 910 | work := filepath.Join(parent, "work") | |
| 911 | git(t, parent, "init", "-q", "--bare", "--initial-branch=main", bare) | |
| 912 | git(t, parent, "init", "-q", "--initial-branch=main", work) | |
| 913 | git(t, work, "commit", "-q", "--allow-empty", "-m", "one") | |
| 914 | git(t, work, "push", "-q", bare, "main") | |
| 915 | sha := git(t, work, "rev-parse", "HEAD") | |
| 916 | execPath := git(t, parent, "--exec-path") | |
| 917 | srv := httptest.NewServer(&cgi.Handler{ | |
| 918 | Path: filepath.Join(execPath, "git-http-backend"), | |
| 919 | Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"}, | |
| 920 | }) | |
| 921 | t.Cleanup(srv.Close) | |
| 922 | return srv.URL + "/remote.git", sha | |
| 923 | } | |
| 924 | ||
| 925 | // local returns a store with alice/app, its bare repository under root, | |
| 926 | // and the pull mirror row for url. | |
| 927 | func local(t *testing.T, root, mirrorURL string) (*store.Store, store.Mirror, string) { | |
| 928 | t.Helper() | |
| 929 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 930 | if err != nil { | |
| 931 | t.Fatal(err) | |
| 932 | } | |
| 933 | t.Cleanup(func() { st.Close() }) | |
| 934 | if err := st.MigrateUp(); err != nil { | |
| 935 | t.Fatal(err) | |
| 936 | } | |
| 937 | uid, err := st.CreateUser("alice", false) | |
| 938 | if err != nil { | |
| 939 | t.Fatal(err) | |
| 940 | } | |
| 941 | repoID, err := st.CreateRepo("user", uid, "app", "public") | |
| 942 | if err != nil { | |
| 943 | t.Fatal(err) | |
| 944 | } | |
| 945 | dir := control.RepoDir(root, "alice", "app") | |
| 946 | os.MkdirAll(filepath.Dir(dir), 0o755) | |
| 947 | git(t, root, "init", "-q", "--bare", dir) | |
| 948 | if _, err := st.AddMirror(repoID, "pull", mirrorURL, "", ""); err != nil { | |
| 949 | t.Fatal(err) | |
| 950 | } | |
| 951 | due, err := st.DueMirrors(900) | |
| 952 | if err != nil || len(due) != 1 { | |
| 953 | t.Fatalf("due mirrors: %v %v", due, err) | |
| 954 | } | |
| 955 | return st, due[0], dir | |
| 956 | } | |
| 957 | ||
| 958 | // mirror.test does not resolve; the fetch works only because git was | |
| 959 | // pinned to the address the worker looked up and checked. | |
| 960 | func TestSyncConnectsToTheCheckedAddress(t *testing.T) { | |
| 961 | remote, sha := upstream(t) | |
| 962 | u, _ := url.Parse(remote) | |
| 963 | root := t.TempDir() | |
| 964 | st, m, dir := local(t, root, "http://mirror.test:"+u.Port()+"/remote.git") | |
| 965 | var cfg config.Config | |
| 966 | cfg.Server.Root = root | |
| 967 | cfg.Webhooks.AllowLocal = true | |
| 968 | var asked []string | |
| 969 | w := &Worker{St: st, Cfg: cfg, Lookup: func(ctx context.Context, host string) ([]net.IP, error) { | |
| 970 | asked = append(asked, host) | |
| 971 | return []net.IP{net.ParseIP("127.0.0.1")}, nil | |
| 972 | }} | |
| 973 | if err := w.sync(m); err != nil { | |
| 974 | t.Fatal(err) | |
| 975 | } | |
| 976 | if got := git(t, dir, "rev-parse", "refs/heads/main"); got != sha { | |
| 977 | t.Fatalf("main = %s, want %s", got, sha) | |
| 978 | } | |
| 979 | if !slices.Equal(asked, []string{"mirror.test"}) { | |
| 980 | t.Fatalf("looked up %v", asked) | |
| 981 | } | |
| 982 | } | |
| 983 | ||
| 984 | // The URL passed the check when it was saved; the answer at sync time | |
| 985 | // is what counts. | |
| 986 | func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) { | |
| 987 | root := t.TempDir() | |
| 988 | st, m, _ := local(t, root, "https://mirror.test/x.git") | |
| 989 | var cfg config.Config | |
| 990 | cfg.Server.Root = root | |
| 991 | w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) { | |
| 992 | return []net.IP{net.ParseIP("10.0.0.7")}, nil | |
| 993 | }} | |
| 994 | err := w.sync(m) | |
| 995 | if err == nil || !strings.Contains(err.Error(), "10.0.0.7") { | |
| 996 | t.Fatalf("sync = %v, want a refusal naming 10.0.0.7", err) | |
| 997 | } | |
| 998 | } | |
| 999 | ||
| 1000 | func TestPinArgs(t *testing.T) { | |
| 1001 | u, _ := url.Parse("https://git.example/x.git") | |
| 1002 | got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")}) | |
| 1003 | want := []string{"-c", "http.followRedirects=false", | |
| 1004 | "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"} | |
| 1005 | if !slices.Equal(got, want) { | |
| 1006 | t.Fatalf("https: %q", got) | |
| 1007 | } | |
| 1008 | u, _ = url.Parse("http://git.example:8080/x.git") | |
| 1009 | if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" { | |
| 1010 | t.Fatalf("http with port: %q", got) | |
| 1011 | } | |
| 1012 | // An address literal is its own resolution; there is nothing to pin. | |
| 1013 | u, _ = url.Parse("https://203.0.113.5/x.git") | |
| 1014 | if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) { | |
| 1015 | t.Fatalf("literal: %q", got) | |
| 1016 | } | |
| 1017 | } | |
| 1018 | ``` | |
| 1019 | ||
| 1020 | - [ ] **Step 2: Run them and see them fail** | |
| 1021 | ||
| 1022 | Run: `go test ./internal/mirror -count=1` | |
| 1023 | Expected: build failure, `unknown field Lookup` / `undefined: pinArgs`. | |
| 1024 | ||
| 1025 | - [ ] **Step 3: Implement** | |
| 1026 | ||
| 1027 | `Worker` and `New`: | |
| 1028 | ||
| 1029 | ```go | |
| 1030 | type Worker struct { | |
| 1031 | St *store.Store | |
| 1032 | Cfg config.Config | |
| 1033 | Tick time.Duration | |
| 1034 | // Lookup resolves a mirror's host immediately before each sync. | |
| 1035 | Lookup func(ctx context.Context, host string) ([]net.IP, error) | |
| 1036 | } | |
| 1037 | ||
| 1038 | func New(st *store.Store, cfg config.Config) *Worker { | |
| 1039 | tick := 10 * time.Second | |
| 1040 | if v := os.Getenv("GITBAY_MIRROR_TICK"); v != "" { | |
| 1041 | if d, err := time.ParseDuration(v); err == nil { | |
| 1042 | tick = d | |
| 1043 | } | |
| 1044 | } | |
| 1045 | return &Worker{St: st, Cfg: cfg, Tick: tick, | |
| 1046 | Lookup: func(ctx context.Context, host string) ([]net.IP, error) { | |
| 1047 | return net.DefaultResolver.LookupIP(ctx, "ip", host) | |
| 1048 | }} | |
| 1049 | } | |
| 1050 | ``` | |
| 1051 | ||
| 1052 | `sync` from the top through the argv; the askpass block is unchanged | |
| 1053 | and the timeout context moves above the lookup so the lookup shares it: | |
| 1054 | ||
| 1055 | ```go | |
| 1056 | func (w *Worker) sync(m store.Mirror) error { | |
| 1057 | repo, err := w.St.RepoByID(m.RepoID) | |
| 1058 | if err != nil { | |
| 1059 | return err | |
| 1060 | } | |
| 1061 | dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 1062 | u, err := url.Parse(m.URL) | |
| 1063 | if err != nil { | |
| 1064 | return err | |
| 1065 | } | |
| 1066 | if u.Scheme != "https" && u.Scheme != "http" { | |
| 1067 | return fmt.Errorf("mirror URL scheme %q is not http or https", u.Scheme) | |
| 1068 | } | |
| 1069 | ||
| 1070 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) | |
| 1071 | defer cancel() | |
| 1072 | // The URL was checked when saved, but DNS can answer differently | |
| 1073 | // now. Check what it resolves to at sync time, then let git connect | |
| 1074 | // to exactly those addresses. | |
| 1075 | ips, err := w.Lookup(ctx, u.Hostname()) | |
| 1076 | if err != nil { | |
| 1077 | return fmt.Errorf("resolving %s: %w", u.Hostname(), err) | |
| 1078 | } | |
| 1079 | if err := webhook.CheckAddrs(u.Hostname(), ips, w.Cfg.Webhooks.AllowLocal); err != nil { | |
| 1080 | return err | |
| 1081 | } | |
| 1082 | ||
| 1083 | env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root} | |
| 1084 | if m.Token != "" { | |
| 1085 | // (askpass block unchanged) | |
| 1086 | } | |
| 1087 | ||
| 1088 | args := append(pinArgs(u, ips), "-C", dir) | |
| 1089 | if m.Direction == "push" { | |
| 1090 | // Branches and tags only: internal refs (merge-requests) stay home. | |
| 1091 | args = append(args, "push", "--prune", m.URL, | |
| 1092 | "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*") | |
| 1093 | } else { | |
| 1094 | args = append(args, "fetch", "--prune", m.URL, | |
| 1095 | "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*") | |
| 1096 | } | |
| 1097 | cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...) | |
| 1098 | cmd.Env = env | |
| 1099 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 1100 | return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out) | |
| 1101 | } | |
| 1102 | return nil | |
| 1103 | } | |
| 1104 | ||
| 1105 | // pinArgs keeps git on the addresses just checked: curl's resolve list | |
| 1106 | // pins the host, and with redirects off a server cannot send git on to | |
| 1107 | // a host nobody checked. An address literal needs no pin. | |
| 1108 | func pinArgs(u *url.URL, ips []net.IP) []string { | |
| 1109 | args := []string{"-c", "http.followRedirects=false"} | |
| 1110 | host := u.Hostname() | |
| 1111 | if net.ParseIP(host) != nil { | |
| 1112 | return args | |
| 1113 | } | |
| 1114 | port := u.Port() | |
| 1115 | if port == "" { | |
| 1116 | port = "443" | |
| 1117 | if u.Scheme == "http" { | |
| 1118 | port = "80" | |
| 1119 | } | |
| 1120 | } | |
| 1121 | addrs := make([]string, len(ips)) | |
| 1122 | for i, ip := range ips { | |
| 1123 | if ip.To4() == nil { | |
| 1124 | addrs[i] = "[" + ip.String() + "]" | |
| 1125 | } else { | |
| 1126 | addrs[i] = ip.String() | |
| 1127 | } | |
| 1128 | } | |
| 1129 | return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ",")) | |
| 1130 | } | |
| 1131 | ``` | |
| 1132 | ||
| 1133 | Imports gain `net`, `net/url`, `strings`, and | |
| 1134 | `gitbay.org/gitbay/internal/webhook`. `mirror` does not import | |
| 1135 | `webhook` today; `webhook` imports only `store`, so there is no cycle. | |
| 1136 | The `// (askpass block unchanged)` line stands for lines 84-97 kept as | |
| 1137 | they are; do not type it literally. | |
| 1138 | ||
| 1139 | - [ ] **Step 4: Run the package and the existing mirror e2e** | |
| 1140 | ||
| 1141 | Run: `go test ./internal/mirror ./internal/webhook -count=1 && go vet ./internal/mirror` | |
| 1142 | Expected: PASS. | |
| 1143 | ||
| 1144 | Run: `go test ./e2e -run TestMirrors -count=1` | |
| 1145 | Expected: PASS (its URLs are `http://127.0.0.1:<port>/…`, address | |
| 1146 | literals, so they take the no-pin branch; redirects are not used). | |
| 1147 | ||
| 1148 | - [ ] **Step 5: Docs** | |
| 1149 | ||
| 1150 | `Admin.org`, `** [mirrors]` last line becomes: | |
| 1151 | ||
| 1152 | ```org | |
| 1153 | Mirror URLs pass the same SSRF rules as webhook targets, when saved | |
| 1154 | and again before every sync; git then connects only to the addresses | |
| 1155 | that were checked (=http.curloptResolve=) and does not follow | |
| 1156 | redirects, so a mirror of a renamed repository fails until its URL | |
| 1157 | is updated. Needs git 2.37 or later on the server. | |
| 1158 | ``` | |
| 1159 | ||
| 1160 | `Threat-Model.org`, the paragraph under `* Network-facing request forgery`: | |
| 1161 | ||
| 1162 | ```org | |
| 1163 | Anything that makes the *server* open an outbound connection to a | |
| 1164 | user-supplied address — webhook delivery, GitHub-history import | |
| 1165 | =--api-base=, mirror remotes — passes the same SSRF guard: the scheme | |
| 1166 | must be http/https and, unless =webhooks.allow_local= is set, the | |
| 1167 | resolved address must not be loopback, private, or link-local. The | |
| 1168 | webhook dialer re-checks at connect time, and the mirror worker | |
| 1169 | resolves and checks before each sync and pins git to the checked | |
| 1170 | addresses, so a DNS answer that changes after validation still cannot | |
| 1171 | reach private space. Redirects are never followed. | |
| 1172 | ``` | |
| 1173 | ||
| 1174 | `Architecture/03-Deployment.org`, Mirror URLs row: | |
| 1175 | ||
| 1176 | ```org | |
| 1177 | | Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) | | |
| 1178 | ``` | |
| 1179 | ||
| 1180 | `Architecture/09-Controls.org`, SSRF row: | |
| 1181 | ||
| 1182 | ```org | |
| 1183 | | SSRF protection on user-supplied URLs | in place | webhooks at save and connect; mirrors at save and sync, git pinned to the checked address (=internal/mirror/mirror.go=) | | |
| 1184 | ``` | |
| 1185 | ||
| 1186 | `Architecture/10-Known-Gaps.org`: delete the `#279` row. | |
| 1187 | ||
| 1188 | - [ ] **Step 6: Commit, MR, merge** | |
| 1189 | ||
| 1190 | ```bash | |
| 1191 | git add internal/mirror .gitbay/wiki/Admin.org .gitbay/wiki/Threat-Model.org \ | |
| 1192 | .gitbay/wiki/Architecture/03-Deployment.org .gitbay/wiki/Architecture/09-Controls.org \ | |
| 1193 | .gitbay/wiki/Architecture/10-Known-Gaps.org | |
| 1194 | git commit -S -m "mirror: check the address before each sync and pin git to it | |
| 1195 | ||
| 1196 | Closes #279" | |
| 1197 | git push -u origin mirror-pin-address | |
| 1198 | gitbay mr create --source mirror-pin-address --target main --title "mirror: check the address before each sync and pin git to it" | |
| 1199 | ``` | |
| 1200 | ||
| 1201 | Before merging, the runbook's #279 check (git ≥ 2.37 on bay1). Merge | |
| 1202 | `--strategy ff` after CI, delete the branch both places. | |
| 1203 | ||
| 1204 | --- | |
| 1205 | ||
| 1206 | # MR 4: authenticated hook socket (branch `hook-socket-auth`, closes #282) | |
| 1207 | ||
| 1208 | ### Task 4.1: `push_tokens` table and store methods | |
| 1209 | ||
| 1210 | **Files:** | |
| 1211 | - Create: `internal/store/migrations/0069_push_tokens.up.sql`, `0069_push_tokens.down.sql` | |
| 1212 | - Create: `internal/store/pushtokens.go`, `internal/store/pushtokens_test.go` | |
| 1213 | - Modify: `internal/store/retention.go:47-54` (`expired` list) | |
| 1214 | ||
| 1215 | **Interfaces:** | |
| 1216 | - Produces: | |
| 1217 | - `type PushToken struct { RepoID, UserID int64; Scope string }` | |
| 1218 | - `func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error)` — returns the raw token; stores `HashToken(token)`; expires in 24h. | |
| 1219 | - `func (s *Store) PushTokenByHash(hash string) (PushToken, error)` — `ErrNotFound` when absent or expired. | |
| 1220 | - `func (s *Store) DeletePushToken(token string) error` — takes the raw token. | |
| 1221 | ||
| 1222 | - [ ] **Step 1: Write the failing test** | |
| 1223 | ||
| 1224 | `internal/store/pushtokens_test.go`: | |
| 1225 | ||
| 1226 | ```go | |
| 1227 | package store | |
| 1228 | ||
| 1229 | import ( | |
| 1230 | "errors" | |
| 1231 | "testing" | |
| 1232 | "time" | |
| 1233 | ) | |
| 1234 | ||
| 1235 | func TestPushTokens(t *testing.T) { | |
| 1236 | s := open(t) | |
| 1237 | if err := s.MigrateUp(); err != nil { | |
| 1238 | t.Fatal(err) | |
| 1239 | } | |
| 1240 | uid, err := s.CreateUser("alice", false) | |
| 1241 | if err != nil { | |
| 1242 | t.Fatal(err) | |
| 1243 | } | |
| 1244 | repoID, err := s.CreateRepo("user", uid, "app", "public") | |
| 1245 | if err != nil { | |
| 1246 | t.Fatal(err) | |
| 1247 | } | |
| 1248 | token, err := s.CreatePushToken(repoID, uid, "full") | |
| 1249 | if err != nil { | |
| 1250 | t.Fatal(err) | |
| 1251 | } | |
| 1252 | got, err := s.PushTokenByHash(HashToken(token)) | |
| 1253 | if err != nil || got != (PushToken{RepoID: repoID, UserID: uid, Scope: "full"}) { | |
| 1254 | t.Fatalf("lookup = %+v, %v", got, err) | |
| 1255 | } | |
| 1256 | if err := s.DeletePushToken(token); err != nil { | |
| 1257 | t.Fatal(err) | |
| 1258 | } | |
| 1259 | if _, err := s.PushTokenByHash(HashToken(token)); !errors.Is(err, ErrNotFound) { | |
| 1260 | t.Fatalf("after delete: %v", err) | |
| 1261 | } | |
| 1262 | ||
| 1263 | // A token whose receive-pack never cleaned up is swept after a day. | |
| 1264 | stale, err := s.CreatePushToken(repoID, uid, "full") | |
| 1265 | if err != nil { | |
| 1266 | t.Fatal(err) | |
| 1267 | } | |
| 1268 | swept, err := s.Sweep(Retention{}, time.Now().Add(25*time.Hour)) | |
| 1269 | if err != nil || swept["push_tokens"] != 1 { | |
| 1270 | t.Fatalf("sweep = %v, %v", swept, err) | |
| 1271 | } | |
| 1272 | if _, err := s.PushTokenByHash(HashToken(stale)); !errors.Is(err, ErrNotFound) { | |
| 1273 | t.Fatalf("after sweep: %v", err) | |
| 1274 | } | |
| 1275 | } | |
| 1276 | ``` | |
| 1277 | ||
| 1278 | - [ ] **Step 2: Run it and see it fail** | |
| 1279 | ||
| 1280 | Run: `go test ./internal/store -run TestPushTokens -count=1` | |
| 1281 | Expected: build failure, `s.CreatePushToken undefined`. | |
| 1282 | ||
| 1283 | - [ ] **Step 3: Implement** | |
| 1284 | ||
| 1285 | `0069_push_tokens.up.sql`: | |
| 1286 | ||
| 1287 | ```sql | |
| 1288 | -- One row per receive-pack in flight. The hook names its push by the | |
| 1289 | -- token; hookd answers only a live one. Only the SHA-256 is stored. | |
| 1290 | CREATE TABLE push_tokens ( | |
| 1291 | token_hash TEXT PRIMARY KEY, | |
| 1292 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 1293 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 1294 | scope TEXT NOT NULL, | |
| 1295 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 1296 | expires_at TEXT NOT NULL | |
| 1297 | ); | |
| 1298 | ``` | |
| 1299 | ||
| 1300 | `0069_push_tokens.down.sql`: | |
| 1301 | ||
| 1302 | ```sql | |
| 1303 | DROP TABLE push_tokens; | |
| 1304 | ``` | |
| 1305 | ||
| 1306 | `internal/store/pushtokens.go`: | |
| 1307 | ||
| 1308 | ```go | |
| 1309 | package store | |
| 1310 | ||
| 1311 | import ( | |
| 1312 | "database/sql" | |
| 1313 | "errors" | |
| 1314 | "time" | |
| 1315 | ) | |
| 1316 | ||
| 1317 | // PushToken is the receive-pack a hook request speaks for. | |
| 1318 | type PushToken struct { | |
| 1319 | RepoID int64 | |
| 1320 | UserID int64 | |
| 1321 | Scope string | |
| 1322 | } | |
| 1323 | ||
| 1324 | // pushTokenTTL bounds a row whose receive-pack died before deleting it. | |
| 1325 | const pushTokenTTL = 24 * time.Hour | |
| 1326 | ||
| 1327 | // CreatePushToken records a token for one receive-pack and returns it. | |
| 1328 | func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error) { | |
| 1329 | token, hash, err := NewToken() | |
| 1330 | if err != nil { | |
| 1331 | return "", err | |
| 1332 | } | |
| 1333 | _, err = s.DB.Exec( | |
| 1334 | "INSERT INTO push_tokens (token_hash, repo_id, user_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)", | |
| 1335 | hash, repoID, userID, scope, fmtTime(time.Now().Add(pushTokenTTL))) | |
| 1336 | if err != nil { | |
| 1337 | return "", err | |
| 1338 | } | |
| 1339 | return token, nil | |
| 1340 | } | |
| 1341 | ||
| 1342 | func (s *Store) PushTokenByHash(hash string) (PushToken, error) { | |
| 1343 | var t PushToken | |
| 1344 | err := s.DB.QueryRow( | |
| 1345 | "SELECT repo_id, user_id, scope FROM push_tokens WHERE token_hash = ? AND expires_at > ?", | |
| 1346 | hash, fmtTime(time.Now())).Scan(&t.RepoID, &t.UserID, &t.Scope) | |
| 1347 | if errors.Is(err, sql.ErrNoRows) { | |
| 1348 | return PushToken{}, ErrNotFound | |
| 1349 | } | |
| 1350 | return t, err | |
| 1351 | } | |
| 1352 | ||
| 1353 | func (s *Store) DeletePushToken(token string) error { | |
| 1354 | _, err := s.DB.Exec("DELETE FROM push_tokens WHERE token_hash = ?", HashToken(token)) | |
| 1355 | return err | |
| 1356 | } | |
| 1357 | ``` | |
| 1358 | ||
| 1359 | `internal/store/retention.go`, the `expired` list gains a row: | |
| 1360 | ||
| 1361 | ```go | |
| 1362 | {"web_sessions", "expires_at <= ?"}, | |
| 1363 | {"login_tokens", "expires_at <= ?"}, | |
| 1364 | {"email_tokens", "expires_at <= ?"}, | |
| 1365 | {"push_tokens", "expires_at <= ?"}, | |
| 1366 | ``` | |
| 1367 | ||
| 1368 | - [ ] **Step 4: Run the package** | |
| 1369 | ||
| 1370 | Run: `go test ./internal/store -count=1` | |
| 1371 | Expected: PASS, including `TestMigrateUpDown`. | |
| 1372 | ||
| 1373 | - [ ] **Step 5: Commit** | |
| 1374 | ||
| 1375 | ```bash | |
| 1376 | git add internal/store | |
| 1377 | git commit -S -m "store: push tokens for receive-pack | |
| 1378 | ||
| 1379 | Ref #282" | |
| 1380 | ``` | |
| 1381 | ||
| 1382 | ### Task 4.2: hookd requires 0600, the daemon's uid, and a live push token | |
| 1383 | ||
| 1384 | **Files:** | |
| 1385 | - Modify: `internal/hookd/hookd.go:34-50` (constants, `Request`), `:90-128` (`Serve`, `handle`) | |
| 1386 | - Create: `internal/hookd/peercred_linux.go`, `internal/hookd/peercred_other.go` | |
| 1387 | - Create: `internal/hookd/socket_test.go` | |
| 1388 | ||
| 1389 | **Interfaces:** | |
| 1390 | - Consumes: `store.CreatePushToken`, `store.PushTokenByHash`, `store.HashToken` (Task 4.1). | |
| 1391 | - Produces: `hookd.EnvToken = "GITBAY_PUSH_TOKEN"`; `Request.Token string` (`json:"token"`); unexported `checkPeer(net.Conn) error`. | |
| 1392 | ||
| 1393 | - [ ] **Step 1: Write the failing tests** | |
| 1394 | ||
| 1395 | `internal/hookd/socket_test.go`: | |
| 1396 | ||
| 1397 | ```go | |
| 1398 | package hookd | |
| 1399 | ||
| 1400 | import ( | |
| 1401 | "os" | |
| 1402 | "path/filepath" | |
| 1403 | "strings" | |
| 1404 | "testing" | |
| 1405 | ||
| 1406 | "gitbay.org/gitbay/internal/config" | |
| 1407 | "gitbay.org/gitbay/internal/store" | |
| 1408 | ) | |
| 1409 | ||
| 1410 | func serveSocket(t *testing.T) (sock string, st *store.Store, repoID, uid int64) { | |
| 1411 | t.Helper() | |
| 1412 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 1413 | if err != nil { | |
| 1414 | t.Fatal(err) | |
| 1415 | } | |
| 1416 | t.Cleanup(func() { st.Close() }) | |
| 1417 | if err := st.MigrateUp(); err != nil { | |
| 1418 | t.Fatal(err) | |
| 1419 | } | |
| 1420 | if uid, err = st.CreateUser("alice", false); err != nil { | |
| 1421 | t.Fatal(err) | |
| 1422 | } | |
| 1423 | if repoID, err = st.CreateRepo("user", uid, "app", "public"); err != nil { | |
| 1424 | t.Fatal(err) | |
| 1425 | } | |
| 1426 | var cfg config.Config | |
| 1427 | cfg.Server.Root = t.TempDir() | |
| 1428 | stop, err := Serve(cfg, st) | |
| 1429 | if err != nil { | |
| 1430 | t.Fatal(err) | |
| 1431 | } | |
| 1432 | t.Cleanup(func() { stop() }) | |
| 1433 | return SocketPath(cfg.Server.Root), st, repoID, uid | |
| 1434 | } | |
| 1435 | ||
| 1436 | func TestSocketIsOwnerOnly(t *testing.T) { | |
| 1437 | sock, _, _, _ := serveSocket(t) | |
| 1438 | fi, err := os.Stat(sock) | |
| 1439 | if err != nil { | |
| 1440 | t.Fatal(err) | |
| 1441 | } | |
| 1442 | if fi.Mode().Perm() != 0o600 { | |
| 1443 | t.Fatalf("mode %v, want 0600", fi.Mode().Perm()) | |
| 1444 | } | |
| 1445 | } | |
| 1446 | ||
| 1447 | // A request speaks for a receive-pack sshd started, and only for the | |
| 1448 | // repository, account and scope that push was started with (#282). | |
| 1449 | func TestHookRequestNeedsItsPushToken(t *testing.T) { | |
| 1450 | sock, st, repoID, uid := serveSocket(t) | |
| 1451 | req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full"} | |
| 1452 | ||
| 1453 | resp, err := Ask(sock, req, nil) | |
| 1454 | if err != nil { | |
| 1455 | t.Fatal(err) | |
| 1456 | } | |
| 1457 | if resp.Allow || !strings.Contains(resp.Message, "not started by this server") { | |
| 1458 | t.Fatalf("no token: %+v", resp) | |
| 1459 | } | |
| 1460 | ||
| 1461 | token, err := st.CreatePushToken(repoID, uid, "full") | |
| 1462 | if err != nil { | |
| 1463 | t.Fatal(err) | |
| 1464 | } | |
| 1465 | req.Token = token | |
| 1466 | if resp, err = Ask(sock, req, nil); err != nil || !resp.Allow { | |
| 1467 | t.Fatalf("with token: %+v, %v", resp, err) | |
| 1468 | } | |
| 1469 | ||
| 1470 | other, err := st.CreateUser("mallory", false) | |
| 1471 | if err != nil { | |
| 1472 | t.Fatal(err) | |
| 1473 | } | |
| 1474 | forged := req | |
| 1475 | forged.UserID = other | |
| 1476 | if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow { | |
| 1477 | t.Fatalf("token for another account: %+v, %v", resp, err) | |
| 1478 | } | |
| 1479 | ||
| 1480 | if err := st.DeletePushToken(token); err != nil { | |
| 1481 | t.Fatal(err) | |
| 1482 | } | |
| 1483 | if resp, err = Ask(sock, req, nil); err != nil || resp.Allow { | |
| 1484 | t.Fatalf("finished push: %+v, %v", resp, err) | |
| 1485 | } | |
| 1486 | } | |
| 1487 | ``` | |
| 1488 | ||
| 1489 | The peer-uid check is exercised by the same test on Linux (CI on | |
| 1490 | bay1): the test process is the daemon's uid, so a refusal there fails | |
| 1491 | the "with token" case. | |
| 1492 | ||
| 1493 | - [ ] **Step 2: Run them and see them fail** | |
| 1494 | ||
| 1495 | Run: `go test ./internal/hookd -run 'TestSocketIsOwnerOnly|TestHookRequestNeedsItsPushToken' -count=1` | |
| 1496 | Expected: build failure, `unknown field Token in struct literal`. | |
| 1497 | ||
| 1498 | - [ ] **Step 3: Implement** | |
| 1499 | ||
| 1500 | `internal/hookd/hookd.go`, constants and `Request`: | |
| 1501 | ||
| 1502 | ```go | |
| 1503 | const ( | |
| 1504 | EnvSocket = "GITBAY_HOOK_SOCKET" | |
| 1505 | EnvRepoID = "GITBAY_REPO_ID" | |
| 1506 | EnvUserID = "GITBAY_USER_ID" | |
| 1507 | EnvScope = "GITBAY_KEY_SCOPE" | |
| 1508 | // EnvToken names the receive-pack this hook runs under. sshd mints | |
| 1509 | // it per push; hookd answers only a request carrying a live one | |
| 1510 | // whose repository, account and scope match the request's. | |
| 1511 | EnvToken = "GITBAY_PUSH_TOKEN" | |
| 1512 | ) | |
| 1513 | ||
| 1514 | type Request struct { | |
| 1515 | Hook string `json:"hook"` // pre-receive | post-receive | |
| 1516 | RepoID int64 `json:"repo_id"` | |
| 1517 | UserID int64 `json:"user_id"` | |
| 1518 | // Scope is the pushing key's scope. The user id alone is the account | |
| 1519 | // the key belongs to, and a deploy key grants nothing outside its | |
| 1520 | // binding, so anything acting on another repository needs this too. | |
| 1521 | Scope string `json:"scope"` | |
| 1522 | Token string `json:"token"` | |
| 1523 | Updates []policy.RefUpdate `json:"updates"` | |
| 1524 | } | |
| 1525 | ``` | |
| 1526 | ||
| 1527 | `Serve`, after `net.Listen`: | |
| 1528 | ||
| 1529 | ```go | |
| 1530 | ln, err := net.Listen("unix", path) | |
| 1531 | if err != nil { | |
| 1532 | return nil, err | |
| 1533 | } | |
| 1534 | // Listen creates the socket under the process umask. Hooks run as | |
| 1535 | // the daemon's own user; nobody else has a reason to connect. | |
| 1536 | if err := os.Chmod(path, 0o600); err != nil { | |
| 1537 | ln.Close() | |
| 1538 | return nil, err | |
| 1539 | } | |
| 1540 | ``` | |
| 1541 | ||
| 1542 | `handle`: | |
| 1543 | ||
| 1544 | ```go | |
| 1545 | func (s *Server) handle(conn net.Conn) { | |
| 1546 | defer conn.Close() | |
| 1547 | dec := json.NewDecoder(conn) | |
| 1548 | enc := json.NewEncoder(conn) | |
| 1549 | if err := checkPeer(conn); err != nil { | |
| 1550 | slog.Warn("hook socket: refused connection", "err", err) | |
| 1551 | enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()}) | |
| 1552 | return | |
| 1553 | } | |
| 1554 | var req Request | |
| 1555 | if err := dec.Decode(&req); err != nil { | |
| 1556 | enc.Encode(Response{Allow: false, Message: "bad hook request"}) | |
| 1557 | return | |
| 1558 | } | |
| 1559 | if msg := s.authorize(req); msg != "" { | |
| 1560 | enc.Encode(Response{Allow: false, Message: msg}) | |
| 1561 | return | |
| 1562 | } | |
| 1563 | switch req.Hook { | |
| 1564 | case "pre-receive": | |
| 1565 | s.preReceive(req, dec, enc) | |
| 1566 | case "post-receive": | |
| 1567 | s.postReceive(req) | |
| 1568 | enc.Encode(Response{Allow: true}) | |
| 1569 | default: | |
| 1570 | enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)}) | |
| 1571 | } | |
| 1572 | } | |
| 1573 | ||
| 1574 | // authorize ties a request to a receive-pack sshd started: its token | |
| 1575 | // must be live and name the same repository, account and key scope. | |
| 1576 | func (s *Server) authorize(req Request) string { | |
| 1577 | if req.Token == "" { | |
| 1578 | return "push not started by this server" | |
| 1579 | } | |
| 1580 | tok, err := s.st.PushTokenByHash(store.HashToken(req.Token)) | |
| 1581 | if err != nil { | |
| 1582 | return "push not started by this server" | |
| 1583 | } | |
| 1584 | if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope { | |
| 1585 | return "push token does not match this request" | |
| 1586 | } | |
| 1587 | return "" | |
| 1588 | } | |
| 1589 | ``` | |
| 1590 | ||
| 1591 | `internal/hookd/peercred_linux.go`: | |
| 1592 | ||
| 1593 | ```go | |
| 1594 | //go:build linux | |
| 1595 | ||
| 1596 | package hookd | |
| 1597 | ||
| 1598 | import ( | |
| 1599 | "fmt" | |
| 1600 | "net" | |
| 1601 | "os" | |
| 1602 | "syscall" | |
| 1603 | ) | |
| 1604 | ||
| 1605 | // checkPeer refuses a connection from any uid but the daemon's: git, | |
| 1606 | // and so every hook, runs as the daemon's user. | |
| 1607 | func checkPeer(conn net.Conn) error { | |
| 1608 | uc, ok := conn.(*net.UnixConn) | |
| 1609 | if !ok { | |
| 1610 | return fmt.Errorf("not a unix socket connection") | |
| 1611 | } | |
| 1612 | raw, err := uc.SyscallConn() | |
| 1613 | if err != nil { | |
| 1614 | return err | |
| 1615 | } | |
| 1616 | var cred *syscall.Ucred | |
| 1617 | var credErr error | |
| 1618 | if err := raw.Control(func(fd uintptr) { | |
| 1619 | cred, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED) | |
| 1620 | }); err != nil { | |
| 1621 | return err | |
| 1622 | } | |
| 1623 | if credErr != nil { | |
| 1624 | return credErr | |
| 1625 | } | |
| 1626 | if int(cred.Uid) != os.Getuid() { | |
| 1627 | return fmt.Errorf("peer uid %d is not the daemon's (%d)", cred.Uid, os.Getuid()) | |
| 1628 | } | |
| 1629 | return nil | |
| 1630 | } | |
| 1631 | ``` | |
| 1632 | ||
| 1633 | `internal/hookd/peercred_other.go`: | |
| 1634 | ||
| 1635 | ```go | |
| 1636 | //go:build !linux | |
| 1637 | ||
| 1638 | package hookd | |
| 1639 | ||
| 1640 | import "net" | |
| 1641 | ||
| 1642 | // checkPeer reads peer credentials on Linux only; elsewhere the | |
| 1643 | // socket's 0600 mode is the boundary. | |
| 1644 | func checkPeer(net.Conn) error { return nil } | |
| 1645 | ``` | |
| 1646 | ||
| 1647 | - [ ] **Step 4: Run the package, and vet for Linux** | |
| 1648 | ||
| 1649 | Run: `go test ./internal/hookd -count=1 && GOOS=linux go vet ./internal/hookd` | |
| 1650 | Expected: PASS; vet clean for both build-tag files. | |
| 1651 | ||
| 1652 | - [ ] **Step 5: Commit** | |
| 1653 | ||
| 1654 | ```bash | |
| 1655 | git add internal/hookd | |
| 1656 | git commit -S -m "hookd: 0600 socket, peer uid check, push token required | |
| 1657 | ||
| 1658 | Ref #282" | |
| 1659 | ``` | |
| 1660 | ||
| 1661 | ### Task 4.3: sshd mints the token; the hook sends it | |
| 1662 | ||
| 1663 | **Files:** | |
| 1664 | - Modify: `internal/sshd/sshd.go:441-464` (`runGit`, env and transport) | |
| 1665 | - Modify: `cmd/gitbayd/hook.go:170-178` (`hookd.Ask` request) | |
| 1666 | ||
| 1667 | **Interfaces:** | |
| 1668 | - Consumes: `store.CreatePushToken`, `store.DeletePushToken`, `hookd.EnvToken`, `hookd.Request.Token`. | |
| 1669 | ||
| 1670 | - [ ] **Step 1: Implement in sshd** | |
| 1671 | ||
| 1672 | In `runGit`, after the quota block (line 463) and before | |
| 1673 | `gitutil.Transport`: | |
| 1674 | ||
| 1675 | ```go | |
| 1676 | if write { | |
| 1677 | // hookd answers only a hook that names this receive-pack. | |
| 1678 | token, err := st.CreatePushToken(repo.ID, user.ID, scope) | |
| 1679 | if err != nil { | |
| 1680 | fmt.Fprintln(stderr, "internal error") | |
| 1681 | return protocol.ExitFailure | |
| 1682 | } | |
| 1683 | defer st.DeletePushToken(token) | |
| 1684 | env = append(env, hookd.EnvToken+"="+token) | |
| 1685 | } | |
| 1686 | ``` | |
| 1687 | ||
| 1688 | The token is deleted when `Transport` returns, which is after | |
| 1689 | post-receive: receive-pack runs post-receive before it exits. | |
| 1690 | ||
| 1691 | - [ ] **Step 2: Implement in the hook** | |
| 1692 | ||
| 1693 | `cmd/gitbayd/hook.go`, the request becomes: | |
| 1694 | ||
| 1695 | ```go | |
| 1696 | resp, err := hookd.Ask(sock, hookd.Request{ | |
| 1697 | Hook: args[0], | |
| 1698 | RepoID: repoID, | |
| 1699 | UserID: userID, | |
| 1700 | Scope: os.Getenv(hookd.EnvScope), | |
| 1701 | Token: os.Getenv(hookd.EnvToken), | |
| 1702 | Updates: updates, | |
| 1703 | }, func(emit func(hookd.RawCommit) error) error { | |
| 1704 | return streamIncomingCommits(updates, emit) | |
| 1705 | }) | |
| 1706 | ``` | |
| 1707 | ||
| 1708 | - [ ] **Step 3: Build, vet, unit tests; one push e2e** | |
| 1709 | ||
| 1710 | Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/hookd ./cmd/gitbayd -count=1` | |
| 1711 | Expected: PASS. | |
| 1712 | ||
| 1713 | Run: `go test ./e2e -run TestAuditAndHardening -count=1` | |
| 1714 | Expected: PASS. It pushes over SSH (including an oversized push | |
| 1715 | refused by receive-pack), so pre-receive and post-receive both go | |
| 1716 | through the token check end to end. This is the one e2e run for this | |
| 1717 | MR; CI runs the rest of the push tests. | |
| 1718 | ||
| 1719 | - [ ] **Step 4: Docs** | |
| 1720 | ||
| 1721 | `Architecture/03-Deployment.org`, hook.sock row: | |
| 1722 | ||
| 1723 | ```org | |
| 1724 | | =<root>/hook.sock= | Unix socket | gitbayd | on | mode 0600; peer uid must be the daemon's (Linux); per-push token | =internal/hookd/hookd.go= | | |
| 1725 | ``` | |
| 1726 | ||
| 1727 | `Architecture/04-Trust-Boundaries.org`, TB5 row: | |
| 1728 | ||
| 1729 | ```org | |
| 1730 | | TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, push token, commit objects | the socket is mode 0600 and, on Linux, refuses a peer whose uid is not the daemon's; a request must carry the token sshd minted for its receive-pack (stored hashed in =push_tokens=) and name the same repository, account and scope. The daemon then decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=) | | |
| 1731 | ``` | |
| 1732 | ||
| 1733 | `Architecture/04-Trust-Boundaries.org`, step 2 of the push sequence | |
| 1734 | (line 57): append ", and a push token" after "key scope" in the list | |
| 1735 | of what `git receive-pack` runs with. | |
| 1736 | ||
| 1737 | `Architecture/10-Known-Gaps.org`: delete the `#282` row. | |
| 1738 | ||
| 1739 | - [ ] **Step 5: Commit, MR, merge** | |
| 1740 | ||
| 1741 | ```bash | |
| 1742 | git add internal/sshd/sshd.go cmd/gitbayd/hook.go .gitbay/wiki/Architecture | |
| 1743 | git commit -S -m "sshd: mint a push token per receive-pack; hook sends it | |
| 1744 | ||
| 1745 | Closes #282" | |
| 1746 | git push -u origin hook-socket-auth | |
| 1747 | gitbay mr create --source hook-socket-auth --target main --title "hookd: authenticate the hook socket" | |
| 1748 | ``` | |
| 1749 | ||
| 1750 | Merge `--strategy ff` after CI, delete the branch both places. Deploy | |
| 1751 | with no push in flight (see runbook): a receive-pack started by the old | |
| 1752 | daemon has no token and its post-receive is refused by the new one. | |
| 1753 | ||
| 1754 | --- | |
| 1755 | ||
| 1756 | # MR 5: audited refusals and a hash-chained audit log (branch `audit-refusals-chain`, closes #275) | |
| 1757 | ||
| 1758 | ### Task 5.1: chained audit rows, the journal line, verification | |
| 1759 | ||
| 1760 | **Files:** | |
| 1761 | - Create: `internal/store/migrations/0070_audit_chain.up.sql`, `0070_audit_chain.down.sql` | |
| 1762 | - Modify: `internal/store/audit.go:1-19` (`Audit`) | |
| 1763 | - Modify: `internal/store/store.go:23-30` (`Store` gains `AuditJournal`) | |
| 1764 | - Create: `internal/store/auditchain_test.go` | |
| 1765 | ||
| 1766 | **Interfaces:** | |
| 1767 | - Produces: | |
| 1768 | - `Store.AuditJournal *slog.Logger` — when set, every audit row is also logged there. | |
| 1769 | - `type AuditChain struct { Rows, Unchained int; First, Last int64; LastHash string; BrokenAt int64; Reason string }` | |
| 1770 | - `func (s *Store) VerifyAuditChain() (AuditChain, error)` | |
| 1771 | - `Audit` signature unchanged. | |
| 1772 | ||
| 1773 | - [ ] **Step 1: Write the failing tests** | |
| 1774 | ||
| 1775 | `internal/store/auditchain_test.go`: | |
| 1776 | ||
| 1777 | ```go | |
| 1778 | package store | |
| 1779 | ||
| 1780 | import ( | |
| 1781 | "bytes" | |
| 1782 | "log/slog" | |
| 1783 | "strings" | |
| 1784 | "testing" | |
| 1785 | ) | |
| 1786 | ||
| 1787 | func chainStore(t *testing.T) *Store { | |
| 1788 | t.Helper() | |
| 1789 | s := open(t) | |
| 1790 | if err := s.MigrateUp(); err != nil { | |
| 1791 | t.Fatal(err) | |
| 1792 | } | |
| 1793 | return s | |
| 1794 | } | |
| 1795 | ||
| 1796 | func TestAuditChainIntact(t *testing.T) { | |
| 1797 | s := chainStore(t) | |
| 1798 | s.Audit(0, "a", map[string]any{"n": 1}) | |
| 1799 | s.Audit(0, "b", nil) | |
| 1800 | s.Audit(0, "c", map[string]any{"n": 3}) | |
| 1801 | res, err := s.VerifyAuditChain() | |
| 1802 | if err != nil { | |
| 1803 | t.Fatal(err) | |
| 1804 | } | |
| 1805 | if res.Rows != 3 || res.BrokenAt != 0 || res.First != 1 || res.Last != 3 || len(res.LastHash) != 64 { | |
| 1806 | t.Fatalf("%+v", res) | |
| 1807 | } | |
| 1808 | } | |
| 1809 | ||
| 1810 | func TestAuditChainDetectsAnEditedRow(t *testing.T) { | |
| 1811 | s := chainStore(t) | |
| 1812 | for _, a := range []string{"a", "b", "c"} { | |
| 1813 | s.Audit(0, a, nil) | |
| 1814 | } | |
| 1815 | if _, err := s.DB.Exec("UPDATE audit_log SET action = 'x' WHERE id = 2"); err != nil { | |
| 1816 | t.Fatal(err) | |
| 1817 | } | |
| 1818 | res, err := s.VerifyAuditChain() | |
| 1819 | if err != nil { | |
| 1820 | t.Fatal(err) | |
| 1821 | } | |
| 1822 | if res.BrokenAt != 2 || !strings.Contains(res.Reason, "contents") { | |
| 1823 | t.Fatalf("%+v", res) | |
| 1824 | } | |
| 1825 | } | |
| 1826 | ||
| 1827 | func TestAuditChainDetectsARemovedRow(t *testing.T) { | |
| 1828 | s := chainStore(t) | |
| 1829 | for _, a := range []string{"a", "b", "c"} { | |
| 1830 | s.Audit(0, a, nil) | |
| 1831 | } | |
| 1832 | if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 2"); err != nil { | |
| 1833 | t.Fatal(err) | |
| 1834 | } | |
| 1835 | res, err := s.VerifyAuditChain() | |
| 1836 | if err != nil { | |
| 1837 | t.Fatal(err) | |
| 1838 | } | |
| 1839 | if res.BrokenAt != 3 || !strings.Contains(res.Reason, "previous hash") { | |
| 1840 | t.Fatalf("%+v", res) | |
| 1841 | } | |
| 1842 | } | |
| 1843 | ||
| 1844 | // Retention removes the oldest rows, and deleting an account nulls | |
| 1845 | // actor_id; neither is tampering. | |
| 1846 | func TestAuditChainSurvivesRetentionAndAccountDeletion(t *testing.T) { | |
| 1847 | s := chainStore(t) | |
| 1848 | uid, err := s.CreateUser("alice", false) | |
| 1849 | if err != nil { | |
| 1850 | t.Fatal(err) | |
| 1851 | } | |
| 1852 | s.Audit(0, "a", nil) | |
| 1853 | s.Audit(uid, "b", nil) | |
| 1854 | s.Audit(0, "c", nil) | |
| 1855 | if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 1"); err != nil { | |
| 1856 | t.Fatal(err) | |
| 1857 | } | |
| 1858 | if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", uid); err != nil { | |
| 1859 | t.Fatal(err) | |
| 1860 | } | |
| 1861 | res, err := s.VerifyAuditChain() | |
| 1862 | if err != nil { | |
| 1863 | t.Fatal(err) | |
| 1864 | } | |
| 1865 | if res.BrokenAt != 0 || res.First != 2 || res.Last != 3 { | |
| 1866 | t.Fatalf("%+v", res) | |
| 1867 | } | |
| 1868 | } | |
| 1869 | ||
| 1870 | // Rows written before migration 0070 carry no hash; the chain starts | |
| 1871 | // after them, and a hashless row after that start is a break. | |
| 1872 | func TestAuditChainLegacyRows(t *testing.T) { | |
| 1873 | s := chainStore(t) | |
| 1874 | if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('legacy')"); err != nil { | |
| 1875 | t.Fatal(err) | |
| 1876 | } | |
| 1877 | s.Audit(0, "a", nil) | |
| 1878 | res, err := s.VerifyAuditChain() | |
| 1879 | if err != nil { | |
| 1880 | t.Fatal(err) | |
| 1881 | } | |
| 1882 | if res.Unchained != 1 || res.BrokenAt != 0 || res.First != 2 { | |
| 1883 | t.Fatalf("%+v", res) | |
| 1884 | } | |
| 1885 | if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('injected')"); err != nil { | |
| 1886 | t.Fatal(err) | |
| 1887 | } | |
| 1888 | if res, _ = s.VerifyAuditChain(); res.BrokenAt != 3 { | |
| 1889 | t.Fatalf("hashless row after the chain: %+v", res) | |
| 1890 | } | |
| 1891 | } | |
| 1892 | ||
| 1893 | func TestAuditJournal(t *testing.T) { | |
| 1894 | s := chainStore(t) | |
| 1895 | var buf bytes.Buffer | |
| 1896 | s.AuditJournal = slog.New(slog.NewTextHandler(&buf, nil)) | |
| 1897 | s.Audit(0, "cmd repo create", map[string]any{"argv": []string{"a/b"}}) | |
| 1898 | line := buf.String() | |
| 1899 | for _, want := range []string{"msg=audit", "action=\"cmd repo create\"", "id=1", "hash="} { | |
| 1900 | if !strings.Contains(line, want) { | |
| 1901 | t.Fatalf("journal line %q lacks %q", line, want) | |
| 1902 | } | |
| 1903 | } | |
| 1904 | } | |
| 1905 | ``` | |
| 1906 | ||
| 1907 | - [ ] **Step 2: Run them and see them fail** | |
| 1908 | ||
| 1909 | Run: `go test ./internal/store -run 'TestAuditChain|TestAuditJournal' -count=1` | |
| 1910 | Expected: build failure, `s.VerifyAuditChain undefined`. | |
| 1911 | ||
| 1912 | - [ ] **Step 3: Implement** | |
| 1913 | ||
| 1914 | `0070_audit_chain.up.sql`: | |
| 1915 | ||
| 1916 | ```sql | |
| 1917 | -- Each row carries the hash of the row before it. actor_ref is the actor | |
| 1918 | -- id as written: actor_id is set to NULL when the account is deleted, | |
| 1919 | -- and the hash must not change with it. Rows written before this | |
| 1920 | -- migration keep an empty hash; the chain starts after them. | |
| 1921 | ALTER TABLE audit_log ADD COLUMN actor_ref INTEGER NOT NULL DEFAULT 0; | |
| 1922 | ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT ''; | |
| 1923 | ALTER TABLE audit_log ADD COLUMN hash TEXT NOT NULL DEFAULT ''; | |
| 1924 | UPDATE audit_log SET actor_ref = COALESCE(actor_id, 0); | |
| 1925 | ``` | |
| 1926 | ||
| 1927 | `0070_audit_chain.down.sql`: | |
| 1928 | ||
| 1929 | ```sql | |
| 1930 | ALTER TABLE audit_log DROP COLUMN hash; | |
| 1931 | ALTER TABLE audit_log DROP COLUMN prev_hash; | |
| 1932 | ALTER TABLE audit_log DROP COLUMN actor_ref; | |
| 1933 | ``` | |
| 1934 | ||
| 1935 | `internal/store/store.go`, `Store` gains: | |
| 1936 | ||
| 1937 | ```go | |
| 1938 | // AuditJournal, when set, receives a copy of every audit row. The | |
| 1939 | // daemon sets it to its own logger, whose output the service | |
| 1940 | // journal keeps outside the database. | |
| 1941 | AuditJournal *slog.Logger | |
| 1942 | ``` | |
| 1943 | ||
| 1944 | (`store.go` imports `log/slog`.) | |
| 1945 | ||
| 1946 | `internal/store/audit.go`, replacing `Audit` (lines 5-19) and adding | |
| 1947 | the chain helpers; `AuditEntry`, `AuditFilter` and `AuditEntries` stay | |
| 1948 | as they are: | |
| 1949 | ||
| 1950 | ```go | |
| 1951 | package store | |
| 1952 | ||
| 1953 | import ( | |
| 1954 | "crypto/sha256" | |
| 1955 | "database/sql" | |
| 1956 | "encoding/hex" | |
| 1957 | "encoding/json" | |
| 1958 | "errors" | |
| 1959 | "time" | |
| 1960 | ) | |
| 1961 | ||
| 1962 | // Audit appends to the security feed. Events are the product feed; this | |
| 1963 | // records who did what, from where, for an operator. actorID 0 means the | |
| 1964 | // host admin (gitbayd admin commands) or an unauthenticated source. | |
| 1965 | func (s *Store) Audit(actorID int64, action string, data map[string]any) { | |
| 1966 | raw, err := json.Marshal(data) | |
| 1967 | if err != nil { | |
| 1968 | raw = []byte("{}") | |
| 1969 | } | |
| 1970 | id, createdAt, hash, err := s.appendAudit(actorID, action, string(raw), time.Now()) | |
| 1971 | if s.AuditJournal == nil { | |
| 1972 | return | |
| 1973 | } | |
| 1974 | if err != nil { | |
| 1975 | s.AuditJournal.Error("audit: append", "action", action, "err", err) | |
| 1976 | return | |
| 1977 | } | |
| 1978 | s.AuditJournal.Info("audit", "id", id, "actor", actorID, "action", action, | |
| 1979 | "data", string(raw), "created_at", createdAt, "hash", hash) | |
| 1980 | } | |
| 1981 | ||
| 1982 | // appendAudit writes one row and its chain hash in one transaction. The | |
| 1983 | // store begins every transaction IMMEDIATE, so two writers — the daemon | |
| 1984 | // and a gitbayd admin command, say — cannot both read the same last | |
| 1985 | // hash. | |
| 1986 | func (s *Store) appendAudit(actorID int64, action, data string, now time.Time) (int64, string, string, error) { | |
| 1987 | tx, err := s.DB.Begin() | |
| 1988 | if err != nil { | |
| 1989 | return 0, "", "", err | |
| 1990 | } | |
| 1991 | defer tx.Rollback() | |
| 1992 | var prev string | |
| 1993 | err = tx.QueryRow("SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prev) | |
| 1994 | if err != nil && !errors.Is(err, sql.ErrNoRows) { | |
| 1995 | return 0, "", "", err | |
| 1996 | } | |
| 1997 | var actor any | |
| 1998 | if actorID != 0 { | |
| 1999 | actor = actorID | |
| 2000 | } | |
| 2001 | createdAt := fmtTime(now) | |
| 2002 | res, err := tx.Exec( | |
| 2003 | "INSERT INTO audit_log (actor_id, actor_ref, action, data_json, created_at, prev_hash) VALUES (?, ?, ?, ?, ?, ?)", | |
| 2004 | actor, actorID, action, data, createdAt, prev) | |
| 2005 | if err != nil { | |
| 2006 | return 0, "", "", err | |
| 2007 | } | |
| 2008 | id, err := res.LastInsertId() | |
| 2009 | if err != nil { | |
| 2010 | return 0, "", "", err | |
| 2011 | } | |
| 2012 | hash := auditHash(prev, id, actorID, action, createdAt, data) | |
| 2013 | if _, err := tx.Exec("UPDATE audit_log SET hash = ? WHERE id = ?", hash, id); err != nil { | |
| 2014 | return 0, "", "", err | |
| 2015 | } | |
| 2016 | return id, createdAt, hash, tx.Commit() | |
| 2017 | } | |
| 2018 | ||
| 2019 | // auditHash covers every column an operator reads, plus the previous | |
| 2020 | // row's hash. A JSON array keeps field boundaries unambiguous. | |
| 2021 | func auditHash(prev string, id, actor int64, action, createdAt, data string) string { | |
| 2022 | b, _ := json.Marshal([]any{prev, id, actor, action, createdAt, data}) | |
| 2023 | sum := sha256.Sum256(b) | |
| 2024 | return hex.EncodeToString(sum[:]) | |
| 2025 | } | |
| 2026 | ||
| 2027 | // AuditChain is what VerifyAuditChain found. | |
| 2028 | type AuditChain struct { | |
| 2029 | Rows int // rows read | |
| 2030 | Unchained int // rows from before migration 0070, which carry no hash | |
| 2031 | First int64 // first chained row; its prev_hash is taken as given, since retention may have removed the row it names | |
| 2032 | Last int64 | |
| 2033 | LastHash string | |
| 2034 | BrokenAt int64 // 0 when the chain is intact | |
| 2035 | Reason string | |
| 2036 | } | |
| 2037 | ||
| 2038 | // VerifyAuditChain recomputes every row's hash in id order and stops at | |
| 2039 | // the first row that does not match. It cannot see rows removed from | |
| 2040 | // the end of the table; the journal copy covers those. | |
| 2041 | func (s *Store) VerifyAuditChain() (AuditChain, error) { | |
| 2042 | rows, err := s.DB.Query(`SELECT id, actor_ref, action, data_json, created_at, prev_hash, hash | |
| 2043 | FROM audit_log ORDER BY id`) | |
| 2044 | if err != nil { | |
| 2045 | return AuditChain{}, err | |
| 2046 | } | |
| 2047 | defer rows.Close() | |
| 2048 | var res AuditChain | |
| 2049 | for rows.Next() { | |
| 2050 | var ( | |
| 2051 | id, actor int64 | |
| 2052 | action, data, createdAt, prev, hash string | |
| 2053 | ) | |
| 2054 | if err := rows.Scan(&id, &actor, &action, &data, &createdAt, &prev, &hash); err != nil { | |
| 2055 | return res, err | |
| 2056 | } | |
| 2057 | res.Rows++ | |
| 2058 | switch { | |
| 2059 | case hash == "" && res.First == 0: | |
| 2060 | res.Unchained++ | |
| 2061 | continue | |
| 2062 | case hash == "": | |
| 2063 | res.BrokenAt, res.Reason = id, "row has no hash after the chain began" | |
| 2064 | case res.First != 0 && prev != res.LastHash: | |
| 2065 | res.BrokenAt, res.Reason = id, "previous hash does not match: a row before it was removed or changed" | |
| 2066 | case auditHash(prev, id, actor, action, createdAt, data) != hash: | |
| 2067 | res.BrokenAt, res.Reason = id, "row contents do not match its hash" | |
| 2068 | } | |
| 2069 | if res.BrokenAt != 0 { | |
| 2070 | return res, nil | |
| 2071 | } | |
| 2072 | if res.First == 0 { | |
| 2073 | res.First = id | |
| 2074 | } | |
| 2075 | res.Last, res.LastHash = id, hash | |
| 2076 | } | |
| 2077 | return res, rows.Err() | |
| 2078 | } | |
| 2079 | ``` | |
| 2080 | ||
| 2081 | The previous `Audit` ignored every error; it still returns nothing, | |
| 2082 | and reports an append failure only where there is a journal to report | |
| 2083 | it to. | |
| 2084 | ||
| 2085 | - [ ] **Step 4: Run the package** | |
| 2086 | ||
| 2087 | Run: `go test ./internal/store -count=1` | |
| 2088 | Expected: PASS, `TestMigrateUpDown` and `TestSweep*` included (the | |
| 2089 | retention sweep still deletes by `created_at`). | |
| 2090 | ||
| 2091 | - [ ] **Step 5: Commit** | |
| 2092 | ||
| 2093 | ```bash | |
| 2094 | git add internal/store | |
| 2095 | git commit -S -m "store: hash-chained audit rows, journal copy, chain verification | |
| 2096 | ||
| 2097 | Ref #275" | |
| 2098 | ``` | |
| 2099 | ||
| 2100 | ### Task 5.2: refused mutating commands are audited, rate-limited per actor | |
| 2101 | ||
| 2102 | **Files:** | |
| 2103 | - Create: `internal/control/auditrefusal.go`, `internal/control/auditrefusal_test.go` | |
| 2104 | - Modify: `internal/control/control.go:153-191` (`Dispatch` from the scope check to the end) | |
| 2105 | ||
| 2106 | **Interfaces:** | |
| 2107 | - Produces: `func AuditRefused(st *store.Store, actorID int64, action string, data map[string]any)` — records at most `refusalsPerMinute` (10) rows per actor per minute, then one `refused.throttled` row for the rest of that minute; a nil store records nothing. | |
| 2108 | - Dispatch audit actions: `"cmd <path>"` on success (unchanged), `"refused <path>"` on exit 4 or exit 3, for commands that are not `ReadOnly`, with data `{"argv", "source", "exit"}`. | |
| 2109 | ||
| 2110 | - [ ] **Step 1: Write the failing tests** | |
| 2111 | ||
| 2112 | `internal/control/auditrefusal_test.go`: | |
| 2113 | ||
| 2114 | ```go | |
| 2115 | package control | |
| 2116 | ||
| 2117 | import ( | |
| 2118 | "testing" | |
| 2119 | ||
| 2120 | "gitbay.org/gitbay/internal/protocol" | |
| 2121 | "gitbay.org/gitbay/internal/store" | |
| 2122 | ) | |
| 2123 | ||
| 2124 | func TestRefusedWritesAreAudited(t *testing.T) { | |
| 2125 | refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | |
| 2126 | st, repo, _ := newQueueTestRepo(t) | |
| 2127 | bobID, err := st.CreateUser("bob", false) | |
| 2128 | if err != nil { | |
| 2129 | t.Fatal(err) | |
| 2130 | } | |
| 2131 | bob := store.User{ID: bobID, Username: "bob"} | |
| 2132 | ||
| 2133 | c, _ := pruneCtx(st, t.TempDir(), bob) | |
| 2134 | if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitDenied { | |
| 2135 | t.Fatalf("exit %d, want %d", code, protocol.ExitDenied) | |
| 2136 | } | |
| 2137 | // A refused read is not a write attempt. | |
| 2138 | c, _ = pruneCtx(st, t.TempDir(), bob) | |
| 2139 | if code := Dispatch(c, []string{"audit"}); code != protocol.ExitDenied { | |
| 2140 | t.Fatalf("audit: exit %d", code) | |
| 2141 | } | |
| 2142 | got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused", Limit: 10}) | |
| 2143 | if err != nil { | |
| 2144 | t.Fatal(err) | |
| 2145 | } | |
| 2146 | if len(got) != 1 || got[0].Action != "refused repo delete" || got[0].Actor != "bob" { | |
| 2147 | t.Fatalf("entries: %+v", got) | |
| 2148 | } | |
| 2149 | } | |
| 2150 | ||
| 2151 | func TestRefusalAuditIsRateLimited(t *testing.T) { | |
| 2152 | refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | |
| 2153 | st, repo, _ := newQueueTestRepo(t) | |
| 2154 | bobID, err := st.CreateUser("bob", false) | |
| 2155 | if err != nil { | |
| 2156 | t.Fatal(err) | |
| 2157 | } | |
| 2158 | for range refusalsPerMinute + 5 { | |
| 2159 | c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"}) | |
| 2160 | Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}) | |
| 2161 | } | |
| 2162 | refused, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 100}) | |
| 2163 | throttled, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused.throttled", Limit: 100}) | |
| 2164 | if len(refused) != refusalsPerMinute || len(throttled) != 1 { | |
| 2165 | t.Fatalf("%d refused rows, %d throttled rows", len(refused), len(throttled)) | |
| 2166 | } | |
| 2167 | } | |
| 2168 | ``` | |
| 2169 | ||
| 2170 | `AuditEntries` with prefix `"refused "` (trailing space) excludes | |
| 2171 | `refused.throttled`. | |
| 2172 | ||
| 2173 | - [ ] **Step 2: Run them and see them fail** | |
| 2174 | ||
| 2175 | Run: `go test ./internal/control -run 'TestRefusedWritesAreAudited|TestRefusalAuditIsRateLimited' -count=1` | |
| 2176 | Expected: build failure, `undefined: refusals`. | |
| 2177 | ||
| 2178 | - [ ] **Step 3: Implement the limiter** | |
| 2179 | ||
| 2180 | `internal/control/auditrefusal.go`: | |
| 2181 | ||
| 2182 | ```go | |
| 2183 | package control | |
| 2184 | ||
| 2185 | import ( | |
| 2186 | "sync" | |
| 2187 | "time" | |
| 2188 | ||
| 2189 | "gitbay.org/gitbay/internal/store" | |
| 2190 | ) | |
| 2191 | ||
| 2192 | // refusalsPerMinute bounds audit rows for refused writes per actor. A | |
| 2193 | // probe is what these rows record, and a loop of probes must not grow | |
| 2194 | // the table without bound. | |
| 2195 | const refusalsPerMinute = 10 | |
| 2196 | ||
| 2197 | type refusalLimiter struct { | |
| 2198 | mu sync.Mutex | |
| 2199 | seen map[int64]*refusalWindow | |
| 2200 | } | |
| 2201 | ||
| 2202 | type refusalWindow struct { | |
| 2203 | start time.Time | |
| 2204 | n int | |
| 2205 | } | |
| 2206 | ||
| 2207 | var refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | |
| 2208 | ||
| 2209 | // allow reports whether to record this refusal, and whether it is the | |
| 2210 | // first one past the limit in the current minute. | |
| 2211 | func (l *refusalLimiter) allow(actor int64, now time.Time) (record, firstDropped bool) { | |
| 2212 | l.mu.Lock() | |
| 2213 | defer l.mu.Unlock() | |
| 2214 | if len(l.seen) > 4096 { | |
| 2215 | for k, w := range l.seen { | |
| 2216 | if now.Sub(w.start) >= time.Minute { | |
| 2217 | delete(l.seen, k) | |
| 2218 | } | |
| 2219 | } | |
| 2220 | } | |
| 2221 | w := l.seen[actor] | |
| 2222 | if w == nil || now.Sub(w.start) >= time.Minute { | |
| 2223 | w = &refusalWindow{start: now} | |
| 2224 | l.seen[actor] = w | |
| 2225 | } | |
| 2226 | w.n++ | |
| 2227 | return w.n <= refusalsPerMinute, w.n == refusalsPerMinute+1 | |
| 2228 | } | |
| 2229 | ||
| 2230 | // AuditRefused records a refused attempt to change something. Past the | |
| 2231 | // per-actor limit it records one refused.throttled row a minute and | |
| 2232 | // drops the rest. Dispatcher tests run without a store. | |
| 2233 | func AuditRefused(st *store.Store, actorID int64, action string, data map[string]any) { | |
| 2234 | if st == nil { | |
| 2235 | return | |
| 2236 | } | |
| 2237 | switch record, first := refusals.allow(actorID, time.Now()); { | |
| 2238 | case record: | |
| 2239 | st.Audit(actorID, action, data) | |
| 2240 | case first: | |
| 2241 | st.Audit(actorID, "refused.throttled", map[string]any{"limit_per_minute": refusalsPerMinute}) | |
| 2242 | } | |
| 2243 | } | |
| 2244 | ``` | |
| 2245 | ||
| 2246 | - [ ] **Step 4: Route every Dispatch refusal through it** | |
| 2247 | ||
| 2248 | In `internal/control/control.go`, everything in `Dispatch` from the | |
| 2249 | scope check (line 154) to the end moves into `runChecked`, and | |
| 2250 | `Dispatch` ends: | |
| 2251 | ||
| 2252 | ```go | |
| 2253 | c.Argv = args | |
| 2254 | code := runChecked(c, cmd, args) | |
| 2255 | if !cmd.ReadOnly { | |
| 2256 | data := map[string]any{"argv": auditArgs(args), "source": c.Source} | |
| 2257 | switch code { | |
| 2258 | case protocol.ExitOK: | |
| 2259 | // Every successful mutating command lands in the audit log. | |
| 2260 | c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), data) | |
| 2261 | case protocol.ExitDenied, protocol.ExitNotFound: | |
| 2262 | // So does every refused one: probing leaves a trace. | |
| 2263 | data["exit"] = code | |
| 2264 | AuditRefused(c.Store, c.User.ID, "refused "+joinPath(cmd.Path), data) | |
| 2265 | } | |
| 2266 | } | |
| 2267 | return code | |
| 2268 | } | |
| 2269 | ||
| 2270 | // runChecked applies the dispatcher's own gates, then runs the command. | |
| 2271 | func runChecked(c *Ctx, cmd Command, args []string) int { | |
| 2272 | // A runner-scoped key reaches the runner protocol and nothing else, so | |
| 2273 | // the key a CI host holds cannot administer the instance. | |
| 2274 | if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { | |
| 2275 | return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope) | |
| 2276 | } | |
| 2277 | if c.ReadOnly && !cmd.ReadOnly { | |
| 2278 | return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) | |
| 2279 | } | |
| 2280 | // The SSH listener refuses a disabled account before it gets here; the | |
| 2281 | // API and the web reach Dispatch directly, so the check lives here too. | |
| 2282 | if c.User.Disabled { | |
| 2283 | return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it") | |
| 2284 | } | |
| 2285 | // The admin noun is gated here as well as in each handler, so a new | |
| 2286 | // admin command that forgets requireInstanceAdmin is still refused. | |
| 2287 | if cmd.Path[0] == "admin" && !c.User.IsAdmin { | |
| 2288 | return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one") | |
| 2289 | } | |
| 2290 | if c.User.Pending && !pendingAllowed(cmd.Path) { | |
| 2291 | return c.fail(protocol.ExitDenied, | |
| 2292 | "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") | |
| 2293 | } | |
| 2294 | if code := limitWrites(c, cmd); code >= 0 { | |
| 2295 | return code | |
| 2296 | } | |
| 2297 | if !cmd.ReadsStdin { | |
| 2298 | c.Stdin = emptyReader{} | |
| 2299 | } | |
| 2300 | return cmd.Run(c, args) | |
| 2301 | } | |
| 2302 | ``` | |
| 2303 | ||
| 2304 | The gates and their messages are unchanged; only their position moves. | |
| 2305 | A successful command's audit data keeps exactly `argv` and `source`. | |
| 2306 | ||
| 2307 | - [ ] **Step 5: Run the package** | |
| 2308 | ||
| 2309 | Run: `go test ./internal/control -count=1` | |
| 2310 | Expected: PASS, including `TestAdminNounGatedInDispatch` and | |
| 2311 | `TestRefusalsHonourJSON` (nil store: `AuditRefused` returns early). | |
| 2312 | ||
| 2313 | - [ ] **Step 6: Commit** | |
| 2314 | ||
| 2315 | ```bash | |
| 2316 | git add internal/control | |
| 2317 | git commit -S -m "control: audit refused mutating commands, rate-limited per actor | |
| 2318 | ||
| 2319 | Ref #275" | |
| 2320 | ``` | |
| 2321 | ||
| 2322 | ### Task 5.3: refused pushes, the daemon's journal, `gitbayd admin audit verify` | |
| 2323 | ||
| 2324 | **Files:** | |
| 2325 | - Modify: `internal/sshd/sshd.go:355-360` (`Exec`, git transport case) | |
| 2326 | - Create: `internal/sshd/refusal_test.go` | |
| 2327 | - Create: `cmd/gitbayd/auditverify.go` | |
| 2328 | - Modify: `cmd/gitbayd/main.go:138-142` (`serveCmd`, after `openStore`), `:416` (`admin audit` registration) | |
| 2329 | - Modify: `e2e/audit_test.go` (new test `TestAuditChainVerify`) | |
| 2330 | ||
| 2331 | **Interfaces:** | |
| 2332 | - Consumes: `control.AuditRefused` (Task 5.2), `store.AuditJournal`, `store.VerifyAuditChain` (Task 5.1). | |
| 2333 | - Produces: `func auditVerifyCmd() *cobra.Command` in package `main`. | |
| 2334 | ||
| 2335 | - [ ] **Step 1: Write the failing sshd test** | |
| 2336 | ||
| 2337 | `internal/sshd/refusal_test.go`: | |
| 2338 | ||
| 2339 | ```go | |
| 2340 | package sshd | |
| 2341 | ||
| 2342 | import ( | |
| 2343 | "bytes" | |
| 2344 | "path/filepath" | |
| 2345 | "strings" | |
| 2346 | "testing" | |
| 2347 | ||
| 2348 | "gitbay.org/gitbay/internal/config" | |
| 2349 | "gitbay.org/gitbay/internal/control" | |
| 2350 | "gitbay.org/gitbay/internal/protocol" | |
| 2351 | "gitbay.org/gitbay/internal/store" | |
| 2352 | ) | |
| 2353 | ||
| 2354 | // execFixture: alice owns the public alice/app; bob has no grant on it. | |
| 2355 | func execFixture(t *testing.T) (config.Config, *store.Store, store.User) { | |
| 2356 | t.Helper() | |
| 2357 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 2358 | if err != nil { | |
| 2359 | t.Fatal(err) | |
| 2360 | } | |
| 2361 | t.Cleanup(func() { st.Close() }) | |
| 2362 | if err := st.MigrateUp(); err != nil { | |
| 2363 | t.Fatal(err) | |
| 2364 | } | |
| 2365 | alice, err := st.CreateUser("alice", false) | |
| 2366 | if err != nil { | |
| 2367 | t.Fatal(err) | |
| 2368 | } | |
| 2369 | if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil { | |
| 2370 | t.Fatal(err) | |
| 2371 | } | |
| 2372 | bobID, err := st.CreateUser("bob", false) | |
| 2373 | if err != nil { | |
| 2374 | t.Fatal(err) | |
| 2375 | } | |
| 2376 | bob, err := st.UserByID(bobID) | |
| 2377 | if err != nil { | |
| 2378 | t.Fatal(err) | |
| 2379 | } | |
| 2380 | cfg := config.Default() | |
| 2381 | cfg.Server.Root = t.TempDir() | |
| 2382 | return cfg, st, bob | |
| 2383 | } | |
| 2384 | ||
| 2385 | func TestRefusedPushIsAudited(t *testing.T) { | |
| 2386 | cfg, st, bob := execFixture(t) | |
| 2387 | var out, errOut bytes.Buffer | |
| 2388 | code := Exec(cfg, st, bob, "full", "SHA256:test", control.Term{}, "git-receive-pack alice/app", | |
| 2389 | strings.NewReader(""), &out, &errOut, nil, nil) | |
| 2390 | if code != protocol.ExitDenied { | |
| 2391 | t.Fatalf("exit %d: %s", code, errOut.String()) | |
| 2392 | } | |
| 2393 | got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5}) | |
| 2394 | if err != nil || len(got) != 1 || got[0].Actor != "bob" || !strings.Contains(got[0].Data, "alice/app") { | |
| 2395 | t.Fatalf("entries %+v, %v", got, err) | |
| 2396 | } | |
| 2397 | } | |
| 2398 | ``` | |
| 2399 | ||
| 2400 | - [ ] **Step 2: Run it and see it fail** | |
| 2401 | ||
| 2402 | Run: `go test ./internal/sshd -run TestRefusedPushIsAudited -count=1` | |
| 2403 | Expected: FAIL, `entries [] <nil>`. | |
| 2404 | ||
| 2405 | - [ ] **Step 3: Implement in `Exec`** | |
| 2406 | ||
| 2407 | ```go | |
| 2408 | case "git-upload-pack", "git-receive-pack", "git-upload-archive": | |
| 2409 | if user.Pending { | |
| 2410 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | |
| 2411 | return protocol.ExitDenied | |
| 2412 | } | |
| 2413 | code := runGit(cfg, st, user, scope, argv, stdin, stdout, stderr) | |
| 2414 | if argv[0] == "git-receive-pack" && (code == protocol.ExitDenied || code == protocol.ExitNotFound) { | |
| 2415 | control.AuditRefused(st, user.ID, "refused git-receive-pack", | |
| 2416 | map[string]any{"argv": argv[1:], "source": source}) | |
| 2417 | } | |
| 2418 | return code | |
| 2419 | ``` | |
| 2420 | ||
| 2421 | Run: `go test ./internal/sshd -count=1` | |
| 2422 | Expected: PASS. | |
| 2423 | ||
| 2424 | - [ ] **Step 4: Write the failing e2e test** | |
| 2425 | ||
| 2426 | Append to `e2e/audit_test.go` (imports gain `path/filepath` — already | |
| 2427 | there — and `gitbay.org/gitbay/internal/store`): | |
| 2428 | ||
| 2429 | ```go | |
| 2430 | // The audit log is a hash chain: gitbayd admin audit verify passes on | |
| 2431 | // an untouched log and names the first row that was edited (#275). | |
| 2432 | func TestAuditChainVerify(t *testing.T) { | |
| 2433 | t.Parallel() | |
| 2434 | inst := startInstance(t) | |
| 2435 | aliceKey := inst.newKey(t, "alice") | |
| 2436 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | |
| 2437 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 2438 | t.Fatal("repo create failed") | |
| 2439 | } | |
| 2440 | if out := inst.admin(t, "admin", "audit", "verify"); !strings.Contains(out, "intact") { | |
| 2441 | t.Fatalf("verify: %s", out) | |
| 2442 | } | |
| 2443 | ||
| 2444 | st, err := store.Open(filepath.Join(inst.root, "gitbay.db")) | |
| 2445 | if err != nil { | |
| 2446 | t.Fatal(err) | |
| 2447 | } | |
| 2448 | var id int64 | |
| 2449 | if err := st.DB.QueryRow("SELECT id FROM audit_log WHERE action = 'cmd repo create'").Scan(&id); err != nil { | |
| 2450 | t.Fatal(err) | |
| 2451 | } | |
| 2452 | if _, err := st.DB.Exec("UPDATE audit_log SET data_json = '{}' WHERE id = ?", id); err != nil { | |
| 2453 | t.Fatal(err) | |
| 2454 | } | |
| 2455 | st.Close() | |
| 2456 | out := inst.forgedAdminErr(t, "admin", "audit", "verify") | |
| 2457 | if !strings.Contains(out, fmt.Sprintf("row %d", id)) { | |
| 2458 | t.Fatalf("verify after edit: %s", out) | |
| 2459 | } | |
| 2460 | } | |
| 2461 | ``` | |
| 2462 | ||
| 2463 | (`fmt` is added to the file's imports.) | |
| 2464 | ||
| 2465 | - [ ] **Step 5: Run it and see it fail** | |
| 2466 | ||
| 2467 | Run: `go build ./... && go test ./e2e -run TestAuditChainVerify -count=1` | |
| 2468 | Expected: FAIL, `gitbayd [admin audit verify]: exit status 2` — the | |
| 2469 | host `audit` command reads `verify` as an unknown argument. | |
| 2470 | ||
| 2471 | - [ ] **Step 6: Implement the verify command and the journal** | |
| 2472 | ||
| 2473 | `cmd/gitbayd/auditverify.go`: | |
| 2474 | ||
| 2475 | ```go | |
| 2476 | package main | |
| 2477 | ||
| 2478 | import ( | |
| 2479 | "fmt" | |
| 2480 | ||
| 2481 | "github.com/spf13/cobra" | |
| 2482 | ||
| 2483 | "gitbay.org/gitbay/internal/config" | |
| 2484 | ) | |
| 2485 | ||
| 2486 | // auditVerifyCmd recomputes the audit log's hash chain. A break names | |
| 2487 | // the first row that does not match; rows removed from the end of the | |
| 2488 | // log leave no break, and the journal copy is the record for those. | |
| 2489 | func auditVerifyCmd() *cobra.Command { | |
| 2490 | return &cobra.Command{ | |
| 2491 | Use: "verify", | |
| 2492 | Short: "check the audit log's hash chain", | |
| 2493 | Args: cobra.NoArgs, | |
| 2494 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 2495 | cfg, err := config.Load(configPath) | |
| 2496 | if err != nil { | |
| 2497 | return err | |
| 2498 | } | |
| 2499 | st, err := openStore(cfg) | |
| 2500 | if err != nil { | |
| 2501 | return err | |
| 2502 | } | |
| 2503 | defer st.Close() | |
| 2504 | res, err := st.VerifyAuditChain() | |
| 2505 | if err != nil { | |
| 2506 | return err | |
| 2507 | } | |
| 2508 | fmt.Printf("read %d rows (%d from before the chain)\n", res.Rows, res.Unchained) | |
| 2509 | if res.BrokenAt != 0 { | |
| 2510 | return fmt.Errorf("chain broken at row %d: %s", res.BrokenAt, res.Reason) | |
| 2511 | } | |
| 2512 | if res.Last == 0 { | |
| 2513 | fmt.Println("no chained rows yet") | |
| 2514 | return nil | |
| 2515 | } | |
| 2516 | fmt.Printf("chain intact from row %d to row %d\nlast hash %s\n", res.First, res.Last, res.LastHash) | |
| 2517 | return nil | |
| 2518 | }, | |
| 2519 | } | |
| 2520 | } | |
| 2521 | ``` | |
| 2522 | ||
| 2523 | `cmd/gitbayd/main.go`, in `adminCmd`, replace the `hostCmd("audit …")` | |
| 2524 | entry in `admin.AddCommand(…)` with a variable built before the call: | |
| 2525 | ||
| 2526 | ```go | |
| 2527 | auditCmd := hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit") | |
| 2528 | auditCmd.AddCommand(auditVerifyCmd()) | |
| 2529 | ``` | |
| 2530 | ||
| 2531 | and pass `auditCmd` in its place. cobra resolves `verify` as the child | |
| 2532 | before the parent's passthrough arguments are considered, so | |
| 2533 | `gitbayd admin audit --limit 5` is unchanged. | |
| 2534 | ||
| 2535 | In `serveCmd`, after `defer st.Close()`: | |
| 2536 | ||
| 2537 | ```go | |
| 2538 | // The daemon's stderr is the service journal: a copy of each | |
| 2539 | // audit row outside the database the daemon can write. | |
| 2540 | st.AuditJournal = slog.Default() | |
| 2541 | ``` | |
| 2542 | ||
| 2543 | `gitbayd shell` and host `gitbayd admin` commands leave it unset: | |
| 2544 | their stderr is the SSH client or the operator's terminal (open | |
| 2545 | question 1). | |
| 2546 | ||
| 2547 | - [ ] **Step 7: Run it** | |
| 2548 | ||
| 2549 | Run: `go build ./... && go vet ./... && go test ./cmd/gitbayd ./internal/sshd ./internal/control ./internal/store -count=1 && go test ./e2e -run TestAuditChainVerify -count=1` | |
| 2550 | Expected: PASS. | |
| 2551 | ||
| 2552 | - [ ] **Step 8: Docs** | |
| 2553 | ||
| 2554 | `Admin.org`, under `* Audit and account control`, the first paragraph | |
| 2555 | becomes: | |
| 2556 | ||
| 2557 | ```org | |
| 2558 | The audit log is the security feed (events are the product feed): every | |
| 2559 | successful mutating command with its argv and source credential (SSH key | |
| 2560 | fingerprint or API), every refused one (exit 3 or 4) as =refused | |
| 2561 | <command>=, refused pushes as =refused git-receive-pack=, registrations, | |
| 2562 | admin actions, force-pushes, and auth failures/throttling. Refusals are | |
| 2563 | recorded up to ten a minute per account; past that, one | |
| 2564 | =refused.throttled= row stands for the rest of the minute. Secrets | |
| 2565 | never appear — they travel on stdin, never in argv. | |
| 2566 | ||
| 2567 | Each row carries the SHA-256 of the row before it. =gitbayd admin audit | |
| 2568 | verify= recomputes the chain and names the first row that was edited or | |
| 2569 | whose predecessor was removed; it prints the last hash, which an | |
| 2570 | operator can note elsewhere. Retention removing the oldest rows is not | |
| 2571 | a break. Rows removed from the end leave no break, so the daemon also | |
| 2572 | logs every row to its journal (=journalctl -u gitbayd -g '^.*msg=audit'=), | |
| 2573 | outside the database it writes. Rows written before the chain existed | |
| 2574 | are counted and skipped. | |
| 2575 | ``` | |
| 2576 | ||
| 2577 | and add to the command block: | |
| 2578 | ||
| 2579 | ```org | |
| 2580 | gitbayd admin audit verify # check the hash chain; exit 1 names the first bad row | |
| 2581 | ``` | |
| 2582 | ||
| 2583 | `Architecture/09-Controls.org`, the two Logging rows: | |
| 2584 | ||
| 2585 | ```org | |
| 2586 | | Denied attempts audited | in place | refused mutating commands and pushes, ten a minute per actor (=internal/control/auditrefusal.go=) | | |
| 2587 | | Audit log tamper resistance | partial | hash chain checked by =gitbayd admin audit verify=; every row copied to the journal; the table itself is writable by the daemon user | | |
| 2588 | ``` | |
| 2589 | ||
| 2590 | `Architecture/06-Data-and-Cryptography.org`, the Audit and feed row | |
| 2591 | (line 21): append ", a hash chain (=prev_hash=, =hash=)" to its | |
| 2592 | description column. | |
| 2593 | ||
| 2594 | `Architecture/10-Known-Gaps.org`: delete the `#275` row. | |
| 2595 | ||
| 2596 | - [ ] **Step 9: Commit, MR, merge** | |
| 2597 | ||
| 2598 | ```bash | |
| 2599 | git add internal/sshd cmd/gitbayd e2e/audit_test.go .gitbay/wiki | |
| 2600 | git commit -S -m "audit: refused pushes, journal copy, admin audit verify | |
| 2601 | ||
| 2602 | Closes #275" | |
| 2603 | git push -u origin audit-refusals-chain | |
| 2604 | gitbay mr create --source audit-refusals-chain --target main --title "audit: record refusals; hash-chain the log" | |
| 2605 | ``` | |
| 2606 | ||
| 2607 | Merge `--strategy ff` after CI, delete the branch both places. | |
| 2608 | ||
| 2609 | --- | |
| 2610 | ||
| 2611 | # MR 6: one limit on pack generation (branch `pack-limit`, closes #262) | |
| 2612 | ||
| 2613 | ### Task 6.1: `internal/packlimit` | |
| 2614 | ||
| 2615 | **Files:** | |
| 2616 | - Create: `internal/packlimit/packlimit.go`, `internal/packlimit/packlimit_test.go` | |
| 2617 | ||
| 2618 | **Interfaces:** | |
| 2619 | - Produces: | |
| 2620 | - `var ErrBusy error`, `var ErrGone error` | |
| 2621 | - `func New(max, per, queue int, wait time.Duration) *Limiter` — nil when `max <= 0` (no limit); `per <= 0` means no per-principal cap; `queue` is how many may wait (0: none). | |
| 2622 | - `func (l *Limiter) Acquire(done <-chan struct{}, principal string) (release func(), err error)` — nil receiver never waits; `release` is idempotent. | |
| 2623 | ||
| 2624 | - [ ] **Step 1: Write the failing tests** | |
| 2625 | ||
| 2626 | ```go | |
| 2627 | package packlimit | |
| 2628 | ||
| 2629 | import ( | |
| 2630 | "errors" | |
| 2631 | "testing" | |
| 2632 | "time" | |
| 2633 | ) | |
| 2634 | ||
| 2635 | func TestGlobalCap(t *testing.T) { | |
| 2636 | l := New(2, 0, 0, time.Second) | |
| 2637 | r1, err1 := l.Acquire(nil, "a") | |
| 2638 | r2, err2 := l.Acquire(nil, "b") | |
| 2639 | if err1 != nil || err2 != nil { | |
| 2640 | t.Fatal(err1, err2) | |
| 2641 | } | |
| 2642 | if _, err := l.Acquire(nil, "c"); !errors.Is(err, ErrBusy) { | |
| 2643 | t.Fatalf("third with no queue: %v", err) | |
| 2644 | } | |
| 2645 | r1() | |
| 2646 | r1() // a second release is a no-op | |
| 2647 | r3, err := l.Acquire(nil, "c") | |
| 2648 | if err != nil { | |
| 2649 | t.Fatal(err) | |
| 2650 | } | |
| 2651 | if _, err := l.Acquire(nil, "d"); !errors.Is(err, ErrBusy) { | |
| 2652 | t.Fatalf("double release freed two slots: %v", err) | |
| 2653 | } | |
| 2654 | r2() | |
| 2655 | r3() | |
| 2656 | } | |
| 2657 | ||
| 2658 | func TestPerPrincipalCap(t *testing.T) { | |
| 2659 | l := New(4, 1, 4, 50*time.Millisecond) | |
| 2660 | ra, err := l.Acquire(nil, "a") | |
| 2661 | if err != nil { | |
| 2662 | t.Fatal(err) | |
| 2663 | } | |
| 2664 | defer ra() | |
| 2665 | if _, err := l.Acquire(nil, "a"); !errors.Is(err, ErrBusy) { | |
| 2666 | t.Fatalf("second for a: %v", err) | |
| 2667 | } | |
| 2668 | rb, err := l.Acquire(nil, "b") | |
| 2669 | if err != nil { | |
| 2670 | t.Fatalf("b blocked by a: %v", err) | |
| 2671 | } | |
| 2672 | rb() | |
| 2673 | } | |
| 2674 | ||
| 2675 | func TestWaiterGetsReleasedSlot(t *testing.T) { | |
| 2676 | l := New(1, 0, 1, 5*time.Second) | |
| 2677 | r1, _ := l.Acquire(nil, "a") | |
| 2678 | got := make(chan error, 1) | |
| 2679 | go func() { | |
| 2680 | r, err := l.Acquire(nil, "b") | |
| 2681 | if err == nil { | |
| 2682 | r() | |
| 2683 | } | |
| 2684 | got <- err | |
| 2685 | }() | |
| 2686 | waitQueued(t, l, 1) | |
| 2687 | r1() | |
| 2688 | select { | |
| 2689 | case err := <-got: | |
| 2690 | if err != nil { | |
| 2691 | t.Fatal(err) | |
| 2692 | } | |
| 2693 | case <-time.After(2 * time.Second): | |
| 2694 | t.Fatal("waiter never got the slot") | |
| 2695 | } | |
| 2696 | } | |
| 2697 | ||
| 2698 | func TestQueueIsBounded(t *testing.T) { | |
| 2699 | l := New(1, 0, 1, 5*time.Second) | |
| 2700 | r1, _ := l.Acquire(nil, "a") | |
| 2701 | defer r1() | |
| 2702 | go l.Acquire(nil, "b") | |
| 2703 | waitQueued(t, l, 1) | |
| 2704 | if _, err := l.Acquire(nil, "c"); !errors.Is(err, ErrBusy) { | |
| 2705 | t.Fatalf("queue over its bound: %v", err) | |
| 2706 | } | |
| 2707 | } | |
| 2708 | ||
| 2709 | // A principal cannot fill the queue on its own. | |
| 2710 | func TestPrincipalQueueIsBounded(t *testing.T) { | |
| 2711 | l := New(1, 1, 8, 5*time.Second) | |
| 2712 | r1, _ := l.Acquire(nil, "x") | |
| 2713 | defer r1() | |
| 2714 | go l.Acquire(nil, "a") | |
| 2715 | waitQueued(t, l, 1) | |
| 2716 | if _, err := l.Acquire(nil, "a"); !errors.Is(err, ErrBusy) { | |
| 2717 | t.Fatalf("second waiter for a: %v", err) | |
| 2718 | } | |
| 2719 | } | |
| 2720 | ||
| 2721 | func TestClientGoneWhileQueued(t *testing.T) { | |
| 2722 | l := New(1, 0, 1, 5*time.Second) | |
| 2723 | r1, _ := l.Acquire(nil, "a") | |
| 2724 | defer r1() | |
| 2725 | done := make(chan struct{}) | |
| 2726 | close(done) | |
| 2727 | if _, err := l.Acquire(done, "b"); !errors.Is(err, ErrGone) { | |
| 2728 | t.Fatalf("got %v, want ErrGone", err) | |
| 2729 | } | |
| 2730 | } | |
| 2731 | ||
| 2732 | func TestWaitRunsOut(t *testing.T) { | |
| 2733 | l := New(1, 0, 1, 20*time.Millisecond) | |
| 2734 | r1, _ := l.Acquire(nil, "a") | |
| 2735 | defer r1() | |
| 2736 | if _, err := l.Acquire(nil, "b"); !errors.Is(err, ErrBusy) { | |
| 2737 | t.Fatalf("got %v, want ErrBusy", err) | |
| 2738 | } | |
| 2739 | } | |
| 2740 | ||
| 2741 | func TestNilLimiterNeverWaits(t *testing.T) { | |
| 2742 | var l *Limiter | |
| 2743 | if l = New(0, 1, 1, time.Second); l != nil { | |
| 2744 | t.Fatal("max 0 should mean no limit") | |
| 2745 | } | |
| 2746 | r, err := l.Acquire(nil, "a") | |
| 2747 | if err != nil { | |
| 2748 | t.Fatal(err) | |
| 2749 | } | |
| 2750 | r() | |
| 2751 | } | |
| 2752 | ||
| 2753 | func waitQueued(t *testing.T, l *Limiter, n int) { | |
| 2754 | t.Helper() | |
| 2755 | deadline := time.Now().Add(2 * time.Second) | |
| 2756 | for time.Now().Before(deadline) { | |
| 2757 | l.mu.Lock() | |
| 2758 | q := l.queued | |
| 2759 | l.mu.Unlock() | |
| 2760 | if q == n { | |
| 2761 | return | |
| 2762 | } | |
| 2763 | time.Sleep(time.Millisecond) | |
| 2764 | } | |
| 2765 | t.Fatalf("queue never reached %d", n) | |
| 2766 | } | |
| 2767 | ``` | |
| 2768 | ||
| 2769 | - [ ] **Step 2: Run them and see them fail** | |
| 2770 | ||
| 2771 | Run: `go test ./internal/packlimit -count=1` | |
| 2772 | Expected: `no non-test Go files` / build failure. | |
| 2773 | ||
| 2774 | - [ ] **Step 3: Implement** | |
| 2775 | ||
| 2776 | `internal/packlimit/packlimit.go`: | |
| 2777 | ||
| 2778 | ```go | |
| 2779 | // Package packlimit bounds concurrent git pack generation. upload-pack | |
| 2780 | // and upload-archive over SSH, smart HTTP and git:// draw on one | |
| 2781 | // budget: a global cap, a cap per principal (an account, or a client | |
| 2782 | // address on the anonymous transports), and a bounded queue whose | |
| 2783 | // waiters give up after a fixed wait or when the client goes away. | |
| 2784 | // Waiters are not served in order; the wait bounds how long any one | |
| 2785 | // of them waits. | |
| 2786 | package packlimit | |
| 2787 | ||
| 2788 | import ( | |
| 2789 | "errors" | |
| 2790 | "sync" | |
| 2791 | "time" | |
| 2792 | ) | |
| 2793 | ||
| 2794 | var ( | |
| 2795 | ErrBusy = errors.New("the server is busy generating packs for other clients; try again in a minute") | |
| 2796 | ErrGone = errors.New("client went away while queued") | |
| 2797 | ) | |
| 2798 | ||
| 2799 | type Limiter struct { | |
| 2800 | max, per, queue int | |
| 2801 | wait time.Duration | |
| 2802 | ||
| 2803 | mu sync.Mutex | |
| 2804 | running int | |
| 2805 | queued int | |
| 2806 | held map[string]int // running, per principal | |
| 2807 | waiting map[string]int // queued, per principal | |
| 2808 | changed chan struct{} // closed and replaced on every release | |
| 2809 | } | |
| 2810 | ||
| 2811 | // New returns a limiter, or nil — no limit — when max is not positive. | |
| 2812 | func New(max, per, queue int, wait time.Duration) *Limiter { | |
| 2813 | if max <= 0 { | |
| 2814 | return nil | |
| 2815 | } | |
| 2816 | return &Limiter{max: max, per: per, queue: queue, wait: wait, | |
| 2817 | held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{})} | |
| 2818 | } | |
| 2819 | ||
| 2820 | // Acquire takes a slot for principal, queueing when none is free. | |
| 2821 | // release is called once git has exited. done, when it closes, ends | |
| 2822 | // the wait. | |
| 2823 | func (l *Limiter) Acquire(done <-chan struct{}, principal string) (release func(), err error) { | |
| 2824 | if l == nil { | |
| 2825 | return func() {}, nil | |
| 2826 | } | |
| 2827 | l.mu.Lock() | |
| 2828 | if l.fits(principal) { | |
| 2829 | l.take(principal) | |
| 2830 | l.mu.Unlock() | |
| 2831 | return l.releaser(principal), nil | |
| 2832 | } | |
| 2833 | if l.queued >= l.queue || (l.per > 0 && l.waiting[principal] >= l.per) { | |
| 2834 | l.mu.Unlock() | |
| 2835 | return nil, ErrBusy | |
| 2836 | } | |
| 2837 | l.queued++ | |
| 2838 | l.waiting[principal]++ | |
| 2839 | l.mu.Unlock() | |
| 2840 | defer func() { | |
| 2841 | l.mu.Lock() | |
| 2842 | l.queued-- | |
| 2843 | if l.waiting[principal]--; l.waiting[principal] == 0 { | |
| 2844 | delete(l.waiting, principal) | |
| 2845 | } | |
| 2846 | l.mu.Unlock() | |
| 2847 | }() | |
| 2848 | ||
| 2849 | timer := time.NewTimer(l.wait) | |
| 2850 | defer timer.Stop() | |
| 2851 | for { | |
| 2852 | l.mu.Lock() | |
| 2853 | if l.fits(principal) { | |
| 2854 | l.take(principal) | |
| 2855 | l.mu.Unlock() | |
| 2856 | return l.releaser(principal), nil | |
| 2857 | } | |
| 2858 | changed := l.changed | |
| 2859 | l.mu.Unlock() | |
| 2860 | select { | |
| 2861 | case <-changed: | |
| 2862 | case <-timer.C: | |
| 2863 | return nil, ErrBusy | |
| 2864 | case <-done: | |
| 2865 | return nil, ErrGone | |
| 2866 | } | |
| 2867 | } | |
| 2868 | } | |
| 2869 | ||
| 2870 | func (l *Limiter) fits(principal string) bool { | |
| 2871 | return l.running < l.max && (l.per <= 0 || l.held[principal] < l.per) | |
| 2872 | } | |
| 2873 | ||
| 2874 | func (l *Limiter) take(principal string) { | |
| 2875 | l.running++ | |
| 2876 | l.held[principal]++ | |
| 2877 | } | |
| 2878 | ||
| 2879 | func (l *Limiter) releaser(principal string) func() { | |
| 2880 | var once sync.Once | |
| 2881 | return func() { | |
| 2882 | once.Do(func() { | |
| 2883 | l.mu.Lock() | |
| 2884 | defer l.mu.Unlock() | |
| 2885 | l.running-- | |
| 2886 | if l.held[principal]--; l.held[principal] == 0 { | |
| 2887 | delete(l.held, principal) | |
| 2888 | } | |
| 2889 | close(l.changed) | |
| 2890 | l.changed = make(chan struct{}) | |
| 2891 | }) | |
| 2892 | } | |
| 2893 | } | |
| 2894 | ``` | |
| 2895 | ||
| 2896 | - [ ] **Step 4: Run it, with the race detector** | |
| 2897 | ||
| 2898 | Run: `go test -race ./internal/packlimit -count=3` | |
| 2899 | Expected: PASS. | |
| 2900 | ||
| 2901 | - [ ] **Step 5: Commit** | |
| 2902 | ||
| 2903 | ```bash | |
| 2904 | git add internal/packlimit | |
| 2905 | git commit -S -m "packlimit: global and per-principal limit with a bounded queue | |
| 2906 | ||
| 2907 | Ref #262" | |
| 2908 | ``` | |
| 2909 | ||
| 2910 | ### Task 6.2: config knobs | |
| 2911 | ||
| 2912 | **Files:** | |
| 2913 | - Modify: `internal/config/config.go:187-209` (`Limits`), `Validate` (after the `max_snippets_per_user` check, line 344-346) | |
| 2914 | - Test: `internal/config/config_test.go` | |
| 2915 | ||
| 2916 | **Interfaces:** | |
| 2917 | - Produces: `Limits.PackConcurrency`, `PackPerPrincipal`, `PackQueue int`, `PackQueueWait string`; `func (l Limits) PackLimits() (max, per, queue int, wait time.Duration)`; constants `DefaultPackConcurrency = 3`, `DefaultPackPerPrincipal = 2`, `DefaultPackQueue = 32`, `DefaultPackQueueWait = time.Minute`. | |
| 2918 | ||
| 2919 | - [ ] **Step 1: Write the failing tests** | |
| 2920 | ||
| 2921 | ```go | |
| 2922 | func TestPackLimits(t *testing.T) { | |
| 2923 | max, per, queue, wait := Limits{}.PackLimits() | |
| 2924 | if max != DefaultPackConcurrency || per != DefaultPackPerPrincipal || queue != DefaultPackQueue || wait != DefaultPackQueueWait { | |
| 2925 | t.Fatalf("defaults: %d %d %d %s", max, per, queue, wait) | |
| 2926 | } | |
| 2927 | max, per, queue, wait = Limits{PackConcurrency: -1, PackPerPrincipal: -1, PackQueue: -1, PackQueueWait: "5s"}.PackLimits() | |
| 2928 | if max != 0 || per != 0 || queue != 0 || wait != 5*time.Second { | |
| 2929 | t.Fatalf("off: %d %d %d %s", max, per, queue, wait) | |
| 2930 | } | |
| 2931 | max, per, queue, _ = Limits{PackConcurrency: 8, PackPerPrincipal: 3, PackQueue: 64}.PackLimits() | |
| 2932 | if max != 8 || per != 3 || queue != 64 { | |
| 2933 | t.Fatalf("set: %d %d %d", max, per, queue) | |
| 2934 | } | |
| 2935 | } | |
| 2936 | ``` | |
| 2937 | ||
| 2938 | (`config_test.go` imports gain `time`.) And one `TestContradictions` case: | |
| 2939 | ||
| 2940 | ```go | |
| 2941 | { | |
| 2942 | "bad pack_queue_wait", | |
| 2943 | minimal + "\n[limits]\npack_queue_wait = \"soon\"\n", | |
| 2944 | "limits.pack_queue_wait", | |
| 2945 | }, | |
| 2946 | ``` | |
| 2947 | ||
| 2948 | - [ ] **Step 2: Run them and see them fail** | |
| 2949 | ||
| 2950 | Run: `go test ./internal/config -run 'TestPackLimits|TestContradictions' -count=1` | |
| 2951 | Expected: build failure, `PackLimits undefined`. | |
| 2952 | ||
| 2953 | - [ ] **Step 3: Implement** | |
| 2954 | ||
| 2955 | Add to `Limits`: | |
| 2956 | ||
| 2957 | ```go | |
| 2958 | // PackConcurrency caps git pack generation (upload-pack and | |
| 2959 | // upload-archive) running at once across SSH, smart HTTP and git://. | |
| 2960 | // PackPerPrincipal caps it per account, or per client address on the | |
| 2961 | // anonymous transports. PackQueue is how many may wait for a slot, | |
| 2962 | // for at most PackQueueWait ("60s"). For the three counts 0 takes the | |
| 2963 | // default and a negative value turns that bound off. | |
| 2964 | PackConcurrency int `toml:"pack_concurrency"` | |
| 2965 | PackPerPrincipal int `toml:"pack_per_principal"` | |
| 2966 | PackQueue int `toml:"pack_queue"` | |
| 2967 | PackQueueWait string `toml:"pack_queue_wait"` | |
| 2968 | ``` | |
| 2969 | ||
| 2970 | After `DefaultWriteRate`: | |
| 2971 | ||
| 2972 | ```go | |
| 2973 | // Pack generation defaults for a four-core host: a full clone of a large | |
| 2974 | // repository runs git at about 1.5 cores (Performance wiki page). | |
| 2975 | const ( | |
| 2976 | DefaultPackConcurrency = 3 | |
| 2977 | DefaultPackPerPrincipal = 2 | |
| 2978 | DefaultPackQueue = 32 | |
| 2979 | DefaultPackQueueWait = time.Minute | |
| 2980 | ) | |
| 2981 | ``` | |
| 2982 | ||
| 2983 | After `Limits`: | |
| 2984 | ||
| 2985 | ```go | |
| 2986 | // PackLimits resolves the pack_* settings for packlimit.New. A zero | |
| 2987 | // count is no bound. | |
| 2988 | func (l Limits) PackLimits() (max, per, queue int, wait time.Duration) { | |
| 2989 | pick := func(v, def int) int { | |
| 2990 | switch { | |
| 2991 | case v == 0: | |
| 2992 | return def | |
| 2993 | case v < 0: | |
| 2994 | return 0 | |
| 2995 | } | |
| 2996 | return v | |
| 2997 | } | |
| 2998 | wait = DefaultPackQueueWait | |
| 2999 | if d, err := time.ParseDuration(l.PackQueueWait); err == nil && d > 0 { | |
| 3000 | wait = d | |
| 3001 | } | |
| 3002 | return pick(l.PackConcurrency, DefaultPackConcurrency), | |
| 3003 | pick(l.PackPerPrincipal, DefaultPackPerPrincipal), | |
| 3004 | pick(l.PackQueue, DefaultPackQueue), wait | |
| 3005 | } | |
| 3006 | ``` | |
| 3007 | ||
| 3008 | In `Validate`: | |
| 3009 | ||
| 3010 | ```go | |
| 3011 | if w := c.Limits.PackQueueWait; w != "" { | |
| 3012 | if d, err := time.ParseDuration(w); err != nil || d <= 0 { | |
| 3013 | errs = append(errs, fmt.Errorf("limits.pack_queue_wait %q must be a positive duration such as 60s", w)) | |
| 3014 | } | |
| 3015 | } | |
| 3016 | ``` | |
| 3017 | ||
| 3018 | - [ ] **Step 4: Run the package** | |
| 3019 | ||
| 3020 | Run: `go test ./internal/config -count=1` | |
| 3021 | Expected: PASS. | |
| 3022 | ||
| 3023 | - [ ] **Step 5: Commit** | |
| 3024 | ||
| 3025 | ```bash | |
| 3026 | git add internal/config | |
| 3027 | git commit -S -m "config: pack_concurrency, pack_per_principal, pack_queue, pack_queue_wait | |
| 3028 | ||
| 3029 | Ref #262" | |
| 3030 | ``` | |
| 3031 | ||
| 3032 | ### Task 6.3: SSH transports acquire a slot and die with their client | |
| 3033 | ||
| 3034 | **Files:** | |
| 3035 | - Modify: `internal/gitutil/gitutil.go:35-56` (`Transport` takes a context) | |
| 3036 | - Modify: `internal/sshd/sshd.go:34-71` (`Server.packs`, `New`), `:299-313` (`runExec`), `:339-385` (`Exec`), `:387-468` (`runGit`) | |
| 3037 | - Modify: `cmd/gitbayd/system.go:97` | |
| 3038 | - Modify: `internal/sshd/sshd_test.go:65` (`New(cfg, st, nil)`) | |
| 3039 | - Modify: `internal/sshd/refusal_test.go` (Exec call gains `nil` packs; new busy test) | |
| 3040 | ||
| 3041 | **Interfaces:** | |
| 3042 | - Consumes: `packlimit.Limiter`, `packlimit.New` (Task 6.1). | |
| 3043 | - Produces: | |
| 3044 | - `func Transport(ctx context.Context, service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error` | |
| 3045 | - `func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) (*Server, error)` | |
| 3046 | - `func Exec(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, scope, source string, term control.Term, cmdline string, stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int` | |
| 3047 | ||
| 3048 | - [ ] **Step 1: Write the failing test** | |
| 3049 | ||
| 3050 | In `internal/sshd/refusal_test.go`, update `TestRefusedPushIsAudited`'s | |
| 3051 | call to `Exec(cfg, st, nil, bob, …)` and add (imports gain `time` and | |
| 3052 | `gitbay.org/gitbay/internal/packlimit`): | |
| 3053 | ||
| 3054 | ```go | |
| 3055 | func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) { | |
| 3056 | cfg, st, bob := execFixture(t) | |
| 3057 | packs := packlimit.New(1, 0, 0, time.Second) | |
| 3058 | hold, err := packs.Acquire(nil, "ip:elsewhere") | |
| 3059 | if err != nil { | |
| 3060 | t.Fatal(err) | |
| 3061 | } | |
| 3062 | defer hold() | |
| 3063 | var out, errOut bytes.Buffer | |
| 3064 | code := Exec(cfg, st, packs, bob, "full", "SHA256:test", control.Term{}, "git-upload-pack alice/app", | |
| 3065 | strings.NewReader(""), &out, &errOut, nil, nil) | |
| 3066 | if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") { | |
| 3067 | t.Fatalf("exit %d: %q", code, errOut.String()) | |
| 3068 | } | |
| 3069 | } | |
| 3070 | ``` | |
| 3071 | ||
| 3072 | - [ ] **Step 2: Run it and see it fail** | |
| 3073 | ||
| 3074 | Run: `go test ./internal/sshd -run TestCloneRefusedWhenPackSlotsAreFull -count=1` | |
| 3075 | Expected: build failure, too many arguments to `Exec`. | |
| 3076 | ||
| 3077 | - [ ] **Step 3: Implement `Transport(ctx, …)`** | |
| 3078 | ||
| 3079 | ```go | |
| 3080 | func Transport(ctx context.Context, service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error { | |
| 3081 | // (argument building unchanged) | |
| 3082 | cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...) | |
| 3083 | cmd.Env = append(os.Environ(), extraEnv...) | |
| 3084 | cmd.Stdin = stdin | |
| 3085 | cmd.Stdout = stdout | |
| 3086 | cmd.Stderr = errW | |
| 3087 | return cmd.Run() | |
| 3088 | } | |
| 3089 | ``` | |
| 3090 | ||
| 3091 | The doc comment gains: "ctx ending kills git." | |
| 3092 | ||
| 3093 | - [ ] **Step 4: Implement in sshd** | |
| 3094 | ||
| 3095 | `Server` gains `packs *packlimit.Limiter`; `New`: | |
| 3096 | ||
| 3097 | ```go | |
| 3098 | func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) (*Server, error) { | |
| 3099 | s := &Server{cfg: cfg, st: st, packs: packs, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}, stopping: make(chan struct{})} | |
| 3100 | ``` | |
| 3101 | ||
| 3102 | `runExec` passes it: `return Exec(s.cfg, s.st, s.packs, user, …, done, s.stopping)`. | |
| 3103 | ||
| 3104 | `Exec` takes `packs` after `st` and passes `packs, done, stopping` | |
| 3105 | to `runGit`: | |
| 3106 | ||
| 3107 | ```go | |
| 3108 | code := runGit(cfg, st, packs, user, scope, argv, stdin, stdout, stderr, done, stopping) | |
| 3109 | ``` | |
| 3110 | ||
| 3111 | `runGit` signature: | |
| 3112 | ||
| 3113 | ```go | |
| 3114 | func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, scope string, argv []string, | |
| 3115 | stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int { | |
| 3116 | ``` | |
| 3117 | ||
| 3118 | and after the pull-mirror refusal (line 439), before `dir :=`: | |
| 3119 | ||
| 3120 | ```go | |
| 3121 | // Pack generation shares one budget with smart HTTP and git://. | |
| 3122 | // receive-pack stays outside it: its post-receive runs after the | |
| 3123 | // client has its report, and must not be queued or killed. | |
| 3124 | ctx := context.Background() | |
| 3125 | if !write { | |
| 3126 | release, err := packs.Acquire(done, "user:"+strconv.FormatInt(user.ID, 10)) | |
| 3127 | if err != nil { | |
| 3128 | fmt.Fprintln(stderr, err) | |
| 3129 | return protocol.ExitFailure | |
| 3130 | } | |
| 3131 | defer release() | |
| 3132 | var cancel context.CancelFunc | |
| 3133 | ctx, cancel = context.WithCancel(ctx) | |
| 3134 | defer cancel() | |
| 3135 | go func() { | |
| 3136 | select { | |
| 3137 | case <-done: | |
| 3138 | // done closes on a restart too; a clone already running | |
| 3139 | // finishes then. Only a departed client ends it. | |
| 3140 | select { | |
| 3141 | case <-stopping: | |
| 3142 | default: | |
| 3143 | cancel() | |
| 3144 | } | |
| 3145 | case <-ctx.Done(): | |
| 3146 | } | |
| 3147 | }() | |
| 3148 | } | |
| 3149 | ``` | |
| 3150 | ||
| 3151 | and the transport call becomes | |
| 3152 | `gitutil.Transport(ctx, service, dir, stdin, stdout, stderr, env, maxPack)`. | |
| 3153 | ||
| 3154 | `internal/sshd/sshd.go` imports `gitbay.org/gitbay/internal/packlimit`. | |
| 3155 | ||
| 3156 | `cmd/gitbayd/system.go:97`: | |
| 3157 | ||
| 3158 | ```go | |
| 3159 | // Each forced command is its own process, so there is no | |
| 3160 | // shared pack budget in system mode (see Admin, [limits]). | |
| 3161 | code := sshd.Exec(cfg, st, nil, user, key.Scope, key.Fingerprint, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil) | |
| 3162 | ``` | |
| 3163 | ||
| 3164 | `internal/sshd/sshd_test.go:65`: `srv, err := New(cfg, st, nil)`. | |
| 3165 | ||
| 3166 | `cmd/gitbayd/main.go:208`: `srv, err := sshd.New(cfg, st, nil)`, so | |
| 3167 | this commit builds; Task 6.5 passes the shared limiter. | |
| 3168 | ||
| 3169 | - [ ] **Step 5: Run the packages** | |
| 3170 | ||
| 3171 | Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/gitutil -count=1` | |
| 3172 | Expected: PASS. | |
| 3173 | ||
| 3174 | - [ ] **Step 6: Commit** | |
| 3175 | ||
| 3176 | ```bash | |
| 3177 | git add internal/gitutil internal/sshd cmd/gitbayd/system.go cmd/gitbayd/main.go | |
| 3178 | git commit -S -m "sshd: upload-pack and upload-archive take a pack slot; killed when the client leaves | |
| 3179 | ||
| 3180 | Ref #262" | |
| 3181 | ``` | |
| 3182 | ||
| 3183 | ### Task 6.4: smart HTTP and git:// acquire a slot; ls-refs does not | |
| 3184 | ||
| 3185 | **Files:** | |
| 3186 | - Modify: `internal/httpd/smart.go:25-38` (`Server.packs`, `New`), `:122-146` (`uploadPack`) | |
| 3187 | - Create: `internal/httpd/packlimit_test.go` | |
| 3188 | - Modify: `internal/gitd/gitd.go:22-27` (`Server.packs`, `New`), `:64-77` (`handle`) | |
| 3189 | - Create: `internal/gitd/gitd_test.go` | |
| 3190 | ||
| 3191 | **Interfaces:** | |
| 3192 | - Consumes: `packlimit` (Task 6.1). | |
| 3193 | - Produces: `func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server` in both `httpd` and `gitd`; unexported `lsRefs(br *bufio.Reader) bool` in `httpd`. | |
| 3194 | ||
| 3195 | - [ ] **Step 1: Write the failing tests** | |
| 3196 | ||
| 3197 | `internal/httpd/packlimit_test.go`: | |
| 3198 | ||
| 3199 | ```go | |
| 3200 | package httpd | |
| 3201 | ||
| 3202 | import ( | |
| 3203 | "net/http" | |
| 3204 | "net/http/httptest" | |
| 3205 | "path/filepath" | |
| 3206 | "strings" | |
| 3207 | "testing" | |
| 3208 | "time" | |
| 3209 | ||
| 3210 | "gitbay.org/gitbay/internal/config" | |
| 3211 | "gitbay.org/gitbay/internal/packlimit" | |
| 3212 | "gitbay.org/gitbay/internal/store" | |
| 3213 | ) | |
| 3214 | ||
| 3215 | func busyServer(t *testing.T) *Server { | |
| 3216 | t.Helper() | |
| 3217 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 3218 | if err != nil { | |
| 3219 | t.Fatal(err) | |
| 3220 | } | |
| 3221 | t.Cleanup(func() { st.Close() }) | |
| 3222 | if err := st.MigrateUp(); err != nil { | |
| 3223 | t.Fatal(err) | |
| 3224 | } | |
| 3225 | uid, err := st.CreateUser("alice", false) | |
| 3226 | if err != nil { | |
| 3227 | t.Fatal(err) | |
| 3228 | } | |
| 3229 | if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil { | |
| 3230 | t.Fatal(err) | |
| 3231 | } | |
| 3232 | packs := packlimit.New(1, 0, 0, time.Second) | |
| 3233 | hold, err := packs.Acquire(nil, "ip:elsewhere") | |
| 3234 | if err != nil { | |
| 3235 | t.Fatal(err) | |
| 3236 | } | |
| 3237 | t.Cleanup(hold) | |
| 3238 | var cfg config.Config | |
| 3239 | cfg.Server.Root = t.TempDir() | |
| 3240 | return &Server{cfg: cfg, st: st, packs: packs, stopping: make(chan struct{})} | |
| 3241 | } | |
| 3242 | ||
| 3243 | func post(s *Server, body string) *httptest.ResponseRecorder { | |
| 3244 | r := httptest.NewRequest("POST", "/alice/app/git-upload-pack", strings.NewReader(body)) | |
| 3245 | r.SetPathValue("owner", "alice") | |
| 3246 | r.SetPathValue("repo", "app") | |
| 3247 | w := httptest.NewRecorder() | |
| 3248 | s.uploadPack(w, r) | |
| 3249 | return w | |
| 3250 | } | |
| 3251 | ||
| 3252 | func TestUploadPackBusyIs503(t *testing.T) { | |
| 3253 | w := post(busyServer(t), "0000") | |
| 3254 | if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" { | |
| 3255 | t.Fatalf("status %d, Retry-After %q", w.Code, w.Header().Get("Retry-After")) | |
| 3256 | } | |
| 3257 | } | |
| 3258 | ||
| 3259 | // A protocol v2 ref listing generates no pack and is never queued. | |
| 3260 | func TestLsRefsBypassesTheLimit(t *testing.T) { | |
| 3261 | w := post(busyServer(t), "0014command=ls-refs\n0000") | |
| 3262 | if w.Code == http.StatusServiceUnavailable { | |
| 3263 | t.Fatal("ls-refs was held to the pack limit") | |
| 3264 | } | |
| 3265 | } | |
| 3266 | ``` | |
| 3267 | ||
| 3268 | The repository directory does not exist, so the ls-refs request's git | |
| 3269 | exits non-zero; the test asserts only that it was not refused. | |
| 3270 | ||
| 3271 | `internal/gitd/gitd_test.go`: | |
| 3272 | ||
| 3273 | ```go | |
| 3274 | package gitd | |
| 3275 | ||
| 3276 | import ( | |
| 3277 | "fmt" | |
| 3278 | "net" | |
| 3279 | "path/filepath" | |
| 3280 | "strings" | |
| 3281 | "testing" | |
| 3282 | "time" | |
| 3283 | ||
| 3284 | "gitbay.org/gitbay/internal/config" | |
| 3285 | "gitbay.org/gitbay/internal/packlimit" | |
| 3286 | "gitbay.org/gitbay/internal/store" | |
| 3287 | ) | |
| 3288 | ||
| 3289 | func TestBusyAnswersERR(t *testing.T) { | |
| 3290 | st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db")) | |
| 3291 | if err != nil { | |
| 3292 | t.Fatal(err) | |
| 3293 | } | |
| 3294 | defer st.Close() | |
| 3295 | if err := st.MigrateUp(); err != nil { | |
| 3296 | t.Fatal(err) | |
| 3297 | } | |
| 3298 | uid, err := st.CreateUser("alice", false) | |
| 3299 | if err != nil { | |
| 3300 | t.Fatal(err) | |
| 3301 | } | |
| 3302 | repoID, err := st.CreateRepo("user", uid, "app", "public") | |
| 3303 | if err != nil { | |
| 3304 | t.Fatal(err) | |
| 3305 | } | |
| 3306 | if _, err := st.UpdateRepoSettings(repoID, func(rs *store.RepoSettings) { rs.GitDaemon = true }); err != nil { | |
| 3307 | t.Fatal(err) | |
| 3308 | } | |
| 3309 | packs := packlimit.New(1, 0, 0, time.Second) | |
| 3310 | hold, _ := packs.Acquire(nil, "ip:elsewhere") | |
| 3311 | defer hold() | |
| 3312 | ||
| 3313 | s := New(config.Config{Server: config.Server{Root: t.TempDir()}}, st, packs) | |
| 3314 | client, server := net.Pipe() | |
| 3315 | defer client.Close() | |
| 3316 | go s.handle(server) | |
| 3317 | req := "git-upload-pack /alice/app.git\x00host=x\x00" | |
| 3318 | fmt.Fprintf(client, "%04x%s", len(req)+4, req) | |
| 3319 | client.SetReadDeadline(time.Now().Add(5 * time.Second)) | |
| 3320 | line, err := readPktLine(client) | |
| 3321 | if err != nil || !strings.HasPrefix(line, "ERR ") || !strings.Contains(line, "busy") { | |
| 3322 | t.Fatalf("got %q, %v", line, err) | |
| 3323 | } | |
| 3324 | } | |
| 3325 | ``` | |
| 3326 | ||
| 3327 | - [ ] **Step 2: Run them and see them fail** | |
| 3328 | ||
| 3329 | Run: `go test ./internal/httpd -run 'TestUploadPackBusyIs503|TestLsRefsBypassesTheLimit' -count=1; go test ./internal/gitd -run TestBusyAnswersERR -count=1` | |
| 3330 | Expected: build failures, `unknown field packs`. | |
| 3331 | ||
| 3332 | - [ ] **Step 3: Implement in httpd** | |
| 3333 | ||
| 3334 | `Server` gains `packs *packlimit.Limiter`; `New`: | |
| 3335 | ||
| 3336 | ```go | |
| 3337 | func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server { | |
| 3338 | proxies, _ := cfg.HTTP.TrustedProxyNets() // validated at config load | |
| 3339 | return &Server{cfg: cfg, st: st, packs: packs, apiLimit: newAPILimiter(cfg.Limits.APIRate), proxies: proxies, | |
| 3340 | stopping: make(chan struct{})} | |
| 3341 | } | |
| 3342 | ``` | |
| 3343 | ||
| 3344 | `uploadPack`, from the gzip block to the end: | |
| 3345 | ||
| 3346 | ```go | |
| 3347 | body := io.Reader(r.Body) | |
| 3348 | if r.Header.Get("Content-Encoding") == "gzip" { | |
| 3349 | gz, err := gzip.NewReader(body) | |
| 3350 | if err != nil { | |
| 3351 | http.Error(w, "bad gzip body", http.StatusBadRequest) | |
| 3352 | return | |
| 3353 | } | |
| 3354 | defer gz.Close() | |
| 3355 | body = gz | |
| 3356 | } | |
| 3357 | br := bufio.NewReader(body) | |
| 3358 | if !lsRefs(br) { | |
| 3359 | // Waiting ends when the client leaves or the daemon stops, so a | |
| 3360 | // queued clone does not hold up a restart's drain. | |
| 3361 | release, err := s.packs.Acquire(s.until(r), "ip:"+s.clientIP(r)) | |
| 3362 | if err != nil { | |
| 3363 | if errors.Is(err, packlimit.ErrBusy) { | |
| 3364 | w.Header().Set("Retry-After", "30") | |
| 3365 | http.Error(w, err.Error(), http.StatusServiceUnavailable) | |
| 3366 | } | |
| 3367 | return | |
| 3368 | } | |
| 3369 | defer release() | |
| 3370 | } | |
| 3371 | w.Header().Set("Content-Type", "application/x-git-upload-pack-result") | |
| 3372 | w.Header().Set("Cache-Control", "no-cache") | |
| 3373 | dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name) | |
| 3374 | cmd := exec.CommandContext(r.Context(), toolpath.Look("git"), "upload-pack", "--stateless-rpc", dir) | |
| 3375 | cmd.Env = append(os.Environ(), gitProtocolEnv(r)...) | |
| 3376 | cmd.Stdin = br | |
| 3377 | cmd.Stdout = w | |
| 3378 | cmd.Run() | |
| 3379 | } | |
| 3380 | ||
| 3381 | // lsRefs reports whether a protocol v2 request is a ref listing, which | |
| 3382 | // generates no pack. Its first pkt-line is "command=ls-refs". | |
| 3383 | func lsRefs(br *bufio.Reader) bool { | |
| 3384 | const want = "command=ls-refs" | |
| 3385 | head, err := br.Peek(4 + len(want)) | |
| 3386 | return err == nil && string(head[4:]) == want | |
| 3387 | } | |
| 3388 | ``` | |
| 3389 | ||
| 3390 | Imports gain `bufio`, `errors`, and | |
| 3391 | `gitbay.org/gitbay/internal/packlimit`. | |
| 3392 | ||
| 3393 | - [ ] **Step 4: Implement in gitd** | |
| 3394 | ||
| 3395 | ```go | |
| 3396 | type Server struct { | |
| 3397 | cfg config.Config | |
| 3398 | st *store.Store | |
| 3399 | packs *packlimit.Limiter | |
| 3400 | } | |
| 3401 | ||
| 3402 | func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server { | |
| 3403 | return &Server{cfg: cfg, st: st, packs: packs} | |
| 3404 | } | |
| 3405 | ``` | |
| 3406 | ||
| 3407 | In `handle`, after the "repository not exported" check: | |
| 3408 | ||
| 3409 | ```go | |
| 3410 | host, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) | |
| 3411 | release, err := s.packs.Acquire(nil, "ip:"+host) | |
| 3412 | if err != nil { | |
| 3413 | writeErr(conn, err.Error()) | |
| 3414 | return | |
| 3415 | } | |
| 3416 | defer release() | |
| 3417 | ``` | |
| 3418 | ||
| 3419 | `net.Pipe`'s address is `"pipe"`, which `SplitHostPort` rejects; `host` | |
| 3420 | is then empty and the principal is `"ip:"`, which is fine for the test. | |
| 3421 | ||
| 3422 | `cmd/gitbayd/main.go:225` and `:313`: `httpd.New(cfg, st, nil)` and | |
| 3423 | `gitd.New(cfg, st, nil)`, so this commit builds; Task 6.5 passes the | |
| 3424 | shared limiter. | |
| 3425 | ||
| 3426 | - [ ] **Step 5: Run the packages** | |
| 3427 | ||
| 3428 | Run: `go build ./... && go vet ./... && go test ./internal/httpd ./internal/gitd -count=1` | |
| 3429 | Expected: PASS. | |
| 3430 | ||
| 3431 | - [ ] **Step 6: Commit** | |
| 3432 | ||
| 3433 | ```bash | |
| 3434 | git add internal/httpd internal/gitd cmd/gitbayd/main.go | |
| 3435 | git commit -S -m "httpd, gitd: pack generation takes a slot; ls-refs does not | |
| 3436 | ||
| 3437 | Ref #262" | |
| 3438 | ``` | |
| 3439 | ||
| 3440 | ### Task 6.5: one limiter for the daemon; benchmark script; docs | |
| 3441 | ||
| 3442 | **Files:** | |
| 3443 | - Modify: `cmd/gitbayd/main.go` (before `sshd.New`, line 204-208; `httpd.New`, line 225; `gitd.New`, line 313) | |
| 3444 | - Create: `deploy/clonebench.sh` | |
| 3445 | - Modify: `.gitbay/wiki/Admin.org` (`** [limits]`), `.gitbay/wiki/Performance.org`, | |
| 3446 | `.gitbay/wiki/Architecture/09-Controls.org`, `.gitbay/wiki/Architecture/10-Known-Gaps.org` | |
| 3447 | ||
| 3448 | **Interfaces:** | |
| 3449 | - Consumes: `config.Limits.PackLimits()` (Task 6.2), `packlimit.New` (Task 6.1), the three `New` signatures (Tasks 6.3, 6.4). | |
| 3450 | ||
| 3451 | - [ ] **Step 1: Wire the limiter** | |
| 3452 | ||
| 3453 | Before `errCh := make(chan error, 3)`: | |
| 3454 | ||
| 3455 | ```go | |
| 3456 | // One pack-generation budget for SSH, smart HTTP and git://. | |
| 3457 | packs := packlimit.New(cfg.Limits.PackLimits()) | |
| 3458 | ``` | |
| 3459 | ||
| 3460 | then `sshd.New(cfg, st, packs)`, `httpd.New(cfg, st, packs)`, | |
| 3461 | `gitd.New(cfg, st, packs).Serve(gln)`. Import | |
| 3462 | `gitbay.org/gitbay/internal/packlimit`. | |
| 3463 | ||
| 3464 | - [ ] **Step 2: Build, vet, touched packages** | |
| 3465 | ||
| 3466 | Run: `go build ./... && go vet ./... && go test ./cmd/gitbayd ./internal/sshd ./internal/httpd ./internal/gitd ./internal/packlimit ./internal/config ./internal/gitutil -count=1` | |
| 3467 | Expected: PASS. | |
| 3468 | ||
| 3469 | - [ ] **Step 3: Benchmark script** | |
| 3470 | ||
| 3471 | `deploy/clonebench.sh` (mode 0755): | |
| 3472 | ||
| 3473 | ```sh | |
| 3474 | #!/bin/sh | |
| 3475 | # clonebench.sh <clone-url> <n>: start n full bare clones of <clone-url> | |
| 3476 | # at once and print each one's wall time and outcome, then the total. | |
| 3477 | # Run from a machine other than the server, against a public repository. | |
| 3478 | set -eu | |
| 3479 | url=$1 | |
| 3480 | n=$2 | |
| 3481 | dir=$(mktemp -d) | |
| 3482 | trap 'rm -rf "$dir"' EXIT | |
| 3483 | start=$(date +%s) | |
| 3484 | i=1 | |
| 3485 | while [ "$i" -le "$n" ]; do | |
| 3486 | ( | |
| 3487 | s=$(date +%s) | |
| 3488 | if git clone --quiet --bare "$url" "$dir/$i.git" 2>"$dir/$i.err"; then | |
| 3489 | echo "$i ok $(( $(date +%s) - s ))s" | |
| 3490 | else | |
| 3491 | echo "$i failed $(( $(date +%s) - s ))s: $(head -n 1 "$dir/$i.err")" | |
| 3492 | fi | |
| 3493 | ) & | |
| 3494 | i=$((i + 1)) | |
| 3495 | done | |
| 3496 | wait | |
| 3497 | echo "total $(( $(date +%s) - start ))s for $n clones" | |
| 3498 | ``` | |
| 3499 | ||
| 3500 | Run: `sh -n deploy/clonebench.sh` | |
| 3501 | Expected: no output (syntax ok). | |
| 3502 | ||
| 3503 | - [ ] **Step 4: Docs** | |
| 3504 | ||
| 3505 | `Admin.org`, `** [limits]`, add after the `max_bytes_per_user` bullet: | |
| 3506 | ||
| 3507 | ```org | |
| 3508 | - =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32), | |
| 3509 | =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches, | |
| 3510 | =git archive --remote=) over SSH, smart HTTP and git:// shares one | |
| 3511 | budget: this many at once, this many per account (per client | |
| 3512 | address when anonymous), and this many waiting for at most the wait. | |
| 3513 | Past that an SSH client gets "the server is busy…" and exit 1, HTTP | |
| 3514 | gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued | |
| 3515 | client that disconnects leaves the queue; a running clone whose | |
| 3516 | client disconnects is killed. Ref listings (info/refs, protocol v2 | |
| 3517 | =ls-refs=), pushes and web archives are outside the budget. For the | |
| 3518 | three counts 0 means the default and a negative value turns that | |
| 3519 | bound off. The defaults suit a four-core host; see [[Performance]]. | |
| 3520 | With =ssh.mode = "system"= each SSH session is its own process and | |
| 3521 | SSH clones are not counted. | |
| 3522 | ``` | |
| 3523 | ||
| 3524 | `Performance.org`, the last paragraph of `* Why it holds` becomes: | |
| 3525 | ||
| 3526 | ```org | |
| 3527 | The practical ceiling on this hardware is concurrent pack generation: | |
| 3528 | full clones of large repositories are CPU-bound in git itself (the 17s | |
| 3529 | clone ran git at ~156% CPU). =limits.pack_concurrency= bounds how many | |
| 3530 | run at once across SSH, HTTP and git://, with a queue behind it (see | |
| 3531 | [[Admin]], =[limits]=); the measurements below set its default. | |
| 3532 | ``` | |
| 3533 | ||
| 3534 | and a new section at the end: | |
| 3535 | ||
| 3536 | ```org | |
| 3537 | * Concurrent clones | |
| 3538 | ||
| 3539 | Measured with =deploy/clonebench.sh https://gitbay.org/krz/gitbay.git <n>= | |
| 3540 | from a machine outside bay1 (four cores), before and after the pack | |
| 3541 | limit was deployed with its defaults (=pack_concurrency= 3, | |
| 3542 | =pack_per_principal= 2, =pack_queue= 32, =pack_queue_wait= 60s). All | |
| 3543 | clones in one run come from one address, so the per-principal cap | |
| 3544 | applies to them; the "limit off" run sets the counts to -1. | |
| 3545 | ``` | |
| 3546 | ||
| 3547 | The table itself is added by the operator from the runbook's #262 | |
| 3548 | measurements, in the follow-up wiki MR described there. | |
| 3549 | ||
| 3550 | `Architecture/09-Controls.org`, the concurrency row: | |
| 3551 | ||
| 3552 | ```org | |
| 3553 | | Concurrency limit on git pack generation | in place | global, per-principal, bounded queue across SSH, HTTP and git:// (=internal/packlimit=); not in system SSH mode | | |
| 3554 | ``` | |
| 3555 | ||
| 3556 | `Architecture/10-Known-Gaps.org`: delete the `#262` row. The question | |
| 3557 | row "How many concurrent clones does the host sustain?" stays until the | |
| 3558 | runbook's numbers are on the Performance page. | |
| 3559 | ||
| 3560 | - [ ] **Step 5: Commit, MR, merge** | |
| 3561 | ||
| 3562 | ```bash | |
| 3563 | chmod 0755 deploy/clonebench.sh | |
| 3564 | git add cmd/gitbayd/main.go deploy/clonebench.sh .gitbay/wiki | |
| 3565 | git commit -S -m "gitbayd: one pack-generation limit for SSH, HTTP and git:// | |
| 3566 | ||
| 3567 | Closes #262" | |
| 3568 | git push -u origin pack-limit | |
| 3569 | gitbay mr create --source pack-limit --target main --title "git: limit concurrent pack generation across HTTP and SSH" | |
| 3570 | ``` | |
| 3571 | ||
| 3572 | Run the runbook's #262 "before" measurement against production before | |
| 3573 | deploying this MR. Merge `--strategy ff` after CI, delete the branch | |
| 3574 | both places. | |
| 3575 | ||
| 3576 | --- | |
| 3577 | ||
| 3578 | # Runbook for the operator (cmc) | |
| 3579 | ||
| 3580 | The classifier refuses root ssh to bay1 from an assistant session; these | |
| 3581 | steps are run by hand. Operator ssh is `ssh -p 2222 root@gitbay.org`. | |
| 3582 | ||
| 3583 | 1. **Before merging MR 2 (#280).** `grep -A6 '^\[mail\]' /etc/gitbay/config.toml` | |
| 3584 | on bay1. If `smtp_host` is not `localhost`/loopback, check the relay | |
| 3585 | offers STARTTLS: `openssl s_client -starttls smtp -connect <smtp_host> -brief </dev/null` | |
| 3586 | must complete a handshake. If it does not, either set | |
| 3587 | `require_tls = false` in `[mail]` before deploying (and record why | |
| 3588 | on the Admin page), or switch to the relay's implicit-TLS port with | |
| 3589 | `tls = "implicit"`. After deploying, `gitbay dashboard --json | jq .queues` | |
| 3590 | shows no mail failures after the next notification. | |
| 3591 | 2. **Before merging MR 3 (#279).** `git --version` on bay1 must be | |
| 3592 | 2.37 or later (`http.curloptResolve`). If not, upgrade git first; | |
| 3593 | mirrors fail with an unknown-config error otherwise. After | |
| 3594 | deploying, `gitbay repo mirror sync krz/gitbay` then | |
| 3595 | `gitbay repo mirror list krz/gitbay` shows the GitHub push mirror | |
| 3596 | with no error. | |
| 3597 | 3. **Deploying MR 4 (#282).** Check `gitbay build list` and the | |
| 3598 | journal for a push in progress, then `make deploy`. After it: | |
| 3599 | `stat -c '%a %U' /var/lib/gitbay/hook.sock` shows `600 gitbay`; | |
| 3600 | push a commit to a scratch repository and confirm it lands and its | |
| 3601 | push event appears in `gitbay feed`. | |
| 3602 | 4. **After deploying MR 5 (#275).** On bay1, as the gitbay user: | |
| 3603 | `sudo -u gitbay gitbayd --config /etc/gitbay/config.toml admin audit verify` | |
| 3604 | prints "chain intact" with the unchained count equal to the rows | |
| 3605 | written before the upgrade. `journalctl -u gitbayd -g 'msg=audit' -n 5` | |
| 3606 | shows the rows the verify run's own session produced. Record the | |
| 3607 | printed last hash somewhere off the host (a note in the | |
| 3608 | password manager) if a manual anchor is wanted. | |
| 3609 | 5. **MR 6 (#262) benchmark.** From the laptop, before deploying MR 6: | |
| 3610 | `for n in 1 2 4 8; do sh deploy/clonebench.sh https://gitbay.org/krz/gitbay.git $n; done`, | |
| 3611 | and on bay1 `uptime` during the n=8 run. After deploying MR 6 | |
| 3612 | (defaults), repeat, and additionally n=16 and n=40 (40 exceeds | |
| 3613 | concurrency + queue from one address with per-principal 2 and shows | |
| 3614 | the refusals). Record per n: total wall time, slowest clone, | |
| 3615 | refused count, load average. Put the table under | |
| 3616 | `* Concurrent clones` in `.gitbay/wiki/Performance.org` on a branch | |
| 3617 | `wiki-clone-benchmark`, remove the "How many concurrent clones" | |
| 3618 | question row from `Architecture/10-Known-Gaps.org`, and open an MR | |
| 3619 | with `Ref #262`. If the numbers show the web staying slow with 3 | |
| 3620 | concurrent clones, lower `DefaultPackConcurrency` in the same MR | |
| 3621 | and say so on the Admin page. | |
| 3622 | 6. **After MR 1 (#281).** `openssl s_client -connect gitbay.org:443 -tls1_1 </dev/null` | |
| 3623 | fails; `-tls1_2` and `-tls1_3` succeed. | |
| 3624 | ||
| 3625 | # Release notes for whoever tags these | |
| 3626 | ||
| 3627 | - mail: `mail.require_tls` defaults on for non-local relays; a relay | |
| 3628 | without STARTTLS stops receiving mail unless `require_tls = false`. | |
| 3629 | New `mail.tls = "implicit"` for port 465. | |
| 3630 | - mirror: git ≥ 2.37 required on the server; mirrors no longer follow | |
| 3631 | redirects. | |
| 3632 | - hookd: pushes in flight across the upgrade lose their post-receive | |
| 3633 | effects; deploy with none running. | |
| 3634 | - audit: schema 0070 adds the chain; rows before it are reported as | |
| 3635 | unchained by `gitbayd admin audit verify`. | |
| 3636 | - limits: new `pack_*` settings with non-zero defaults; a burst of | |
| 3637 | clones now queues and, past the queue, is refused with 503 / exit 1. | |
| 3638 | ||
| 3639 | # Open questions | |
| 3640 | ||
| 3641 | 1. **System SSH mode.** With `ssh.mode = "system"` every session is a | |
| 3642 | separate `gitbayd shell` process, so (a) the pack limiter cannot | |
| 3643 | count SSH clones across sessions — this plan passes `nil` and says | |
| 3644 | so on the Admin page — and (b) audit rows written there are not | |
| 3645 | copied to the journal, because that process's stderr is the SSH | |
| 3646 | client. The same applies to host `gitbayd admin …` commands (stderr | |
| 3647 | is the operator's terminal). Options: file-lock slots under | |
| 3648 | `<root>/packslots/` for (a), and `log/syslog` (journald collects it) | |
| 3649 | for (b). gitbay.org runs embedded mode, so neither is needed there. | |
| 3650 | Decide whether system mode needs them. | |
| 3651 | 2. **Default pack limits.** 3 / 2 / 32 / 60s are an estimate from the | |
| 3652 | one measured full clone (~1.5 cores). The runbook's benchmark | |
| 3653 | decides whether they stand. | |
| 3654 | 3. **receive-pack and the limit.** index-pack on a large push is also | |
| 3655 | CPU-bound, but pushes need an account with write access and | |
| 3656 | killing or queueing receive-pack risks post-receive. This plan keeps | |
| 3657 | pushes outside the limit. Confirm. | |
| 3658 | 4. **bay1's mail relay and git version** are not visible from the | |
| 3659 | source tree; runbook steps 1 and 2 answer them before the matching | |
| 3660 | MRs merge. | |
| 3661 | ||
| 3662 | # Self-review | |
| 3663 | ||
| 3664 | - Coverage against the issues: #281 MinVersion + Admin (MR 1). #280 | |
| 3665 | require_tls default by locality, implicit TLS (MR 2). #279 resolve | |
| 3666 | and check before each sync, pin for git, http(s) only per | |
| 3667 | `ValidateURL` (MR 3). #282 chmod 0600, SO_PEERCRED behind build | |
| 3668 | tags, per-push token minted in `runGit` and required by hookd, works | |
| 3669 | in both SSH modes through SQLite (MR 4). #275 refusals audited with | |
| 3670 | per-actor limit, hash chain with `actor_ref`, `gitbayd admin audit | |
| 3671 | verify`, journal copy from the daemon (MR 5). #262 global and | |
| 3672 | per-principal limit, bounded queue, cancellation while queued (done / | |
| 3673 | request context / stop) and while running (SSH ctx, HTTP request | |
| 3674 | context), ls-refs and info/refs outside, knobs with 4-core defaults, | |
| 3675 | benchmark script and Performance section, results via runbook (MR 6). | |
| 3676 | - Signatures used across tasks: `packlimit.New(max, per, queue int, wait time.Duration)` | |
| 3677 | and `Limits.PackLimits() (max, per, queue int, wait time.Duration)` | |
| 3678 | match; `Acquire(done <-chan struct{}, principal string)` is called | |
| 3679 | with `done` (SSH), `s.until(r)` (HTTP), `nil` (git://, tests). | |
| 3680 | `Exec` gains `packs` in MR 6 only; MR 5's test call is updated in | |
| 3681 | Task 6.3 Step 1. `CreatePushToken` returns the raw token and | |
| 3682 | `DeletePushToken` takes the raw token in both sshd and tests. | |
| 3683 | - Migrations 0069/0070 are within plan 3's range; renumber if another | |
| 3684 | plan has taken them by then. | |
| 3685 | - No new route, template, ReadOnly command, control command or stdin | |
| 3686 | reader, so no registry rows. | |
docs/plans/2026-09-27-web-ux.md added +3045
| @@ -0,0 +1,3045 @@ | ||
| 1 | # Web UX sweep implementation plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Close out the architecture-review web findings (#261), the two | |
| 6 | web UX-review issues (#263, #271), the empty-state sweep (#270), the | |
| 7 | missing API-token page (#264), the MR range-diff view (#269), and the | |
| 8 | wiki non-page-link 404 (#283). | |
| 9 | ||
| 10 | **Architecture:** No new subsystems. Every write goes through | |
| 11 | `s.runControl`/`s.runControlCode` into the existing control registry | |
| 12 | (`internal/httpd/control.go`), the same rule every other web write | |
| 13 | already follows — this plan fixes the three handlers that did not | |
| 14 | (`pinToggle`, `watchToggle`, and adds a mute state to the latter). Reads | |
| 15 | either dispatch into a command (range-diff, whose text output the CLI | |
| 16 | and iOS already render, so the web reuses it rather than re-implementing | |
| 17 | revision resolution) or read the store directly, matching the existing | |
| 18 | convention for GET handlers (`accountPage` already reads | |
| 19 | `ListSSHKeys`/`ListPGPKeys` directly). Template copy changes are text-only; | |
| 20 | one new page (`mrrangediff.html`) and one new settings section | |
| 21 | (`Settings → Tokens`) are added following the existing settings-page and | |
| 22 | repo-page patterns. | |
| 23 | ||
| 24 | **Tech stack:** Go, `html/template`, the existing SQLite store, cobra | |
| 25 | (`cmd/gitbay`). | |
| 26 | ||
| 27 | **Spec:** none — this plan is written directly from the issue texts | |
| 28 | (`.gitbay/wiki` doc drift, architecture-review findings) and the current | |
| 29 | source; there is no separate design doc. | |
| 30 | ||
| 31 | ## Global constraints | |
| 32 | ||
| 33 | - Five MRs (one skipped: this plan issues #261/#263/#269/#270/#271/#283 | |
| 34 | are covered by five MRs; #264 is a sixth), each on its own branch off | |
| 35 | `main`. Commits are signed (the repository refuses unsigned ones); | |
| 36 | messages end with `Ref #N`, and the last commit closing an issue ends | |
| 37 | with `Closes #N`. No attribution to any assistant, model or AI | |
| 38 | anywhere: commits, MR bodies, comments. | |
| 39 | - `gitbay mr create --source <branch> --target main --title "..."`; | |
| 40 | merge with `gitbay mr merge <n> --strategy ff` once CI is green, then | |
| 41 | delete the branch locally and on the remote. If the merge reports the | |
| 42 | branch is behind, rebase onto `main`, force-push, merge again. | |
| 43 | - Locally: `go build ./...`, `go vet ./...`, the unit tests of touched | |
| 44 | packages, and at most the one e2e test being written | |
| 45 | (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full | |
| 46 | suite (`go test ./...`), including `TestMainWidthClass`, | |
| 47 | `e2e/readonly_test.go`'s `readArgs` coverage, and the `cmd/gitbay` | |
| 48 | coverage test over `pass()` registrations — none of this plan's tasks | |
| 49 | add a new control command, so none of those three registries gain a | |
| 50 | new required row, but a new page template (`mrrangediff.html`) **does** | |
| 51 | need a row in `TestMainWidthClass`'s width map | |
| 52 | (`internal/web/web_test.go`). | |
| 53 | - No migrations in this plan (no schema changes). | |
| 54 | - Web writes dispatch through control commands | |
| 55 | (`internal/httpd/control.go`: `runControl`/`runControlCode`/ | |
| 56 | `runControlStdin`); a handler that calls the store directly for a | |
| 57 | *write* is exactly the bug #261 reports for `pinToggle` and | |
| 58 | `watchToggle`, and this plan does not introduce a new instance of it. | |
| 59 | Reads may call the store directly (the existing convention throughout | |
| 60 | `internal/httpd`) or dispatch when the logic they need (e.g. revision | |
| 61 | resolution for range-diff) already lives in a command. | |
| 62 | - Secrets travel on stdin or, for the one case that already carries one | |
| 63 | in a URL by design (the emailed login link), never in a place the | |
| 64 | code cannot document and cache-guard; never logged. | |
| 65 | - Wiki pages live in `.gitbay/wiki/`: `Parity.org`, `API.org`, | |
| 66 | `Threat-Model.org`. Update the page in the same MR that changes the | |
| 67 | behaviour it describes. | |
| 68 | - Writing style: plain, direct, no hype; UI copy uses the register fixed | |
| 69 | in MR 4 (Task 4.1) everywhere else it appears afterward. | |
| 70 | - CLAUDE.md's rules apply throughout: surgical changes only, no | |
| 71 | unrelated refactors, no speculative flexibility. | |
| 72 | ||
| 73 | ## Order and dependencies | |
| 74 | ||
| 75 | 1. **`web-audit-fixes`** (branch `web-audit-fixes`) — closes #261. | |
| 76 | Independent. Landing this first matters because MR 6 (#271's mute | |
| 77 | option) depends on the toggle-dispatch fix here. | |
| 78 | 2. **`web-settings-commands`** (branch `web-settings-commands`) — closes | |
| 79 | #263. Independent of 1. Lands after `cli-ux-help` (CLI UX plan), | |
| 80 | which carries the auth summary and help part of #263. | |
| 81 | 3. **`web-mr-range-diff`** (branch `web-mr-range-diff`) — closes #269. | |
| 82 | Independent. | |
| 83 | 4. **`web-empty-states`** (branch `web-empty-states`) — closes #270. | |
| 84 | Independent, but touches `mrs.html` and `globalsearch.html`; land | |
| 85 | before MR 6 to avoid the same files diverging on two branches at | |
| 86 | once (MR 6 does not touch either). | |
| 87 | 5. **`web-ux-small-fixes`** (branch `web-ux-small-fixes`) — closes #271. | |
| 88 | Depends on MR 1 (`repo watch`/`repo mute` dispatch and the cycling | |
| 89 | toggle it introduces; #271's Muted option builds directly on it). | |
| 90 | 6. **`wiki-raw-links`** (branch `wiki-raw-links`) — closes #283. | |
| 91 | Independent; small, can land anywhere, placed last only because it | |
| 92 | is unrelated to the rest. | |
| 93 | 7. **`web-api-tokens`** (branch `web-api-tokens`) — closes #264. | |
| 94 | Depends on plan 1 (`credentials-and-sessions`, #257): that plan | |
| 95 | changes `token create`'s own default scope to `read`. This plan's | |
| 96 | Task 7.1 makes the *web form* always send an explicit `--scope` | |
| 97 | value regardless of what the command defaults to, so this MR does | |
| 98 | not have to wait for plan 1 to land — but merge it after plan 1 to | |
| 99 | pick up the release-note and any command-message changes plan 1 | |
| 100 | makes to `token create`. If plan 1 has not landed yet, this MR still | |
| 101 | works correctly (the form never relies on the flag's default); note | |
| 102 | in the MR description that it does not depend on plan 1 having | |
| 103 | merged, only on eventually being consistent with it. | |
| 104 | ||
| 105 | --- | |
| 106 | ||
| 107 | # MR 1: architecture-review small fixes (branch `web-audit-fixes`) | |
| 108 | ||
| 109 | Closes #261. | |
| 110 | ||
| 111 | ### Task 1.1: run `foreign_key_check` inside the migration transaction, before commit | |
| 112 | ||
| 113 | **Files:** | |
| 114 | - Modify: `internal/store/store.go:182-259` (`step`, the `fkOff` branch) | |
| 115 | - Test: `internal/store/store_test.go` (create the case if no existing | |
| 116 | migration test exercises an `fkOff` step; check first) | |
| 117 | ||
| 118 | **Interfaces:** | |
| 119 | - No signature changes; `step`'s behaviour changes only. | |
| 120 | ||
| 121 | - [ ] **Step 1: Confirm there is no existing FK-violation test to build on** | |
| 122 | ||
| 123 | Run: `grep -n "foreign_key_check\|fkOff\|foreign_keys: off" internal/store/store_test.go` | |
| 124 | If nothing matches, the test below is new. | |
| 125 | ||
| 126 | - [ ] **Step 2: Write the failing test** | |
| 127 | ||
| 128 | Add to `internal/store/store_test.go`: | |
| 129 | ||
| 130 | ```go | |
| 131 | // A migration marked "-- foreign_keys: off" must have its | |
| 132 | // foreign_key_check run before the transaction commits, not after — | |
| 133 | // otherwise a violation is reported once the bad schema and | |
| 134 | // user_version are already persisted (#261). | |
| 135 | func TestFKOffMigrationChecksBeforeCommit(t *testing.T) { | |
| 136 | dir := t.TempDir() | |
| 137 | st, err := Open(dir + "/test.db") | |
| 138 | if err != nil { | |
| 139 | t.Fatal(err) | |
| 140 | } | |
| 141 | defer st.Close() | |
| 142 | ||
| 143 | // A minimal two-step schema: a parent table, then a child that | |
| 144 | // references it, or el se this migration wouldn't exercise anything. | |
| 145 | // Reach in through the exported entry point rather than duplicating | |
| 146 | // migration internals: two ad hoc migrations appended to the real | |
| 147 | // list would require touching the embedded migration files, so this | |
| 148 | // test instead runs the real migration set up to its current head | |
| 149 | // and then drives step() through a synthetic single-migration | |
| 150 | // upgrade using the unexported hook the package already has for | |
| 151 | // tests, if one exists. | |
| 152 | if err := st.MigrateUp(); err != nil { | |
| 153 | t.Fatal(err) | |
| 154 | } | |
| 155 | before, err := st.DB.Query("PRAGMA user_version") | |
| 156 | if err != nil { | |
| 157 | t.Fatal(err) | |
| 158 | } | |
| 159 | before.Close() | |
| 160 | ||
| 161 | // Insert a row through a raw statement that a fkOff rebuild would | |
| 162 | // have to preserve or complain about: a milestone with no matching | |
| 163 | // repo_id (the deliberately impossible case a corrupt migration | |
| 164 | // would produce). | |
| 165 | if _, err := st.DB.Exec("PRAGMA foreign_keys = OFF"); err != nil { | |
| 166 | t.Fatal(err) | |
| 167 | } | |
| 168 | if _, err := st.DB.Exec( | |
| 169 | "INSERT INTO milestones (repo_id, title, state, created_at) VALUES (99999, 'orphan', 'open', datetime('now'))"); err != nil { | |
| 170 | t.Fatal(err) | |
| 171 | } | |
| 172 | if _, err := st.DB.Exec("PRAGMA foreign_keys = ON"); err != nil { | |
| 173 | t.Fatal(err) | |
| 174 | } | |
| 175 | ||
| 176 | // A no-op fkOff step (rewriting milestones to itself) must now | |
| 177 | // refuse — before it commits, not after — because the orphan row | |
| 178 | // fails foreign_key_check. Confirm today's ordering leaves the | |
| 179 | // schema version bumped despite the row it can never satisfy: this | |
| 180 | // is the bug. Run the check directly the way step() will, and | |
| 181 | // compare against the version left behind. | |
| 182 | versionBefore := currentUserVersion(t, st) | |
| 183 | err = st.runFKOffStepForTest( | |
| 184 | "UPDATE sqlite_master SET name = name WHERE 0", versionBefore+1) | |
| 185 | if err == nil { | |
| 186 | t.Fatal("expected foreign_key_check to refuse the orphaned row") | |
| 187 | } | |
| 188 | if got := currentUserVersion(t, st); got != versionBefore { | |
| 189 | t.Fatalf("user_version changed to %d despite the refused check (should stay %d)", got, versionBefore) | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | func currentUserVersion(t *testing.T, st *Store) int { | |
| 194 | t.Helper() | |
| 195 | var v int | |
| 196 | if err := st.DB.QueryRow("PRAGMA user_version").Scan(&v); err != nil { | |
| 197 | t.Fatal(err) | |
| 198 | } | |
| 199 | return v | |
| 200 | } | |
| 201 | ``` | |
| 202 | ||
| 203 | This calls an unexported `runFKOffStepForTest` that does not exist yet — | |
| 204 | it is Step 3's job to expose the already-unexported `step` closure's | |
| 205 | `fkOff` path under a name the test package can call. `step` is currently | |
| 206 | a closure local to `MigrateTo`; Step 3 promotes it to a package-level | |
| 207 | method so this test (and the real migration loop) can call the same | |
| 208 | code. | |
| 209 | ||
| 210 | - [ ] **Step 2: Run it and see it fail to compile** | |
| 211 | ||
| 212 | Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1` | |
| 213 | Expected: FAIL to compile (`runFKOffStepForTest` undefined). | |
| 214 | ||
| 215 | - [ ] **Step 3: Promote `step` to a method and fix the ordering** | |
| 216 | ||
| 217 | In `internal/store/store.go`, replace the `step` closure inside | |
| 218 | `MigrateTo` (the whole `step := func(sqlText string, newVersion int, | |
| 219 | fkOff bool) (retErr error) { ... }` block at lines 182-259) with a call | |
| 220 | to a new method, and move its body there: | |
| 221 | ||
| 222 | ```go | |
| 223 | func (s *Store) migrateStep(sqlText string, newVersion int, fkOff bool) (retErr error) { | |
| 224 | if !fkOff { | |
| 225 | tx, err := s.DB.Begin() | |
| 226 | if err != nil { | |
| 227 | return err | |
| 228 | } | |
| 229 | defer tx.Rollback() | |
| 230 | if _, err := tx.Exec(sqlText); err != nil { | |
| 231 | return err | |
| 232 | } | |
| 233 | if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil { | |
| 234 | return err | |
| 235 | } | |
| 236 | return tx.Commit() | |
| 237 | } | |
| 238 | ||
| 239 | // A script whose first line is "-- foreign_keys: off" rebuilds a | |
| 240 | // table that other tables reference (labels, milestones): with | |
| 241 | // foreign keys on, the rebuild-by-rename loses the children's | |
| 242 | // rows. PRAGMA foreign_keys is a no-op inside a transaction, and | |
| 243 | // the pool gives no guarantee that a pragma set on one connection | |
| 244 | // is seen by the connection Begin() draws next, so the whole step | |
| 245 | // — pragma off, transaction, foreign_key_check, commit, pragma on — | |
| 246 | // runs on a single pinned connection. The check runs before commit: | |
| 247 | // checking after would report a violation once the bad schema and | |
| 248 | // user_version were already persisted. | |
| 249 | ctx := context.Background() | |
| 250 | conn, err := s.DB.Conn(ctx) | |
| 251 | if err != nil { | |
| 252 | return err | |
| 253 | } | |
| 254 | defer conn.Close() | |
| 255 | if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil { | |
| 256 | return err | |
| 257 | } | |
| 258 | // The connection goes back to the pool when this returns, so every | |
| 259 | // path out of here has to put foreign keys back on first. | |
| 260 | restoreFK := func() error { | |
| 261 | _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON") | |
| 262 | return err | |
| 263 | } | |
| 264 | defer func() { | |
| 265 | if err := restoreFK(); err != nil && retErr == nil { | |
| 266 | retErr = err | |
| 267 | } | |
| 268 | }() | |
| 269 | tx, err := conn.BeginTx(ctx, nil) | |
| 270 | if err != nil { | |
| 271 | return err | |
| 272 | } | |
| 273 | defer tx.Rollback() | |
| 274 | if _, err := tx.Exec(sqlText); err != nil { | |
| 275 | return err | |
| 276 | } | |
| 277 | if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil { | |
| 278 | return err | |
| 279 | } | |
| 280 | // foreign_key_check works with enforcement off: it inspects the | |
| 281 | // data directly rather than consulting the pragma. Running it here, | |
| 282 | // inside the transaction, means a violation rolls back the whole | |
| 283 | // rebuild (deferred tx.Rollback fires) instead of leaving the bad | |
| 284 | // schema and version committed. | |
| 285 | rows, err := tx.QueryContext(ctx, "PRAGMA foreign_key_check") | |
| 286 | if err != nil { | |
| 287 | return err | |
| 288 | } | |
| 289 | if rows.Next() { | |
| 290 | var table string | |
| 291 | var rowid sql.NullInt64 | |
| 292 | var referredTable string | |
| 293 | var fkid int | |
| 294 | if err := rows.Scan(&table, &rowid, &referredTable, &fkid); err != nil { | |
| 295 | rows.Close() | |
| 296 | return err | |
| 297 | } | |
| 298 | rows.Close() | |
| 299 | return fmt.Errorf("foreign_key_check failed after migration: %s", table) | |
| 300 | } | |
| 301 | if err := rows.Err(); err != nil { | |
| 302 | rows.Close() | |
| 303 | return err | |
| 304 | } | |
| 305 | rows.Close() | |
| 306 | return tx.Commit() | |
| 307 | } | |
| 308 | ||
| 309 | // runFKOffStepForTest exposes migrateStep's fkOff path to the package's | |
| 310 | // own tests, which need to drive one step in isolation rather than the | |
| 311 | // whole migration list MigrateTo runs. | |
| 312 | func (s *Store) runFKOffStepForTest(sqlText string, newVersion int) error { | |
| 313 | return s.migrateStep(sqlText, newVersion, true) | |
| 314 | } | |
| 315 | ``` | |
| 316 | ||
| 317 | Update `MigrateTo`'s two loops to call the method instead of the removed | |
| 318 | closure: | |
| 319 | ||
| 320 | ```go | |
| 321 | for cur < target { | |
| 322 | m := ms[cur] | |
| 323 | if err := s.migrateStep(m.up, m.version, m.upFKOff); err != nil { | |
| 324 | return fmt.Errorf("migration %d up: %w", m.version, err) | |
| 325 | } | |
| 326 | cur = m.version | |
| 327 | } | |
| 328 | for cur > target { | |
| 329 | m := ms[cur-1] | |
| 330 | if err := s.migrateStep(m.down, m.version-1, m.downFKOff); err != nil { | |
| 331 | return fmt.Errorf("migration %d down: %w", m.version, err) | |
| 332 | } | |
| 333 | cur = m.version - 1 | |
| 334 | } | |
| 335 | ``` | |
| 336 | ||
| 337 | `runFKOffStepForTest` is exported to the test file only in the sense | |
| 338 | that it is an ordinary method in a `_test.go`-adjacent non-test file, so | |
| 339 | it ships in the binary; that is acceptable here since it is a one-line | |
| 340 | wrapper with no side effect beyond calling the real path, and keeping it | |
| 341 | out of the production file would mean either duplicating `migrateStep` | |
| 342 | in a test-only file or using an unexported test hook pattern the package | |
| 343 | does not otherwise have. If review prefers it test-only, move it to | |
| 344 | `internal/store/storetest_export_test.go` (package `store`) instead — | |
| 345 | functionally identical either way. | |
| 346 | ||
| 347 | - [ ] **Step 4: Run the test** | |
| 348 | ||
| 349 | Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1` | |
| 350 | Expected: PASS (the orphan row now fails the check before commit, and | |
| 351 | `user_version` is left unchanged because `tx.Rollback()` fires). | |
| 352 | ||
| 353 | - [ ] **Step 5: Run the full package** | |
| 354 | ||
| 355 | Run: `go test ./internal/store -count=1` | |
| 356 | Expected: PASS — this is a reordering, not a behaviour change, for every | |
| 357 | migration that does not already violate its own foreign keys. | |
| 358 | ||
| 359 | - [ ] **Step 6: Commit** | |
| 360 | ||
| 361 | ```bash | |
| 362 | git add internal/store/store.go internal/store/store_test.go | |
| 363 | git commit -m "store: run foreign_key_check inside the migration transaction, before commit" -m "Ref #261" | |
| 364 | ``` | |
| 365 | ||
| 366 | ### Task 1.2: `pinToggle` and `watchToggle` dispatch through their commands | |
| 367 | ||
| 368 | **Files:** | |
| 369 | - Modify: `internal/httpd/accounts.go:241-250` (`pinToggle`) | |
| 370 | - Modify: `internal/httpd/notifyweb.go:61-72` (`watchToggle`) | |
| 371 | - Test: `internal/httpd/accounts_test.go` or `internal/httpd/account_test.go` | |
| 372 | (check which file already has repo-toggle tests; add beside them) | |
| 373 | ||
| 374 | **Interfaces:** | |
| 375 | - Consumes: `s.runControl` (`internal/httpd/control.go:25`, already used | |
| 376 | by `bookmarkToggle`, the correct existing model for this fix). | |
| 377 | - Produces: no new exported names; `watchToggle`'s behaviour becomes a | |
| 378 | three-way cycle (`""` → `watching` → `muted` → `""`), which Task 5.x | |
| 379 | in MR 5 (`web-ux-small-fixes`) builds on to expose "Muted" as a | |
| 380 | reachable state rather than adding a new endpoint. | |
| 381 | ||
| 382 | - [ ] **Step 1: Write the failing tests** | |
| 383 | ||
| 384 | Add to `internal/httpd/accounts_test.go` (create the file if repo pin/watch | |
| 385 | tests do not already live somewhere; check with | |
| 386 | `grep -rln "pinToggle\|watchToggle" internal/httpd/*_test.go` first and | |
| 387 | add beside whatever that finds): | |
| 388 | ||
| 389 | ```go | |
| 390 | package httpd | |
| 391 | ||
| 392 | import ( | |
| 393 | "net/http/httptest" | |
| 394 | "testing" | |
| 395 | ||
| 396 | "gitbay.org/gitbay/internal/config" | |
| 397 | "gitbay.org/gitbay/internal/store" | |
| 398 | ) | |
| 399 | ||
| 400 | // Pinning writes through the repo pin command, not the store directly, | |
| 401 | // so it carries the same audit trail and write budget as every other | |
| 402 | // mutating command (#261). | |
| 403 | func TestPinToggleDispatchesRepoPin(t *testing.T) { | |
| 404 | st, err := store.Open(":memory:") | |
| 405 | if err != nil { | |
| 406 | t.Fatal(err) | |
| 407 | } | |
| 408 | defer st.Close() | |
| 409 | if err := st.MigrateUp(); err != nil { | |
| 410 | t.Fatal(err) | |
| 411 | } | |
| 412 | uid, err := st.CreateUser("alice", false) | |
| 413 | if err != nil { | |
| 414 | t.Fatal(err) | |
| 415 | } | |
| 416 | u := store.User{ID: uid, Username: "alice"} | |
| 417 | if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil { | |
| 418 | t.Fatal(err) | |
| 419 | } | |
| 420 | ||
| 421 | s := New(config.Default(), st) | |
| 422 | req := httptest.NewRequest("POST", "/alice/app/pin", nil) | |
| 423 | req.SetPathValue("owner", "alice") | |
| 424 | req.SetPathValue("repo", "app") | |
| 425 | rr := httptest.NewRecorder() | |
| 426 | s.pinToggle(rr, req, u) | |
| 427 | ||
| 428 | repo, err := st.RepoByPath("alice/app") | |
| 429 | if err != nil { | |
| 430 | t.Fatal(err) | |
| 431 | } | |
| 432 | if !st.IsPinned(uid, repo.ID) { | |
| 433 | t.Fatal("pin did not take effect") | |
| 434 | } | |
| 435 | ||
| 436 | rr2 := httptest.NewRecorder() | |
| 437 | s.pinToggle(rr2, req, u) | |
| 438 | if st.IsPinned(uid, repo.ID) { | |
| 439 | t.Fatal("second toggle should have unpinned") | |
| 440 | } | |
| 441 | } | |
| 442 | ||
| 443 | // The watch button cycles default, watching, muted — the three states | |
| 444 | // repo watch/repo mute/repo unwatch already support — rather than the | |
| 445 | // two the store-writing version offered (#261, #271). | |
| 446 | func TestWatchToggleCyclesThroughMuted(t *testing.T) { | |
| 447 | st, err := store.Open(":memory:") | |
| 448 | if err != nil { | |
| 449 | t.Fatal(err) | |
| 450 | } | |
| 451 | defer st.Close() | |
| 452 | if err := st.MigrateUp(); err != nil { | |
| 453 | t.Fatal(err) | |
| 454 | } | |
| 455 | uid, err := st.CreateUser("alice", false) | |
| 456 | if err != nil { | |
| 457 | t.Fatal(err) | |
| 458 | } | |
| 459 | u := store.User{ID: uid, Username: "alice"} | |
| 460 | if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil { | |
| 461 | t.Fatal(err) | |
| 462 | } | |
| 463 | repo, err := st.RepoByPath("alice/app") | |
| 464 | if err != nil { | |
| 465 | t.Fatal(err) | |
| 466 | } | |
| 467 | ||
| 468 | s := New(config.Default(), st) | |
| 469 | req := httptest.NewRequest("POST", "/alice/app/watch", nil) | |
| 470 | req.SetPathValue("owner", "alice") | |
| 471 | req.SetPathValue("repo", "app") | |
| 472 | ||
| 473 | click := func() string { | |
| 474 | rr := httptest.NewRecorder() | |
| 475 | s.watchToggle(rr, req, u) | |
| 476 | return st.RepoWatchState(repo.ID, uid) | |
| 477 | } | |
| 478 | if got := click(); got != "watching" { | |
| 479 | t.Fatalf("first click: got %q, want watching", got) | |
| 480 | } | |
| 481 | if got := click(); got != "muted" { | |
| 482 | t.Fatalf("second click: got %q, want muted", got) | |
| 483 | } | |
| 484 | if got := click(); got != "" { | |
| 485 | t.Fatalf("third click: got %q, want default (unwatched)", got) | |
| 486 | } | |
| 487 | } | |
| 488 | ``` | |
| 489 | ||
| 490 | (Check `CreateRepo`'s exact signature with | |
| 491 | `grep -n "func (s \*Store) CreateRepo" internal/store/*.go` before | |
| 492 | using it — adjust argument order/names to match if it differs from the | |
| 493 | guess above.) | |
| 494 | ||
| 495 | - [ ] **Step 2: Run and see them fail** | |
| 496 | ||
| 497 | Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1` | |
| 498 | Expected: FAIL — pin toggles once but not twice cleanly is unlikely to | |
| 499 | be the failure; more likely the watch test fails because today's | |
| 500 | `watchToggle` only ever sets `"watching"` or clears it, never `"muted"`. | |
| 501 | ||
| 502 | - [ ] **Step 3: Fix `pinToggle`** | |
| 503 | ||
| 504 | In `internal/httpd/accounts.go`, replace: | |
| 505 | ||
| 506 | ```go | |
| 507 | // pinToggle pins or unpins the repo for the logged-in viewer. | |
| 508 | func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 509 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | |
| 510 | if !ok { | |
| 511 | return | |
| 512 | } | |
| 513 | if s.st.IsPinned(u.ID, repo.ID) { | |
| 514 | s.st.UnpinRepo(u.ID, repo.ID) | |
| 515 | } else { | |
| 516 | s.st.PinRepo(u.ID, repo.ID) | |
| 517 | } | |
| 518 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | |
| 519 | } | |
| 520 | ``` | |
| 521 | ||
| 522 | with: | |
| 523 | ||
| 524 | ```go | |
| 525 | // pinToggle pins or unpins the repo for the logged-in viewer, through | |
| 526 | // repo pin/repo unpin — the same commands the CLI runs — rather than | |
| 527 | // writing the store directly (#261). | |
| 528 | func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 529 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | |
| 530 | if !ok { | |
| 531 | return | |
| 532 | } | |
| 533 | verb := "pin" | |
| 534 | if s.st.IsPinned(u.ID, repo.ID) { | |
| 535 | verb = "unpin" | |
| 536 | } | |
| 537 | if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok { | |
| 538 | s.setFlash(w, msg) | |
| 539 | } | |
| 540 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | |
| 541 | } | |
| 542 | ``` | |
| 543 | ||
| 544 | - [ ] **Step 4: Fix `watchToggle`** | |
| 545 | ||
| 546 | In `internal/httpd/notifyweb.go`, replace: | |
| 547 | ||
| 548 | ```go | |
| 549 | // watchToggle turns watching a repository on and off from its header, | |
| 550 | // the way the pin button does. | |
| 551 | func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 552 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | |
| 553 | if !ok { | |
| 554 | return | |
| 555 | } | |
| 556 | if s.st.RepoWatchState(repo.ID, u.ID) == "watching" { | |
| 557 | s.st.ClearRepoWatch(repo.ID, u.ID) | |
| 558 | } else { | |
| 559 | s.st.SetRepoWatch(repo.ID, u.ID, "watching") | |
| 560 | } | |
| 561 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | |
| 562 | } | |
| 563 | ``` | |
| 564 | ||
| 565 | with: | |
| 566 | ||
| 567 | ```go | |
| 568 | // watchToggle cycles the viewer's watch state on a repository: default, | |
| 569 | // watching, muted, back to default — through repo watch/repo mute/repo | |
| 570 | // unwatch, the same commands the CLI runs (#261, #271). | |
| 571 | func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) { | |
| 572 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) | |
| 573 | if !ok { | |
| 574 | return | |
| 575 | } | |
| 576 | next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"} | |
| 577 | verb := next[s.st.RepoWatchState(repo.ID, u.ID)] | |
| 578 | if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok { | |
| 579 | s.setFlash(w, msg) | |
| 580 | } | |
| 581 | http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) | |
| 582 | } | |
| 583 | ``` | |
| 584 | ||
| 585 | - [ ] **Step 5: Run** | |
| 586 | ||
| 587 | Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1 && go test ./internal/httpd -count=1` | |
| 588 | Expected: PASS. If an existing test asserted the old two-state watch | |
| 589 | behaviour, update its expectation to the three-state cycle rather than | |
| 590 | reverting the fix. | |
| 591 | ||
| 592 | - [ ] **Step 6: Commit** | |
| 593 | ||
| 594 | ```bash | |
| 595 | git add internal/httpd/accounts.go internal/httpd/notifyweb.go internal/httpd/accounts_test.go | |
| 596 | git commit -m "web: pin and watch toggles dispatch through repo pin/watch/mute/unwatch" -m "Ref #261" | |
| 597 | ``` | |
| 598 | ||
| 599 | ### Task 1.3: Cache-Control: no-store on the login-link consuming request | |
| 600 | ||
| 601 | **Files:** | |
| 602 | - Modify: `internal/httpd/accounts.go:123-157` (`login`) | |
| 603 | - Test: `internal/httpd/logincookie_test.go` (add beside its existing | |
| 604 | login tests) | |
| 605 | ||
| 606 | - [ ] **Step 1: Write the failing test** | |
| 607 | ||
| 608 | Add to `internal/httpd/logincookie_test.go`: | |
| 609 | ||
| 610 | ```go | |
| 611 | // The login link's token rides in the query string — the one | |
| 612 | // documented exception to "never in a URL" — so the response that | |
| 613 | // consumes it must never be cached by an intermediary that might log | |
| 614 | // or replay the URL (#261). | |
| 615 | func TestLoginNoStoreHeader(t *testing.T) { | |
| 616 | st, err := store.Open(":memory:") | |
| 617 | if err != nil { | |
| 618 | t.Fatal(err) | |
| 619 | } | |
| 620 | defer st.Close() | |
| 621 | if err := st.MigrateUp(); err != nil { | |
| 622 | t.Fatal(err) | |
| 623 | } | |
| 624 | s := New(config.Default(), st) | |
| 625 | rr := httptest.NewRecorder() | |
| 626 | req := httptest.NewRequest("GET", "/login?token=bogus", nil) | |
| 627 | s.login(rr, req) | |
| 628 | if got := rr.Header().Get("Cache-Control"); got != "no-store" { | |
| 629 | t.Errorf("Cache-Control = %q, want no-store", got) | |
| 630 | } | |
| 631 | } | |
| 632 | ``` | |
| 633 | ||
| 634 | Check the file's existing imports (`config`, `store`, `httptest`, `testing`) | |
| 635 | before adding — they are almost certainly already present given the | |
| 636 | file already tests `/login`. | |
| 637 | ||
| 638 | - [ ] **Step 2: Run and see it fail** | |
| 639 | ||
| 640 | Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1` | |
| 641 | Expected: FAIL (`Cache-Control` header absent). | |
| 642 | ||
| 643 | - [ ] **Step 3: Set the header** | |
| 644 | ||
| 645 | In `internal/httpd/accounts.go`, at the top of `login`: | |
| 646 | ||
| 647 | ```go | |
| 648 | func (s *Server) login(w http.ResponseWriter, r *http.Request) { | |
| 649 | // token, when present, is a single-use secret in the query string — | |
| 650 | // the documented exception to "never in a URL" (Threat-Model). No | |
| 651 | // cache may keep a copy of this response. | |
| 652 | w.Header().Set("Cache-Control", "no-store") | |
| 653 | token := r.URL.Query().Get("token") | |
| 654 | ``` | |
| 655 | ||
| 656 | - [ ] **Step 4: Run** | |
| 657 | ||
| 658 | Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1 && go test ./internal/httpd -count=1` | |
| 659 | Expected: PASS. | |
| 660 | ||
| 661 | - [ ] **Step 5: Commit** | |
| 662 | ||
| 663 | ```bash | |
| 664 | git add internal/httpd/accounts.go internal/httpd/logincookie_test.go | |
| 665 | git commit -m "web: Cache-Control: no-store on the login-link request" -m "Ref #261" | |
| 666 | ``` | |
| 667 | ||
| 668 | ### Task 1.4: doc drift — API.org, Parity.org, Threat-Model.org | |
| 669 | ||
| 670 | **Files:** | |
| 671 | - Modify: `.gitbay/wiki/API.org` (the token-refusal line, near "Git | |
| 672 | transport commands and the token commands are refused by name.") | |
| 673 | - Modify: `.gitbay/wiki/Parity.org` (the "Batched review is not built." | |
| 674 | line; the watch/pin dispatch paragraph at lines 249-251) | |
| 675 | - Modify: `.gitbay/wiki/Threat-Model.org` (the "never as command | |
| 676 | arguments... or query strings" line) | |
| 677 | ||
| 678 | No test — these are prose fixes; CI has no wiki-content check beyond | |
| 679 | what already exists (link and page-name tests in | |
| 680 | `internal/httpd/wiki_test.go`, untouched by this task). | |
| 681 | ||
| 682 | - [ ] **Step 1: Fix API.org's incorrect claim about token commands** | |
| 683 | ||
| 684 | The 401 message (`internal/httpd/api.go:131`, unchanged by this task) | |
| 685 | already reads `missing bearer token; mint one over SSH: token create | |
| 686 | --name <n>` — it does not say token commands are refused on the API, | |
| 687 | because they are not: `token create`/`token list`/`token revoke` all | |
| 688 | dispatch normally through `POST /api/v1/cmd` like any other command | |
| 689 | (minting a token from a token is exactly what a full-scope token can do, | |
| 690 | per `#234`/the "one registry" rule). Replace the false claim: | |
| 691 | ||
| 692 | ``` | |
| 693 | Commands that emit raw text rather than an envelope (=help=, =mr diff=) | |
| 694 | come wrapped as ={"output": "..."}=. Git transport commands are refused | |
| 695 | by name; the token commands are not — a full-scope token can mint, | |
| 696 | list and revoke tokens the same way it can run anything else. | |
| 697 | ``` | |
| 698 | ||
| 699 | (Replaces the sentence "Git transport commands and the token commands | |
| 700 | are refused by name.") | |
| 701 | ||
| 702 | - [ ] **Step 2: Fix Parity.org's stale "batched review" claim** | |
| 703 | ||
| 704 | Find (near "view. Batched review is not built."): | |
| 705 | ||
| 706 | ``` | |
| 707 | =mr range-diff= compares two heads: the iOS client | |
| 708 | shows it from a revision to the one before, as text; the web has no | |
| 709 | view. Batched review is not built. | |
| 710 | ``` | |
| 711 | ||
| 712 | `mr comment --pending`/`--discard` and `PublishPendingComments` | |
| 713 | (`internal/control/mr.go:161-162,1044,1052`) are the batched-review | |
| 714 | mechanism, and the web's diff-comment form already composes pending | |
| 715 | comments before publishing (`internal/httpd/mractions.go`'s | |
| 716 | `mrDiffCommentSubmit`, unchanged by this task — confirm with | |
| 717 | `grep -n "diff-comment\|PendingComments" internal/httpd/*.go`). Replace: | |
| 718 | ||
| 719 | ``` | |
| 720 | =mr range-diff= compares two heads: the iOS client shows it from a | |
| 721 | revision to the one before, as text; the web renders the same view | |
| 722 | (krz/gitbay#269). Batched review — draft diff comments held with | |
| 723 | =mr comment --pending= and sent together with =--comment=/=--discard= | |
| 724 | or a verdict — is built and the web uses it: composing a review comments | |
| 725 | before publishing them is the same round trip as the CLI's =--pending= | |
| 726 | flag. | |
| 727 | ``` | |
| 728 | ||
| 729 | Leave the `mr range-diff` web-view claim as `krz/gitbay#269` for now; | |
| 730 | MR 3 of this plan (`web-mr-range-diff`) lands the actual view and | |
| 731 | updates this sentence again to drop the issue reference — do not | |
| 732 | pre-empt that here, since this task's branch may merge before or after | |
| 733 | MR 3 and the wiki text must describe what is actually deployed at each | |
| 734 | point. (If MR 3 has already merged when this task is done, skip the | |
| 735 | issue-reference wording and write the view as already existing instead; | |
| 736 | check `ls internal/web/templates/mrrangediff.html` first.) | |
| 737 | ||
| 738 | - [ ] **Step 3: Fix the pin/watch dispatch paragraph** | |
| 739 | ||
| 740 | Find (lines 249-251): | |
| 741 | ||
| 742 | ``` | |
| 743 | The web's watch and pin controls write the store directly instead of | |
| 744 | dispatching =repo watch= and =repo pin=. That is why the web cannot | |
| 745 | mute: its toggle knows watching and default only. | |
| 746 | ``` | |
| 747 | ||
| 748 | Replace: | |
| 749 | ||
| 750 | ``` | |
| 751 | The web's watch and pin controls dispatch =repo pin=/=repo unpin= and | |
| 752 | =repo watch=/=repo mute=/=repo unwatch=, the same commands the CLI runs | |
| 753 | (krz/gitbay#261). The single watch button cycles default, watching and | |
| 754 | muted. | |
| 755 | ``` | |
| 756 | ||
| 757 | And update the `mute` row in the capability table (around line 189) | |
| 758 | from: | |
| 759 | ||
| 760 | ``` | |
| 761 | | mute | yes | no | yes | | |
| 762 | ``` | |
| 763 | ||
| 764 | to: | |
| 765 | ||
| 766 | ``` | |
| 767 | | mute | yes | yes | yes | | |
| 768 | ``` | |
| 769 | ||
| 770 | - [ ] **Step 4: Fix Threat-Model.org's "never in a URL" claim** | |
| 771 | ||
| 772 | Find (in "What gitbay never does"): | |
| 773 | ||
| 774 | ``` | |
| 775 | - *Put secrets in argv, URLs, or logs.* Import and mirror credentials, | |
| 776 | registration invites, and API tokens travel on stdin or in request | |
| 777 | bodies, never as command arguments (visible in =/proc=) or query | |
| 778 | strings. Tokens are stored only as SHA-256 hashes. | |
| 779 | ``` | |
| 780 | ||
| 781 | Replace with (documenting the one deliberate exception and its | |
| 782 | mitigations from Task 1.3): | |
| 783 | ||
| 784 | ``` | |
| 785 | - *Put secrets in argv, URLs, or logs, with one documented exception.* | |
| 786 | Import and mirror credentials, registration invites, and API tokens | |
| 787 | travel on stdin or in request bodies, never as command arguments | |
| 788 | (visible in =/proc=) or query strings. The one exception is the | |
| 789 | emailed login link, =/login?token=...=: single-use, 15-minute expiry, | |
| 790 | and the response that consumes it carries =Cache-Control: no-store= so | |
| 791 | no intermediary keeps a copy. An operator running gitbay behind a | |
| 792 | reverse proxy should configure that proxy to strip the query string | |
| 793 | from its own access logs. Tokens are stored only as SHA-256 hashes. | |
| 794 | ``` | |
| 795 | ||
| 796 | - [ ] **Step 5: Commit** | |
| 797 | ||
| 798 | ```bash | |
| 799 | git add .gitbay/wiki/API.org .gitbay/wiki/Parity.org .gitbay/wiki/Threat-Model.org | |
| 800 | git commit -m "wiki: fix API token-refusal claim, batched-review status, watch/pin dispatch, login-link URL exception" -m "Ref #261" | |
| 801 | ``` | |
| 802 | ||
| 803 | ### Task 1.5: open MR 1 | |
| 804 | ||
| 805 | - [ ] **Step 1: Push and open the MR** | |
| 806 | ||
| 807 | ```bash | |
| 808 | git push -u origin web-audit-fixes | |
| 809 | gitbay mr create --source web-audit-fixes --target main --title "Architecture review small fixes: FK check, web toggles, doc drift" | |
| 810 | ``` | |
| 811 | ||
| 812 | - [ ] **Step 2: Wait for CI, merge, delete the branch** | |
| 813 | ||
| 814 | ```bash | |
| 815 | gitbay mr merge <n> --strategy ff | |
| 816 | git branch -d web-audit-fixes | |
| 817 | git push origin --delete web-audit-fixes | |
| 818 | ``` | |
| 819 | ||
| 820 | The last commit in this branch (Task 1.4's) should be amended in message | |
| 821 | only if not already — reference `Closes #261` there instead of `Ref | |
| 822 | #261` before pushing, since this MR closes the issue in full. | |
| 823 | ||
| 824 | --- | |
| 825 | ||
| 826 | # MR 2: settings page quotes working commands (branch `web-settings-commands`) | |
| 827 | ||
| 828 | Closes #263. | |
| 829 | ||
| 830 | ### Task 2.1: fix the two known-wrong quoted commands | |
| 831 | ||
| 832 | **Files:** | |
| 833 | - Modify: `internal/web/templates/account.html:186,199-200` | |
| 834 | ||
| 835 | - [ ] **Step 1: Fix the `whoami` line** | |
| 836 | ||
| 837 | Find (`account.html:199-200`): | |
| 838 | ||
| 839 | ```html | |
| 840 | <p class="meta">All of it works from stock OpenSSH too: | |
| 841 | <code>ssh git@{{.Host}} auth whoami</code>.</p> | |
| 842 | ``` | |
| 843 | ||
| 844 | `auth` is a CLI-only grouping (`cmd/gitbay/main.go`'s `authCmd`); the | |
| 845 | server command is `whoami` (`internal/control/identity.go:18`). Replace: | |
| 846 | ||
| 847 | ```html | |
| 848 | <p class="meta">All of it works from stock OpenSSH too: | |
| 849 | <code>ssh git@{{.Host}} whoami</code>.</p> | |
| 850 | ``` | |
| 851 | ||
| 852 | - [ ] **Step 2: Show both forms for the token line** | |
| 853 | ||
| 854 | Find (`account.html:194-197`): | |
| 855 | ||
| 856 | ```html | |
| 857 | <pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens | |
| 858 | gitbay web sessions list # browser sessions | |
| 859 | gitbay admin ... # instance administration</pre> | |
| 860 | ``` | |
| 861 | ||
| 862 | The CLI form (`gitbay auth token create ...`) and the literal stock-SSH | |
| 863 | form (`ssh git@host token create ...`) differ because `token` is nested | |
| 864 | under the CLI-only `auth` group but is a top-level server command | |
| 865 | (`internal/control/token.go`). Replace the pre block and the sentence | |
| 866 | after it: | |
| 867 | ||
| 868 | ```html | |
| 869 | <pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens | |
| 870 | gitbay web sessions list # browser sessions | |
| 871 | gitbay admin ... # instance administration</pre> | |
| 872 | <p class="meta">All of it works from stock OpenSSH too, with the CLI's | |
| 873 | grouping words dropped: <code>ssh git@{{.Host}} whoami</code>, | |
| 874 | <code>ssh git@{{.Host}} token create --name laptop</code>.</p> | |
| 875 | ``` | |
| 876 | ||
| 877 | (This merges the "works from stock OpenSSH" sentence that Step 1 edited | |
| 878 | with the new token example, so it appears once rather than twice — | |
| 879 | remove the now-duplicate sentence Step 1 produced and keep this single | |
| 880 | combined one instead. After this step, the section reads: the `<pre>` | |
| 881 | block, then one `<p class="meta">` with both stock-SSH examples.) | |
| 882 | ||
| 883 | - [ ] **Step 3: Fix `gitbay account export`** | |
| 884 | ||
| 885 | Find (`account.html:186`, in the Export section): | |
| 886 | ||
| 887 | ```html | |
| 888 | <p class="meta">Your profile, repositories, issues and merge requests as one | |
| 889 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are | |
| 890 | never included; a replayed bundle's emails arrive unverified.</p> | |
| 891 | ``` | |
| 892 | ||
| 893 | `account` is not a real top-level CLI command — the real path is `auth | |
| 894 | export` (`cmd/gitbay/main.go:471`, nested under `authCmd`), as | |
| 895 | `privacy.html:20` already correctly says. Replace: | |
| 896 | ||
| 897 | ```html | |
| 898 | <p class="meta">Your profile, repositories, issues and merge requests as one | |
| 899 | JSON bundle, the same one <code>gitbay auth export</code> writes. Keys are | |
| 900 | never included; a replayed bundle's emails arrive unverified.</p> | |
| 901 | ``` | |
| 902 | ||
| 903 | - [ ] **Step 4: Commit (folded into Task 2.3, which adds the test these | |
| 904 | fixes make pass — do not commit yet; Task 2.2 and 2.3 come first so | |
| 905 | the fixes and their proof land together)** | |
| 906 | ||
| 907 | Skip committing here; continue to Task 2.2. | |
| 908 | ||
| 909 | ### Task 2.2: auth summary and help — done in the CLI UX plan | |
| 910 | ||
| 911 | The auth summary ("whoami, SSH and PGP keys, email, API tokens") and | |
| 912 | the registry-layout `gitbay auth --help` are Task 2.3 of | |
| 913 | `docs/plans/2026-09-27-cli-ux.md` (MR `cli-ux-help`, Ref #267), which | |
| 914 | adds `nounAliases` to `internal/control/help.go` so `help auth` renders | |
| 915 | in one pass. Land `cli-ux-help` before this MR; nothing to do here. | |
| 916 | Check after rebasing: `go run ./cmd/gitbay auth --help` lists email and | |
| 917 | token commands. | |
| 918 | ||
| 919 | ### Task 2.3: a test that runs every quoted command through the registry | |
| 920 | ||
| 921 | **Files:** | |
| 922 | - Create: `cmd/gitbay/templatecmds_test.go` | |
| 923 | ||
| 924 | **Interfaces:** | |
| 925 | - Consumes: `newRoot()` (`cmd/gitbay/main.go:30`, unexported — this test | |
| 926 | must live in package `main`), `control.Lookup` | |
| 927 | (`internal/control/control.go:102`), `web.Pages`/`web.TemplateSource` | |
| 928 | (`internal/web/web.go:271,277`). | |
| 929 | ||
| 930 | - [ ] **Step 1: Write the test** | |
| 931 | ||
| 932 | ```go | |
| 933 | package main | |
| 934 | ||
| 935 | import ( | |
| 936 | "regexp" | |
| 937 | "strings" | |
| 938 | "testing" | |
| 939 | ||
| 940 | "gitbay.org/gitbay/internal/control" | |
| 941 | "gitbay.org/gitbay/internal/web" | |
| 942 | ) | |
| 943 | ||
| 944 | // quotedRe finds the two shapes a command appears in on a page: inline | |
| 945 | // in <code>, or one per line in a <pre class="quickstart"> quickstart | |
| 946 | // block. Both need (?s) so a multi-line <pre> is captured as one match. | |
| 947 | var quotedRe = regexp.MustCompile(`(?s)<code>(.*?)</code>|<pre class="quickstart"[^>]*>(.*?)</pre>`) | |
| 948 | ||
| 949 | // commandArgv reads the literal words at the front of a quoted command | |
| 950 | // line — the part naming the command rather than its arguments — and | |
| 951 | // stops at the first flag, template action, or literal ellipsis, since | |
| 952 | // those mark the boundary between "what command" and "what argument". | |
| 953 | func commandArgv(rest string) []string { | |
| 954 | var argv []string | |
| 955 | for _, tok := range strings.Fields(rest) { | |
| 956 | if strings.HasPrefix(tok, "-") || strings.Contains(tok, "{{") || strings.Contains(tok, "...") { | |
| 957 | break | |
| 958 | } | |
| 959 | argv = append(argv, tok) | |
| 960 | } | |
| 961 | return argv | |
| 962 | } | |
| 963 | ||
| 964 | // TestTemplateQuotedCommandsResolve runs every command quoted in a web | |
| 965 | // template through the same registry the server uses, so a renamed | |
| 966 | // command fails CI instead of shipping a dead instruction (#263). | |
| 967 | // | |
| 968 | // A line starting "gitbay " is checked against the CLI's own command | |
| 969 | // tree with cobra's Find, since the CLI's grouping words (like "auth") | |
| 970 | // are not part of the server's argv. A line starting "ssh git@{{.Host}} | |
| 971 | // " is checked directly against control.Lookup, since that is exactly | |
| 972 | // the argv the server receives. | |
| 973 | func TestTemplateQuotedCommandsResolve(t *testing.T) { | |
| 974 | root := newRoot() | |
| 975 | for _, name := range web.Pages() { | |
| 976 | src, err := web.TemplateSource(name) | |
| 977 | if err != nil { | |
| 978 | t.Fatalf("%s: %v", name, err) | |
| 979 | } | |
| 980 | for _, m := range quotedRe.FindAllStringSubmatch(src, -1) { | |
| 981 | block := m[1] + m[2] | |
| 982 | for _, line := range strings.Split(block, "\n") { | |
| 983 | if i := strings.Index(line, "#"); i >= 0 { | |
| 984 | line = line[:i] | |
| 985 | } | |
| 986 | line = strings.TrimSpace(line) | |
| 987 | switch { | |
| 988 | case strings.HasPrefix(line, "gitbay "): | |
| 989 | argv := commandArgv(strings.TrimPrefix(line, "gitbay ")) | |
| 990 | if len(argv) == 0 { | |
| 991 | continue | |
| 992 | } | |
| 993 | found, _, err := root.Find(argv) | |
| 994 | if err != nil || found == root { | |
| 995 | t.Errorf("%s: %q: gitbay %s does not resolve (%v)", name, line, strings.Join(argv, " "), err) | |
| 996 | } | |
| 997 | case strings.HasPrefix(line, "ssh git@{{.Host}} "): | |
| 998 | argv := commandArgv(strings.TrimPrefix(line, "ssh git@{{.Host}} ")) | |
| 999 | if len(argv) == 0 { | |
| 1000 | continue | |
| 1001 | } | |
| 1002 | if _, _, ok := control.Lookup(argv); !ok { | |
| 1003 | t.Errorf("%s: %q: %s is not in the control registry", name, line, strings.Join(argv, " ")) | |
| 1004 | } | |
| 1005 | } | |
| 1006 | } | |
| 1007 | } | |
| 1008 | } | |
| 1009 | } | |
| 1010 | ``` | |
| 1011 | ||
| 1012 | - [ ] **Step 2: Run and see it fail on the two known bugs** | |
| 1013 | ||
| 1014 | Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1` | |
| 1015 | Expected: FAIL on `account.html`'s `ssh git@{{.Host}} auth whoami` (not | |
| 1016 | in the registry — `auth` is not a server path) and `gitbay account | |
| 1017 | export` (not a real CLI path — the top-level command is `auth`, not | |
| 1018 | `account`), unless Task 2.1's edits are already applied (do Task 2.1 and | |
| 1019 | Task 2.2 first if not already committed, then this test should already | |
| 1020 | pass on those — if it still fails, the fixes in Task 2.1 or 2.2 are | |
| 1021 | incomplete). | |
| 1022 | ||
| 1023 | - [ ] **Step 3: Confirm it passes with Tasks 2.1 and 2.2 applied** | |
| 1024 | ||
| 1025 | Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1` | |
| 1026 | Expected: PASS. If it fails on a *different* template than | |
| 1027 | `account.html`, that is a genuine additional bug this test caught — | |
| 1028 | fix the template's text the same way (correct the command to what | |
| 1029 | `control.Lookup`/cobra's tree actually accepts), do not weaken the test. | |
| 1030 | As of this plan being written, every other quoted command in the | |
| 1031 | templates (`admin.html`, `adminusers.html`, `issues.html`, `landing.html`, | |
| 1032 | `login.html`, `mrs.html`, `mrnew.html`, `privacy.html`, `registered.html`, | |
| 1033 | plus the ones this task edits) was checked by hand against | |
| 1034 | `cmd/gitbay/main.go` and `internal/control/*.go` and resolves correctly — | |
| 1035 | see the research notes in this plan's Order section — but the test is | |
| 1036 | the source of truth, not that hand check. | |
| 1037 | ||
| 1038 | - [ ] **Step 4: Run the full package** | |
| 1039 | ||
| 1040 | Run: `go build ./... && go vet ./... && go test ./cmd/gitbay -count=1` | |
| 1041 | Expected: PASS. | |
| 1042 | ||
| 1043 | - [ ] **Step 5: Commit everything for this MR** | |
| 1044 | ||
| 1045 | ```bash | |
| 1046 | git add internal/web/templates/account.html cmd/gitbay/main.go cmd/gitbay/templatecmds_test.go | |
| 1047 | git commit -m "web, cli: fix two dead quoted commands; test every quoted command against the registry" -m "Closes #263" | |
| 1048 | ``` | |
| 1049 | ||
| 1050 | ### Task 2.4: open MR 2 | |
| 1051 | ||
| 1052 | ```bash | |
| 1053 | git push -u origin web-settings-commands | |
| 1054 | gitbay mr create --source web-settings-commands --target main --title "Settings page: working stock-OpenSSH commands, registry-checked" | |
| 1055 | ``` | |
| 1056 | ||
| 1057 | Wait for CI, `gitbay mr merge <n> --strategy ff`, delete the branch both | |
| 1058 | places. | |
| 1059 | ||
| 1060 | --- | |
| 1061 | ||
| 1062 | # MR 3: MR range-diff page (branch `web-mr-range-diff`) | |
| 1063 | ||
| 1064 | Closes #269. | |
| 1065 | ||
| 1066 | ### Task 3.1: `/{owner}/{repo}/mrs/{n}/range-diff` | |
| 1067 | ||
| 1068 | **Files:** | |
| 1069 | - Create: `internal/httpd/mrrangediff.go` | |
| 1070 | - Create: `internal/web/templates/mrrangediff.html` | |
| 1071 | - Modify: `internal/httpd/routes.go` (add the route beside | |
| 1072 | `/{owner}/{repo}/mrs/{n}` at line 102, in the always-registered block) | |
| 1073 | - Modify: `internal/web/web_test.go` (`TestMainWidthClass`'s `wide` map) | |
| 1074 | - Test: `internal/httpd/mrrangediff_test.go` | |
| 1075 | ||
| 1076 | **Interfaces:** | |
| 1077 | - Consumes: `mrArgs` (`internal/httpd/mractions.go:37`), `s.repoFor`, | |
| 1078 | `s.runControlCode`, `s.webViewer`. | |
| 1079 | - Produces: `func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request)` | |
| 1080 | ||
| 1081 | - [ ] **Step 1: Write the failing test** | |
| 1082 | ||
| 1083 | ```go | |
| 1084 | package httpd | |
| 1085 | ||
| 1086 | import ( | |
| 1087 | "net/http/httptest" | |
| 1088 | "strings" | |
| 1089 | "testing" | |
| 1090 | ||
| 1091 | "gitbay.org/gitbay/internal/config" | |
| 1092 | "gitbay.org/gitbay/internal/store" | |
| 1093 | ) | |
| 1094 | ||
| 1095 | // The range-diff page dispatches mr range-diff and renders its text | |
| 1096 | // output, the same comparison the CLI and iOS already show (#269). | |
| 1097 | func TestMRRangeDiffPageRendersCommandOutput(t *testing.T) { | |
| 1098 | st, err := store.Open(":memory:") | |
| 1099 | if err != nil { | |
| 1100 | t.Fatal(err) | |
| 1101 | } | |
| 1102 | defer st.Close() | |
| 1103 | if err := st.MigrateUp(); err != nil { | |
| 1104 | t.Fatal(err) | |
| 1105 | } | |
| 1106 | uid, err := st.CreateUser("alice", false) | |
| 1107 | if err != nil { | |
| 1108 | t.Fatal(err) | |
| 1109 | } | |
| 1110 | u := store.User{ID: uid, Username: "alice"} | |
| 1111 | if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil { | |
| 1112 | t.Fatal(err) | |
| 1113 | } | |
| 1114 | ||
| 1115 | s := New(config.Default(), st) | |
| 1116 | req := httptest.NewRequest("GET", "/alice/app/mrs/1/range-diff", nil) | |
| 1117 | req.SetPathValue("owner", "alice") | |
| 1118 | req.SetPathValue("repo", "app") | |
| 1119 | req.SetPathValue("n", "1") | |
| 1120 | rr := httptest.NewRecorder() | |
| 1121 | s.mrRangeDiff(rr, req) | |
| 1122 | ||
| 1123 | // No merge request 1 exists yet, so this must 404 rather than error. | |
| 1124 | if rr.Code != 404 { | |
| 1125 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | |
| 1126 | } | |
| 1127 | _ = strings.TrimSpace // placeholder import use removed once a real MR fixture is added below | |
| 1128 | } | |
| 1129 | ``` | |
| 1130 | ||
| 1131 | (`CreateRepo`'s signature: confirm with | |
| 1132 | `grep -n "func (s \*Store) CreateRepo" internal/store/*.go` and adjust | |
| 1133 | the call above to match — the guess here follows the shape used | |
| 1134 | elsewhere in this plan's other tests.) | |
| 1135 | ||
| 1136 | - [ ] **Step 2: Run and see it fail to compile** | |
| 1137 | ||
| 1138 | Run: `go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1` | |
| 1139 | Expected: FAIL to compile (`s.mrRangeDiff` undefined). | |
| 1140 | ||
| 1141 | - [ ] **Step 3: Write the handler** | |
| 1142 | ||
| 1143 | Create `internal/httpd/mrrangediff.go`: | |
| 1144 | ||
| 1145 | ```go | |
| 1146 | package httpd | |
| 1147 | ||
| 1148 | import ( | |
| 1149 | "net/http" | |
| 1150 | "strconv" | |
| 1151 | ||
| 1152 | "gitbay.org/gitbay/internal/protocol" | |
| 1153 | "gitbay.org/gitbay/internal/store" | |
| 1154 | ) | |
| 1155 | ||
| 1156 | // mrRangeDiff renders what changed between two revisions of a merge | |
| 1157 | // request — the same comparison `mr range-diff` prints on the CLI and | |
| 1158 | // the iOS app already show — so a reviewer whose approval a force-push | |
| 1159 | // staled can see what moved without leaving the browser (#269). | |
| 1160 | func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request) { | |
| 1161 | p, ok := s.repoFor(w, r, "") | |
| 1162 | if !ok { | |
| 1163 | return | |
| 1164 | } | |
| 1165 | p.Tab = "merge requests" | |
| 1166 | n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) | |
| 1167 | if err != nil { | |
| 1168 | s.notFound(w, r) | |
| 1169 | return | |
| 1170 | } | |
| 1171 | m, err := s.st.MRByNumber(p.Repo.ID, n) | |
| 1172 | if err != nil { | |
| 1173 | s.notFound(w, r) | |
| 1174 | return | |
| 1175 | } | |
| 1176 | ||
| 1177 | viewer := s.webViewer(r) | |
| 1178 | argv := mrArgs(r, "range-diff") | |
| 1179 | if from := r.URL.Query().Get("from"); from != "" { | |
| 1180 | argv = append(argv, "--from", from) | |
| 1181 | } | |
| 1182 | if to := r.URL.Query().Get("to"); to != "" { | |
| 1183 | argv = append(argv, "--to", to) | |
| 1184 | } | |
| 1185 | out, msg, code := s.runControlCode(viewer, argv) | |
| 1186 | if code == protocol.ExitNotFound { | |
| 1187 | s.notFound(w, r) | |
| 1188 | return | |
| 1189 | } | |
| 1190 | errMsg := "" | |
| 1191 | if code != protocol.ExitOK { | |
| 1192 | errMsg = msg | |
| 1193 | } | |
| 1194 | s.render(w, "mrrangediff.html", struct { | |
| 1195 | repoPage | |
| 1196 | MR store.MR | |
| 1197 | Diff string | |
| 1198 | Error string | |
| 1199 | }{p, m, out, errMsg}) | |
| 1200 | } | |
| 1201 | ``` | |
| 1202 | ||
| 1203 | - [ ] **Step 4: Write the template** | |
| 1204 | ||
| 1205 | Create `internal/web/templates/mrrangediff.html`: | |
| 1206 | ||
| 1207 | ```html | |
| 1208 | {{define "width"}}wide{{end}} | |
| 1209 | {{define "title"}}range-diff · !{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | |
| 1210 | {{define "content"}} | |
| 1211 | <h1>Range-diff <span class="issuenumber">!{{.MR.Number}}</span></h1> | |
| 1212 | <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}">back to !{{.MR.Number}} {{.MR.Title}}</a></p> | |
| 1213 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p> | |
| 1214 | {{else if .Diff}}<pre class="code buildlog" tabindex="0">{{.Diff}}</pre> | |
| 1215 | {{else}}<p class="empty-note">Nothing to compare: this merge request has one revision.</p>{{end}} | |
| 1216 | {{end}} | |
| 1217 | ``` | |
| 1218 | ||
| 1219 | - [ ] **Step 5: Register the route** | |
| 1220 | ||
| 1221 | In `internal/httpd/routes.go`, right after the existing | |
| 1222 | `/{owner}/{repo}/mrs/{n}` route (line 102, in the block registered | |
| 1223 | regardless of `web.mode`, so range-diff reads the same way the MR page | |
| 1224 | itself does — anonymously on a public repository): | |
| 1225 | ||
| 1226 | ```go | |
| 1227 | Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr}, | |
| 1228 | Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}/range-diff", Handler: s.mrRangeDiff}, | |
| 1229 | ``` | |
| 1230 | ||
| 1231 | - [ ] **Step 6: Add the width-map row** | |
| 1232 | ||
| 1233 | In `internal/web/web_test.go`, `TestMainWidthClass`, add | |
| 1234 | `"mrrangediff.html": true` to the `wide` map (alongside `"mrs.html"` and | |
| 1235 | `"build.html"`, which it resembles). | |
| 1236 | ||
| 1237 | - [ ] **Step 7: Run** | |
| 1238 | ||
| 1239 | Run: `go build ./... && go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1 && go test ./internal/web -run TestMainWidthClass -count=1` | |
| 1240 | Expected: PASS. | |
| 1241 | ||
| 1242 | - [ ] **Step 8: Commit** | |
| 1243 | ||
| 1244 | ```bash | |
| 1245 | git add internal/httpd/mrrangediff.go internal/httpd/mrrangediff_test.go internal/httpd/routes.go internal/web/templates/mrrangediff.html internal/web/web_test.go | |
| 1246 | git commit -m "web: range-diff page for a merge request's revisions" -m "Ref #269" | |
| 1247 | ``` | |
| 1248 | ||
| 1249 | ### Task 3.2: revisions list with a "compare to previous" link per row | |
| 1250 | ||
| 1251 | **Files:** | |
| 1252 | - Modify: `internal/web/templates/mr.html:136-138` | |
| 1253 | - Test: `internal/httpd/mrpage_test.go` | |
| 1254 | ||
| 1255 | - [ ] **Step 1: Write the failing test** | |
| 1256 | ||
| 1257 | Add to `internal/httpd/mrpage_test.go`: | |
| 1258 | ||
| 1259 | ```go | |
| 1260 | // Each revision after the first carries a link comparing it to the one | |
| 1261 | // before, so a reviewer does not have to type mr range-diff by hand | |
| 1262 | // (#269). | |
| 1263 | func TestMRPageListsRevisionsWithCompareLinks(t *testing.T) { | |
| 1264 | var sb strings.Builder | |
| 1265 | revs := []store.MRHead{ | |
| 1266 | {SHA: "aaaa1111", CreatedAt: "2026-09-23T10:00:00Z"}, | |
| 1267 | {SHA: "bbbb2222", CreatedAt: "2026-09-24T10:00:00Z"}, | |
| 1268 | } | |
| 1269 | if err := web.Render(&sb, "mr.html", mrPageData{ | |
| 1270 | repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Revisions: revs, | |
| 1271 | }); err != nil { | |
| 1272 | t.Fatalf("render: %v", err) | |
| 1273 | } | |
| 1274 | out := sb.String() | |
| 1275 | for _, want := range []string{"aaaa1111", "bbbb2222", "compare to previous", "from=aaaa1111", "to=bbbb2222"} { | |
| 1276 | if !strings.Contains(out, want) { | |
| 1277 | t.Errorf("missing %q in:\n%s", want, out) | |
| 1278 | } | |
| 1279 | } | |
| 1280 | if strings.Contains(out, "gitbay mr range-diff") { | |
| 1281 | t.Error("still quotes the CLI command instead of linking the new page") | |
| 1282 | } | |
| 1283 | } | |
| 1284 | ``` | |
| 1285 | ||
| 1286 | - [ ] **Step 2: Run and see it fail** | |
| 1287 | ||
| 1288 | Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1` | |
| 1289 | Expected: FAIL (no "compare to previous" text yet). | |
| 1290 | ||
| 1291 | - [ ] **Step 3: Replace the one-liner with a revisions list** | |
| 1292 | ||
| 1293 | Find (`mr.html:136-138`): | |
| 1294 | ||
| 1295 | ```html | |
| 1296 | {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two: | |
| 1297 | <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}} | |
| 1298 | </div> | |
| 1299 | ``` | |
| 1300 | ||
| 1301 | Replace with: | |
| 1302 | ||
| 1303 | ```html | |
| 1304 | </div> | |
| 1305 | {{if .Revisions}}<div class="grp"> | |
| 1306 | <h2>Revisions</h2> | |
| 1307 | {{range $i, $rv := .Revisions}}<p class="row none">{{add $i 1}}. <code>{{short $rv.SHA}}</code> {{when $rv.CreatedAt}}{{if $i}} · <a href="{{$base}}/range-diff?from={{(index $.Revisions (sub $i 1)).SHA}}&to={{$rv.SHA}}">compare to previous</a>{{end}}</p> | |
| 1308 | {{end}} | |
| 1309 | </div>{{end}} | |
| 1310 | ``` | |
| 1311 | ||
| 1312 | (The closing `</div>` that used to end the Reviews `grp` stays where it | |
| 1313 | is — this adds a new sibling `grp` right after it, using `add`/`sub`, | |
| 1314 | already registered template funcs in `internal/web/web.go`.) | |
| 1315 | ||
| 1316 | - [ ] **Step 4: Run** | |
| 1317 | ||
| 1318 | Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1 && go test ./internal/httpd -count=1` | |
| 1319 | Expected: PASS. | |
| 1320 | ||
| 1321 | - [ ] **Step 5: Commit** | |
| 1322 | ||
| 1323 | ```bash | |
| 1324 | git add internal/web/templates/mr.html internal/httpd/mrpage_test.go | |
| 1325 | git commit -m "web: list each MR revision with a compare-to-previous link" -m "Ref #269" | |
| 1326 | ``` | |
| 1327 | ||
| 1328 | ### Task 3.3: update Parity | |
| 1329 | ||
| 1330 | **Files:** | |
| 1331 | - Modify: `.gitbay/wiki/Parity.org` | |
| 1332 | ||
| 1333 | - [ ] **Step 1: Fix the range-diff line** | |
| 1334 | ||
| 1335 | Find: | |
| 1336 | ||
| 1337 | ``` | |
| 1338 | =mr range-diff= compares two heads: the iOS client | |
| 1339 | shows it from a revision to the one before, as text; the web has no | |
| 1340 | view. Batched review is not built. | |
| 1341 | ``` | |
| 1342 | ||
| 1343 | (If MR 1's Task 1.4 already changed this sentence to reference | |
| 1344 | `krz/gitbay#269`, edit that version instead — the end state either way | |
| 1345 | is:) | |
| 1346 | ||
| 1347 | ``` | |
| 1348 | =mr range-diff= compares two heads: the iOS client shows it from a | |
| 1349 | revision to the one before, as text; the web renders the same view, with | |
| 1350 | a "compare to previous" link on each revision after the first. Batched | |
| 1351 | review — draft diff comments held with =mr comment --pending= and sent | |
| 1352 | together with =--comment=/=--discard= or a verdict — is built and the | |
| 1353 | web uses it. | |
| 1354 | ``` | |
| 1355 | ||
| 1356 | - [ ] **Step 2: Commit** | |
| 1357 | ||
| 1358 | ```bash | |
| 1359 | git add .gitbay/wiki/Parity.org | |
| 1360 | git commit -m "wiki: Parity reflects the web range-diff view" -m "Closes #269" | |
| 1361 | ``` | |
| 1362 | ||
| 1363 | ### Task 3.4: open MR 3 | |
| 1364 | ||
| 1365 | ```bash | |
| 1366 | git push -u origin web-mr-range-diff | |
| 1367 | gitbay mr create --source web-mr-range-diff --target main --title "Web: MR range-diff page" | |
| 1368 | ``` | |
| 1369 | ||
| 1370 | Wait for CI, merge (`--strategy ff`), delete the branch both places. | |
| 1371 | ||
| 1372 | --- | |
| 1373 | ||
| 1374 | # MR 4: empty states and contribution hints sweep (branch `web-empty-states`) | |
| 1375 | ||
| 1376 | Closes #270. | |
| 1377 | ||
| 1378 | This MR is one register applied across templates. The table below is | |
| 1379 | the full set of strings this task changes — every empty-state or | |
| 1380 | contribution-hint string identified in the issue and confirmed against | |
| 1381 | the current template source. Implement exactly this table; do not | |
| 1382 | invent additional wording beyond it. | |
| 1383 | ||
| 1384 | | File:line | Current | New | | |
| 1385 | |---|---|---| | |
| 1386 | | `mrs.html:29` (no query) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code>` | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests` (the create instruction moves to the new "New merge request"/fork/sign-in line — Task 4.2 — and is never a CLI command on the web) | | |
| 1387 | | `mrs.html:28` (search, no match) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching "{{.Query}}"` | unchanged (already follows the register: lower-case, no CLI command, states the fact) | | |
| 1388 | | `mr.html:136` (reviews, none) | `none yet` | unchanged — "none yet" is correct register for a section that can still gain entries (open MR); Task 4.1's rule is "no 'yet' on a *finished* item", and reviews on an open MR are not finished | | |
| 1389 | | `mr.html:143` (reviewers, none) | `nobody yet` | `no reviewers` (drops "yet" for consistency with the rest of the sweep's noun-first register even though the MR could still be open — "nobody yet" reads as a placeholder guess about who *will* review, which is not information the page has; "no reviewers" states the fact plainly, matching `dashboard.html`'s "No open merge requests") | | |
| 1390 | | `mr.html:174` (labels, none, on a merged/closed MR) | `none yet` | `no labels` when `.MR.State` is `merged` or `closed` (a finished item gets no "yet"); keep `none yet` when open | | |
| 1391 | | `builds.html:53` | `no builds — push a commit with a <code>.gitbay/ci.yml</code>` | `no builds` when the viewer cannot push (`.CanWrite` false or absent); `no builds — push a commit with a .gitbay/ci.yml` (drop `<code>`, since a filename is not a command) stays for a viewer who can push. Never plain "no builds" for a writer, since that leaves them without the one instruction the page can give them | | |
| 1392 | | `dashboard.html:29` | `Nothing pinned yet. Press Pin on a repository.` | `nothing pinned — press Pin on a repository you visit` | | |
| 1393 | | `dashboard.html:42` (`Empty` value for MRs queue) | `No open merge requests` | unchanged (already matches the register: capital first word is this partial's own convention — see Step 1 below — lower-case the whole sweep *within* `<li class="empty">` items, leave `queue` partial's own `Empty` string as-is since it is Title Case by that partial's design, confirmed by reading the `queue` template define before changing it) | | |
| 1394 | | `notifications.html:21` (all read) | `nothing here yet` | `no unread notifications` when `.All` is false is already separate; the `.All` branch (nothing at all, read or unread, in the *whole* inbox) becomes `nothing to show` | | |
| 1395 | | `notifications.html:21` (unread, default) | `nothing unread — <a>show all</a>` | `no unread notifications — <a href="/notifications?all=1">show all</a>` (already close; #265, a different plan, covers the CLI side of this exact wording — keep the two in step: `no unread notifications` matches what plan 6's CLI task sets for `notifications list`) | | |
| 1396 | | `globalsearch.html:47` | shown only when `.Query` is empty | shown always, as a permanent caption under the search input (Task 4.3) | | |
| 1397 | ||
| 1398 | - [ ] **Step 1: Read the `queue` partial before touching `dashboard.html`** | |
| 1399 | ||
| 1400 | Run: `grep -n '{{define "queue"}}' -A 15 internal/web/templates/dashboard.html` | |
| 1401 | Confirm whether its `Empty` value is rendered as given (in which case | |
| 1402 | `"No open merge requests"` stays capitalised by the caller's choice) or | |
| 1403 | lower-cased by the partial itself. Write down which, then leave that | |
| 1404 | line's casing exactly as the partial expects — do not change | |
| 1405 | `dashboard.html:42-43`'s `"Empty"` values in this task; the table above | |
| 1406 | already reflects "unchanged" for it. | |
| 1407 | ||
| 1408 | ### Task 4.1: apply the table | |
| 1409 | ||
| 1410 | **Files:** | |
| 1411 | - Modify: `internal/web/templates/mrs.html:28-29` | |
| 1412 | - Modify: `internal/web/templates/mr.html:143,174` | |
| 1413 | - Modify: `internal/web/templates/builds.html:53` | |
| 1414 | - Modify: `internal/web/templates/dashboard.html:29` | |
| 1415 | - Modify: `internal/web/templates/notifications.html:21` | |
| 1416 | - Test: `internal/httpd/mrpage_test.go`, `internal/httpd/buildpages_test.go` | |
| 1417 | (or wherever a `builds.html` render test already lives — check with | |
| 1418 | `grep -rln '"builds.html"' internal/httpd/*_test.go`), a new or | |
| 1419 | existing dashboard render test, a new or existing notifications test. | |
| 1420 | ||
| 1421 | - [ ] **Step 1: Write the failing tests** | |
| 1422 | ||
| 1423 | Add to `internal/httpd/mrpage_test.go`: | |
| 1424 | ||
| 1425 | ```go | |
| 1426 | // A finished merge request states an empty label list as a fact, not a | |
| 1427 | // promise something is still coming (#270). | |
| 1428 | func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) { | |
| 1429 | var sb strings.Builder | |
| 1430 | if err := web.Render(&sb, "mr.html", mrPageData{ | |
| 1431 | repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation", | |
| 1432 | }); err != nil { | |
| 1433 | t.Fatalf("render: %v", err) | |
| 1434 | } | |
| 1435 | if !strings.Contains(sb.String(), "no labels") { | |
| 1436 | t.Error(`merged MR with no labels should read "no labels", not "none yet"`) | |
| 1437 | } | |
| 1438 | } | |
| 1439 | ||
| 1440 | func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) { | |
| 1441 | var sb strings.Builder | |
| 1442 | if err := web.Render(&sb, "mr.html", mrPageData{ | |
| 1443 | repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", | |
| 1444 | }); err != nil { | |
| 1445 | t.Fatalf("render: %v", err) | |
| 1446 | } | |
| 1447 | if strings.Contains(sb.String(), "nobody yet") { | |
| 1448 | t.Error(`reviewers empty state should read "no reviewers"`) | |
| 1449 | } | |
| 1450 | if !strings.Contains(sb.String(), "no reviewers") { | |
| 1451 | t.Error(`missing "no reviewers"`) | |
| 1452 | } | |
| 1453 | } | |
| 1454 | ``` | |
| 1455 | ||
| 1456 | Add to whichever file already renders `builds.html` (or create | |
| 1457 | `internal/httpd/buildslist_test.go` if none does; check first with the | |
| 1458 | grep in the Files list above): | |
| 1459 | ||
| 1460 | ```go | |
| 1461 | // A writer sees the instruction to add CI; a reader without push access | |
| 1462 | // sees only the fact, since the instruction is not theirs to act on | |
| 1463 | // (#270). | |
| 1464 | func TestBuildsEmptyStateOmitsInstructionForReaders(t *testing.T) { | |
| 1465 | var sb strings.Builder | |
| 1466 | if err := web.Render(&sb, "builds.html", buildsPageData{ | |
| 1467 | repoPage: testRepoPage(), CanWrite: false, | |
| 1468 | }); err != nil { | |
| 1469 | t.Fatalf("render: %v", err) | |
| 1470 | } | |
| 1471 | out := sb.String() | |
| 1472 | if !strings.Contains(out, "no builds") { | |
| 1473 | t.Error(`missing "no builds"`) | |
| 1474 | } | |
| 1475 | if strings.Contains(out, "ci.yml") { | |
| 1476 | t.Error("a reader without push access should not see the push instruction") | |
| 1477 | } | |
| 1478 | } | |
| 1479 | ``` | |
| 1480 | ||
| 1481 | (`buildsPageData` may not exist as a named type the way `mrPageData` | |
| 1482 | does for `mr.html` — check | |
| 1483 | `grep -n '"builds.html"' internal/httpd/web.go` for the anonymous | |
| 1484 | struct `builds` (the list handler, not `build`, the single-build one) | |
| 1485 | renders with, and mirror its fields the way `mrPageData` mirrors | |
| 1486 | `mr.html`'s, the same pattern `mrpage_test.go` already uses.) | |
| 1487 | ||
| 1488 | - [ ] **Step 2: Run and see them fail** | |
| 1489 | ||
| 1490 | Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1` | |
| 1491 | Expected: FAIL. | |
| 1492 | ||
| 1493 | - [ ] **Step 3: `mr.html` reviewers and labels** | |
| 1494 | ||
| 1495 | Find (`mr.html:143`): | |
| 1496 | ||
| 1497 | ```html | |
| 1498 | {{else}}<p class="none">nobody yet</p>{{end}} | |
| 1499 | ``` | |
| 1500 | ||
| 1501 | (in the Reviewers `grp`). Replace: | |
| 1502 | ||
| 1503 | ```html | |
| 1504 | {{else}}<p class="none">no reviewers</p>{{end}} | |
| 1505 | ``` | |
| 1506 | ||
| 1507 | Find (`mr.html:174`, in the Labels `grp`): | |
| 1508 | ||
| 1509 | ```html | |
| 1510 | {{else}}<p class="none">none yet</p>{{end}} | |
| 1511 | ``` | |
| 1512 | ||
| 1513 | Replace: | |
| 1514 | ||
| 1515 | ```html | |
| 1516 | {{else}}<p class="none">{{if or (eq .MR.State "merged") (eq .MR.State "closed")}}no labels{{else}}none yet{{end}}</p>{{end}} | |
| 1517 | ``` | |
| 1518 | ||
| 1519 | - [ ] **Step 4: `builds.html`** | |
| 1520 | ||
| 1521 | Read the current line first: `grep -n "no builds" internal/web/templates/builds.html`. | |
| 1522 | Replace it (adjust the exact surrounding markup to match what that grep | |
| 1523 | shows; the text change is): | |
| 1524 | ||
| 1525 | ```html | |
| 1526 | {{else}}<li class="empty">no builds{{if .CanWrite}} — push a commit with a <code>.gitbay/ci.yml</code>{{end}}</li>{{end}} | |
| 1527 | ``` | |
| 1528 | ||
| 1529 | Check whether `builds.html`'s page struct already carries `CanWrite` | |
| 1530 | (`grep -n "CanWrite" internal/httpd/builds.go internal/web/templates/builds.html`); | |
| 1531 | if it does not, add it the way `mr.html`'s does | |
| 1532 | (`s.canWriteRepo(r, p.Repo)` in the handler, a new `CanWrite bool` field | |
| 1533 | in the render struct). | |
| 1534 | ||
| 1535 | - [ ] **Step 5: `dashboard.html`** | |
| 1536 | ||
| 1537 | Find (`dashboard.html:29`): | |
| 1538 | ||
| 1539 | ```html | |
| 1540 | {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}} | |
| 1541 | ``` | |
| 1542 | ||
| 1543 | Replace: | |
| 1544 | ||
| 1545 | ```html | |
| 1546 | {{else}}<p class="none">nothing pinned — press Pin on a repository you visit</p>{{end}} | |
| 1547 | ``` | |
| 1548 | ||
| 1549 | - [ ] **Step 6: `notifications.html`** | |
| 1550 | ||
| 1551 | Find (`notifications.html:21`): | |
| 1552 | ||
| 1553 | ```html | |
| 1554 | {{else}}<li class="empty">{{if .All}}nothing here yet{{else}}nothing unread — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}} | |
| 1555 | ``` | |
| 1556 | ||
| 1557 | Replace: | |
| 1558 | ||
| 1559 | ```html | |
| 1560 | {{else}}<li class="empty">{{if .All}}nothing to show{{else}}no unread notifications — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}} | |
| 1561 | ``` | |
| 1562 | ||
| 1563 | - [ ] **Step 7: `mrs.html`** | |
| 1564 | ||
| 1565 | Find (`mrs.html:28-29`): | |
| 1566 | ||
| 1567 | ```html | |
| 1568 | {{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li> | |
| 1569 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}} | |
| 1570 | ``` | |
| 1571 | ||
| 1572 | Replace (the create instruction moves to Task 4.2's contribution-hint | |
| 1573 | line, so the empty state itself states only the fact): | |
| 1574 | ||
| 1575 | ```html | |
| 1576 | {{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li> | |
| 1577 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests</li>{{end}}{{end}} | |
| 1578 | ``` | |
| 1579 | ||
| 1580 | - [ ] **Step 8: Run** | |
| 1581 | ||
| 1582 | Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1 && go test ./internal/httpd -count=1` | |
| 1583 | Expected: PASS. Fix any pre-existing test that asserted the old strings | |
| 1584 | ("nobody yet", "none yet" on a merged MR's labels, "Nothing pinned yet", | |
| 1585 | "nothing here yet", "nothing unread", the old `mrs.html` CLI-command | |
| 1586 | text) to expect the new ones — these are exactly the tests this sweep | |
| 1587 | is supposed to change. | |
| 1588 | ||
| 1589 | - [ ] **Step 9: Commit** | |
| 1590 | ||
| 1591 | ```bash | |
| 1592 | git add internal/web/templates/mr.html internal/web/templates/builds.html internal/web/templates/dashboard.html internal/web/templates/notifications.html internal/web/templates/mrs.html internal/httpd/mrpage_test.go internal/httpd/*_test.go | |
| 1593 | git commit -m "web: one empty-state register — no CLI commands, no 'yet' on finished items" -m "Ref #270" | |
| 1594 | ``` | |
| 1595 | ||
| 1596 | ### Task 4.2: MR list contribution hint by access level | |
| 1597 | ||
| 1598 | **Files:** | |
| 1599 | - Modify: `internal/httpd/web.go:2061-2126` (`mrs` handler, add `CanWrite`) | |
| 1600 | - Modify: `internal/web/templates/mrs.html:16` | |
| 1601 | - Test: `internal/httpd/mrslist_test.go` (create, or add beside an | |
| 1602 | existing `mrs.html` render test if one exists — check first) | |
| 1603 | ||
| 1604 | - [ ] **Step 1: Write the failing test** | |
| 1605 | ||
| 1606 | ```go | |
| 1607 | package httpd | |
| 1608 | ||
| 1609 | import ( | |
| 1610 | "net/http" | |
| 1611 | "net/http/httptest" | |
| 1612 | "strings" | |
| 1613 | "testing" | |
| 1614 | "time" | |
| 1615 | ||
| 1616 | "gitbay.org/gitbay/internal/config" | |
| 1617 | "gitbay.org/gitbay/internal/store" | |
| 1618 | ) | |
| 1619 | ||
| 1620 | // A repository's MR list offers the right next step by access level: a | |
| 1621 | // writer gets "New merge request", a signed-in reader without push gets | |
| 1622 | // a fork link, and a signed-out visitor gets a sign-in prompt (#270). | |
| 1623 | func TestMRsListContributionHintByAccess(t *testing.T) { | |
| 1624 | st, err := store.Open(":memory:") | |
| 1625 | if err != nil { | |
| 1626 | t.Fatal(err) | |
| 1627 | } | |
| 1628 | defer st.Close() | |
| 1629 | if err := st.MigrateUp(); err != nil { | |
| 1630 | t.Fatal(err) | |
| 1631 | } | |
| 1632 | owner, err := st.CreateUser("alice", false) | |
| 1633 | if err != nil { | |
| 1634 | t.Fatal(err) | |
| 1635 | } | |
| 1636 | reader, err := st.CreateUser("bob", false) | |
| 1637 | if err != nil { | |
| 1638 | t.Fatal(err) | |
| 1639 | } | |
| 1640 | if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil { | |
| 1641 | t.Fatal(err) | |
| 1642 | } | |
| 1643 | ||
| 1644 | cfg := config.Default() | |
| 1645 | cfg.Web.Mode = "accounts" | |
| 1646 | s := New(cfg, st) | |
| 1647 | ||
| 1648 | // mrs reads the viewer through s.viewer(r), which resolves a | |
| 1649 | // session cookie (internal/httpd/accounts.go:37-47) rather than | |
| 1650 | // taking the viewer as a parameter the way a POST handler test | |
| 1651 | // does. Give a real viewer a real session; leave the request | |
| 1652 | // cookie-less for the anonymous case. | |
| 1653 | sessionFor := func(uid int64) *http.Cookie { | |
| 1654 | tok, hash, err := store.NewToken() | |
| 1655 | if err != nil { | |
| 1656 | t.Fatal(err) | |
| 1657 | } | |
| 1658 | if err := st.CreateWebSession(hash, uid, time.Hour); err != nil { | |
| 1659 | t.Fatal(err) | |
| 1660 | } | |
| 1661 | return s.sessionCookieFor(tok) | |
| 1662 | } | |
| 1663 | ||
| 1664 | get := func(uid int64) string { | |
| 1665 | req := httptest.NewRequest("GET", "/alice/app/mrs", nil) | |
| 1666 | req.SetPathValue("owner", "alice") | |
| 1667 | req.SetPathValue("repo", "app") | |
| 1668 | if uid != 0 { | |
| 1669 | req.AddCookie(sessionFor(uid)) | |
| 1670 | } | |
| 1671 | rr := httptest.NewRecorder() | |
| 1672 | s.mrs(rr, req) | |
| 1673 | return rr.Body.String() | |
| 1674 | } | |
| 1675 | anonymous := get(0) | |
| 1676 | if !strings.Contains(anonymous, "Sign in to propose a change") { | |
| 1677 | t.Errorf("signed-out visitor: missing sign-in prompt:\n%s", anonymous) | |
| 1678 | } | |
| 1679 | if strings.Contains(anonymous, "New merge request") { | |
| 1680 | t.Error("signed-out visitor should not see New merge request") | |
| 1681 | } | |
| 1682 | ||
| 1683 | readerOut := get(reader) | |
| 1684 | if !strings.Contains(readerOut, "Fork this repository to propose a change") { | |
| 1685 | t.Errorf("reader without push: missing fork hint:\n%s", readerOut) | |
| 1686 | } | |
| 1687 | ||
| 1688 | ownerOut := get(owner) | |
| 1689 | if !strings.Contains(ownerOut, "New merge request") { | |
| 1690 | t.Errorf("owner: missing New merge request link:\n%s", ownerOut) | |
| 1691 | } | |
| 1692 | } | |
| 1693 | ``` | |
| 1694 | ||
| 1695 | - [ ] **Step 2: Run and see it fail** | |
| 1696 | ||
| 1697 | Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1` | |
| 1698 | Expected: FAIL (no such text yet — `mrs.html:16` still only checks | |
| 1699 | `.Viewer`). | |
| 1700 | ||
| 1701 | - [ ] **Step 3: Add `CanWrite` to the `mrs` page struct** | |
| 1702 | ||
| 1703 | In `internal/httpd/web.go`, in `mrs` (around line 2061), after | |
| 1704 | `p.Tab = "merge requests"`: | |
| 1705 | ||
| 1706 | ```go | |
| 1707 | canWrite := s.canWriteRepo(r, p.Repo) | |
| 1708 | ``` | |
| 1709 | ||
| 1710 | and add `CanWrite bool` to the anonymous struct passed to `s.render`, | |
| 1711 | with `canWrite` in the corresponding position of the literal. | |
| 1712 | ||
| 1713 | - [ ] **Step 4: Update `mrs.html`** | |
| 1714 | ||
| 1715 | Find (`mrs.html:16`): | |
| 1716 | ||
| 1717 | ```html | |
| 1718 | {{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} | |
| 1719 | ``` | |
| 1720 | ||
| 1721 | Replace: | |
| 1722 | ||
| 1723 | ```html | |
| 1724 | {{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p> | |
| 1725 | {{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p> | |
| 1726 | {{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}} | |
| 1727 | ``` | |
| 1728 | ||
| 1729 | - [ ] **Step 5: Run** | |
| 1730 | ||
| 1731 | Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1 && go test ./internal/httpd -count=1` | |
| 1732 | Expected: PASS. | |
| 1733 | ||
| 1734 | - [ ] **Step 6: Commit** | |
| 1735 | ||
| 1736 | ```bash | |
| 1737 | git add internal/httpd/web.go internal/web/templates/mrs.html internal/httpd/mrslist_test.go | |
| 1738 | git commit -m "web: MR list offers a fork link or a sign-in prompt to visitors who cannot open one directly" -m "Ref #270" | |
| 1739 | ``` | |
| 1740 | ||
| 1741 | ### Task 4.3: search scope caption always visible; tab zero-count rule | |
| 1742 | ||
| 1743 | **Files:** | |
| 1744 | - Modify: `internal/web/templates/globalsearch.html:45-48` | |
| 1745 | - Modify: `internal/web/templates/layout.html:71-72` (comment only — see | |
| 1746 | Step 2) | |
| 1747 | - Test: `internal/httpd/searchweb_test.go` (or wherever a | |
| 1748 | `globalsearch.html` render test already lives) | |
| 1749 | ||
| 1750 | - [ ] **Step 1: Write the failing test** | |
| 1751 | ||
| 1752 | ```go | |
| 1753 | // The scope sentence is a permanent caption, not a first-visit-only | |
| 1754 | // hint: a visitor who has already searched still needs to know what a | |
| 1755 | // search here does and does not cover (#270). | |
| 1756 | func TestGlobalSearchScopeCaptionAlwaysShown(t *testing.T) { | |
| 1757 | var sb strings.Builder | |
| 1758 | if err := web.Render(&sb, "globalsearch.html", struct { | |
| 1759 | basePage | |
| 1760 | Query, Kind string | |
| 1761 | Results []searchHit | |
| 1762 | QueryErr string | |
| 1763 | }{Query: "gitbay"}); err != nil { | |
| 1764 | t.Fatalf("render: %v", err) | |
| 1765 | } | |
| 1766 | if !strings.Contains(sb.String(), "File contents are searched per repository") { | |
| 1767 | t.Error("scope caption missing once a query is present") | |
| 1768 | } | |
| 1769 | } | |
| 1770 | ``` | |
| 1771 | ||
| 1772 | (Check the real render struct's field names and the `searchHit` type | |
| 1773 | name with `grep -n '"globalsearch.html"' internal/httpd/*.go` and match | |
| 1774 | them exactly — the struct above is a best guess at the shape from | |
| 1775 | reading the template, not a verified signature.) | |
| 1776 | ||
| 1777 | - [ ] **Step 2: Run and see it fail** | |
| 1778 | ||
| 1779 | Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1` | |
| 1780 | Expected: FAIL (the caption is currently inside the `{{else}}` branch | |
| 1781 | that only renders when `.Query` is empty). | |
| 1782 | ||
| 1783 | - [ ] **Step 3: Move the caption out of the conditional** | |
| 1784 | ||
| 1785 | Find (`globalsearch.html:45-48`): | |
| 1786 | ||
| 1787 | ```html | |
| 1788 | {{/* The count line above already says nothing matched, so this one | |
| 1789 | carries the way out instead of repeating it. */}} | |
| 1790 | {{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}} | |
| 1791 | {{else}} | |
| 1792 | <p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p> | |
| 1793 | {{end}} | |
| 1794 | ``` | |
| 1795 | ||
| 1796 | Replace with (the scope sentence moves out to render unconditionally, | |
| 1797 | right after the search form, and the "no results" hint keeps its own | |
| 1798 | conditional unchanged): | |
| 1799 | ||
| 1800 | ```html | |
| 1801 | {{/* The count line above already says nothing matched, so this one | |
| 1802 | carries the way out instead of repeating it. */}} | |
| 1803 | {{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}} | |
| 1804 | {{end}} | |
| 1805 | <p class="meta">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p> | |
| 1806 | ``` | |
| 1807 | ||
| 1808 | (Dropping the outer `{{if .QueryErr}}...{{else if .Query}}...{{else}}...{{end}}`'s | |
| 1809 | final `{{else}}` branch this way requires re-reading the template's | |
| 1810 | actual brace nesting before editing — the three-way `{{if | |
| 1811 | .QueryErr}}{{else if .Query}}{{else}}{{end}}` collapses to a two-way | |
| 1812 | `{{if .QueryErr}}{{else}}...{{end}}` once the "no query yet" case no | |
| 1813 | longer needs its own branch for this sentence. Read | |
| 1814 | `globalsearch.html`'s full `{{if}}/{{else}}` structure before editing — | |
| 1815 | line numbers above are from this plan's research and may have shifted.) | |
| 1816 | ||
| 1817 | - [ ] **Step 4: Run** | |
| 1818 | ||
| 1819 | Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1 && go test ./internal/httpd -count=1` | |
| 1820 | Expected: PASS. | |
| 1821 | ||
| 1822 | - [ ] **Step 5: Document the tab zero-count rule (no code change: the | |
| 1823 | current behaviour is already the rule)** | |
| 1824 | ||
| 1825 | Reading `layout.html:71-74`: `Issues` and `Merge requests` already hide | |
| 1826 | their count badge at zero (`{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}`, | |
| 1827 | same for `OpenMRs`); `Builds`, `Releases`, `Wiki` and `Settings` never | |
| 1828 | carry a count at all. The inconsistency the issue names ("Issues 9, then | |
| 1829 | Merge requests with no count") is two tabs following the same rule | |
| 1830 | producing different-looking output depending on the data, not a code | |
| 1831 | bug — but `dashboard.html`'s pin row (`<b{{if .Issues}} class="wants"{{end}}>{{.Issues}} ...`) | |
| 1832 | always prints the number, including `0`, which genuinely is a different | |
| 1833 | rule from the tabs'. Fix that inconsistency by hiding a zero the same | |
| 1834 | way the tabs do. Find (`dashboard.html:27`, inside the pin row): | |
| 1835 | ||
| 1836 | ```html | |
| 1837 | <b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b> | |
| 1838 | ``` | |
| 1839 | ||
| 1840 | Replace: | |
| 1841 | ||
| 1842 | ```html | |
| 1843 | <b{{if .Issues}} class="wants"{{end}}>{{if .Issues}}{{.Issues}}{{else}}0{{end}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{if .MRs}}{{.MRs}}{{else}}0{{end}} <span class="vh">open merge requests</span></b> | |
| 1844 | ``` | |
| 1845 | ||
| 1846 | Wait — re-read this before implementing: this keeps `0` printed, which | |
| 1847 | does not change anything (`{{.Issues}}` and `{{if .Issues}}{{.Issues}}{{else}}0{{end}}` | |
| 1848 | render identically for an int, since Go's `%v`-style template output of | |
| 1849 | `0` is already `"0"`). The dashboard pin row is not actually | |
| 1850 | inconsistent with the tabs in a way a template edit can fix: it is a | |
| 1851 | `<b>` badge that always shows a resting value (like a count chip | |
| 1852 | elsewhere in the app, e.g. label/milestone counts), whereas the tabs | |
| 1853 | hide their `<i>` badge entirely at zero because an empty `<i>` there | |
| 1854 | would look like stray punctuation next to the tab word. These are | |
| 1855 | two different UI elements with two different, both-reasonable rules. | |
| 1856 | Do not change `dashboard.html` in this task. Instead add a one-line | |
| 1857 | comment at `layout.html:69` (just above the `<nav class="tabs">`) | |
| 1858 | recording the decision so a future pass does not "fix" this again: | |
| 1859 | ||
| 1860 | ```html | |
| 1861 | {{/* A tab's own count badge is omitted at zero (an empty <i> reads as | |
| 1862 | stray punctuation next to the tab word); the dashboard pin row's | |
| 1863 | count chip always shows its number, zero included, the same as | |
| 1864 | every other count chip in the app. Two elements, two rules, | |
| 1865 | decided once here (#270). */}} | |
| 1866 | <nav class="tabs" aria-label="Repository"> | |
| 1867 | ``` | |
| 1868 | ||
| 1869 | - [ ] **Step 6: Run the full package once more** | |
| 1870 | ||
| 1871 | Run: `go test ./internal/httpd ./internal/web -count=1` | |
| 1872 | Expected: PASS. | |
| 1873 | ||
| 1874 | - [ ] **Step 7: Commit** | |
| 1875 | ||
| 1876 | ```bash | |
| 1877 | git add internal/web/templates/globalsearch.html internal/web/templates/layout.html internal/httpd/searchweb_test.go | |
| 1878 | git commit -m "web: search scope caption is permanent; document the tab zero-count rule" -m "Closes #270" | |
| 1879 | ``` | |
| 1880 | ||
| 1881 | ### Task 4.4: open MR 4 | |
| 1882 | ||
| 1883 | ```bash | |
| 1884 | git push -u origin web-empty-states | |
| 1885 | gitbay mr create --source web-empty-states --target main --title "Web: empty-state and contribution-hint sweep" | |
| 1886 | ``` | |
| 1887 | ||
| 1888 | Wait for CI, merge, delete the branch both places. | |
| 1889 | ||
| 1890 | --- | |
| 1891 | ||
| 1892 | # MR 5: UX review small fixes (branch `web-ux-small-fixes`) | |
| 1893 | ||
| 1894 | Closes #271. Depends on MR 1 (`repo watch`/`repo mute` dispatch and the | |
| 1895 | cycling toggle). | |
| 1896 | ||
| 1897 | ### Task 5.1: issue form gains milestone and assignee | |
| 1898 | ||
| 1899 | **Files:** | |
| 1900 | - Modify: `internal/web/templates/issuenew.html` | |
| 1901 | - Modify: `internal/httpd/accounts.go:455-477` (`issueCreateSubmit`) | |
| 1902 | - Test: `internal/httpd/accounts_test.go` or wherever an existing | |
| 1903 | `issueCreateSubmit` test lives (`grep -rln "issueCreateSubmit" internal/httpd/*_test.go`) | |
| 1904 | ||
| 1905 | **Ground truth from reading the code:** `issue create` | |
| 1906 | (`internal/control/issue.go:18-31`) takes only `--title`, `--body`/ | |
| 1907 | `--file`, and `--format` — no `--milestone`/`--assignee` flags. | |
| 1908 | `issueCreateSubmit` (`internal/httpd/accounts.go:455-477`) already | |
| 1909 | handles this shape for labels: it creates the issue first (decoding the | |
| 1910 | created issue's number via `dispatchIntoStdin` into `control.Created`), | |
| 1911 | then, only if the labels field was non-empty, makes a second dispatch | |
| 1912 | (`issue label ... --add ...`) with that number. Milestone and assignee | |
| 1913 | follow the same two-step shape, using the existing commands `issue | |
| 1914 | milestone <owner/name> <n> <title>` and `issue assign <owner/name> <n> | |
| 1915 | [--add <user>]` (`internal/control/issue.go:101-109` for assign; the | |
| 1916 | milestone command's exact path is confirmed by | |
| 1917 | `internal/httpd/issueactions.go`'s `issueMilestoneSubmit`, which already | |
| 1918 | calls `issueArgs(r, "milestone", title)`). | |
| 1919 | ||
| 1920 | - [ ] **Step 1: Write the failing test** | |
| 1921 | ||
| 1922 | ```go | |
| 1923 | // The new-issue form takes milestone and assignee, the same as the | |
| 1924 | // issue page's own edit controls already do (#271). | |
| 1925 | func TestIssueCreateFormHasMilestoneAndAssignee(t *testing.T) { | |
| 1926 | var sb strings.Builder | |
| 1927 | if err := web.Render(&sb, "issuenew.html", struct { | |
| 1928 | basePage | |
| 1929 | Repo store.Repo | |
| 1930 | Milestones []string | |
| 1931 | Draft *draft | |
| 1932 | }{Repo: store.Repo{OwnerName: "alice", Name: "app"}}); err != nil { | |
| 1933 | t.Fatalf("render: %v", err) | |
| 1934 | } | |
| 1935 | out := sb.String() | |
| 1936 | if !strings.Contains(out, `name="milestone"`) { | |
| 1937 | t.Error("no milestone field") | |
| 1938 | } | |
| 1939 | if !strings.Contains(out, `name="assignee"`) { | |
| 1940 | t.Error("no assignee field") | |
| 1941 | } | |
| 1942 | } | |
| 1943 | ``` | |
| 1944 | ||
| 1945 | (Match the render struct to whatever `issueCreateForm` actually passes — | |
| 1946 | read its handler first, per Step 1, and adjust field names here.) | |
| 1947 | ||
| 1948 | - [ ] **Step 2: Run and see it fail** | |
| 1949 | ||
| 1950 | Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1` | |
| 1951 | Expected: FAIL. | |
| 1952 | ||
| 1953 | - [ ] **Step 3: Add the fields to the form** | |
| 1954 | ||
| 1955 | Add to `issuenew.html`, alongside the existing labels input (matching | |
| 1956 | its markup style exactly — an `<input>` with the same classes/attributes | |
| 1957 | the labels field uses, adjusted for name and placeholder): | |
| 1958 | ||
| 1959 | ```html | |
| 1960 | <p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)"></p> | |
| 1961 | <p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignee, one username (optional)"></p> | |
| 1962 | ``` | |
| 1963 | ||
| 1964 | Place these after the existing labels `<input>` and before the submit | |
| 1965 | button, matching the vertical rhythm (`<p>` wrapping) the rest of the | |
| 1966 | form uses. | |
| 1967 | ||
| 1968 | - [ ] **Step 4: Wire them into the handler as follow-up dispatches** | |
| 1969 | ||
| 1970 | In `internal/httpd/accounts.go`, `issueCreateSubmit` (lines 455-477), | |
| 1971 | add two more follow-up dispatches after the existing labels one, using | |
| 1972 | the same `n` (the created issue's number, already decoded from | |
| 1973 | `created.Number`): | |
| 1974 | ||
| 1975 | ```go | |
| 1976 | if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 { | |
| 1977 | s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...)) | |
| 1978 | } | |
| 1979 | if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" { | |
| 1980 | s.runControl(u, []string{"issue", "milestone", repoPath, fmt.Sprint(n), milestone}) | |
| 1981 | } | |
| 1982 | if assignee := strings.TrimSpace(r.FormValue("assignee")); assignee != "" { | |
| 1983 | s.runControl(u, []string{"issue", "assign", repoPath, fmt.Sprint(n), "--add", assignee}) | |
| 1984 | } | |
| 1985 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther) | |
| 1986 | ``` | |
| 1987 | ||
| 1988 | (The first block — the existing labels dispatch — is unchanged; the | |
| 1989 | milestone and assignee blocks are new, inserted between it and the | |
| 1990 | final redirect.) | |
| 1991 | ||
| 1992 | - [ ] **Step 5: Run** | |
| 1993 | ||
| 1994 | Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1 && go test ./internal/httpd -count=1` | |
| 1995 | Expected: PASS. | |
| 1996 | ||
| 1997 | - [ ] **Step 6: Commit** | |
| 1998 | ||
| 1999 | ```bash | |
| 2000 | git add internal/web/templates/issuenew.html internal/httpd/accounts.go internal/httpd/*_test.go | |
| 2001 | git commit -m "web: new-issue form takes milestone and assignee" -m "Ref #271" | |
| 2002 | ``` | |
| 2003 | ||
| 2004 | ### Task 5.2: "Muted" reachable on the watch control | |
| 2005 | ||
| 2006 | MR 1 (Task 1.2) already made `watchToggle` cycle default → watching → | |
| 2007 | muted → default, closing the functional half of this. This task is the | |
| 2008 | UI half: the header button's label and title must describe all three | |
| 2009 | states (today it only ever renders "Watch" or "Watching"). | |
| 2010 | ||
| 2011 | **Files:** | |
| 2012 | - Modify: `internal/web/templates/layout.html:64` | |
| 2013 | - Test: a `layout.html` render test, or a repo-page test that already | |
| 2014 | checks the watch button (`grep -rln 'aria-pressed.*Watch\|"Watching"' internal/httpd/*_test.go`) | |
| 2015 | ||
| 2016 | - [ ] **Step 1: Write the failing test** | |
| 2017 | ||
| 2018 | ```go | |
| 2019 | // The watch button names all three states it cycles through, including | |
| 2020 | // muted, which MR 1 made reachable (#271). | |
| 2021 | func TestRepoHeaderWatchButtonNamesMutedState(t *testing.T) { | |
| 2022 | var sb strings.Builder | |
| 2023 | rp := testRepoPage() | |
| 2024 | rp.Watch = "muted" | |
| 2025 | if err := web.Render(&sb, "dashboard.html", struct { | |
| 2026 | repoPage | |
| 2027 | }{rp}); err != nil { | |
| 2028 | t.Fatalf("render: %v", err) | |
| 2029 | } | |
| 2030 | if !strings.Contains(sb.String(), "Muted") { | |
| 2031 | t.Error(`watch button does not render "Muted" for a muted repo`) | |
| 2032 | } | |
| 2033 | } | |
| 2034 | ``` | |
| 2035 | ||
| 2036 | `dashboard.html` is not a repo page and will not carry the header at | |
| 2037 | all — use whatever page template this plan's other tasks already found | |
| 2038 | does render `field $ "Repo"` (any `repoPage`-embedding page works, | |
| 2039 | e.g. `mr.html`); adjust the render call to a page that actually shows | |
| 2040 | the header (check with `grep -n 'field \$ "Repo"' internal/web/templates/layout.html` | |
| 2041 | and pick any page in the `repoPage` family, such as `mrs.html`, matching | |
| 2042 | whatever fixture data that page's own tests already use). | |
| 2043 | ||
| 2044 | - [ ] **Step 2: Run and see it fail** | |
| 2045 | ||
| 2046 | Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1` | |
| 2047 | Expected: FAIL. | |
| 2048 | ||
| 2049 | - [ ] **Step 3: Update the button** | |
| 2050 | ||
| 2051 | Find (`layout.html:64`): | |
| 2052 | ||
| 2053 | ```html | |
| 2054 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> | |
| 2055 | ``` | |
| 2056 | ||
| 2057 | Replace: | |
| 2058 | ||
| 2059 | ```html | |
| 2060 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if ne (str $ "Watch") ""}}true{{else}}false{{end}}" title="{{if eq (str $ "Watch") "watching"}}Watching: every issue, request and build. Click to mute.{{else if eq (str $ "Watch") "muted"}}Muted: nothing from this repository. Click to stop muting.{{else}}Only what involves you. Click to watch everything.{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else if eq (str $ "Watch") "muted"}}Muted{{else}}Watch{{end}}</button></form> | |
| 2061 | ``` | |
| 2062 | ||
| 2063 | - [ ] **Step 4: Run** | |
| 2064 | ||
| 2065 | Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1 && go test ./internal/httpd -count=1` | |
| 2066 | Expected: PASS. | |
| 2067 | ||
| 2068 | - [ ] **Step 5: Commit** | |
| 2069 | ||
| 2070 | ```bash | |
| 2071 | git add internal/web/templates/layout.html internal/httpd/*_test.go | |
| 2072 | git commit -m "web: watch button names all three states, muted included" -m "Ref #271" | |
| 2073 | ``` | |
| 2074 | ||
| 2075 | ### Task 5.3: rail and phone "More" menu render from one list | |
| 2076 | ||
| 2077 | **Files:** | |
| 2078 | - Modify: `internal/web/web.go` (add `railItem` type, `railOptItems` | |
| 2079 | func, register it in `funcs`) | |
| 2080 | - Modify: `internal/web/templates/layout.html:26,30-31,37-40` | |
| 2081 | - Test: `internal/web/web_test.go` (`TestRailIconsAreLabelled` already | |
| 2082 | parses `layout.html`; add a focused new test rather than folding into | |
| 2083 | that one) | |
| 2084 | ||
| 2085 | - [ ] **Step 1: Write the failing test** | |
| 2086 | ||
| 2087 | Add to `internal/web/web_test.go`: | |
| 2088 | ||
| 2089 | ```go | |
| 2090 | // The main rail and the phone "More" menu render New repository, | |
| 2091 | // Settings, Admin and Log out from one list, so adding a destination in | |
| 2092 | // one place reaches both (#271). | |
| 2093 | func TestRailOptItemsDriveBothRailAndMoreMenu(t *testing.T) { | |
| 2094 | items := railOptItems(struct { | |
| 2095 | Tab string | |
| 2096 | Admin bool | |
| 2097 | }{Tab: "admin", Admin: true}) | |
| 2098 | if len(items) != 4 { | |
| 2099 | t.Fatalf("got %d items, want 4 (New repository, Settings, Admin, Log out)", len(items)) | |
| 2100 | } | |
| 2101 | if items[2].Name != "Admin" || !items[2].Show { | |
| 2102 | t.Errorf("Admin item: %+v", items[2]) | |
| 2103 | } | |
| 2104 | if !items[2].Current { | |
| 2105 | t.Error("Admin item should be Current when Tab is admin") | |
| 2106 | } | |
| 2107 | ||
| 2108 | nonAdmin := railOptItems(struct { | |
| 2109 | Tab string | |
| 2110 | Admin bool | |
| 2111 | }{Tab: "account"}) | |
| 2112 | if nonAdmin[2].Show { | |
| 2113 | t.Error("Admin item should not Show for a non-admin viewer") | |
| 2114 | } | |
| 2115 | if !nonAdmin[1].Current { | |
| 2116 | t.Error("Settings item should be Current when Tab is account") | |
| 2117 | } | |
| 2118 | } | |
| 2119 | ``` | |
| 2120 | ||
| 2121 | - [ ] **Step 2: Run and see it fail to compile** | |
| 2122 | ||
| 2123 | Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1` | |
| 2124 | Expected: FAIL (`railOptItems` undefined). | |
| 2125 | ||
| 2126 | - [ ] **Step 3: Add the type and function** | |
| 2127 | ||
| 2128 | In `internal/web/web.go`, near the other template-data helpers (before | |
| 2129 | the `funcs` map, so it can be referenced there): | |
| 2130 | ||
| 2131 | ```go | |
| 2132 | // railItem is one destination the rail's icon strip and the phone | |
| 2133 | // "More" menu both render — from this one list, so a destination added | |
| 2134 | // here reaches both instead of the two being hand-kept in step (#271). | |
| 2135 | type railItem struct { | |
| 2136 | Href string | |
| 2137 | Icon string | |
| 2138 | Name string | |
| 2139 | Current bool | |
| 2140 | Count int64 // unused by railOptItems; present so "raillink" can read it uniformly | |
| 2141 | Show bool | |
| 2142 | } | |
| 2143 | ||
| 2144 | // railField and railBool read a named field off the page value the | |
| 2145 | // layout was given — the same reflection str/field already do for the | |
| 2146 | // repo header, duplicated narrowly here rather than exported, since | |
| 2147 | // railOptItems is their only other caller. | |
| 2148 | func railField(v any, name string) string { | |
| 2149 | rv := reflect.ValueOf(v) | |
| 2150 | for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { | |
| 2151 | rv = rv.Elem() | |
| 2152 | } | |
| 2153 | if rv.Kind() != reflect.Struct { | |
| 2154 | return "" | |
| 2155 | } | |
| 2156 | f := rv.FieldByName(name) | |
| 2157 | if !f.IsValid() || f.Kind() != reflect.String { | |
| 2158 | return "" | |
| 2159 | } | |
| 2160 | return f.String() | |
| 2161 | } | |
| 2162 | ||
| 2163 | func railBool(v any, name string) bool { | |
| 2164 | rv := reflect.ValueOf(v) | |
| 2165 | for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface { | |
| 2166 | rv = rv.Elem() | |
| 2167 | } | |
| 2168 | if rv.Kind() != reflect.Struct { | |
| 2169 | return false | |
| 2170 | } | |
| 2171 | f := rv.FieldByName(name) | |
| 2172 | return f.IsValid() && f.Kind() == reflect.Bool && f.Bool() | |
| 2173 | } | |
| 2174 | ||
| 2175 | // railOptItems is the rail's "New repository", "Settings", "Admin" and | |
| 2176 | // "Log out" destinations, in the order the rail shows them. v is the | |
| 2177 | // page value the layout renders (any page struct that embeds | |
| 2178 | // basePage), read by field name since the layout has no single common | |
| 2179 | // type for every page. | |
| 2180 | func railOptItems(v any) []railItem { | |
| 2181 | tab := railField(v, "Tab") | |
| 2182 | admin := railBool(v, "Admin") | |
| 2183 | return []railItem{ | |
| 2184 | {Href: "/new", Icon: "plus", Name: "New repository", Show: true}, | |
| 2185 | {Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true}, | |
| 2186 | {Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin}, | |
| 2187 | {Href: "/logout", Icon: "signout", Name: "Log out", Show: true}, | |
| 2188 | } | |
| 2189 | } | |
| 2190 | ``` | |
| 2191 | ||
| 2192 | Add `"reflect"` to the file's imports if not already present (it almost | |
| 2193 | certainly is, since `str`/`field` already use it). | |
| 2194 | ||
| 2195 | Register it in the `funcs` map (anywhere in the literal, e.g. beside | |
| 2196 | `"initial"`): | |
| 2197 | ||
| 2198 | ```go | |
| 2199 | "railOptItems": railOptItems, | |
| 2200 | ``` | |
| 2201 | ||
| 2202 | - [ ] **Step 4: Run the new test** | |
| 2203 | ||
| 2204 | Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1` | |
| 2205 | Expected: PASS. | |
| 2206 | ||
| 2207 | - [ ] **Step 5: Use it in `layout.html`** | |
| 2208 | ||
| 2209 | Find (lines 21-32): | |
| 2210 | ||
| 2211 | ```html | |
| 2212 | <ul class="raillist"> | |
| 2213 | {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}} | |
| 2214 | <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li> | |
| 2215 | <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li> | |
| 2216 | {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li> | |
| 2217 | <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}} | |
| 2218 | </ul> | |
| 2219 | <span class="railgap"></span> | |
| 2220 | <ul class="raillist"> | |
| 2221 | {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}} | |
| 2222 | {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}} | |
| 2223 | </ul> | |
| 2224 | ``` | |
| 2225 | ||
| 2226 | Replace: | |
| 2227 | ||
| 2228 | ```html | |
| 2229 | <ul class="raillist"> | |
| 2230 | {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}} | |
| 2231 | <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li> | |
| 2232 | <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li> | |
| 2233 | {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li> | |
| 2234 | <li class="railopt">{{template "raillink" (index (railOptItems .) 0)}}</li>{{end}} | |
| 2235 | </ul> | |
| 2236 | <span class="railgap"></span> | |
| 2237 | <ul class="raillist"> | |
| 2238 | {{if .Viewer}}<li class="railopt">{{template "raillink" (index (railOptItems .) 1)}}</li> | |
| 2239 | {{if (index (railOptItems .) 2).Show}}<li class="railopt">{{template "raillink" (index (railOptItems .) 2)}}</li>{{end}}{{end}} | |
| 2240 | </ul> | |
| 2241 | ``` | |
| 2242 | ||
| 2243 | Find (lines 34-42): | |
| 2244 | ||
| 2245 | ```html | |
| 2246 | {{if .Viewer}}<details class="railmore"> | |
| 2247 | <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary> | |
| 2248 | <div class="raildrop"> | |
| 2249 | <a href="/new">{{template "icon" "plus"}} New repository</a> | |
| 2250 | <a href="/settings">{{template "icon" "gear"}} Settings</a> | |
| 2251 | {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}} | |
| 2252 | <a href="/logout">{{template "icon" "signout"}} Log out</a> | |
| 2253 | </div> | |
| 2254 | </details> | |
| 2255 | ``` | |
| 2256 | ||
| 2257 | Replace: | |
| 2258 | ||
| 2259 | ```html | |
| 2260 | {{if .Viewer}}<details class="railmore"> | |
| 2261 | <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary> | |
| 2262 | <div class="raildrop"> | |
| 2263 | {{range railOptItems .}}{{if .Show}}<a href="{{.Href}}">{{template "icon" .Icon}} {{.Name}}</a>{{end}}{{end}} | |
| 2264 | </div> | |
| 2265 | </details> | |
| 2266 | ``` | |
| 2267 | ||
| 2268 | - [ ] **Step 6: Run** | |
| 2269 | ||
| 2270 | Run: `go test ./internal/web -count=1 && go test ./internal/httpd -count=1` | |
| 2271 | Expected: PASS. `TestRailIconsAreLabelled` must still pass unchanged — | |
| 2272 | `raillink`'s template still receives the same field names (`Href`, | |
| 2273 | `Icon`, `Name`, `Current`, `Count`), now off a `railItem` struct instead | |
| 2274 | of a `dict` map, which `html/template`'s field access treats | |
| 2275 | identically. | |
| 2276 | ||
| 2277 | - [ ] **Step 7: Commit** | |
| 2278 | ||
| 2279 | ```bash | |
| 2280 | git add internal/web/web.go internal/web/web_test.go internal/web/templates/layout.html | |
| 2281 | git commit -m "web: rail and phone More menu render New repository/Settings/Admin/Log out from one list" -m "Ref #271" | |
| 2282 | ``` | |
| 2283 | ||
| 2284 | ### Task 5.4: "Discussion" heading before the comment thread | |
| 2285 | ||
| 2286 | **Files:** | |
| 2287 | - Modify: `internal/web/templates/mr.html` (inside the `conversation` view) | |
| 2288 | - Modify: `internal/web/templates/issue.html` | |
| 2289 | - Test: existing render tests in `mrpage_test.go`; a new or existing | |
| 2290 | `issue.html` render test | |
| 2291 | ||
| 2292 | - [ ] **Step 1: Write the failing tests** | |
| 2293 | ||
| 2294 | Add to `internal/httpd/mrpage_test.go`: | |
| 2295 | ||
| 2296 | ```go | |
| 2297 | // A heading precedes the comment thread, so a screen-reader user | |
| 2298 | // skimming by heading does not fall from the aside's groups straight | |
| 2299 | // into the first comment with no landmark (#271). | |
| 2300 | func TestMRPageHasDiscussionHeading(t *testing.T) { | |
| 2301 | var sb strings.Builder | |
| 2302 | if err := web.Render(&sb, "mr.html", mrPageData{ | |
| 2303 | repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", | |
| 2304 | }); err != nil { | |
| 2305 | t.Fatalf("render: %v", err) | |
| 2306 | } | |
| 2307 | if !strings.Contains(sb.String(), "<h2>Discussion</h2>") { | |
| 2308 | t.Error("no Discussion heading") | |
| 2309 | } | |
| 2310 | } | |
| 2311 | ``` | |
| 2312 | ||
| 2313 | Add the equivalent for `issue.html` in whatever file already tests it | |
| 2314 | (`grep -rln '"issue.html"' internal/httpd/*_test.go`). | |
| 2315 | ||
| 2316 | - [ ] **Step 2: Run and see them fail** | |
| 2317 | ||
| 2318 | Run: `go test ./internal/httpd -run TestMRPageHasDiscussionHeading -count=1` | |
| 2319 | Expected: FAIL. | |
| 2320 | ||
| 2321 | - [ ] **Step 3: `mr.html`** | |
| 2322 | ||
| 2323 | Find, inside the `{{if eq .View "conversation"}}` block, right after | |
| 2324 | `<div class="prose">`: | |
| 2325 | ||
| 2326 | ```html | |
| 2327 | {{if eq .View "conversation"}} | |
| 2328 | <div class="prose"> | |
| 2329 | {{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary> | |
| 2330 | ``` | |
| 2331 | ||
| 2332 | Replace: | |
| 2333 | ||
| 2334 | ```html | |
| 2335 | {{if eq .View "conversation"}} | |
| 2336 | <div class="prose"> | |
| 2337 | <h2>Discussion</h2> | |
| 2338 | {{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary> | |
| 2339 | ``` | |
| 2340 | ||
| 2341 | - [ ] **Step 4: `issue.html`** | |
| 2342 | ||
| 2343 | Find, right after `<div class="mainside">`: | |
| 2344 | ||
| 2345 | ```html | |
| 2346 | <div class="mainside"> | |
| 2347 | {{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary> | |
| 2348 | ``` | |
| 2349 | ||
| 2350 | Replace: | |
| 2351 | ||
| 2352 | ```html | |
| 2353 | <div class="mainside"> | |
| 2354 | <h2>Discussion</h2> | |
| 2355 | {{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary> | |
| 2356 | ``` | |
| 2357 | ||
| 2358 | - [ ] **Step 5: Run** | |
| 2359 | ||
| 2360 | Run: `go test ./internal/httpd -count=1` | |
| 2361 | Expected: PASS. | |
| 2362 | ||
| 2363 | - [ ] **Step 6: Commit** | |
| 2364 | ||
| 2365 | ```bash | |
| 2366 | git add internal/web/templates/mr.html internal/web/templates/issue.html internal/httpd/*_test.go | |
| 2367 | git commit -m "web: Discussion heading before the comment thread on issue and MR pages" -m "Ref #271" | |
| 2368 | ``` | |
| 2369 | ||
| 2370 | ### Task 5.5: build page's "Live" note says the page updates itself | |
| 2371 | ||
| 2372 | **Files:** | |
| 2373 | - Modify: `internal/web/templates/build.html:15` | |
| 2374 | - Test: `internal/httpd/builds_test.go` (or wherever a live-build render | |
| 2375 | test already exists — check with `grep -rln '"Live"' internal/httpd/*_test.go`) | |
| 2376 | ||
| 2377 | - [ ] **Step 1: Write the failing test** | |
| 2378 | ||
| 2379 | ```go | |
| 2380 | // The Live note says the page updates itself, in plain words, rather | |
| 2381 | // than the more technical "streams here" (#271). | |
| 2382 | func TestBuildPageLiveNoteSaysItUpdatesItself(t *testing.T) { | |
| 2383 | var sb strings.Builder | |
| 2384 | if err := web.Render(&sb, "build.html", buildView{ | |
| 2385 | repoPage: testRepoPage(), Live: true, | |
| 2386 | }); err != nil { | |
| 2387 | t.Fatalf("render: %v", err) | |
| 2388 | } | |
| 2389 | if !strings.Contains(sb.String(), "This page updates itself") { | |
| 2390 | t.Error(`Live note does not say the page updates itself`) | |
| 2391 | } | |
| 2392 | } | |
| 2393 | ``` | |
| 2394 | ||
| 2395 | - [ ] **Step 2: Run and see it fail** | |
| 2396 | ||
| 2397 | Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1` | |
| 2398 | Expected: FAIL. | |
| 2399 | ||
| 2400 | - [ ] **Step 3: Update the note** | |
| 2401 | ||
| 2402 | Find (`build.html:15`): | |
| 2403 | ||
| 2404 | ```html | |
| 2405 | {{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p> | |
| 2406 | ``` | |
| 2407 | ||
| 2408 | Replace: | |
| 2409 | ||
| 2410 | ```html | |
| 2411 | {{if .Live}}<p class="meta">This page updates itself until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p> | |
| 2412 | ``` | |
| 2413 | ||
| 2414 | - [ ] **Step 4: Run** | |
| 2415 | ||
| 2416 | Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1 && go test ./internal/httpd -count=1` | |
| 2417 | Expected: PASS. | |
| 2418 | ||
| 2419 | - [ ] **Step 5: Commit** | |
| 2420 | ||
| 2421 | ```bash | |
| 2422 | git add internal/web/templates/build.html internal/httpd/builds_test.go | |
| 2423 | git commit -m "web: build page's Live note says the page updates itself" -m "Closes #271" | |
| 2424 | ``` | |
| 2425 | ||
| 2426 | ### Task 5.6: open MR 5 | |
| 2427 | ||
| 2428 | ```bash | |
| 2429 | git push -u origin web-ux-small-fixes | |
| 2430 | gitbay mr create --source web-ux-small-fixes --target main --title "Web UX review small fixes: issue form, mute, rail list, Discussion heading" | |
| 2431 | ``` | |
| 2432 | ||
| 2433 | Wait for CI, merge, delete the branch both places. | |
| 2434 | ||
| 2435 | --- | |
| 2436 | ||
| 2437 | # MR 6: wiki non-page links go to `_raw` (branch `wiki-raw-links`) | |
| 2438 | ||
| 2439 | Closes #283. | |
| 2440 | ||
| 2441 | ### Task 6.1: `rewriteWikiLinks` sends a non-page file link to `_raw` | |
| 2442 | ||
| 2443 | **Files:** | |
| 2444 | - Modify: `internal/httpd/wiki.go:250-253` | |
| 2445 | - Test: `internal/httpd/wiki_test.go` | |
| 2446 | ||
| 2447 | **Interfaces:** | |
| 2448 | - No signature changes — `rewriteWikiLinks`'s parameters and the | |
| 2449 | `isPage`/`isFile` predicates it already takes are unchanged; only its | |
| 2450 | href branch's internal logic changes. | |
| 2451 | ||
| 2452 | - [ ] **Step 1: Write the failing test** | |
| 2453 | ||
| 2454 | Add to `internal/httpd/wiki_test.go`, in `TestRewriteWikiLinksInSubfolder` | |
| 2455 | (extend the existing test rather than adding a new one — it already sets | |
| 2456 | up exactly the `pages`/`files` fixtures this needs): | |
| 2457 | ||
| 2458 | ```go | |
| 2459 | in := template.HTML(`<a href="Identity.org">i</a><a href="Admin.org">a</a>` + | |
| 2460 | `<a href="b.svg">diagram</a>` + | |
| 2461 | `<img src="b.svg"><img src="diagrams/a.svg"><a href="https://x.test/">x</a>`) | |
| 2462 | out := string(rewriteWikiLinks(in, p, "Architecture/Trust", | |
| 2463 | func(s string) bool { return pages[s] }, func(s string) bool { return files[s] })) | |
| 2464 | for _, want := range []string{ | |
| 2465 | `href="/krz/gitbay/wiki/Architecture/Identity"`, | |
| 2466 | `href="/krz/gitbay/wiki/Admin"`, | |
| 2467 | `href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`, | |
| 2468 | `src="/krz/gitbay/wiki/_raw/Architecture/b.svg"`, | |
| 2469 | `src="/krz/gitbay/wiki/_raw/diagrams/a.svg"`, | |
| 2470 | `href="https://x.test/"`, | |
| 2471 | } { | |
| 2472 | ``` | |
| 2473 | ||
| 2474 | (This adds the `<a href="b.svg">` link to the input and the matching | |
| 2475 | `href="...wiki/_raw/Architecture/b.svg"` expectation to the existing | |
| 2476 | `for _, want := range` loop — the surrounding test body, `p`, `pages` | |
| 2477 | and `files` setup, and the final `if !strings.Contains` check stay | |
| 2478 | exactly as they are.) | |
| 2479 | ||
| 2480 | - [ ] **Step 2: Run and see it fail** | |
| 2481 | ||
| 2482 | Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1` | |
| 2483 | Expected: FAIL — the new href expectation | |
| 2484 | (`href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`) is missing; today's | |
| 2485 | code rewrites that link to `href="/krz/gitbay/wiki/Architecture/b.svg"` | |
| 2486 | instead (a page-style link to a file that is not a page, which 404s — | |
| 2487 | the bug #283 reports). | |
| 2488 | ||
| 2489 | - [ ] **Step 3: Fix `rewriteWikiLinks`** | |
| 2490 | ||
| 2491 | Find (`internal/httpd/wiki.go:250-253`): | |
| 2492 | ||
| 2493 | ```go | |
| 2494 | if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok { | |
| 2495 | n.Attr[i].Val = base + "/" + target | |
| 2496 | } | |
| 2497 | ``` | |
| 2498 | ||
| 2499 | Replace: | |
| 2500 | ||
| 2501 | ```go | |
| 2502 | // A plain link is usually to another page, but a link to | |
| 2503 | // an existing non-page file (an .svg, .txt, .pdf) must | |
| 2504 | // go to _raw the same as an image src, or it 404s | |
| 2505 | // against the page route (#283). | |
| 2506 | if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok { | |
| 2507 | if raw, rok := wikiResolve(page, v, isFile); rok && isFile(raw) && !isPage(target) { | |
| 2508 | n.Attr[i].Val = base + "/_raw/" + raw | |
| 2509 | } else { | |
| 2510 | n.Attr[i].Val = base + "/" + target | |
| 2511 | } | |
| 2512 | } | |
| 2513 | ``` | |
| 2514 | ||
| 2515 | - [ ] **Step 4: Run** | |
| 2516 | ||
| 2517 | Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1` | |
| 2518 | Expected: PASS. | |
| 2519 | ||
| 2520 | - [ ] **Step 5: Run the package** | |
| 2521 | ||
| 2522 | Run: `go test ./internal/httpd -count=1` | |
| 2523 | Expected: PASS. | |
| 2524 | ||
| 2525 | - [ ] **Step 6: Commit** | |
| 2526 | ||
| 2527 | ```bash | |
| 2528 | git add internal/httpd/wiki.go internal/httpd/wiki_test.go | |
| 2529 | git commit -m "wiki: a link to an existing non-page file resolves to _raw, not the page route" -m "Closes #283" | |
| 2530 | ``` | |
| 2531 | ||
| 2532 | ### Task 6.2: open MR 6 | |
| 2533 | ||
| 2534 | ```bash | |
| 2535 | git push -u origin wiki-raw-links | |
| 2536 | gitbay mr create --source wiki-raw-links --target main --title "Wiki: links to non-page files resolve to _raw" | |
| 2537 | ``` | |
| 2538 | ||
| 2539 | Wait for CI, merge, delete the branch both places. | |
| 2540 | ||
| 2541 | --- | |
| 2542 | ||
| 2543 | # MR 7: API token page (branch `web-api-tokens`) | |
| 2544 | ||
| 2545 | Closes #264. Depends on plan 1 (`credentials-and-sessions`, #257) per | |
| 2546 | the "Order and dependencies" section above — implementable and testable | |
| 2547 | independently, merge after plan 1 lands. | |
| 2548 | ||
| 2549 | ### Task 7.1: `Settings → Tokens`: create, list, revoke | |
| 2550 | ||
| 2551 | **Files:** | |
| 2552 | - Modify: `internal/httpd/account.go` (`accountPage`, `accountSubmit`) | |
| 2553 | - Modify: `internal/httpd/flash.go` (a token-shown-once cookie, parallel | |
| 2554 | to the existing flash cookie) | |
| 2555 | - Modify: `internal/web/templates/account.html` | |
| 2556 | - Test: `internal/httpd/account_test.go` | |
| 2557 | ||
| 2558 | **Interfaces:** | |
| 2559 | - Consumes: `store.ListAPITokens`/`RevokeAPIToken` | |
| 2560 | (`internal/store/tokens.go:53,74`, read/delete directly, matching how | |
| 2561 | `accountPage` already reads keys and PGP keys); `s.runControl` | |
| 2562 | dispatching `token create` (a write, so it goes through the command, | |
| 2563 | matching every other write on this page). | |
| 2564 | - Produces: `func (s *Server) setTokenFlash(w http.ResponseWriter, msg string)`, | |
| 2565 | `func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string` | |
| 2566 | (`internal/httpd/flash.go`). | |
| 2567 | ||
| 2568 | - [ ] **Step 1: Write the failing tests** | |
| 2569 | ||
| 2570 | Add to `internal/httpd/account_test.go`: | |
| 2571 | ||
| 2572 | ```go | |
| 2573 | // The settings page lists a user's API tokens with scope and expiry, | |
| 2574 | // and creating one shows the token exactly once, never in the URL | |
| 2575 | // (#264). | |
| 2576 | func TestAccountPageListsTokens(t *testing.T) { | |
| 2577 | st, err := store.Open(":memory:") | |
| 2578 | if err != nil { | |
| 2579 | t.Fatal(err) | |
| 2580 | } | |
| 2581 | defer st.Close() | |
| 2582 | if err := st.MigrateUp(); err != nil { | |
| 2583 | t.Fatal(err) | |
| 2584 | } | |
| 2585 | uid, err := st.CreateUser("alice", false) | |
| 2586 | if err != nil { | |
| 2587 | t.Fatal(err) | |
| 2588 | } | |
| 2589 | if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil { | |
| 2590 | t.Fatal(err) | |
| 2591 | } | |
| 2592 | ||
| 2593 | s := New(config.Default(), st) | |
| 2594 | rr := httptest.NewRecorder() | |
| 2595 | req := httptest.NewRequest("GET", "/settings", nil) | |
| 2596 | s.accountPage(rr, req, store.User{ID: uid, Username: "alice"}) | |
| 2597 | ||
| 2598 | body := rr.Body.String() | |
| 2599 | if !strings.Contains(body, "laptop") || !strings.Contains(body, "read") { | |
| 2600 | t.Fatalf("token row missing: %s", body) | |
| 2601 | } | |
| 2602 | if strings.Contains(body, "somehash") { | |
| 2603 | t.Fatal("the page printed a token hash") | |
| 2604 | } | |
| 2605 | } | |
| 2606 | ||
| 2607 | // Creating a token always sends an explicit --scope, defaulting the | |
| 2608 | // form to read regardless of what token create itself defaults to, so | |
| 2609 | // this page's behaviour does not depend on that command's default | |
| 2610 | // (#264, #257). | |
| 2611 | func TestAccountSubmitTokenCreateDefaultsToReadScope(t *testing.T) { | |
| 2612 | st, err := store.Open(":memory:") | |
| 2613 | if err != nil { | |
| 2614 | t.Fatal(err) | |
| 2615 | } | |
| 2616 | defer st.Close() | |
| 2617 | if err := st.MigrateUp(); err != nil { | |
| 2618 | t.Fatal(err) | |
| 2619 | } | |
| 2620 | uid, err := st.CreateUser("alice", false) | |
| 2621 | if err != nil { | |
| 2622 | t.Fatal(err) | |
| 2623 | } | |
| 2624 | u := store.User{ID: uid, Username: "alice"} | |
| 2625 | s := New(config.Default(), st) | |
| 2626 | ||
| 2627 | rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}}) | |
| 2628 | if rr.Code != http.StatusSeeOther { | |
| 2629 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | |
| 2630 | } | |
| 2631 | if !strings.Contains(strings.Join(rr.Result().Header.Values("Set-Cookie"), ";"), "gitbay_token=") { | |
| 2632 | t.Fatal("no token-shown cookie set") | |
| 2633 | } | |
| 2634 | tokens, err := st.ListAPITokens(uid) | |
| 2635 | if err != nil || len(tokens) != 1 { | |
| 2636 | t.Fatalf("tokens: %v %v", tokens, err) | |
| 2637 | } | |
| 2638 | if tokens[0].Scope != "read" { | |
| 2639 | t.Errorf("scope = %q, want read", tokens[0].Scope) | |
| 2640 | } | |
| 2641 | } | |
| 2642 | ||
| 2643 | // Revoking a token requires the name typed back, the same guard every | |
| 2644 | // other removal on this page uses. | |
| 2645 | func TestAccountSubmitTokenRevokeRequiresConfirm(t *testing.T) { | |
| 2646 | st, err := store.Open(":memory:") | |
| 2647 | if err != nil { | |
| 2648 | t.Fatal(err) | |
| 2649 | } | |
| 2650 | defer st.Close() | |
| 2651 | if err := st.MigrateUp(); err != nil { | |
| 2652 | t.Fatal(err) | |
| 2653 | } | |
| 2654 | uid, err := st.CreateUser("alice", false) | |
| 2655 | if err != nil { | |
| 2656 | t.Fatal(err) | |
| 2657 | } | |
| 2658 | if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil { | |
| 2659 | t.Fatal(err) | |
| 2660 | } | |
| 2661 | u := store.User{ID: uid, Username: "alice"} | |
| 2662 | s := New(config.Default(), st) | |
| 2663 | ||
| 2664 | submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}}) | |
| 2665 | if tokens, _ := st.ListAPITokens(uid); len(tokens) != 1 { | |
| 2666 | t.Fatal("token revoked without confirmation") | |
| 2667 | } | |
| 2668 | ||
| 2669 | rr := submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}, "confirm": {"laptop"}}) | |
| 2670 | if rr.Code != http.StatusSeeOther { | |
| 2671 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | |
| 2672 | } | |
| 2673 | if tokens, _ := st.ListAPITokens(uid); len(tokens) != 0 { | |
| 2674 | t.Fatal("token not revoked") | |
| 2675 | } | |
| 2676 | } | |
| 2677 | ``` | |
| 2678 | ||
| 2679 | - [ ] **Step 2: Run and see them fail** | |
| 2680 | ||
| 2681 | Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1` | |
| 2682 | Expected: FAIL to compile (no `token-create`/`token-revoke` cases, no | |
| 2683 | token rows on the page). | |
| 2684 | ||
| 2685 | - [ ] **Step 3: Add the token-shown-once cookie** | |
| 2686 | ||
| 2687 | In `internal/httpd/flash.go`, alongside `flashCookie`/`setFlash`/`takeFlash`: | |
| 2688 | ||
| 2689 | ```go | |
| 2690 | // tokenFlashCookie carries a freshly minted API token to the settings | |
| 2691 | // page exactly once. A cookie, not the ?m= query parameter the other | |
| 2692 | // account forms use for their success text, because a token is a | |
| 2693 | // secret and must never ride a URL a browser might history, bookmark, | |
| 2694 | // or hand to a proxy's access log (#264). | |
| 2695 | const tokenFlashCookie = "gitbay_token" | |
| 2696 | ||
| 2697 | // setTokenFlash queues a freshly minted token's display text for the | |
| 2698 | // next render of the settings page. | |
| 2699 | func (s *Server) setTokenFlash(w http.ResponseWriter, msg string) { | |
| 2700 | if msg == "" { | |
| 2701 | return | |
| 2702 | } | |
| 2703 | http.SetCookie(w, &http.Cookie{ | |
| 2704 | Name: tokenFlashCookie, Value: url.QueryEscape(msg), Path: "/settings", | |
| 2705 | HttpOnly: true, SameSite: http.SameSiteLaxMode, | |
| 2706 | Secure: s.cfg.HTTP.TLS != "off", | |
| 2707 | MaxAge: 60, | |
| 2708 | }) | |
| 2709 | } | |
| 2710 | ||
| 2711 | // takeTokenFlash returns the queued token text, if any, and clears it. | |
| 2712 | func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string { | |
| 2713 | c, err := r.Cookie(tokenFlashCookie) | |
| 2714 | if err != nil || c.Value == "" { | |
| 2715 | return "" | |
| 2716 | } | |
| 2717 | http.SetCookie(w, s.clearCookie(tokenFlashCookie, http.SameSiteLaxMode)) | |
| 2718 | msg, err := url.QueryUnescape(c.Value) | |
| 2719 | if err != nil { | |
| 2720 | return "" | |
| 2721 | } | |
| 2722 | return msg | |
| 2723 | } | |
| 2724 | ``` | |
| 2725 | ||
| 2726 | `clearCookie` takes only `name` and `sameSite` and hard-codes `Path: | |
| 2727 | "/"` (`internal/httpd/flash.go:101-108`) — confirm this still clears a | |
| 2728 | cookie set with `Path: "/settings"` (it does: browsers key deletion on | |
| 2729 | name+domain+path, and `"/settings"` is under `"/"`... actually a | |
| 2730 | `Path=/` clearing cookie does **not** delete a `Path=/settings` cookie — | |
| 2731 | paths must match exactly for deletion semantics in most browsers). | |
| 2732 | Fix this by setting `Path: "/settings"` on both the set and the clear: | |
| 2733 | add a `path` parameter to a small local variant, or simplest, write | |
| 2734 | `takeTokenFlash`'s clear inline instead of reusing `clearCookie`: | |
| 2735 | ||
| 2736 | ```go | |
| 2737 | func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string { | |
| 2738 | c, err := r.Cookie(tokenFlashCookie) | |
| 2739 | if err != nil || c.Value == "" { | |
| 2740 | return "" | |
| 2741 | } | |
| 2742 | http.SetCookie(w, &http.Cookie{ | |
| 2743 | Name: tokenFlashCookie, Value: "", Path: "/settings", | |
| 2744 | HttpOnly: true, SameSite: http.SameSiteLaxMode, | |
| 2745 | Secure: s.cfg.HTTP.TLS != "off", MaxAge: -1, | |
| 2746 | }) | |
| 2747 | msg, err := url.QueryUnescape(c.Value) | |
| 2748 | if err != nil { | |
| 2749 | return "" | |
| 2750 | } | |
| 2751 | return msg | |
| 2752 | } | |
| 2753 | ``` | |
| 2754 | ||
| 2755 | (Use this version; drop the `clearCookie` call from the draft above.) | |
| 2756 | ||
| 2757 | - [ ] **Step 4: Add token data to `accountPage`** | |
| 2758 | ||
| 2759 | In `internal/httpd/account.go`, add a view type near `accountDevice`: | |
| 2760 | ||
| 2761 | ```go | |
| 2762 | // accountToken is one API token as the settings page shows it: never | |
| 2763 | // the token itself, only what identifies and describes it. | |
| 2764 | type accountToken struct { | |
| 2765 | Name string | |
| 2766 | Scope string | |
| 2767 | Created string | |
| 2768 | Expires string // "never" or a formatted timestamp | |
| 2769 | LastUsed string // "never" or a formatted timestamp | |
| 2770 | } | |
| 2771 | ``` | |
| 2772 | ||
| 2773 | In `accountPage`, alongside the existing `devices` collection: | |
| 2774 | ||
| 2775 | ```go | |
| 2776 | var tokens []accountToken | |
| 2777 | if list, err := s.st.ListAPITokens(u.ID); err == nil { | |
| 2778 | for _, tk := range list { | |
| 2779 | expires, lastUsed := "never", "never" | |
| 2780 | if tk.ExpiresAt != nil { | |
| 2781 | expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC") | |
| 2782 | } | |
| 2783 | if tk.LastUsedAt != nil { | |
| 2784 | lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC") | |
| 2785 | } | |
| 2786 | tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed}) | |
| 2787 | } | |
| 2788 | } | |
| 2789 | ``` | |
| 2790 | ||
| 2791 | Add `Tokens []accountToken` and `TokenShown string` to the struct passed | |
| 2792 | to `s.render(w, "account.html", ...)`, with `tokens` and | |
| 2793 | `s.takeTokenFlash(w, r)` in the corresponding literal positions. | |
| 2794 | ||
| 2795 | - [ ] **Step 5: Add `token-create` and `token-revoke` to `accountSubmit`** | |
| 2796 | ||
| 2797 | In `internal/httpd/account.go`, `accountSubmit`'s `switch`, add two | |
| 2798 | cases (alongside `email-primary` and before `theme`, or anywhere in the | |
| 2799 | switch — order does not matter): | |
| 2800 | ||
| 2801 | ```go | |
| 2802 | case "token-create": | |
| 2803 | name := strings.TrimSpace(r.FormValue("name")) | |
| 2804 | if name == "" { | |
| 2805 | back("name the token", "") | |
| 2806 | return | |
| 2807 | } | |
| 2808 | scope := r.FormValue("scope") | |
| 2809 | if scope != "full" { | |
| 2810 | scope = "read" // this page's own default, regardless of what token create defaults to (#257, #264) | |
| 2811 | } | |
| 2812 | argv := []string{"token", "create", "--name", name, "--scope", scope} | |
| 2813 | if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" { | |
| 2814 | argv = append(argv, "--ttl", ttl) | |
| 2815 | } | |
| 2816 | out, msg, ok := s.runControl(u, argv) | |
| 2817 | if !ok { | |
| 2818 | back(msg, "") | |
| 2819 | return | |
| 2820 | } | |
| 2821 | s.setTokenFlash(w, out) | |
| 2822 | http.Redirect(w, r, "/settings#tokens", http.StatusSeeOther) | |
| 2823 | return | |
| 2824 | case "token-revoke": | |
| 2825 | name := r.FormValue("name") | |
| 2826 | if ok, msg := confirmed(r, name); !ok { | |
| 2827 | back(msg, "") | |
| 2828 | return | |
| 2829 | } | |
| 2830 | if _, msg, ok := s.runControl(u, []string{"token", "revoke", name}); !ok { | |
| 2831 | back(msg, "") | |
| 2832 | return | |
| 2833 | } | |
| 2834 | back("", "token revoked") | |
| 2835 | ``` | |
| 2836 | ||
| 2837 | (This `switch` does not use `back`'s redirect-with-query pattern for | |
| 2838 | `token-create`'s success path, since the token's display text cannot go | |
| 2839 | through `?m=`; it returns directly after the redirect, matching the | |
| 2840 | early-return shape every other case already uses.) | |
| 2841 | ||
| 2842 | - [ ] **Step 6: Add the Tokens section to `account.html`** | |
| 2843 | ||
| 2844 | Add a new `<section id="tokens">` — placed before the existing `<section | |
| 2845 | id="cli">` (which the sidebar's existing anchor list under "On the | |
| 2846 | command line" leaves in place; add a `<li><a href="#tokens">API | |
| 2847 | tokens</a></li>` to that anchor list too, alongside the other section | |
| 2848 | links): | |
| 2849 | ||
| 2850 | ```html | |
| 2851 | <section id="tokens"><h2>API tokens</h2> | |
| 2852 | <p class="meta">A token signs in the iOS app, or a script, without your | |
| 2853 | password. A phone app needs full scope to comment and merge; full scope | |
| 2854 | on an admin account can administer the instance, so give a token the | |
| 2855 | narrowest scope and shortest lifetime the job needs.</p> | |
| 2856 | {{if .TokenShown}}<pre class="message" tabindex="0">{{.TokenShown}}</pre>{{end}} | |
| 2857 | <table> | |
| 2858 | <tr><th>Name</th><th>Scope</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr> | |
| 2859 | {{range .Tokens}}<tr> | |
| 2860 | <td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created}}</td><td>{{.Expires}}</td><td>{{.LastUsed}}</td> | |
| 2861 | <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="token-revoke"><input type="hidden" name="name" value="{{.Name}}">{{template "confirmfield" .Name}} <button type="submit" class="danger">Revoke</button></form></td> | |
| 2862 | </tr>{{else}}<tr><td colspan="6">no tokens</td></tr>{{end}} | |
| 2863 | </table> | |
| 2864 | <form method="post" action="/settings" class="actions"> | |
| 2865 | <input type="hidden" name="field" value="token-create"> | |
| 2866 | <input type="text" name="name" aria-label="Token name" placeholder="name, e.g. iphone" required> | |
| 2867 | <select name="scope" aria-label="Scope"> | |
| 2868 | <option value="read" selected>read</option> | |
| 2869 | <option value="full">full</option> | |
| 2870 | </select> | |
| 2871 | <input type="text" name="ttl" aria-label="Expires after" placeholder="expires after, e.g. 30d (optional)"> | |
| 2872 | <button type="submit" class="btn">Create token</button> | |
| 2873 | </form> | |
| 2874 | </section> | |
| 2875 | ``` | |
| 2876 | ||
| 2877 | - [ ] **Step 7: Run** | |
| 2878 | ||
| 2879 | Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1 && go test ./internal/httpd -count=1` | |
| 2880 | Expected: PASS. | |
| 2881 | ||
| 2882 | - [ ] **Step 8: Commit** | |
| 2883 | ||
| 2884 | ```bash | |
| 2885 | git add internal/httpd/flash.go internal/httpd/account.go internal/web/templates/account.html internal/httpd/account_test.go | |
| 2886 | git commit -m "web: Settings → Tokens — create, list, revoke API tokens" -m "Ref #264" | |
| 2887 | ``` | |
| 2888 | ||
| 2889 | ### Task 7.2: `registered.html` next steps as a numbered list | |
| 2890 | ||
| 2891 | **Files:** | |
| 2892 | - Modify: `internal/web/templates/registered.html` | |
| 2893 | - Test: a render test in whatever file covers signup | |
| 2894 | (`grep -rln '"registered.html"' internal/httpd/*_test.go`; create one | |
| 2895 | if none exists) | |
| 2896 | ||
| 2897 | - [ ] **Step 1: Write the failing test** | |
| 2898 | ||
| 2899 | ```go | |
| 2900 | func TestRegisteredPageNumberedStepsAndTokenMention(t *testing.T) { | |
| 2901 | var sb strings.Builder | |
| 2902 | if err := web.Render(&sb, "registered.html", struct { | |
| 2903 | basePage | |
| 2904 | Username, Message, Host string | |
| 2905 | }{Username: "alice", Host: "gitbay.org"}); err != nil { | |
| 2906 | t.Fatalf("render: %v", err) | |
| 2907 | } | |
| 2908 | out := sb.String() | |
| 2909 | if !strings.Contains(out, "<ol>") { | |
| 2910 | t.Error("next steps are not a numbered list") | |
| 2911 | } | |
| 2912 | if !strings.Contains(out, "Settings → Tokens") { | |
| 2913 | t.Error("no mention of Settings → Tokens for the iOS app") | |
| 2914 | } | |
| 2915 | } | |
| 2916 | ``` | |
| 2917 | ||
| 2918 | - [ ] **Step 2: Run and see it fail** | |
| 2919 | ||
| 2920 | Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1` | |
| 2921 | Expected: FAIL. | |
| 2922 | ||
| 2923 | - [ ] **Step 3: Rewrite the section** | |
| 2924 | ||
| 2925 | Find (`registered.html:7-10`): | |
| 2926 | ||
| 2927 | ```html | |
| 2928 | <h2>On the web</h2> | |
| 2929 | <p>Check your mail for the code, <a href="/login">sign in</a> with an emailed | |
| 2930 | link, and paste the code under <a href="/settings">Settings</a>. Then + | |
| 2931 | creates your first repository.</p> | |
| 2932 | ``` | |
| 2933 | ||
| 2934 | Replace: | |
| 2935 | ||
| 2936 | ```html | |
| 2937 | <h2>On the web</h2> | |
| 2938 | <ol> | |
| 2939 | <li>Copy the verification code from the mail you were just sent.</li> | |
| 2940 | <li><a href="/login">Sign in</a> with an emailed link.</li> | |
| 2941 | <li>Paste the code in <a href="/settings#emails">Settings → Email</a>.</li> | |
| 2942 | </ol> | |
| 2943 | <p>Then + creates your first repository. Using the iOS app? Create a | |
| 2944 | token in <a href="/settings#tokens">Settings → Tokens</a>.</p> | |
| 2945 | ``` | |
| 2946 | ||
| 2947 | (`#emails` matches the existing section id in `account.html`; confirm | |
| 2948 | with `grep -n 'id="email' internal/web/templates/account.html` — it may | |
| 2949 | be `id="emails"` plural or singular, match whichever is actually there.) | |
| 2950 | ||
| 2951 | - [ ] **Step 4: Run** | |
| 2952 | ||
| 2953 | Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1 && go test ./internal/httpd -count=1` | |
| 2954 | Expected: PASS. | |
| 2955 | ||
| 2956 | - [ ] **Step 5: Commit** | |
| 2957 | ||
| 2958 | ```bash | |
| 2959 | git add internal/web/templates/registered.html internal/httpd/*_test.go | |
| 2960 | git commit -m "web: registered page's next steps as a numbered list, with a token mention for the iOS app" -m "Ref #264" | |
| 2961 | ``` | |
| 2962 | ||
| 2963 | ### Task 7.3: update Parity | |
| 2964 | ||
| 2965 | **Files:** | |
| 2966 | - Modify: `.gitbay/wiki/Parity.org` | |
| 2967 | ||
| 2968 | - [ ] **Step 1: Fix the API token mint row** | |
| 2969 | ||
| 2970 | Find: | |
| 2971 | ||
| 2972 | ``` | |
| 2973 | | API token mint | yes | no | no | | |
| 2974 | ``` | |
| 2975 | ||
| 2976 | Replace: | |
| 2977 | ||
| 2978 | ``` | |
| 2979 | | API token mint | yes | yes | no | | |
| 2980 | ``` | |
| 2981 | ||
| 2982 | (The iOS side — linking to this page from sign-in — is filed separately | |
| 2983 | in `krz/gitbay-ios`, per the issue text, so its column stays `no` here.) | |
| 2984 | ||
| 2985 | - [ ] **Step 2: Commit** | |
| 2986 | ||
| 2987 | ```bash | |
| 2988 | git add .gitbay/wiki/Parity.org | |
| 2989 | git commit -m "wiki: Parity reflects the web API-token page" -m "Closes #264" | |
| 2990 | ``` | |
| 2991 | ||
| 2992 | ### Task 7.4: open MR 7 | |
| 2993 | ||
| 2994 | ```bash | |
| 2995 | git push -u origin web-api-tokens | |
| 2996 | gitbay mr create --source web-api-tokens --target main --title "Web: Settings → Tokens page" | |
| 2997 | ``` | |
| 2998 | ||
| 2999 | Wait for CI, merge, delete the branch both places. | |
| 3000 | ||
| 3001 | --- | |
| 3002 | ||
| 3003 | ## Self-review | |
| 3004 | ||
| 3005 | **Spec coverage** (against the seven issue texts): | |
| 3006 | - #261: FK check ordering (Task 1.1), pin/watch dispatch (Task 1.2), | |
| 3007 | Cache-Control (Task 1.3), all four doc-drift bullets (Task 1.4). ✓. | |
| 3008 | - #263: whoami line, token line both forms, auth summary, registry test | |
| 3009 | (Tasks 2.1-2.3). ✓. | |
| 3010 | - #264: create/list/revoke with confirmfield, scope defaults to read on | |
| 3011 | this page regardless of the command default, registered.html numbered | |
| 3012 | steps + token mention, Parity (Tasks 7.1-7.3). ✓. | |
| 3013 | - #269: range-diff page, compare-to-previous per row, Parity (Tasks | |
| 3014 | 3.1-3.3). ✓. | |
| 3015 | - #270: the empty-state table (Task 4.1), MR list contribution hint | |
| 3016 | (Task 4.2), search caption + tab zero-count rule (Task 4.3). ✓. | |
| 3017 | - #271: issue form fields, Muted reachable, rail/More unification, | |
| 3018 | Discussion heading, Live note (Tasks 5.1-5.5). ✓. | |
| 3019 | - #283: `_raw` fix and test (Task 6.1). ✓. | |
| 3020 | ||
| 3021 | **Placeholder scan:** no task stops short of real code. The three points | |
| 3022 | this plan's first draft could not pin down from the code — `group()`'s | |
| 3023 | help-rendering mechanism (Task 2.2), `issue create`'s flag set (Task | |
| 3024 | 5.1), and how an `internal/httpd` test authenticates a GET as a given | |
| 3025 | viewer (Task 4.2) — were each resolved by reading the relevant source | |
| 3026 | (`cmd/gitbay/main.go`'s `group()`/`serverHelp()`, `internal/control/help.go`'s | |
| 3027 | `runHelp()`, `internal/control/issue.go`'s `issue create`/`issue | |
| 3028 | milestone`/`issue assign` registrations, and `internal/httpd/logincookie_test.go`'s | |
| 3029 | `sessionCookieFor` plus `store.CreateWebSession`) before this plan was | |
| 3030 | finished; the tasks above carry the resolved code directly, not a | |
| 3031 | placeholder. | |
| 3032 | ||
| 3033 | **Type consistency:** `railItem` (Task 5.3) is used identically in | |
| 3034 | `web.go` and `layout.html`; `accountToken` (Task 7.1) fields match the | |
| 3035 | template's `.Name`/`.Scope`/`.Created`/`.Expires`/`.LastUsed` access; | |
| 3036 | `mrRangeDiff`'s render struct (Task 3.1) matches `mrrangediff.html`'s | |
| 3037 | `.MR`/`.Diff`/`.Error`. | |
| 3038 | ||
| 3039 | ## Open questions | |
| 3040 | ||
| 3041 | None outstanding — the three research gaps found while first drafting | |
| 3042 | this plan (Task 2.2's help mechanism, Task 4.2's session-cookie test | |
| 3043 | fixture, Task 5.1's `issue create` flag set) were each resolved by | |
| 3044 | reading the relevant source before this plan was finished; see | |
| 3045 | "Placeholder scan" above for what was read. | |